Skip to content

fix(driver-sql): os migrate plan on a new database prints no DATABASE_ERROR for the tables whose DDL it deferred (#20821) - #21093

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20821-migrate-plan-deferred-reads
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20821-migrate-plan-deferred-reads

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20821
Clause-②: no

os migrate plan against a database that does not exist yet printed six [sql-driver] DATABASE_ERROR … no such table lines on stderr. The plan boots with the SQL driver's DDL deferred, lists every table as pending create_table, and then the same boot reads sys_metadata, sys_metadata_activation and sys_migration. Every one of those reads was refused, and every reader already answered from the refusal. Only the driver's warn line was wrong.

This follows the seat answer on the card (5924276655): Q1 A, a driver-side demotion keyed on the driver's own deferral; Q2 A, the pin is scoped to the three deferred tables.

The six readers (traced with a stack per line at b253fadfb; unchanged at this head)

table reader when
sys_metadata ObjectQLPlugin.restoreMetadataFromDb → ObjectStackProtocolImplementation.loadMetaFromDb (isMissingTableError → loaded: 0) ObjectQLPlugin.start(), phase 2
sys_metadata readAuthoredTranslationLayer (core, fallbacks/authored-translation-sync.ts) → null kernel:ready
sys_metadata ObjectQLPlugin.readAuthoredHookRows via resyncAuthoredHooksNow → null kernel:ready
sys_metadata ObjectQLPlugin.readAuthoredActionRows via resyncAuthoredActionsNow → null kernel:ready
sys_metadata_activation ObjectStoreActionActivationStore.probe via ObjectQLPlugin.hydrateActionActivations → its own functional warn kernel:ready
sys_migration ObjectQL.announceOpenMigrationGates → readMigrationFlagVerified → "not verified, not conclusive" kernel:bootstrapped

The DATABASE_ERROR line is written inside SqlDriver.backendStatementFault, before any of these readers sees the error. So a reader-side catch cannot remove it. "Not asked" was measured and declined: no reader can see the deferral, because no IDataDriver member and no kernel key carries it. Deferred is also not the same as absent: on an existing database the deferral records every object before any hasTable. Making "not asked" honest would need a new contract fact, and Clause-② would become yes.

The change

packages/drivers/driver-sql/src/sql-driver.ts, SqlDriver.backendStatementFault, in the warn decision only. A refused statement goes to logger.debug instead of logger.warn only when all three conditions hold:

  1. this driver has DDL deferred (deferredDdl);
  2. the targeted table is in this driver's own deferredSchemaObjects;
  3. isMissingTableError(envelope, object) holds. This is the one shared predicate, asked over the envelope's declared target. No second message regex.

The throw and the envelope (DATABASE_ERROR, status 500) are unchanged. Every other refusal still warns: a malformed statement on a table that exists, a missing table the driver did not defer, and anything after flushDeferredSchemaDdl (which clears both the flag and the set). The new branch sits beside PR #20818's pre-DDL scope check. It is disjoint from the PR #20988 hunks, the JSON-column gate region and the JSON-membership helpers.

Kept on purpose

Before and after on examples/app-crm

node ../../packages/cli/bin/run.js migrate plan [--json] --database-url file:ABSENT.sqlite. The base is 576afc17b (before the fix). The head is ea9309b80, rebuilt.

reading base head
DATABASE_ERROR lines on stderr 12 6
… naming sys_metadata / sys_metadata_activation / sys_migration 6 (4 / 1 / 1) 0
stderr diff — exactly those 6 lines removed, 0 added
human stdout, normalised for timestamps and Nms durations — diff exit 0
the plan block (ℹ Database: … Apply with:) md5 e102064c7b13bce96303c1e9b698693d e102064c7b13bce96303c1e9b698693d
--json stdout with duration removed — byte-identical (cmp exit 0)
database file left behind none none

Tests

  • Driver pin, new: packages/drivers/driver-sql/src/sql-driver-20821-deferred-ddl-missing-table.test.ts, on a real SQLite file. Every case asserts the envelope code and status.
    • ① DDL deferred, the table in the deferred set and missing: the refusal goes to debug (with no such table), and warn stays empty. After flushDeferredSchemaDdl the same read answers [] with nothing logged.
    • ② Control: a malformed read (40,000 bound variables) on an existing table whose DDL is deferred still warns.
    • ③ Control: a missing table outside the deferred set, on the same deferred driver, still warns.
  • CLI pin, new: packages/cli/src/commands/migrate/plan.deferred-reads.integration.test.ts (integration tier: it spawns bin/run-dev.js). Its fixture is one host config with a lookup field. On the base, that fixture reproduced exactly the card's 6 lines (4 / 1 / 1). In human mode and under --json, it asserts:
    • 0 DATABASE_ERROR lines naming the three tables;
    • the reads still answered: the open [value-shape] gate is announced, and the activation WARN is present;
    • each of the three tables is still pending create_table;
    • no file is written.
  • Suites at ea9309b80:
    • pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2: 204 files passed, 11 skipped; 3285 tests passed, 188 skipped. The skips are the live PG and MySQL cells, which are not provisioned here.
    • @objectstack/cli --project unit: 240 files, 3419 tests passed.
    • @objectstack/cli --project integration, run on this PR's file plus schema-migrate.readonly-probe and schema-migrate.deferred-ddl: 3 files, 9 tests passed.
    • NOT MEASURED: the full cli integration tier. Reason: it ran past the 10-minute foreground cap here (exit 124 at 595s). It is declared to CI.
    • typecheck for driver-sql and cli is green, including cli's check:test-typecheck (debt unchanged). The new cli pin is inside tsc's program, because src is the include.

Reverse verification (from the committed fix, through scripts/ablation-replace.mjs)

  1. The deferred-set condition removed (anchor this.deferredSchemaObjects.has(targetedTable) &&, 1 → 0; blob c626b59d → fecb8704). The driver pin turned red on ③ only (expected [] to have a length of 1 but got +0), and ① and ② stayed green. The driver pin imports src, so no build was involved. Restore: blob c626b59d equals HEAD, and git diff HEAD is empty.
  2. The debug branch removed (the whole if block, 1 → 0; blob c626b59d → e7acc7e7). driver-sql was rebuilt. ablation-dist-preflight --absent 'this driver deferred its DDL' found the marker absent from all 6 built files. The CLI pin turned red on both cases (expected [ …(6) ] to deeply equal []). Restore: blob equals HEAD and git diff HEAD is empty. After a rebuild, the preflight found the marker present in 2 built files and the tree clean against HEAD, and both pins were green again. This leg was run again at fd9f151a4, after the pin's spawn harness changed: same result.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at ea9309b80 derived 66 families. All 66 were run, with exit codes recorded before any pipe, and all exited 0. --ran reports: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
    • check:dual-build-cjs-loads and check:i18n-coverage first answered PREREQUISITE NOT MET (exit 3) on the earlier merge head, because nine packages had no dist/. Those packages were built and both gates re-ran green.
    • The derivation warned that the tree was behind origin/main and named two of its inputs as changed: lint.yml gained timeout keys and comments, and engine-double-contract.pinned.json gained entries for other files. Neither changes the family set. No commit since the merge touches driver-sql or cli/src/commands/migrate.
  • node scripts/check-driver-conformance.mjs, before and after: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 3 touched TypeScript files reports 3 files, 0 errors, 0 warnings. eslint --print-config resolves each of them, and none carries parserOptions.project. eslint.config.mjs enables no type-aware linting, so this diff cannot move the verdict on any file it does not touch. The repo-wide pnpm lint is CI's.

Changeset

.changeset/20821-plan-deferred-ddl-reads.md: a patch for @objectstack/driver-sql. @objectstack/cli ships only dist, README.md and CHANGELOG.md, and tsconfig.build.json excludes src/**/*.test.ts, so the pin publishes nothing and gets no entry.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 1, 2026 03:54
…t a DATABASE_ERROR

`os migrate plan` boots with the SQL driver's DDL deferred, then reads
sys_metadata, sys_metadata_activation and sys_migration, which the plan has
just listed as pending creates. On a new database each read was refused and
each refusal printed a `[sql-driver] DATABASE_ERROR` warn line.

`SqlDriver.backendStatementFault` now sends that refusal to `debug` only when
all three hold: DDL is deferred on this driver, the targeted table is in its
own deferred set, and `isMissingTableError` holds over the envelope. The throw
and the envelope are unchanged; every other refusal still warns.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…helper

`src/` is the cli tsconfig's rootDir, so a src test cannot import
`test/helpers/serve-process.ts`; the pin names `bin/run-dev.js` itself and
strips the same two env families `childEnv()` strips.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 1, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 63d1a7c3781e41dca16cf0e286ee02224a579e04 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 193bc521bad3888b5daaff37eb4bdc391d6f2ad5 — the merge of head ea9309b800acaf173b36d70706453dbc43918721 into base 63d1a7c3781e41dca16cf0e286ee02224a579e04, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 193bc521bad3888b5daaff37eb4bdc391d6f2ad5 && git checkout 193bc521bad3888b5daaff37eb4bdc391d6f2ad5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 63d1a7c3781e41dca16cf0e286ee02224a579e04 ea9309b800acaf173b36d70706453dbc43918721 && git checkout -B drift-repro 63d1a7c3781e41dca16cf0e286ee02224a579e04 && git merge --no-ff ea9309b800acaf173b36d70706453dbc43918721

node scripts/docs-audit/affected-docs.mjs --json 63d1a7c3781e41dca16cf0e286ee02224a579e04

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ea9309b800acaf173b36d70706453dbc43918721
Local-runs: none

Inputs read: card #20821 (body and all 5 comments: triage 5908818119, claim 5924062757, round-0 report 5924224857, seat answer 5924276655, round-1 report 5925703713); PR #21093 body, its 4-file list, and the net diff against the merge-base 2821e9f15b of origin/main (418 insertions, 0 deletions); the check-runs on the head, snapshot 2026-10-01T06:07Z. Read-only: the branch was fetched to a review ref and diffed; nothing built, run or re-run.

① Derived judgments

Accept-set: none. The one source hunk (packages/drivers/driver-sql/src/sql-driver.ts 9779–9806, 29 added lines, 0 removed) is a second early-return inside SqlDriver.backendStatementFault, placed after the #20768 pre-DDL branch and before the logger.warn. The envelope is built at 9755 by backendStatementFaultError(object, error, targetedTable) before either branch and is returned unchanged (DATABASE_ERROR, status 500, the same declared target); every call site (findRows 7386, count 9842, and the two returning terminals 10523 and 10815) still throws it. Nothing is accepted or refused differently — right.

The demotion keys on exactly the seat answer's three conditions (5924276655 Q1 A), judged one by one:

  1. this.deferredDdl — the driver's own flag, set only by setDeferredDdl (12893) — right.
  2. this.deferredSchemaObjects.has(targetedTable) — the Map initObjects fills under deferral (11941–11942), keyed by StorageNameMapping.resolveTableName(obj), which returns obj.name for every non-legacy name, so sys_metadata, sys_metadata_activation and sys_migration key as themselves and match targetedTable (physicalTableByObject[object] ?? object, 9754). A legacy ns__short name could miss the set, and a miss falls through to the warn, never to a demotion outside the set — right.
  3. isMissingTableError(envelope, object) — the one shared predicate from @objectstack/types, asked over the envelope's declared target, the same call shape as the [finding] the first boot of a new database prints [sql-driver] DATABASE_ERROR … no such table: sys_migration on the warn channel: the engine's migration-gate read runs before the table is created #20768 branch — right.

Triage ⛔ 1, no second message regex: the diff contains no regex and no message text comparison — right. Triage ⛔ 2, not every refused read demoted: any condition false reaches the unchanged warn; flushDeferredSchemaDdl clears both the set and the flag (13088–13089), so a booted driver and a driver that never deferred are untouched — right.

Log channel: logger.debug?.(…) carrying the dialect text, the same optional-debug sink and the same demoted-not-deleted shape as the #20768 branch; the default logger has no debug. Under AGENTS.md "Degradation log levels", a refusal handed to the caller is not a degradation, so no error is owed and the warn-to-debug move is a log-level choice, not a contract move — right.

Fenced regions: the hunk is disjoint from the JSON-column gate (JSON_COLUMN_INCOMPATIBLE_OPERATORS and jsonColumnOperatorError near 3322–3374, isJsonColumn near 15734, its throw near 16013) and from the JSON-membership helpers (jsonMembershipCandidates and jsonMembershipPredicate near 3736–3886). PR #20988 is merged; its driver hunks (pre-merge 91, 4402–5051, 15435–17312) are disjoint and already in the merge-base. Claim surface as amended: the four touched paths are backendStatementFault's warn decision, one driver unit test, one CLI pin under packages/cli/src/commands/migrate/, and the changeset — nothing outside the amended claim.

Public surface: none. No export, no new member, no signature change (protected backendStatementFault(object, error): Error is as before); driver-turso and driver-sqlite-wasm inherit a branch that keys only on their own deferral state. No spec, no api-surface artifact, no authorable key.

Pins. The driver pin sql-driver-20821-deferred-ddl-missing-table.test.ts carries the three cases the seat answer named: ① deferred, in the set and missing goes to debug with warn empty and the envelope asserted, then the post-flush read answers [] with nothing logged; ② a malformed read on an existing deferred table still warns; ③ a missing table outside the set on the same deferred driver still warns — right. The CLI pin plan.deferred-reads.integration.test.ts asserts 0 DATABASE_ERROR lines naming the three tables in human and --json mode, the [value-shape] announcement and the activation WARN present (so the reads still happened), each table still pending create_table, and no file written — the three triage pins, scoped per Q2 A — right. It lands in the cli integration tier by the predicate (node:child_process value import plus the run-dev.js entry basename, vitest-tiers.ts 165 and 178), and its only path out of its directory (resolve(HERE, '../../../bin/run-dev.js')) stays inside packages/cli, so check:cross-package-test-inputs has nothing to flag — right.

Check-runs on the head (snapshot 06:07Z, 31 runs). Success, 15: Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, Type Check · debt ledger, Type Check · source gates, filter. Skipped, 3: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). In progress, 13, each one NOT a verdict: Dogfood Regression Gate (1/3), (2/3), (3/3); Lint & Repo Gates; Temporal Conformance (live PG + MySQL); Test Core (1/6), (2/6), (3/6), (4/6), (5/6), (6/6); Type Check · consumer gates; Type Check · workspace. Not waited for and not polled. The Vercel status reads "Canceled by Ignored Build Step" and is not a gate. Landing still requires every check green; this record judges the contract.

② Semver level

.changeset/20821-plan-deferred-ddl-reads.md: '@objectstack/driver-sql': patch, one package. Right: the diff publishes one behavioural fix inside a released package (a warn-channel line becomes debug under three driver-internal conditions), removes nothing, renames nothing, exports nothing. @objectstack/cli gets no entry, and that is right: its files is dist, README.md, CHANGELOG.md, and tsconfig.build.json excludes src/**/*.test.ts, so the CLI pin publishes nothing. No skip-changeset label (labels: documentation, size/m, tests, tooling, all labeler-set). Check Changeset: success. The changeset body states the three conditions, what still warns, and "There is nothing to migrate" — accurate; no ADR-0087 marker is owed because nothing is breaking.

Clause-②: no in the PR body and no in the claim — they match, and the diff bears it out. Nothing is accepted or refused differently (the envelope is thrown unchanged), nothing is exported, and no public option was added for the deferral signal (the driver reads its own fields), so the claim's re-judge condition ("if the deferral signal needs a new public option") did not fire. No (widening) or (narrowing) arm, correctly, since no takes none.

③ Boundary flags

open_questions on the round-1 report: empty. The round-0 questions Q1 and Q2 were answered by seat answer 5924276655 (A, A), and the diff implements both exactly (①).

Dev flags from report 5925703713 and the PR body, each answered:

Escalated: nothing. Triage's two ⛔ hold (①). The edit stays inside the amended claim and out of the fenced regions, and the single-writer path guard is green.

Implemented-by: claude/issue-20821-migrate-plan-deferred-reads
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 06:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit cf0346e Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20821-migrate-plan-deferred-reads branch October 1, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants