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..a8ea1a57a5a --- /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'] }, + ); + 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'] }, + ); + 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..bd7500f7114 --- /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'] }, + ); + 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'] }, + ); + 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 new file mode 100644 index 00000000000..15c48118b4f --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts @@ -0,0 +1,135 @@ +// 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'] }, + 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'] }, + 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-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..0b183a81582 --- /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'] }, + 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'] }, + 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..68ed2f97519 --- /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'] }, + 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'] }, + 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); + }); + } +});