fix(driver-sql): the first boot of a new database no longer prints a sys_migration DATABASE_ERROR (#20768) - #20818
Conversation
…stion leaves the warn channel The first boot of a new database printed one `[sql-driver] DATABASE_ERROR ... no such table: sys_migration` line. The read is the engine's migration-gate read, reached through the ADR-0104 media-arm resolver the driver asks at the start of its first initObjects, before its schema pass has created any table. The resolver now runs inside an async scope, and the read-exit terminal routes one class off the warn channel inside that scope: a missing table, recognised by the shared isMissingTableError over the envelope's declared target. Every other refusal still warns, and so does a missing table read outside the scope. The resolver's answer is unchanged. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…te database, and the refusals that still warn The driver pin runs a resolver shaped like the engine's through initObjects on a new file; the runtime pin boots a real ObjectQL engine over a real SqlDriver twice. Controls: a malformed read on an existing table, a missing table named by another relation, and a missing table read outside the question all still warn. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…wn reads log Measured under the reverse verification: with the fix reverted, the boot's own sys_migration line landed in the same capture and turned the control red. A control must not move with the defect it sits beside. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…rst-boot-migration-gate-read # Conflicts: # packages/drivers/driver-sql/src/sql-driver.ts
…rst-boot-migration-gate-read
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 80a01a70cd2eb54b2992c760d01c3fa074f3c656 && git checkout 80a01a70cd2eb54b2992c760d01c3fa074f3c656
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 157baa75f276e5f9e157cf83c10b4774449cf481 c5ae3a6f8e2559353acb5a5ebe5f02de7b559dc7 && git checkout -B drift-repro 157baa75f276e5f9e157cf83c10b4774449cf481 && git merge --no-ff c5ae3a6f8e2559353acb5a5ebe5f02de7b559dc7
node scripts/docs-audit/affected-docs.mjs --json 157baa75f276e5f9e157cf83c10b4774449cf481 |
Contract reviewServed-tier: PR #20818 (card #20768), branch ① Derived judgmentsDiff: 4 files, +541 / -9.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20768
Clause-②: no
On the first boot of a new database, the SQL driver printed one
[sql-driver] DATABASE_ERROR ... no such table: sys_migrationline on its warn channel. The read behind it is the engine's migration-gate read. The driver's own pre-DDL question reaches that read before the schema pass has created any table. The driver now asks that question inside an async scope. Inside the scope, one class of refusal goes todebuginstead ofwarn: a missing table, recognised by the sharedisMissingTableErrorover the envelope's declared target. Every other refusal still warns, and so does a missing table read anywhere else. No gate's answer changes.Reproduction on the base
objectstack dev --database file:NEW.sqlite -p PORTonexamples/app-crm, base96e724475c(every package in the app's closure built), stdout and stderr captured separately:DATABASE_ERRORlinesconsole.warnsink)The line, verbatim:
The mechanism: which dispatch hypotheses held
H1 held. For one boot, I added a temporary stack-trace line to the built
packages/objectql/dist(restored afterwards,cmpbyte-identical on all four bundles, marker count 0). The first gate read at boot isObjectQL.readMigrationFlagVerified, reached throughreadFileReferencesFlagRowandhaveFileColumnsMoved. It is called by the closure thatregisterDriverhands the driver, whichSqlDriver.resolveFileColumnsMovedasks at the start ofSqlDriver.initObjects, from the schema pass's firstsyncSchema, before any DDL. The other gate readers run after the schema pass has createdsys_migration, so on the first boot they read without error:isValueShapesMigrationVerifiedfrom write hooks, andannounceOpenMigrationGatesatkernel:bootstrapped.H2 falsified. The engine cannot tell whether the table exists without asking the backend:
hasTable.conclusivemust befalse. The same pass creates the table moments later, and a memoized "not verified" would freeze a whole boot's posture from a moment when nothing could answer. That is what the refused read already returns, so it is unchanged.hasTable) would give "not asked", but it needs a new public driver method or a newIDataDrivermember. That enlarges a public surface, against this card'sClause-②: no, so this PR does not add one.H3 held.
SqlDriver.backendStatementFaultwrites the line before the engine sees the error. The engine'sfindOnepath does not log, and its catch inreadMigrationFlagVerifiedanswers{ verified: false, conclusive: false, columnsMoved: false }silently. So the demotion is in the driver, keyed onisMissingTableError, and limited to this read: the async chain of the driver's own question.H4: measured at
c5ae3a6f8e. The second boot prints nothing, as on the base.os migrate plan --database-url file:Xon the database the boots created prints noDATABASE_ERROR. On a path that does not exist yet, the plan printed 7 lines on the base, 2 of them onsys_migration, and prints 6 now. The onesys_migrationline left is theadr-0104-value-shapesread fromannounceOpenMigrationGates, outside the driver's question. The other 5 aresys_metadata(4) andsys_metadata_activation(1). They are not this card. See the acceptance notes.Landing site:
packages/drivers/driver-sql, notpackages/objectql/src/engine.tsThe dispatch expected the engine, with the driver only if a demotion was needed. As H2 and H3 show, the demotion is needed, and the line's producer is the driver. The premature read is the driver's own pre-DDL question.
engine.tsis unchanged.What changed
packages/drivers/driver-sql/src/sql-driver.ts:AsyncLocalStorage(PRE_DDL_QUESTION_SCOPE).resolveFileColumnsMovedruns the resolver inside it, and nothing else does. It is module-level rather than per instance because the question's read goes to whichever driver serves the ledger, which on a multi-datasource composition can be another instance in the same async chain.backendStatementFaultcomposes the envelope first. If the scope is active andisMissingTableError(envelope, object)holds, it writes the same dialect text tothis.logger.debug?.(...)and returns the envelope. Otherwise it warns as before. The throw, the envelope, and what the resolver hears are unchanged.debugchannel. The default sink has none.isMissingTableErroris imported from its home,@objectstack/types.@objectstack/metadata/errorsre-exports the same symbol, and driver-sql cannot depend on@objectstack/metadata. No second message regex..changeset/20768-first-boot-migration-gate-read.md:@objectstack/driver-sqlpatch.@objectstack/runtimegains only a test file, which itsfiles[](dist,README.md,CHANGELOG.md) does not ship, so it gets no changeset entry.After: this branch at
c5ae3a6f8e, builtDATABASE_ERRORlinessys_migrationmentionsobjectstack dev --database file:NEW.sqlite(examples/app-crm), first bootos migrate planon that fileNormalised for timestamps, port and file path, the first-boot stderr differs from the base run in exactly one line: the removed one. Both ledgers end in the same state after the first boot: both flags verified by the fresh-datastore attestation, and
columns_moved_atnull.Tests
packages/drivers/driver-sql/src/sql-driver-20768-pre-ddl-question-missing-table.test.ts(new, SQLite on a new temp file). The resolver is a stand-in that reads the ledger the way the engine does.debugline (no such table). The resolver gets the envelope (code: 'DATABASE_ERROR',status: 500), and the arm stays "not moved".columns_moved_atrow written between the boots makes the arm "moved", so the read reached the row. Nothing is logged.warn: ④ a malformed read on an existing table inside the question (40,000 bound variables, SQLite refuses the statement); ⑤ a view over a dropped table inside the question (a missing table named by another relation); ⑥ a missing table read outside the question.packages/runtime/src/first-boot-migration-gate-read.integration.test.ts(new). A realObjectQLengine over a realSqlDriveron a new file,SysMigrationregistered, andengine.syncSchemas(), which is the samesyncSchema,initObjects, resolver, gate-read chain.sys_migrationDATABASE_ERRORon warn, and one demoted line on the first boot only.c5ae3a6f8e:--project local: 293 files, 4207 tests passed, 1 skipped.check:test-typecheckdebt is unchanged, andtsc --listFilesshows both new tests inside their packages' programs.Reverse verification (from the committed fix)
packages/drivers/driver-sql/src/sql-driver.tswas restored to its base blob withgit restore --source=96e724475c: on-disk blob2462eccd= base, marker count 0. driver-sql was rebuilt, andablation-dist-preflight --absent PRE_DDL_QUESTION_SCOPEfound the marker absent from all 6 built files.expected [ Array(1) ] to deeply equal []). ③ to ⑥ stayed green.Restore:
git checkout HEAD -- packages/drivers/driver-sql/src/sql-driver.ts. Bloba3647fd1= HEAD, andgit diff HEADwas empty. After a rebuild, the preflight found the marker in 2 built files and a clean tree, and both pins were green again.The first run of this verification also turned the runtime control red: it had counted the boot's own
sys_migrationline together with its own. It now counts only its own reads, and the second run above is from that commit.Gates
node scripts/pm/dispatch-gates.mjs --commandsatc5ae3a6f8ederives 63 commands, and all 63 ran on that head with exit 0.--ranreconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)". On the first pass,check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET (9 packages had nodist/). I built those 9 and re-ran it, and it exited 0.Lint, narrowed:
eslint --no-inline-config --format jsonover the 3 touched.tsfiles counted 3 files, 0 errors, 0 warnings. The population is the config'spackages/**/*.{ts,tsx,mts,cts}and**/*.{ts,...}objects. The config never enables type-aware linting (noparserOptions.project), so this diff cannot change the verdict on an untouched file.The branch merged
maintwice. The second merge brought in #20794, the serial constraint. Neither merge touched this diff's files, apart from the tracker-number wording in the sameDATABASE_ERRORline, which this branch now carries asmainspells it.Acceptance notes
os migrate planagainst a database that does not exist yet still prints 6DATABASE_ERRORlines atc5ae3a6f8e:sys_metadata4,sys_metadata_activation1, andsys_migration1. The last is theadr-0104-value-shapesread byannounceOpenMigrationGatesatkernel:bootstrapped, which on a deferred-DDL plan runs over tables the plan never creates. It is the same false-alarm family, on a different door, and is reported to the seat. It is not fixed here.debug. No production composition handsSqlDrivera logger today, so with the default sink the line is dropped. The engine's catch records nothing either.PRE_DDL_QUESTION_SCOPEcovers any read in the resolver's async chain, including one a hook starts inside it. That is intended: every such read is issued for the question, before the schema pass has run.Generated by Claude Code