Skip to content

Commit a2f77f7

Browse files
test(drivers): a keyset walk by id visits every organization member once, in the driver's order (#29659)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8RurSHJiXrGwyzL5u3wQjiC)_ Fixes #29398 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:只加测试。五个 driver 包各加一份一致性钉子:按组织成员补全读的同一查询形状(`organization_id` + `id $gt` 游标、`orderBy id`、`limit`)做 keyset 分页,断言跨页拼起来恰好等于一次不分页的有序读,每个成员恰一次、不混入别的组织。 - 为什么改:卡的第 1 项原本要在解析器里加 JavaScript 的「id 严格递增」校验,已实测证伪(数据库按 collation 排序,ICU en-US 下大小写混排的 id 顺序与码点不同,会让每一页都 503)。席位改判为:解析器不加校验,改由 driver 一致性测试钉住「游标与排序用同一比较」。 - 风险与回滚:不改任何运行时代码,不发布(测试文件不进 `dist`),`skip-changeset`;回滚即 revert。 - 先看:`packages/drivers/driver-sql/src/sql-driver-member-keyset-walk-conformance.test.ts`(按方言单元跑,含 live Postgres/MySQL),再看 `driver-memory` 的同名钉子。 - 两分钟验证:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-member-keyset-walk-conformance.test.ts`,预期 sqlite 单元 4 个通过、未配置的 live 单元为具名跳过。 - 需要你做的:无,席位落地。 ## What changes Test files only, one per driver package. Each walks a `sys_member`-shaped table with the exact query shape of `readOrganizationMembersToCompletion` in `@objectstack/core`: `where { organization_id }` then `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`, `fields: ['id', 'user_id']`, resuming after each full page's last id. For page sizes 1, 3, 4 (an exact multiple of the 16 rows) and 16, it asserts: - the pages joined are the same sequence as one unpaged `orderBy id` read, so the order asserted is the driver's own, never JavaScript's; - every member id appears once, and no row of the other organization appears; - each row carries its `user_id` through the projection; - the number of reads is the keyset's (`floor(16 / pageSize) + 1`), and a walk that does not end fails instead of hanging. The fixture ids are mixed-case nanoid-alphabet values (`AC Ab9 B-2 a-c aB3 ab b_1 0Zz Z0 z_9 _x -y Mm mN1 n Q-q`), no two equal after case folding, plus three ids of another organization. | Package | File | Runs | |:--|:--|:--| | `driver-sql` | `sql-driver-member-keyset-walk-conformance.test.ts` | every `DIALECT_CELLS` cell: sqlite always; live Postgres and MySQL when `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set (CI: `Temporal Conformance (live PG + MySQL)`, which runs the whole package) | | `driver-memory` | `memory-member-keyset-walk-conformance.test.ts` | always | | `driver-sqlite-wasm` | `sqlite-wasm-member-keyset-walk-conformance.test.ts` | always | | `driver-turso` | `turso-member-keyset-walk-conformance.test.ts` | always (local mode) | | `driver-mongodb` | `mongodb-member-keyset-walk-conformance.test.ts` | opt-in, like the package's other live suites (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`); a named skip otherwise | No changeset: no published file changes. Every driver package publishes only `dist`, `README.md` and `CHANGELOG.md`; after a build, `member-keyset` and `org_walked` have zero hits under the drivers' `dist`, and the control (`ORDER` in `driver-sql/dist/index.js`) hits. The PR carries `skip-changeset`. ## Why Item 2 of the card landed as #29545. Item 1 asked for a per-page "each id strictly greater than the last" check in the resolver. That check compares in JavaScript code-point order, but `driver-sql` orders and pages by the database collation, so on an ICU `en-US` PostgreSQL database it would refuse every correctly ordered page (measured in the report on the card). The seat ruled: no order check in `resolve-authz-context.ts`; instead, a driver conformance pin that the keyset walk returns every row once, in the driver's order, across page boundaries. That is this PR. `resolve-authz-context.ts` and every driver are untouched. ## Tests <details><summary>Per driver (head 1647e32)</summary> - `driver-sql`, sqlite cell: Tests 4 passed, 2 skipped (the unprovisioned live Postgres and MySQL cells, named). - `driver-sql` against a local PostgreSQL 16, two databases: `C.UTF-8` and ICU `en-US` (`CREATE DATABASE ... LOCALE_PROVIDER icu ICU_LOCALE 'en-US'`). Both: Tests 8 passed, 1 skipped (MySQL). The two databases order the fixture differently, so both a code-point order and a non-code-point order were walked: - `C.UTF-8` (same as JavaScript): `-y 0Zz AC Ab9 B-2 Mm Q-q Z0 _x a-c aB3 ab b_1 mN1 n z_9` - ICU `en-US`: `_x -y 0Zz a-c ab aB3 Ab9 AC b_1 B-2 Mm mN1 n Q-q z_9 Z0` - `driver-memory`: Tests 4 passed. `driver-sqlite-wasm`: Tests 4 passed. `driver-turso`: Tests 4 passed. - `driver-mongodb`: Tests 4 skipped. NOT MEASURED: no mongod here; the binary download host is refused by this container's proxy (403). No CI job sets `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED`, so CI does not run it either, as for the package's existing pagination suite. - MySQL: NOT MEASURED locally (no server here); CI's live job runs it after merge. - `pnpm typecheck` for the five driver packages: exit 0 (head 3784be1; the later commit only merges `main`). The four `src` test files are in their package's `tsc` file list; the mongodb one is compiled by its `check:test-typecheck` step (OK). </details> <details><summary>The pin can fail (one-time mutation, no file kept)</summary> A scratch copy of the `driver-sql` pin wrapped `find` so that every paged read returned its rows reversed (a driver whose pages are not in its own order). Run on sqlite and on the ICU `en-US` database, then deleted (`git status` clean afterwards): - sqlite: Tests 3 failed, 1 passed. Pages of 3, 4 and 16 are red (`expected [ 'AC', '0Zz', '-y', 'Ab9', …(40) ] to deeply equal [ '-y', '0Zz', 'AC', 'Ab9', …(12) ]`); pages of 1 stay green, since a one-row page has no order to lose. - ICU `en-US` Postgres: Tests 6 failed, 2 passed, the same split in both cells. A second scratch copy dropped `orderBy` from every paged read instead. It stayed green on both, because `driver-sql` adds an `id` order to a limited read that names none. That is a reading about the driver, not a gap in the pin. </details> ## Verification <details><summary>Gates (head 1647e32)</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 49 commands. All 49 were run locally with exit codes captured before any pipe, and all exited 0. `--ran` reconciled them: 49 derived, 49 run, 0 NOT-MEASURED, 0 UNRUN. - The five artifact-roster families whose roster sits under a changed directory, run as well: `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:object-def-param-keys`, `check:tenant-chokepoint`. All exited 0. - `pnpm lint` exited 0. - A first pass on an earlier head turned up one real red: `check:query-options-erasure` counted the test surface growing 236 to 246, because each pin cast its `find` options `as any`. The casts were removed (the options type-check as written), and the gate now reads 236, at the ceiling. - `check-plugin-teardown-shape --self-test` needed `git fetch --unshallow` (its fixture is pinned to an old commit). - Not run here: `Temporal Conformance (live PG + MySQL)` skips pull requests and the merge queue, so the live Postgres and MySQL cells of the `driver-sql` pin first run in `main`'s push battery after merge. The local Postgres readings above stand in for the Postgres half. </details> ## Acceptance notes - The drivers' shared conformance cases live in `@objectstack/spec/data` (`PAGINATION_CASES` and its siblings). This change does not edit `packages/spec`, so each driver package carries its own copy of the fixture and the walk. Hoisting them into `@objectstack/spec/data` beside the pagination cases is a later choice, not a gap. - The pin follows the resolver's query shape by copy. If `readOrganizationMembersToCompletion` changes its shape, these files do not change with it. - MySQL's `utf8mb4_0900_ai_ci` is case-insensitive. Two ids equal after case folding would collide on a case-insensitive primary key at insert, so the fixture holds none. That is a different fact from the one pinned here, and it was not measured. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (Nothing publishes: `skip-changeset`.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Card thread session: `session_01JQwxbZ5Pwrph2FpoA9Ucqq`, seat `domain:server#4`, claim 6590972532. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZ5Pwrph2FpoA9Ucqq --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZ5Pwrph2FpoA9Ucqq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6a369e commit a2f77f7

5 files changed

Lines changed: 564 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A keyset walk by `id` visits every row once, in the driver's own order
5+
* (#29398 item 1) — the query shape of the organization-membership completion
6+
* read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages
7+
* of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`,
8+
* the columns `id` and `user_id`, resuming after each full page's last id.
9+
*
10+
* The resolver cannot check the order itself, because the order is the
11+
* driver's (a SQL database's collation, not JavaScript's code points); the
12+
* walk is correct exactly when the cursor and the order share one comparison.
13+
* This pins that for this driver: the pages, joined, are the sequence of one
14+
* unpaged ordered read, holding every row of the organization once and no row
15+
* of another. The ids are mixed-case nanoid-alphabet values, no two equal
16+
* after case folding. The same pin lives in driver-sql (per dialect cell),
17+
* driver-sqlite-wasm, driver-turso and driver-mongodb; the drivers' shared
18+
* conformance cases live in `@objectstack/spec/data`, which this change does
19+
* not edit.
20+
*/
21+
22+
import { describe, it, expect, beforeAll } from 'vitest';
23+
import { InMemoryDriver } from './memory-driver.js';
24+
25+
const TABLE = 'sys_member';
26+
const ORG = 'org_walked';
27+
const OTHER_ORG = 'org_other';
28+
29+
/** The organization walked: sixteen ids, no two equal after case folding. */
30+
const MEMBER_IDS = [
31+
'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz',
32+
'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q',
33+
] as const;
34+
/** Another organization's rows, interleaved with the walked ones. */
35+
const OTHER_IDS = ['Aa', 'b', 'zZ'] as const;
36+
37+
/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */
38+
const PAGE_SIZES = [1, 3, 4, 16] as const;
39+
40+
const userIdOf = (id: string) => `user_${id}`;
41+
42+
describe("InMemoryDriver — a keyset walk by id visits every member once, in the driver's order (#29398)", () => {
43+
let driver: InMemoryDriver;
44+
45+
beforeAll(async () => {
46+
driver = new InMemoryDriver({ persistence: false });
47+
await driver.connect();
48+
const rows = [
49+
...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })),
50+
...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })),
51+
];
52+
for (const row of rows) await driver.create(TABLE, row);
53+
});
54+
55+
/** The completion read's loop: resume after the last id of each full page. */
56+
const walk = async (pageSize: number) => {
57+
const visited: Array<Record<string, unknown>> = [];
58+
let after: unknown;
59+
let reads = 0;
60+
for (;;) {
61+
const where = after === undefined
62+
? { organization_id: ORG }
63+
: { organization_id: ORG, id: { $gt: after } };
64+
const page: Array<Record<string, unknown>> = await driver.find(
65+
TABLE,
66+
{ where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] },
67+
);
68+
reads += 1;
69+
// A driver that ignores the cursor would page forever: fail instead of hanging.
70+
expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1);
71+
visited.push(...page);
72+
if (page.length < pageSize) break;
73+
after = page[page.length - 1]!.id;
74+
}
75+
return { visited, reads };
76+
};
77+
78+
/** The driver's own order, read once without a page boundary to cross. */
79+
const ordered = async () => {
80+
const rows: Array<Record<string, unknown>> = await driver.find(
81+
TABLE,
82+
{ where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] },
83+
);
84+
return rows.map((r) => String(r.id));
85+
};
86+
87+
for (const pageSize of PAGE_SIZES) {
88+
it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => {
89+
const { visited, reads } = await walk(pageSize);
90+
const ids = visited.map((r) => String(r.id));
91+
92+
expect(ids).toEqual(await ordered());
93+
expect([...ids].sort()).toEqual([...MEMBER_IDS].sort());
94+
expect(new Set(ids).size).toBe(MEMBER_IDS.length);
95+
for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id)));
96+
expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1);
97+
});
98+
}
99+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A keyset walk by `id` visits every row once, in the driver's own order
5+
* (#29398 item 1) — the query shape of the organization-membership completion
6+
* read in `@objectstack/core` (`readOrganizationMembersToCompletion`): pages
7+
* of `{ organization_id, id: { $gt: after } }`, `orderBy id asc`, a `limit`,
8+
* the columns `id` and `user_id`, resuming after each full page's last id.
9+
*
10+
* The resolver cannot check the order itself, because the order is the
11+
* driver's (a SQL database's collation, not JavaScript's code points); the
12+
* walk is correct exactly when the cursor and the order share one comparison.
13+
* This pins that for this driver: the pages, joined, are the sequence of one
14+
* unpaged ordered read, holding every row of the organization once and no row
15+
* of another. The ids are mixed-case nanoid-alphabet values, no two equal
16+
* after case folding. The same pin lives in driver-memory, driver-sql (per dialect cell),
17+
* driver-sqlite-wasm and driver-turso; the drivers' shared
18+
* conformance cases live in `@objectstack/spec/data`, which this change does
19+
* not edit.
20+
*
21+
* Like this package's other live suites it needs a real mongod and is opt-in
22+
* (`test-mongod.ts`): without one it is a named skip.
23+
*/
24+
25+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
26+
import type { MongoMemoryServer } from 'mongodb-memory-server';
27+
import { MongoDBDriver } from './mongodb-driver.js';
28+
import { createTestMongod } from './test-mongod.js';
29+
30+
const TABLE = 'sys_member';
31+
const ORG = 'org_walked';
32+
const OTHER_ORG = 'org_other';
33+
34+
/** The organization walked: sixteen ids, no two equal after case folding. */
35+
const MEMBER_IDS = [
36+
'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz',
37+
'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q',
38+
] as const;
39+
/** Another organization's rows, interleaved with the walked ones. */
40+
const OTHER_IDS = ['Aa', 'b', 'zZ'] as const;
41+
42+
/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */
43+
const PAGE_SIZES = [1, 3, 4, 16] as const;
44+
45+
const userIdOf = (id: string) => `user_${id}`;
46+
47+
const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('member keyset walk conformance');
48+
49+
describe.skipIf(!sharedMongod)("driver-mongodb — a keyset walk by id visits every member once, in the driver's order (#29398)", () => {
50+
const mongod = sharedMongod as MongoMemoryServer;
51+
let driver: MongoDBDriver;
52+
53+
beforeAll(async () => {
54+
driver = new MongoDBDriver({ url: mongod.getUri(), database: 'member_keyset_walk_conformance' });
55+
await driver.connect();
56+
const rows = [
57+
...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })),
58+
...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })),
59+
];
60+
for (const row of rows) await driver.create(TABLE, row);
61+
}, 90_000);
62+
63+
afterAll(async () => {
64+
if (driver) await driver.disconnect();
65+
if (sharedMongod) await sharedMongod.stop();
66+
});
67+
68+
/** The completion read's loop: resume after the last id of each full page. */
69+
const walk = async (pageSize: number) => {
70+
const visited: Array<Record<string, unknown>> = [];
71+
let after: unknown;
72+
let reads = 0;
73+
for (;;) {
74+
const where = after === undefined
75+
? { organization_id: ORG }
76+
: { organization_id: ORG, id: { $gt: after } };
77+
const page: Array<Record<string, unknown>> = await driver.find(
78+
TABLE,
79+
{ where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] },
80+
);
81+
reads += 1;
82+
// A driver that ignores the cursor would page forever: fail instead of hanging.
83+
expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1);
84+
visited.push(...page);
85+
if (page.length < pageSize) break;
86+
after = page[page.length - 1]!.id;
87+
}
88+
return { visited, reads };
89+
};
90+
91+
/** The driver's own order, read once without a page boundary to cross. */
92+
const ordered = async () => {
93+
const rows: Array<Record<string, unknown>> = await driver.find(
94+
TABLE,
95+
{ where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] },
96+
);
97+
return rows.map((r) => String(r.id));
98+
};
99+
100+
for (const pageSize of PAGE_SIZES) {
101+
it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => {
102+
const { visited, reads } = await walk(pageSize);
103+
const ids = visited.map((r) => String(r.id));
104+
105+
expect(ids).toEqual(await ordered());
106+
expect([...ids].sort()).toEqual([...MEMBER_IDS].sort());
107+
expect(new Set(ids).size).toBe(MEMBER_IDS.length);
108+
for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id)));
109+
expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1);
110+
});
111+
}
112+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A keyset walk by `id` visits every row once, in the driver's own order
5+
* (#29398 item 1) — the query shape of the organization-membership completion
6+
* read in `@objectstack/core` (`readOrganizationMembersToCompletion`).
7+
*
8+
* That read pages `sys_member` with `{ organization_id, id: { $gt: after } }`,
9+
* `orderBy id asc`, a `limit`, and the columns `id` and `user_id`, resuming
10+
* after the last id of each full page. Its correctness rests on the driver,
11+
* and on nothing the resolver can check for itself: the order is the
12+
* database's collation, not JavaScript's code-point order (PostgreSQL with an
13+
* ICU `en-US` database sorts `a-c ab aB3 Ab9 AC b_1 B-2`, a `C.UTF-8` one
14+
* sorts `AC Ab9 B-2 a-c aB3 ab b_1`), so a resolver-side "each id is greater
15+
* than the last" check would refuse every correct page on such a database.
16+
* The keyset is collation-safe because `ORDER BY id` and `id > ?` use the
17+
* same comparison. This file pins exactly that, per dialect cell: the pages,
18+
* joined, are the same sequence as one unpaged ordered read, and that sequence
19+
* holds every row of the organization once and no row of another.
20+
*
21+
* The ids are mixed-case nanoid-alphabet values (letters of both cases,
22+
* digits, `-`, `_`), so a cell whose collation folds case or weighs
23+
* punctuation differently still walks a real non-code-point order. No two
24+
* ids are equal after case folding: a case-insensitive primary key (MySQL's
25+
* `utf8mb4_0900_ai_ci`) would refuse such a pair at insert, which is a
26+
* different fact from the one pinned here.
27+
*
28+
* Each driver package carries its own copy of this pin (driver-memory,
29+
* driver-sqlite-wasm, driver-turso, driver-mongodb): the drivers' shared
30+
* conformance cases live in `@objectstack/spec/data`, which this change does
31+
* not edit.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { SqlDriver } from './index.js';
36+
import { DIALECT_CELLS, declareUnprovisionedCell, type DialectCell } from './live-dialect-matrix.testkit.js';
37+
38+
/** Issue-prefixed: the live cells share a database with every other suite in this package. */
39+
const TABLE = 'os29398_member_keyset';
40+
const ORG = 'org_walked';
41+
const OTHER_ORG = 'org_other';
42+
43+
/** The organization walked: sixteen ids, no two equal after case folding. */
44+
const MEMBER_IDS = [
45+
'AC', 'Ab9', 'B-2', 'a-c', 'aB3', 'ab', 'b_1', '0Zz',
46+
'Z0', 'z_9', '_x', '-y', 'Mm', 'mN1', 'n', 'Q-q',
47+
] as const;
48+
/** Another organization's rows, interleaved with the walked ones in any collation. */
49+
const OTHER_IDS = ['Aa', 'b', 'zZ'] as const;
50+
51+
/** Page sizes: one row, a mid-run boundary, an exact multiple (16 = 4 x 4), the whole set. */
52+
const PAGE_SIZES = [1, 3, 4, 16] as const;
53+
54+
const READ_OPTIONS = { bypassTenantAudit: true } as const;
55+
56+
const userIdOf = (id: string) => `user_${id}`;
57+
58+
for (const cell of DIALECT_CELLS) {
59+
if (!cell.available) {
60+
declareUnprovisionedCell(cell, 'member keyset walk conformance');
61+
continue;
62+
}
63+
declareWalk(cell);
64+
}
65+
66+
function declareWalk(cell: DialectCell): void {
67+
describe(`SqlDriver — a keyset walk by id visits every member once, in the driver's order (#29398) (${cell.label})`, () => {
68+
let driver: SqlDriver;
69+
70+
beforeAll(async () => {
71+
driver = new SqlDriver(cell.config());
72+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
73+
await driver.initObjects([
74+
{ name: TABLE, fields: { organization_id: { type: 'string' }, user_id: { type: 'string' } } } as any,
75+
]);
76+
const rows = [
77+
...MEMBER_IDS.map((id) => ({ id, organization_id: ORG, user_id: userIdOf(id) })),
78+
...OTHER_IDS.map((id) => ({ id, organization_id: OTHER_ORG, user_id: userIdOf(id) })),
79+
];
80+
for (const row of rows) await driver.create(TABLE, row, READ_OPTIONS);
81+
});
82+
83+
afterAll(async () => {
84+
await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {});
85+
await driver?.disconnect?.();
86+
});
87+
88+
/** The completion read's loop: resume after the last id of each full page. */
89+
const walk = async (pageSize: number) => {
90+
const visited: Array<Record<string, unknown>> = [];
91+
let after: unknown;
92+
let reads = 0;
93+
for (;;) {
94+
const where = after === undefined
95+
? { organization_id: ORG }
96+
: { organization_id: ORG, id: { $gt: after } };
97+
const page: Array<Record<string, unknown>> = await driver.find(
98+
TABLE,
99+
{ where, orderBy: [{ field: 'id', order: 'asc' }], limit: pageSize, fields: ['id', 'user_id'] },
100+
READ_OPTIONS,
101+
);
102+
reads += 1;
103+
// A driver that ignores the cursor would page forever: fail instead of hanging.
104+
expect(reads, 'the walk did not end; the cursor is not narrowing the read').toBeLessThanOrEqual(MEMBER_IDS.length + 1);
105+
visited.push(...page);
106+
if (page.length < pageSize) break;
107+
after = page[page.length - 1]!.id;
108+
}
109+
return { visited, reads };
110+
};
111+
112+
/** The driver's own order, read once without a page boundary to cross. */
113+
const ordered = async () => {
114+
const rows: Array<Record<string, unknown>> = await driver.find(
115+
TABLE,
116+
{ where: { organization_id: ORG }, orderBy: [{ field: 'id', order: 'asc' }], fields: ['id', 'user_id'] },
117+
READ_OPTIONS,
118+
);
119+
return rows.map((r) => String(r.id));
120+
};
121+
122+
for (const pageSize of PAGE_SIZES) {
123+
it(`pages of ${pageSize}: every member once, no other organization's row, in one ordered read's sequence`, async () => {
124+
const { visited, reads } = await walk(pageSize);
125+
const ids = visited.map((r) => String(r.id));
126+
127+
expect(ids).toEqual(await ordered());
128+
expect([...ids].sort()).toEqual([...MEMBER_IDS].sort());
129+
expect(new Set(ids).size).toBe(MEMBER_IDS.length);
130+
for (const row of visited) expect(row.user_id).toBe(userIdOf(String(row.id)));
131+
expect(reads).toBe(Math.floor(MEMBER_IDS.length / pageSize) + 1);
132+
});
133+
}
134+
});
135+
}

0 commit comments

Comments
 (0)