From 7fcdbf3778691ec51bc12df4c9ef01a71c302582 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:22:52 +0000 Subject: [PATCH 1/3] test(driver-sql): pin the member keyset walk by id across page boundaries (WIP) --- ...ver-member-keyset-walk-conformance.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts new file mode 100644 index 00000000000..725f5cb5868 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A keyset walk by `id` visits every row once, in the driver's own order + * (#29398 item 1) — the query shape of the organization-membership completion + * read in `@objectstack/core` (`readOrganizationMembersToCompletion`). + * + * That read pages `sys_member` with `{ organization_id, id: { $gt: after } }`, + * `orderBy id asc`, a `limit`, and the columns `id` and `user_id`, resuming + * after the last id of each full page. Its correctness rests on the driver, + * and on nothing the resolver can check for itself: the order is the + * database's collation, not JavaScript's code-point order (PostgreSQL with an + * ICU `en-US` database sorts `a-c ab aB3 Ab9 AC b_1 B-2`, a `C.UTF-8` one + * sorts `AC Ab9 B-2 a-c aB3 ab b_1`), so a resolver-side "each id is greater + * than the last" check would refuse every correct page on such a database. + * The keyset is collation-safe because `ORDER BY id` and `id > ?` use the + * same comparison. This file pins exactly that, per dialect cell: the pages, + * joined, are the same sequence as one unpaged ordered read, and that sequence + * holds every row of the organization once and no row of another. + * + * The ids are mixed-case nanoid-alphabet values (letters of both cases, + * digits, `-`, `_`), so a cell whose collation folds case or weighs + * punctuation differently still walks a real non-code-point order. No two + * ids are equal after case folding: a case-insensitive primary key (MySQL's + * `utf8mb4_0900_ai_ci`) would refuse such a pair at insert, which is a + * different fact from the one pinned here. + * + * Each driver package carries its own copy of this pin (driver-memory, + * driver-sqlite-wasm, driver-turso, driver-mongodb): the drivers' shared + * conformance cases live in `@objectstack/spec/data`, which this change does + * not edit. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqlDriver } from './index.js'; +import { DIALECT_CELLS, declareUnprovisionedCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +/** Issue-prefixed: the live cells share a database with every other suite in this package. */ +const TABLE = 'os29398_member_keyset'; +const ORG = 'org_walked'; +const OTHER_ORG = 'org_other'; + +/** The organization walked: sixteen ids, no two equal after case folding. */ +const MEMBER_IDS = [ + 'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz', + 'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q', +] as const; +/** Another organization's rows, interleaved with the walked ones in any collation. */ +const OTHER_IDS = ['Aa', 'b', 'zZ'] as const; + +/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */ +const PAGE_SIZES = [1, 3, 4, 16] as const; + +const READ_OPTIONS = { bypassTenantAudit: true } as const; + +const userIdOf = (id: string) => `user_${id}`; + +for (const cell of DIALECT_CELLS) { + if (!cell.available) { + declareUnprovisionedCell(cell, 'member keyset walk conformance'); + continue; + } + declareWalk(cell); +} + +function declareWalk(cell: DialectCell): void { + describe(`SqlDriver — a keyset walk by id visits every member once, in the driver's order (#29398) (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { name: TABLE, fields: { organization_id: { type: 'string' }, user_id: { type: 'string' } } } as any, + ]); + const rows = [ + ...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })), + ...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })), + ]; + for (const row of rows) await driver.create(TABLE, row, READ_OPTIONS); + }); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver?.disconnect?.(); + }); + + /** The completion read's loop: resume after the last id of each full page. */ + const walk = async (pageSize: number) => { + const visited: Array> = []; + let after: unknown; + let reads = 0; + for (;;) { + const where = after === undefined + ? { organization_id: ORG } + : { organization_id: ORG, id: { $gt: after } }; + const page: Array> = await driver.find( + TABLE, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + reads += 1; + visited.push(...page); + if (page.length < pageSize) break; + after = page[page.length - 1]!.id; + } + return { visited, reads }; + }; + + /** The driver's own order, read once without a page boundary to cross. */ + const ordered = async () => { + const rows: Array> = await driver.find( + TABLE, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + return rows.map((r) => String(r.id)); + }; + + for (const pageSize of PAGE_SIZES) { + it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => { + const { visited, reads } = await walk(pageSize); + const ids = visited.map((r) => String(r.id)); + + expect(ids).toEqual(await ordered()); + expect([...ids].sort()).toEqual([...MEMBER_IDS].sort()); + expect(new Set(ids).size).toBe(MEMBER_IDS.length); + for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id))); + expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1); + }); + } + }); +} From dde7b3ad8b0c8ceb2335b218f1bfe1af9e1fb111 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:28:09 +0000 Subject: [PATCH 2/3] test(drivers): pin the member keyset walk by id on memory, sqlite-wasm, turso and mongodb (WIP) --- ...ory-member-keyset-walk-conformance.test.ts | 99 ++++++++++++++++ ...odb-member-keyset-walk-conformance.test.ts | 112 ++++++++++++++++++ ...ver-member-keyset-walk-conformance.test.ts | 2 + ...asm-member-keyset-walk-conformance.test.ts | 109 +++++++++++++++++ ...rso-member-keyset-walk-conformance.test.ts | 109 +++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts create mode 100644 packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts create mode 100644 packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts diff --git a/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts new file mode 100644 index 00000000000..779f10845ca --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A keyset walk by `id` visits every row once, in the driver's own order + * (#29398 item 1) — the query shape of the organization-membership completion + * read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages + * of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`, + * the columns `id` and `user_id`, resuming after each full page's last id. + * + * The resolver cannot check the order itself, because the order is the + * driver's (a SQL database's collation, not JavaScript's code points); the + * walk is correct exactly when the cursor and the order share one comparison. + * This pins that for this driver: the pages, joined, are the sequence of one + * unpaged ordered read, holding every row of the organization once and no row + * of another. The ids are mixed-case nanoid-alphabet values, no two equal + * after case folding. The same pin lives in driver-sql (per dialect cell), + * driver-sqlite-wasm, driver-turso and driver-mongodb; the drivers' shared + * conformance cases live in `@objectstack/spec/data`, which this change does + * not edit. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; + +const TABLE = 'sys_member'; +const ORG = 'org_walked'; +const OTHER_ORG = 'org_other'; + +/** The organization walked: sixteen ids, no two equal after case folding. */ +const MEMBER_IDS = [ + 'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz', + 'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q', +] as const; +/** Another organization's rows, interleaved with the walked ones. */ +const OTHER_IDS = ['Aa', 'b', 'zZ'] as const; + +/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */ +const PAGE_SIZES = [1, 3, 4, 16] as const; + +const userIdOf = (id: string) => `user_${id}`; + +describe("InMemoryDriver — a keyset walk by id visits every member once, in the driver's order (#29398)", () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + const rows = [ + ...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })), + ...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })), + ]; + for (const row of rows) await driver.create(TABLE, row); + }); + + /** The completion read's loop: resume after the last id of each full page. */ + const walk = async (pageSize: number) => { + const visited: Array> = []; + let after: unknown; + let reads = 0; + for (;;) { + const where = after === undefined + ? { organization_id: ORG } + : { organization_id: ORG, id: { $gt: after } }; + const page: Array> = await driver.find( + TABLE, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + ); + reads += 1; + // A driver that ignores the cursor would page forever: fail instead of hanging. + expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1); + visited.push(...page); + if (page.length < pageSize) break; + after = page[page.length - 1]!.id; + } + return { visited, reads }; + }; + + /** The driver's own order, read once without a page boundary to cross. */ + const ordered = async () => { + const rows: Array> = await driver.find( + TABLE, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + ); + return rows.map((r) => String(r.id)); + }; + + for (const pageSize of PAGE_SIZES) { + it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => { + const { visited, reads } = await walk(pageSize); + const ids = visited.map((r) => String(r.id)); + + expect(ids).toEqual(await ordered()); + expect([...ids].sort()).toEqual([...MEMBER_IDS].sort()); + expect(new Set(ids).size).toBe(MEMBER_IDS.length); + for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id))); + expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1); + }); + } +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts new file mode 100644 index 00000000000..12b56cc6eaf --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A keyset walk by `id` visits every row once, in the driver's own order + * (#29398 item 1) — the query shape of the organization-membership completion + * read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages + * of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`, + * the columns `id` and `user_id`, resuming after each full page's last id. + * + * The resolver cannot check the order itself, because the order is the + * driver's (a SQL database's collation, not JavaScript's code points); the + * walk is correct exactly when the cursor and the order share one comparison. + * This pins that for this driver: the pages, joined, are the sequence of one + * unpaged ordered read, holding every row of the organization once and no row + * of another. The ids are mixed-case nanoid-alphabet values, no two equal + * after case folding. The same pin lives in driver-memory, driver-sql (per dialect cell), + * driver-sqlite-wasm and driver-turso; the drivers' shared + * conformance cases live in `@objectstack/spec/data`, which this change does + * not edit. + * + * Like this package's other live suites it needs a real mongod and is opt-in + * (`test-mongod.ts`): without one it is a named skip. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { MongoMemoryServer } from 'mongodb-memory-server'; +import { MongoDBDriver } from './mongodb-driver.js'; +import { createTestMongod } from './test-mongod.js'; + +const TABLE = 'sys_member'; +const ORG = 'org_walked'; +const OTHER_ORG = 'org_other'; + +/** The organization walked: sixteen ids, no two equal after case folding. */ +const MEMBER_IDS = [ + 'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz', + 'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q', +] as const; +/** Another organization's rows, interleaved with the walked ones. */ +const OTHER_IDS = ['Aa', 'b', 'zZ'] as const; + +/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */ +const PAGE_SIZES = [1, 3, 4, 16] as const; + +const userIdOf = (id: string) => `user_${id}`; + +const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('member keyset walk conformance'); + +describe.skipIf(!sharedMongod)("driver-mongodb — a keyset walk by id visits every member once, in the driver's order (#29398)", () => { + const mongod = sharedMongod as MongoMemoryServer; + let driver: MongoDBDriver; + + beforeAll(async () => { + driver = new MongoDBDriver({ url: mongod.getUri(), database: 'member_keyset_walk_conformance' }); + await driver.connect(); + const rows = [ + ...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })), + ...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })), + ]; + for (const row of rows) await driver.create(TABLE, row); + }, 90_000); + + afterAll(async () => { + if (driver) await driver.disconnect(); + if (sharedMongod) await sharedMongod.stop(); + }); + + /** The completion read's loop: resume after the last id of each full page. */ + const walk = async (pageSize: number) => { + const visited: Array> = []; + let after: unknown; + let reads = 0; + for (;;) { + const where = after === undefined + ? { organization_id: ORG } + : { organization_id: ORG, id: { $gt: after } }; + const page: Array> = await driver.find( + TABLE, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + ); + reads += 1; + // A driver that ignores the cursor would page forever: fail instead of hanging. + expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1); + visited.push(...page); + if (page.length < pageSize) break; + after = page[page.length - 1]!.id; + } + return { visited, reads }; + }; + + /** The driver's own order, read once without a page boundary to cross. */ + const ordered = async () => { + const rows: Array> = await driver.find( + TABLE, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + ); + return rows.map((r) => String(r.id)); + }; + + for (const pageSize of PAGE_SIZES) { + it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => { + const { visited, reads } = await walk(pageSize); + const ids = visited.map((r) => String(r.id)); + + expect(ids).toEqual(await ordered()); + expect([...ids].sort()).toEqual([...MEMBER_IDS].sort()); + expect(new Set(ids).size).toBe(MEMBER_IDS.length); + for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id))); + expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1); + }); + } +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts index 725f5cb5868..8528c39a2d1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts @@ -100,6 +100,8 @@ function declareWalk(cell: DialectCell): void { READ_OPTIONS, ); reads += 1; + // A driver that ignores the cursor would page forever: fail instead of hanging. + expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1); visited.push(...page); if (page.length < pageSize) break; after = page[page.length - 1]!.id; diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts new file mode 100644 index 00000000000..1550d7068dc --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A keyset walk by `id` visits every row once, in the driver's own order + * (#29398 item 1) — the query shape of the organization-membership completion + * read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages + * of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`, + * the columns `id` and `user_id`, resuming after each full page's last id. + * + * The resolver cannot check the order itself, because the order is the + * driver's (a SQL database's collation, not JavaScript's code points); the + * walk is correct exactly when the cursor and the order share one comparison. + * This pins that for this driver: the pages, joined, are the sequence of one + * unpaged ordered read, holding every row of the organization once and no row + * of another. The ids are mixed-case nanoid-alphabet values, no two equal + * after case folding. The same pin lives in driver-memory, driver-sql (per dialect cell), + * driver-turso and driver-mongodb; the drivers' shared + * conformance cases live in `@objectstack/spec/data`, which this change does + * not edit. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqliteWasmDriver } from './index.js'; + +const TABLE = 'sys_member'; +const ORG = 'org_walked'; +const OTHER_ORG = 'org_other'; + +/** The organization walked: sixteen ids, no two equal after case folding. */ +const MEMBER_IDS = [ + 'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz', + 'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q', +] as const; +/** Another organization's rows, interleaved with the walked ones. */ +const OTHER_IDS = ['Aa', 'b', 'zZ'] as const; + +/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */ +const PAGE_SIZES = [1, 3, 4, 16] as const; + +const READ_OPTIONS = { bypassTenantAudit: true } as const; + +const userIdOf = (id: string) => `user_${id}`; + +describe("SqliteWasmDriver — a keyset walk by id visits every member once, in the driver's order (#29398)", () => { + let driver: SqliteWasmDriver; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { name: TABLE, fields: { organization_id: { type: 'string' }, user_id: { type: 'string' } } } as any, + ]); + const rows = [ + ...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })), + ...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })), + ]; + for (const row of rows) await driver.create(TABLE, row, READ_OPTIONS); + }); + + afterAll(async () => { + await driver?.disconnect(); + }); + + /** The completion read's loop: resume after the last id of each full page. */ + const walk = async (pageSize: number) => { + const visited: Array> = []; + let after: unknown; + let reads = 0; + for (;;) { + const where = after === undefined + ? { organization_id: ORG } + : { organization_id: ORG, id: { $gt: after } }; + const page: Array> = await driver.find( + TABLE, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + reads += 1; + // A driver that ignores the cursor would page forever: fail instead of hanging. + expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1); + visited.push(...page); + if (page.length < pageSize) break; + after = page[page.length - 1]!.id; + } + return { visited, reads }; + }; + + /** The driver's own order, read once without a page boundary to cross. */ + const ordered = async () => { + const rows: Array> = await driver.find( + TABLE, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + return rows.map((r) => String(r.id)); + }; + + for (const pageSize of PAGE_SIZES) { + it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => { + const { visited, reads } = await walk(pageSize); + const ids = visited.map((r) => String(r.id)); + + expect(ids).toEqual(await ordered()); + expect([...ids].sort()).toEqual([...MEMBER_IDS].sort()); + expect(new Set(ids).size).toBe(MEMBER_IDS.length); + for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id))); + expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1); + }); + } +}); diff --git a/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts new file mode 100644 index 00000000000..2a3d06f6949 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A keyset walk by `id` visits every row once, in the driver's own order + * (#29398 item 1) — the query shape of the organization-membership completion + * read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages + * of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`, + * the columns `id` and `user_id`, resuming after each full page's last id. + * + * The resolver cannot check the order itself, because the order is the + * driver's (a SQL database's collation, not JavaScript's code points); the + * walk is correct exactly when the cursor and the order share one comparison. + * This pins that for this driver: the pages, joined, are the sequence of one + * unpaged ordered read, holding every row of the organization once and no row + * of another. The ids are mixed-case nanoid-alphabet values, no two equal + * after case folding. The same pin lives in driver-memory, driver-sql (per dialect cell), + * driver-sqlite-wasm and driver-mongodb; the drivers' shared + * conformance cases live in `@objectstack/spec/data`, which this change does + * not edit. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { TursoDriver } from './turso-driver.js'; + +const TABLE = 'sys_member'; +const ORG = 'org_walked'; +const OTHER_ORG = 'org_other'; + +/** The organization walked: sixteen ids, no two equal after case folding. */ +const MEMBER_IDS = [ + 'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz', + 'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q', +] as const; +/** Another organization's rows, interleaved with the walked ones. */ +const OTHER_IDS = ['Aa', 'b', 'zZ'] as const; + +/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */ +const PAGE_SIZES = [1, 3, 4, 16] as const; + +const READ_OPTIONS = { bypassTenantAudit: true } as const; + +const userIdOf = (id: string) => `user_${id}`; + +describe("TursoDriver (local mode) — a keyset walk by id visits every member once, in the driver's order (#29398)", () => { + let driver: TursoDriver; + + beforeAll(async () => { + driver = new TursoDriver({ url: ':memory:' }); + await driver.initObjects([ + { name: TABLE, fields: { organization_id: { type: 'string' }, user_id: { type: 'string' } } } as any, + ]); + const rows = [ + ...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })), + ...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })), + ]; + for (const row of rows) await driver.create(TABLE, row, READ_OPTIONS); + }); + + afterAll(async () => { + await driver?.disconnect(); + }); + + /** The completion read's loop: resume after the last id of each full page. */ + const walk = async (pageSize: number) => { + const visited: Array> = []; + let after: unknown; + let reads = 0; + for (;;) { + const where = after === undefined + ? { organization_id: ORG } + : { organization_id: ORG, id: { $gt: after } }; + const page: Array> = await driver.find( + TABLE, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + reads += 1; + // A driver that ignores the cursor would page forever: fail instead of hanging. + expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1); + visited.push(...page); + if (page.length < pageSize) break; + after = page[page.length - 1]!.id; + } + return { visited, reads }; + }; + + /** The driver's own order, read once without a page boundary to cross. */ + const ordered = async () => { + const rows: Array> = await driver.find( + TABLE, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + READ_OPTIONS, + ); + return rows.map((r) => String(r.id)); + }; + + for (const pageSize of PAGE_SIZES) { + it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => { + const { visited, reads } = await walk(pageSize); + const ids = visited.map((r) => String(r.id)); + + expect(ids).toEqual(await ordered()); + expect([...ids].sort()).toEqual([...MEMBER_IDS].sort()); + expect(new Set(ids).size).toBe(MEMBER_IDS.length); + for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id))); + expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1); + }); + } +}); From 3784be1f1b392c30351e03e2733787d89ead79a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:59:02 +0000 Subject: [PATCH 3/3] test(drivers): type the member keyset walk's find options instead of casting them --- .../src/memory-member-keyset-walk-conformance.test.ts | 4 ++-- .../src/mongodb-member-keyset-walk-conformance.test.ts | 4 ++-- .../src/sql-driver-member-keyset-walk-conformance.test.ts | 4 ++-- .../src/sqlite-wasm-member-keyset-walk-conformance.test.ts | 4 ++-- .../src/turso-member-keyset-walk-conformance.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts index 779f10845ca..a8ea1a57a5a 100644 --- a/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-member-keyset-walk-conformance.test.ts @@ -63,7 +63,7 @@ describe("InMemoryDriver — a keyset walk by id visits every member once, in th : { organization_id: ORG, id: { $gt: after } }; const page: Array> = await driver.find( TABLE, - { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] }, ); reads += 1; // A driver that ignores the cursor would page forever: fail instead of hanging. @@ -79,7 +79,7 @@ describe("InMemoryDriver — a keyset walk by id visits every member once, in th const ordered = async () => { const rows: Array> = await driver.find( TABLE, - { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] }, ); return rows.map((r) => String(r.id)); }; diff --git a/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts index 12b56cc6eaf..bd7500f7114 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-member-keyset-walk-conformance.test.ts @@ -76,7 +76,7 @@ describe.skipIf(!sharedMongod)("driver-mongodb — a keyset walk by id visits ev : { organization_id: ORG, id: { $gt: after } }; const page: Array> = await driver.find( TABLE, - { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] }, ); reads += 1; // A driver that ignores the cursor would page forever: fail instead of hanging. @@ -92,7 +92,7 @@ describe.skipIf(!sharedMongod)("driver-mongodb — a keyset walk by id visits ev const ordered = async () => { const rows: Array> = await driver.find( TABLE, - { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] }, ); return rows.map((r) => String(r.id)); }; diff --git a/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts index 8528c39a2d1..15c48118b4f 100644 --- a/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts @@ -96,7 +96,7 @@ function declareWalk(cell: DialectCell): void { : { organization_id: ORG, id: { $gt: after } }; const page: Array> = await driver.find( TABLE, - { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] }, READ_OPTIONS, ); reads += 1; @@ -113,7 +113,7 @@ function declareWalk(cell: DialectCell): void { const ordered = async () => { const rows: Array> = await driver.find( TABLE, - { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] }, READ_OPTIONS, ); return rows.map((r) => String(r.id)); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts index 1550d7068dc..0b183a81582 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-member-keyset-walk-conformance.test.ts @@ -71,7 +71,7 @@ describe("SqliteWasmDriver — a keyset walk by id visits every member once, in : { organization_id: ORG, id: { $gt: after } }; const page: Array> = await driver.find( TABLE, - { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] }, READ_OPTIONS, ); reads += 1; @@ -88,7 +88,7 @@ describe("SqliteWasmDriver — a keyset walk by id visits every member once, in const ordered = async () => { const rows: Array> = await driver.find( TABLE, - { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] }, READ_OPTIONS, ); return rows.map((r) => String(r.id)); diff --git a/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts b/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts index 2a3d06f6949..68ed2f97519 100644 --- a/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-member-keyset-walk-conformance.test.ts @@ -71,7 +71,7 @@ describe("TursoDriver (local mode) — a keyset walk by id visits every member o : { organization_id: ORG, id: { $gt: after } }; const page: Array> = await driver.find( TABLE, - { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] } as any, + { where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] }, READ_OPTIONS, ); reads += 1; @@ -88,7 +88,7 @@ describe("TursoDriver (local mode) — a keyset walk by id visits every member o const ordered = async () => { const rows: Array> = await driver.find( TABLE, - { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] } as any, + { where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] }, READ_OPTIONS, ); return rows.map((r) => String(r.id));