Skip to content

Commit cd6d8a5

Browse files
fix(objectql,platform-objects,metadata-protocol): read sys_migration flag rows through findOne, ending the paging warning on every upgraded boot (#20648) (#20766)
Fixes #20648 Clause-②: no (narrowing) The platform reads its deployment ledger (`sys_migration`) one row at a time, by primary key. Five readers wrote that read as `find(sys_migration, { where: { id }, limit: 1 })`. The SQL driver cannot tell that apart from page one of a walk. On a table it has not registered, it warns that the walk is not deterministic. All five readers now use `findOne`, the single-row route the driver already exempts. The driver's check and its wording are unchanged. An unsorted `limit` read on a table the driver did not create still warns, and a test pins that control. ## Reproduction on the base, and where the warning came from **Construction: the 17.4 code path.** A one-object probe app, installed from npm with every `@objectstack/*` package pinned to 17.4.0 (`npm ls` shows 261 entries, all 17.4.0). `objectstack dev` on 17.4.0 created a new SQLite file and booted it three times. Each run below then used a fresh copy of that file, booted by this branch's base `01e78dceef` (the same app, wrapped in `defineStack`, which main requires). | run | code | `Paged read of 'sys_migration'` warnings | |:--|:--|:--| | boot 1, 2, 3 on the 17.4 file (control) | npm 17.4.0 | 0, 0, 0 | | `objectstack dev` on the 17.4 file | base `01e78dceef` | **1** | | `os migrate plan` on the 17.4 file | base `01e78dceef` | **1** (the plan also lists `+ sys_migration [add_columns: columns_moved_at]`, as the card says) | | `objectstack dev` on a new file (`examples/app-crm`), first boot | base `01e78dceef` | **1** | | second boot of that file | base `01e78dceef` | **1** | So the warning is not specific to upgraded databases. Every boot of main prints it once, because the read runs before the schema pass registers `sys_migration` with the driver. **The producer was not either of the two sites the card names.** I added a temporary stack-trace line to driver-sql's built `dist/`, behind an env var, logging every unsorted paged read of a table the driver has not registered. On boot and on `os migrate plan`, the only such read of `sys_migration` was `ObjectQL.readMigrationFlagVerified` in `packages/objectql/src/engine.ts`, reached through `haveFileColumnsMoved()`. The dist was restored afterwards, byte-identical to the build: `cmp` against the saved copy matches. 17.4.0 ships the same driver check but has no `haveFileColumnsMoved`, which is why 17.4.0 prints nothing. By the time the two named sites run in these compositions, the table is registered, so they order by `id` and never warn. They still have the defect's shape, so they move too. Landing site, per the dispatch's producer clause: the engine as well as the two named files. ## What changed - **`packages/objectql/src/engine.ts`**: the three `sys_migration` reads (`readMigrationFlagVerified`, `recordObservedDeviation`, `retractCreationAttestation`) use `this.findOne(...)`. - **`packages/platform-objects/src/system/migration-flag.ts`**: `readDataMigrationFlag` uses `engine.findOne(...)`. The duck-typed `MigrationFlagEngine` declares `findOne` in place of `find`, since `find` is no longer called. - **`packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts`**: the receipt's existence check uses `ledger.findOne(...)`. `SeedTenancyLedger` declares `findOne` in place of `find`, and `resolveSeedTenancyLedger` requires `findOne`. The doc comment listing the ledger's readers now says they read through `findOne`. - The ObjectQL engine has both methods, and every caller in the repo passes the engine, so they are unaffected. Only a hand-written stand-in that offers `find` alone changes behaviour: its read fails, which reads as "no flag row", and the gates stay closed. The changeset states the FROM and TO shapes. **H1 checked.** `ObjectQL.findOne` calls `driver.findOne`, which calls `findRows(..., singleRowLookup = true)` in `sql-driver.ts`. The options survive the move. `where: { id }` is a predicate, so `requireFindOnePredicate` passes. The query's `context: { isSystem: true }` goes through the same `mergeReadContext(query.context, options.context)` as on `find`. The platform-objects and metadata-protocol tests pin the exact call: `findOne('sys_migration', { where: { id }, context: { isSystem: true } })`. **H3 checked.** `os migrate plan` makes one unregistered-table read of `sys_migration`: the engine's gate read through `haveFileColumnsMoved()`. Neither named site is reached on that path. The pin is the engine test below. ## After: this branch at `2c59fcaf18`, built | run | `Paged read of 'sys_migration'` warnings | |:--|:--| | `objectstack dev` on a fresh copy of the 17.4.0 file (booted twice from a build of `4ad574e769`, once from a build of `2c59fcaf18`) | 0 | | `os migrate plan` on a fresh copy of the 17.4.0 file (from builds of `4ad574e769` and `2c59fcaf18`) | 0 (the pending `add_columns: columns_moved_at` line is still listed, as expected) | | `os migrate plan` on the file this branch's boot had already opened | 0 | | `objectstack dev` on a new file (`examples/app-crm`), first and second boot (build of `4ad574e769`) | 0, 0 | The flag read still answers correctly on the old schema. The plan logs `this deployment has verified the file-as-reference migration`, read through `findOne` from a `sys_migration` table that has no `columns_moved_at` column. `4ad574e769` is the fix before the merge of `origin/main`, and `2c59fcaf18` is the final head. ## The family sweep: census, and the stop line The census is AST-based, over non-test `packages/**/*.ts`: a call with an object-literal argument that has `limit: 1` (a numeric literal) and a `where`/`filter` object literal with an `id` key, as a shorthand, a scalar or `{ $eq }`. **Blind spots:** a `where` or `limit` held in a variable, and a `top: 1` alias. Lit control: both named sites are hits at the base. - Base `01e78dceef`: **55 hits in 16 packages.** plugin-approvals 11, plugin-security 9, plugin-sharing 8, service-automation 7, runtime 4, objectql 3, plugin-email 3, mcp 2, and one each in metadata-protocol, platform-objects, plugin-auth, plugin-hono-server, service-datasource, service-queue, service-settings and service-storage. - The 5 that read `sys_migration` move here. **50 hits remain in 13 packages** at `2c59fcaf18`. That is far past the dispatch's ~15-site stop line, and most of the remainder is in other lanes' packages. Several are not one-line moves: `runtime/src/action-execution.ts` unwraps `.value` and scans the array, and `plugin-hono-server`'s is `find.call(ql, ...)`. So they are not moved here, and the census goes to the seat. None of the 50 printed a warning in the measured boots. The driver warns only when such a read reaches a table before the table is registered. ```text packages/mcp/src/plugin.ts:597 packages/mcp/src/stdio-data-bridge.ts:146 packages/plugins/plugin-approvals/src/approval-service.ts:1346 1548 2030 3030 3630 4126 4943 5008 5417 6401 6795 packages/plugins/plugin-auth/src/admin-set-user-manager.ts:241 packages/plugins/plugin-email/src/attachment-reclaim.ts:202 packages/plugins/plugin-email/src/email-plugin.ts:720 791 packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:570 packages/plugins/plugin-security/src/delegated-admin-gate.ts:1238 1251 1328 packages/plugins/plugin-security/src/permission-set-projection.ts:151 packages/plugins/plugin-security/src/position-catalog-refusal.ts:433 packages/plugins/plugin-security/src/security-plugin.ts:4832 6386 6397 7372 packages/plugins/plugin-sharing/src/business-unit-graph.ts:267 packages/plugins/plugin-sharing/src/primary-bu-projection.ts:108 packages/plugins/plugin-sharing/src/sharing-service.ts:567 951 1008 1416 1484 packages/plugins/plugin-sharing/src/team-graph.ts:189 packages/runtime/src/action-execution.ts:196 217 261 packages/runtime/src/security/actor-user.ts:142 packages/services/service-automation/src/flow-dispatch-store.ts:136 153 205 packages/services/service-automation/src/suspended-run-store.ts:330 360 736 786 packages/services/service-datasource/src/datasource-secret-binder.ts:122 packages/services/service-queue/src/db-queue-adapter.ts:683 packages/services/service-settings/src/settings-service-plugin.ts:403 packages/services/service-storage/src/storage-service-plugin.ts:1183 ``` ## Tests - **`packages/runtime/src/sys-migration-point-lookup.integration.test.ts`** (new). A real `SqlDriver` over one SQLite file with two driver instances: the first creates `sys_migration` and writes a flag row. The second is one the engine knows about but that has not registered the table. The test asserts `paginationTieBreaker('sys_migration')` is null, as a stated precondition. It then runs the engine's gate read, platform-objects' `readDataMigrationFlag`, and the receipt-shaped `findOne`: zero paging warnings. **Control:** an unsorted `engine.find('sys_migration', { limit: 1 })` afterwards gives exactly one warning. It runs last because the warning is once per object per driver. - **`packages/objectql/src/sys-migration-single-row-route.test.ts`** (new). A recording driver double checks each engine reader on the production path that reaches it: the gate reads, the deviation marker after a lax-admitted write, and revocation of a contradicted creation attestation. It asserts that `sys_migration` reaches the driver only as `findOne`. The probe is lit: a caller's own limit-bound `find` is recorded as a `find`. - The existing flag-reader doubles move to `findOne` and are held to `assertEngineFindOnePredicate`. Files: platform-objects `migration-flag.test.ts`, `migration-flag.column-move.test.ts`, `plugin.test.ts`; metadata-protocol `seed-tenancy-backfill.test.ts`; service-storage `files-to-references-migration.test.ts`, `lax-deviation-reclamation-gate.test.ts`; objectql `engine.test.ts` (the #3617 flag block), `value-shape-scan-advisory.test.ts`, `adr0104-attestation-evidence.test.ts` (the memoization spy). - Suites, all green: - objectql `--project local`: 339 files, 6708 tests (after the merge of `origin/main`). - platform-objects: 59 files, 948 tests. - metadata-protocol: 190 files passed, 3 skipped; 2792 tests passed. - service-storage: 40 files, 627 tests. - runtime: the new pin plus `seed-tenancy-autonumber-split.integration.test.ts`, 15 tests. - Typecheck green for objectql, platform-objects, metadata-protocol, service-storage, runtime and cli. The `check:test-typecheck` debt is unchanged, and `--listFiles` confirms both new test files are in their packages' test programs. **Ablation**, run from the committed fix. `scripts/ablation-replace.mjs` reverted `readMigrationFlagVerified` to `find` with `limit: 1`: anchor hit once, blob `607f1216` to `b828359f`. objectql was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. Result: the runtime pin is **red** (`expected [ Array(1) ] to deeply equal []`), and the objectql pin is **red** on 3 of 4 cases. The restore was proven (blob equals HEAD, `git diff HEAD` empty). objectql was rebuilt again, the preflight `--absent` passed (exit 0, tree clean), and both pins are green again. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `2c59fcaf18` derives 77 commands, and all 77 ran on that head with exit 0. `--ran` reconciliation: "77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED (a DERIVED zero — all 77 recorded an exit code and none of them is 3)". Three gates needed changes on the way: - `check:engine-double-contract`: the new pinned `findOne` doubles were recorded with `--write`, which grew the ledger only. - `check:objectql-double-limit`: ratcheted down for the doubles that no longer implement a limit-blind `find`. - `check:query-options-erasure`: the new tests type their options, so the count stays at its ceiling of 236. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (unbuilt packages). It is green after a full `turbo run build`. ## Acceptance notes - A new database's **first** boot still prints the driver's `[sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_migration' (SQLITE_ERROR) ... no such table` line once. It did so before this change and does so after it. It is the same early read, on a table that does not exist yet. The driver logs it one frame below the engine, whose own missing-table line is already demoted to `debug`. Not changed here. Reported to the seat. - The header table of `packages/objectql/src/engine-find-missing-table-log-level.test.ts` still lists `readMigrationFlagVerified` among the `find` callers it measured. That is a historical reading. The reader now goes through `findOne`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91e8fa1 commit cd6d8a5

17 files changed

Lines changed: 591 additions & 75 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
fix(objectql,platform-objects,metadata-protocol)!: the platform's `sys_migration` primary-key lookups go through `findOne`, so an existing deployment no longer prints "Paged read of 'sys_migration' is NOT deterministic" on every boot and every `os migrate plan` (#20648)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (runtime-interface-only packages/platform-objects/src/system/migration-flag.ts#MigrationFlagEngine, packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts#SeedTenancyLedger) two duck-typed engine interfaces, each the parameter type of a published helper, whose one read method moves from `find` to `findOne`. Neither is a Zod schema, a `packages/spec` declaration or an object definition, neither is a projection of a schema, and no metadata surface references either, so `objectstack migrate meta` has nothing to rewrite. The body carries no migration prescription. The only party affected is the TypeScript author of a hand-written stand-in, and that author's fix is carried by the compiler at their own call site, which names the missing `findOne`. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers an engine interface's method set, and this diff adds none (not `registered` / `already-registered`); and both interfaces were concretely typed at the merge base, not erased (not `type-surface-only`). This category, not the broader `no-migration-prescription`, because the positive reading it verifies is available here: the named symbols have no metadata surface. -->
12+
13+
The deployment ledger is read one row at a time, by primary key. Five readers
14+
spelled that read as `find(sys_migration, { where: { id }, limit: 1 })`: the
15+
engine's migration-gate read (`readMigrationFlagVerified`, behind
16+
`haveFileColumnsMoved`, `isFileReferencesMigrationVerified` and
17+
`isValueShapesMigrationVerified`), the engine's deviation marker and
18+
creation-attestation revocation, `readDataMigrationFlag` in
19+
`@objectstack/platform-objects/system`, and the seed-tenancy repair's receipt.
20+
The SQL driver cannot tell that read from page one of a walk. The engine's gate
21+
read runs at boot before the schema pass registers `sys_migration` with the
22+
driver, and on a table the driver has not registered an unsorted paged read
23+
warns that its pages may repeat or skip rows. Measured on a SQLite database
24+
created by 17.4.0: every 17.5.0 boot and every `os migrate plan` printed that
25+
warning once, for a lookup that cannot return two rows. All five readers now use
26+
`findOne`, the single-row route the driver already exempts. The driver's check is
27+
unchanged: an unsorted `limit` read on a table the driver did not create still
28+
warns.
29+
30+
**BREAKING**: this narrows what two published engine interfaces accept. The
31+
first is `MigrationFlagEngine` in `@objectstack/platform-objects/system`. It is
32+
the parameter type of `readDataMigrationFlag`, `isDataMigrationVerified`,
33+
`mayActIrreversibly`, `recordDataMigrationRun`, `recordFileColumnMove` and
34+
`attestFreshDatastore`, and part of `FilesToReferencesEngine` in
35+
`@objectstack/service-storage`. The second is `SeedTenancyLedger` in
36+
`@objectstack/metadata-protocol`, the type of a `SeedTenancySeam`'s `ledger`.
37+
Each now requires `findOne` where it required `find`, so a hand-written stand-in
38+
that provides only `find` no longer satisfies either type. It ships as `minor`
39+
under the launch-window convention for accept-set narrowings. The ObjectQL engine
40+
has both methods, so a host that passes the engine needs no change.
41+
42+
**Your fix:** a stand-in that implemented `find` for these helpers implements
43+
`findOne(object, options)` instead, answering the row whose `where.id` matches,
44+
or `null`.
45+
46+
At run time, a stand-in that still provides only `find` fails the read.
47+
`readDataMigrationFlag` then answers `null`, the same answer as a missing row, so
48+
the gates it feeds stay closed. `resolveSeedTenancySeam` now attaches a `ledger`
49+
only for a host that has `getObject`, `findOne`, `insert` and `update`. For a
50+
find-only host the seam's `ledger` is `undefined`, and when the seed-tenancy
51+
repair applies, it says at `warn` that it could not record its receipt.

‎packages/metadata-protocol/src/migrations/seed-tenancy-backfill.test.ts‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*/
2323

2424
import { describe, it, expect } from 'vitest';
25-
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
25+
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
2626
import {
2727
backfillSeedTenancy,
2828
buildSeedTenancyReceipt,
@@ -579,13 +579,21 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
579579
function fakeLedger(options: { rows?: Record<string, unknown>[]; failWrites?: boolean } = {}) {
580580
const rows = options.rows ?? [];
581581
const calls: Array<{ op: string; data: Record<string, unknown> }> = [];
582+
const reads: Array<Record<string, unknown>> = [];
582583
return {
583584
rows,
584585
calls,
586+
reads,
585587
ledger: {
586588
getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined),
587-
find: async (_object: string, opts: any) =>
588-
rows.filter((r) => r.id === opts?.where?.id).slice(0, opts?.limit ?? rows.length),
589+
// The receipt's only read: its own row, through the single-row route
590+
// (#20648), held to the real engine's refusal of a query that selects
591+
// no particular record.
592+
findOne: async (object: string, opts: any) => {
593+
reads.push(opts);
594+
assertEngineFindOnePredicate(object, opts);
595+
return rows.find((r) => r.id === opts.where.id) ?? null;
596+
},
589597
insert: async (_object: string, data: Record<string, unknown>) => {
590598
if (options.failWrites) throw new Error('no such table: sys_migration');
591599
calls.push({ op: 'insert', data });
@@ -674,6 +682,9 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
674682
);
675683

676684
expect(result.status).toBe('applied');
685+
// #20648: the existence check is a primary-key lookup through `findOne`,
686+
// carrying the system context the old `find` read carried.
687+
expect(store.reads).toEqual([{ where: { id: SEED_TENANCY_MIGRATION_ID }, context: { isSystem: true } }]);
677688
expect(store.calls.map((c) => c.op)).toEqual(['insert']);
678689
const written = store.calls[0].data;
679690
expect(written.id).toBe(SEED_TENANCY_MIGRATION_ID);
@@ -750,7 +761,10 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
750761
const log = createLogger();
751762
const noLedger = {
752763
getObject: () => undefined,
753-
find: async () => [],
764+
findOne: async (object: string, opts?: any) => {
765+
assertEngineFindOnePredicate(object, opts);
766+
return null;
767+
},
754768
insert: async () => { throw new Error('must not be called'); },
755769
update: async (_object: string, data: Record<string, unknown>, opts?: Record<string, unknown>) => {
756770
assertEngineUpdateDispatch(data as any, opts as any);
@@ -802,7 +816,10 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
802816
const engine = {
803817
driver: { execute: async () => [], config: { client: 'better-sqlite3' } },
804818
getObject: () => ({}),
805-
find: async () => [],
819+
findOne: async (object: string, opts?: any) => {
820+
assertEngineFindOnePredicate(object, opts);
821+
return null;
822+
},
806823
insert: async () => ({}),
807824
update: async (_object: string, data: Record<string, unknown>, opts?: Record<string, unknown>) => {
808825
assertEngineUpdateDispatch(data as any, opts as any);
@@ -814,7 +831,20 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
814831
// A driver-only host resolves an exec but no ledger — the repair still runs
815832
// and says, loudly, that it could not record itself.
816833
expect(resolveSeedTenancySeam({ driver: { execute: async () => [] } })?.ledger).toBeUndefined();
817-
expect(resolveSeedTenancyLedger({ getObject: () => ({}), find: async () => [] })).toBeUndefined();
834+
expect(resolveSeedTenancyLedger({ getObject: () => ({}), findOne: async () => null })).toBeUndefined();
835+
// #20648: the receipt reads through `findOne`, so an engine-shaped host
836+
// offering only `find` is not a ledger — its receipt read would throw.
837+
expect(
838+
resolveSeedTenancyLedger({
839+
getObject: () => ({}),
840+
find: async () => [],
841+
insert: async () => ({}),
842+
update: async (_object: string, data: Record<string, unknown>, opts?: Record<string, unknown>) => {
843+
assertEngineUpdateDispatch(data as any, opts as any);
844+
return {};
845+
},
846+
}),
847+
).toBeUndefined();
818848
});
819849
});
820850

‎packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,13 @@ export const SEED_TENANCY_MIGRATION_ID = 'seed-tenancy-backfill';
10011001
*/
10021002
export interface SeedTenancyLedger {
10031003
getObject(name: string): unknown;
1004-
find(object: string, options: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
1004+
/**
1005+
* The receipt's one read: its own row by primary key, through the engine's
1006+
* single-row route (#20648). ⛔ Not `find` with `limit: 1` — the SQL driver
1007+
* reads that as page one of a walk and, on a table it has not registered,
1008+
* warns that the walk is not deterministic, a false alarm for a key lookup.
1009+
*/
1010+
findOne(object: string, options: Record<string, unknown>): Promise<Record<string, unknown> | null>;
10051011
insert(object: string, data: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
10061012
update(object: string, data: Record<string, unknown>, options: Record<string, unknown>): Promise<unknown>;
10071013
}
@@ -1018,7 +1024,7 @@ export interface SeedTenancyLedger {
10181024
*/
10191025
export function resolveSeedTenancyLedger(engine: unknown): SeedTenancyLedger | undefined {
10201026
const candidate = engine as any;
1021-
for (const method of ['getObject', 'find', 'insert', 'update']) {
1027+
for (const method of ['getObject', 'findOne', 'insert', 'update']) {
10221028
if (typeof candidate?.[method] !== 'function') return undefined;
10231029
}
10241030
return candidate as SeedTenancyLedger;
@@ -1051,7 +1057,8 @@ export function resolveSeedTenancyLedger(engine: unknown): SeedTenancyLedger | u
10511057
* Verified by enumeration rather than assumed: every consumer of this ledger
10521058
* (`ObjectQL.readMigrationFlagVerified`, `recordObservedDeviation`,
10531059
* `retractCreationAttestation`, and `platform-objects`' `readDataMigrationFlag`)
1054-
* reads `where: { id }, limit: 1`. There is no bulk or aggregate read of
1060+
* reads one row by `where: { id }` through the engine's `findOne` (#20648).
1061+
* There is no bulk or aggregate read of
10551062
* `sys_migration` anywhere in the repo, so no reading of these fields under a
10561063
* NEW id can reach another id's gate.
10571064
*
@@ -1098,9 +1105,8 @@ async function persistSeedTenancyReceiptRow(
10981105
flag: DataMigrationFlag,
10991106
): Promise<'inserted' | 'updated'> {
11001107
const context = { isSystem: true };
1101-
const rows = await ledger.find(DATA_MIGRATION_FLAG_OBJECT, {
1108+
const existing = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, {
11021109
where: { id: flag.id },
1103-
limit: 1,
11041110
context,
11051111
});
11061112
const row: Record<string, unknown> = { ...flag, updated_at: flag.last_run_at };
@@ -1110,7 +1116,7 @@ async function persistSeedTenancyReceiptRow(
11101116
// probe finds nothing and later boots return `no-split` — but a run that could
11111117
// only stamp some objects leaves a split behind, and the next boot's row
11121118
// should describe the LATEST attempt.
1113-
if (rows?.[0]?.id === flag.id) {
1119+
if (existing?.id === flag.id) {
11141120
await ledger.update(DATA_MIGRATION_FLAG_OBJECT, row, { context });
11151121
return 'updated';
11161122
}

‎packages/objectql/src/adr0104-attestation-evidence.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,8 @@ describe('migration-flag memoization: a read that never happened is not an answe
349349
const store = newStore();
350350
const engine = boot(store, { created: false });
351351
const driver: any = (engine as any).drivers.get('default');
352-
const find = vi.spyOn(driver, 'find');
352+
// [#20648] The flag read is a primary-key lookup through `findOne`.
353+
const findOne = vi.spyOn(driver, 'findOne');
353354

354355
await engine.insert('showcase_task', { id: 't1', cover: 'file_01H0000000000000000000' });
355356
await engine.insert('showcase_task', { id: 't2', cover: 'file_01H0000000000000000001' });
@@ -359,7 +360,7 @@ describe('migration-flag memoization: a read that never happened is not an answe
359360
// are live — one read each, never one per write. The conclusive negative
360361
// (the ledger is readable and empty) IS memoized; only "could not ask" is
361362
// re-asked.
362-
const flagReads = find.mock.calls.filter((c: unknown[]) => c[0] === 'sys_migration');
363+
const flagReads = findOne.mock.calls.filter((c: unknown[]) => c[0] === 'sys_migration');
363364
expect(flagReads).toHaveLength(2);
364365
expect(flagReads.map((c: any) => c[1]?.where?.id).sort()).toEqual(
365366
[FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID].sort(),

‎packages/objectql/src/engine.test.ts‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,6 +2554,8 @@ describe('ObjectQL Engine', () => {
25542554
* evidence cannot be read keeps writing rather than starting to reject.
25552555
*/
25562556
describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
2557+
// [#20648] The flag read is a primary-key lookup through `findOne`, so the
2558+
// driver's `findOne` is what answers it in every case below.
25572559
let engine: ObjectQL;
25582560
let driver: IDataDriver;
25592561

@@ -2594,28 +2596,28 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
25942596

25952597
it('a verified flag makes a malformed media value reject', async () => {
25962598
withMediaObject();
2597-
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2599+
vi.mocked(driver.findOne).mockResolvedValue(verifiedRow as any);
25982600

25992601
await expect(engine.insert('note', { ...legacyBlob })).rejects.toThrow(/invalid file value/i);
26002602
});
26012603

26022604
it('no flag row → lenient (the write goes through)', async () => {
26032605
withMediaObject();
2604-
vi.mocked(driver.find).mockResolvedValue([]);
2606+
vi.mocked(driver.findOne).mockResolvedValue(null);
26052607

26062608
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
26072609
});
26082610

26092611
it('a failed run (verified_at cleared) → lenient', async () => {
26102612
withMediaObject();
2611-
vi.mocked(driver.find).mockResolvedValue([{ ...verifiedRow, verified_at: null, blocking: 3 }] as any);
2613+
vi.mocked(driver.findOne).mockResolvedValue({ ...verifiedRow, verified_at: null, blocking: 3 } as any);
26122614

26132615
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
26142616
});
26152617

26162618
it('an unreadable sys_migration → lenient, and the write is not failed by the probe', async () => {
26172619
withMediaObject();
2618-
vi.mocked(driver.find).mockRejectedValue(new Error('no such table: sys_migration'));
2620+
vi.mocked(driver.findOne).mockRejectedValue(new Error('no such table: sys_migration'));
26192621

26202622
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
26212623
});
@@ -2631,44 +2633,45 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
26312633
if (name === 'sys_migration') return { name: 'sys_migration', fields: { id: { type: 'text' } } } as any;
26322634
return undefined;
26332635
});
2634-
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2636+
vi.mocked(driver.findOne).mockResolvedValue(verifiedRow as any);
26352637

26362638
await expect(engine.insert('invoice', { amount: 10 })).resolves.toBeDefined();
2637-
expect(vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration')).toHaveLength(0);
2639+
expect(vi.mocked(driver.findOne).mock.calls.filter((c) => c[0] === 'sys_migration')).toHaveLength(0);
26382640
});
26392641

26402642
/** No storage service → no sys_migration object → not even a query. */
26412643
it('costs no query on a kernel without the storage objects', async () => {
26422644
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) =>
26432645
name === 'note' ? ({ name: 'note', fields: { doc: { type: 'file' } } } as any) : undefined,
26442646
);
2645-
vi.mocked(driver.find).mockResolvedValue([]);
2647+
vi.mocked(driver.findOne).mockResolvedValue(null);
26462648

26472649
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
26482650
expect(driver.find).not.toHaveBeenCalled();
2651+
expect(driver.findOne).not.toHaveBeenCalled();
26492652
});
26502653

26512654
it('reads the flag once per process, not once per write', async () => {
26522655
withMediaObject();
2653-
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2656+
vi.mocked(driver.findOne).mockResolvedValue(verifiedRow as any);
26542657

26552658
await expect(engine.insert('note', { doc: 'file_01' })).resolves.toBeDefined();
26562659
await expect(engine.insert('note', { doc: 'file_02' })).resolves.toBeDefined();
26572660
await expect(engine.insert('note', { doc: 'file_03' })).resolves.toBeDefined();
26582661

2659-
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2662+
const flagReads = vi.mocked(driver.findOne).mock.calls.filter((c) => c[0] === 'sys_migration');
26602663
expect(flagReads).toHaveLength(1);
26612664
});
26622665

26632666
it('invalidateDataMigrationFlags forces a re-read', async () => {
26642667
withMediaObject();
2665-
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2668+
vi.mocked(driver.findOne).mockResolvedValue(verifiedRow as any);
26662669

26672670
await engine.insert('note', { doc: 'file_01' });
26682671
engine.invalidateDataMigrationFlags();
26692672
await engine.insert('note', { doc: 'file_02' });
26702673

2671-
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2674+
const flagReads = vi.mocked(driver.findOne).mock.calls.filter((c) => c[0] === 'sys_migration');
26722675
expect(flagReads).toHaveLength(2);
26732676
});
26742677

@@ -2735,7 +2738,7 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
27352738
place: { name: 'place', fields: { spot: { type: 'location' } } },
27362739
sys_migration: SYS_MIGRATION,
27372740
});
2738-
vi.mocked(driver.find).mockResolvedValue([]); // no flag row → gate open
2741+
vi.mocked(driver.findOne).mockResolvedValue(null); // no flag row → gate open
27392742
const info = vi.spyOn((engine as any).logger, 'info');
27402743

27412744
await engine.announceOpenMigrationGates();
@@ -2748,7 +2751,7 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
27482751
note: { name: 'note', fields: { doc: { type: 'file' } } },
27492752
sys_migration: SYS_MIGRATION,
27502753
});
2751-
vi.mocked(driver.find).mockResolvedValue([]);
2754+
vi.mocked(driver.findOne).mockResolvedValue(null);
27522755
const info = vi.spyOn((engine as any).logger, 'info');
27532756

27542757
await engine.announceOpenMigrationGates();
@@ -2763,7 +2766,7 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
27632766
note: { name: 'note', fields: { doc: { type: 'file' } } },
27642767
sys_migration: SYS_MIGRATION,
27652768
});
2766-
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2769+
vi.mocked(driver.findOne).mockResolvedValue(verifiedRow as any);
27672770
const info = vi.spyOn((engine as any).logger, 'info');
27682771

27692772
await engine.announceOpenMigrationGates();
@@ -2782,14 +2785,15 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
27822785

27832786
expect(lines(info)).not.toMatch(/os migrate/);
27842787
expect(driver.find).not.toHaveBeenCalled();
2788+
expect(driver.findOne).not.toHaveBeenCalled();
27852789
});
27862790

27872791
it('announces once per process, not once per caller', async () => {
27882792
registerObjects({
27892793
place: { name: 'place', fields: { spot: { type: 'location' } } },
27902794
sys_migration: SYS_MIGRATION,
27912795
});
2792-
vi.mocked(driver.find).mockResolvedValue([]);
2796+
vi.mocked(driver.findOne).mockResolvedValue(null);
27932797
const info = vi.spyOn((engine as any).logger, 'info');
27942798

27952799
await engine.announceOpenMigrationGates();

0 commit comments

Comments
 (0)