fix(objectql): pick the find failure log level from the cause — "the table is not provisioned yet" is not "the read failed" (#13273) - #13327
Conversation
📓 Docs Drift Check3 anchor(s) derived from 2 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 — 32 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 f1c09443251bbe028cf84ecf5f7cad2e52e7c314 && git checkout f1c09443251bbe028cf84ecf5f7cad2e52e7c314
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0ae9e1e16e34db799cb717ad254165a2b4cef243 75e442122d94bba5d27f67c380e1308517686b13 && git checkout -B drift-repro 0ae9e1e16e34db799cb717ad254165a2b4cef243 && git merge --no-ff 75e442122d94bba5d27f67c380e1308517686b13
node scripts/docs-audit/affected-docs.mjs --json 0ae9e1e16e34db799cb717ad254165a2b4cef243 |
✅ PM review — ACCEPT once CI is green; ⛔ not while it is running (#13273)Undrafting now; arm follows on a complete green read. ⛔ No rework owed — do not push in response to this comment. Clause ②: does not attach. Path limb measured silent (6 files, none under ✅ The fence held — verified from the file list, not the reportThe fence was ⛔ zero edits to ✅ The distinction I fenced on is intact, and pinned in both directionsThe fence that mattered most was ⛔ no silencing by broadening a catch — "table not yet created" and "the read failed" must stay distinguishable, because collapsing them is itself a filed p1 one door over (#13255).
Positive controls on every zero, including one that runs both verdicts in a single command: a DB whose ⭐ The second-order work is what makes this a good PRDemoting
|
…shared read-refusal capture The file carried its own copy of the expected-read-refusal capture and wrapped the engine's `error` channel only. Since #13273/#13327 `ObjectQL.reportFindFailure` picks the level from the cause, so a read whose table was never provisioned -- which is every read this capture is declared over -- is logged at `debug`. The inline recognition arm could therefore no longer match a single frame: measured on this tree before the change, the engine `error` channel was invoked 0 times while 63 `Find operation failed` frames arrived on `debug`, all 63 satisfying that arm's own predicate. The file stayed green because everything it asserts is fed by the driver channel, so the engine-side suppression was dead code reading as live protection. It now uses `captureExpectedReadRefusals` (#10629), which wraps both channels. The `afterAll` assertion moves from the driver-only `withheld.has(table)` loop to `silentChannels(ALWAYS_READ_AUTHZ_TABLES)`, which is strictly stronger: it requires both channels to have fired for every always-read table. The header's counts are re-measured on this tree rather than carried forward, and its prose no longer claims the engine frame arrives on `error`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
Fixes #13273
ObjectQL.findlogged every read failure identically —ERROR Find operation failed, carrying the driver's fault as a stack. That merged two different facts onto one channel, at the level reserved for the second:The repair is at the read path, where the level is chosen. ⛔ No edit to
packages/cli/src/commands/migrate/plan.ts(held by PR #13270) and none topackages/rest/src/rest-server.ts— the change set is six files, listed at the end.The five emitters, by name and file
All five reach one line:
packages/objectql/src/engine.ts, thecatchinfind. Each caller already treats a missing table as a normal answer and says so in its own code.readAuthoredTranslationLayerpackages/core/src/fallbacks/authored-translation-sync.tssys_metadataObjectQLPlugin.readAuthoredHookRowspackages/objectql/src/plugin.tssys_metadatare-synced runtime-authored hooks {authoredRows: 0}and carries onObjectQLPlugin.readAuthoredActionRowspackages/objectql/src/plugin.tssys_metadatare-synced runtime-authored actions {authoredRows: 0}and carries onObjectStoreActionActivationStore.probepackages/objectql/src/action-activation.tssys_metadata_activationwarnnaming the consequence in operator termsObjectQL.readMigrationFlagVerifiedpackages/objectql/src/engine.tssys_migrationThe card reported four; this fixture (an example app that also carries translations) drives five. Same class, one more caller.
Before / after, driven against a real database
Fixture:
examples/app-todoas the project directory,NODE_ENV=production, a fresh sqlite file with no tables. This is the run the command exists to describe.ERROR Find operation failedrefused a read on(warn)origin/mainat3322527f, nothing applied75e44212Both runs succeed and print the same plan. What is gone is five ERROR records and their stack traces; what remains is the driver's own refusal envelope, at
warn, which is deliberate (below).The class that was demoted, and how it was identified
Only the failure that positively identifies as "relation does not exist", asked through the shared
isMissingTableErrorpredicate (@objectstack/metadata/errors) — the same callprobeInstallOrganizationsandresolveFileReferencesalready make, never a hand-rolledcode === '42P01'copy. It is logged atdebug, withreason: 'table-not-provisioned'and no stack.That predicate earns a benign verdict rather than defaulting to one. It matches the table-scoped SQLSTATEs (
42P01,ER_NO_SUCH_TABLE/1146) and the three dialects' phrasings, and it excludes42703/42704/3D000and Postgres'column "x" of relation "y" does not exist— a phrase that contains a legal missing-table phrase but is a column fault on a table that exists (#6347 established that exclusion).SqlDriver.backendStatementFaultcomposes a redacted message and hangs the dialect error off a non-enumerablecause. The predicate walks that chain. A test case drives exactly this so the classification cannot silently start reading the top-level message.⛔ Positive controls — a read that genuinely FAILED is still loud
Every zero above is paired with a case that must stay loud, on the same seam.
Control A — same command, real database, both verdicts in ONE run. A database where
sys_metadataexists but has none of the columns being filtered on, whilesys_metadata_activationandsys_migrationare still absent:sys_metadatano such column: typesys_metadata_activationno such table: sys_metadata_activationsys_migrationno such table: sys_migrationSo the instrument still reports ERROR for a read that genuinely failed, and it does so in the same process, on the same command, beside the reads that went quiet.
Control B — the same five call sites, a backend-level fault. Pointed at a file that is not a sqlite database at all (
SQLITE_NOTADB): 5 ERROR records with stacks, exit 1. The five emitters that fell silent for "not created yet" are all loud again for "the read failed".Control C — unit,
packages/objectql/src/engine-find-missing-table-log-level.test.ts. Through the real envelope shape: three missing-table dialect phrasings demote; connection refused, statement timeout, permission denied, connection terminated, an unclassified error, and the #6347 column-of-a-relation phrasing all stay onerrorcarrying the Error object and its stack.⛔ What did not change, and is pinned
catch, or error envelope moves — this is a log level and nothing else. Pinned in both directions.[sql-driver] DATABASE_ERROR — the backend refused a read on 'TABLE' … no such table: TABLEstill goes towarnon every one of these reads, untouched. It is deliberately the surviving loud half:packages/runtime/src/expected-read-refusal-noise.tsalready documents it as "where this class of fault can still be picked up". Silencing it too would collapse exactly the distinction this card exists to preserve.insert/update/deletekeep their unconditionalerror— a write to a table that does not exist is not a normal answer for any caller, and nothing landed. Pinned.Reverse-verification
Prediction, recorded before the run: reverting the classification turns the demotion pins red and leaves every positive control green (they already assert the
errorchannel).Mutation: the guard in
reportFindFailureneutered toif (false && isMissingTableError(error)). Proved on disk — injected markergrep -c= 1, original guardgrep -c= 0, blob hash moved1de8f730to647a18d2. No rebuild is involved on this path and none was needed: the tests import./engineby a relative path inside the package, so vitest transforms the source, not adistartifact.The 7 reds are exactly the demotion pins (5 in the new file, 2 in the hydrate file). The 5 positive controls, the write-verb pin and the rethrow pin stayed green in the ablated tree — so they discriminate rather than always-fail.
Restore proved by blob hash back to
1de8f730andgit diff HEADempty, not by an exit code.Local verification, at
75e44212Ran on the union after the final commit.
pnpm --filter @objectstack/objectql test— 248 files, 4287 tests, all passed.test -- --maxWorkers=2; vitest discards everything after a bare--, so the worker cap was silently dropped. The run itself is a full-package pass; only the cap was lost.pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2— 200 files, 2956 tests, all passed (includes the channel-asymmetry file and the ~20 fixtures that assertsilentChannels()).packages/core43/1054,trigger-record-change7/78,plugin-approvals34/626 — the cross-package consumers of the shared capture helper. All passed.pnpm --filter @objectstack/objectql --filter @objectstack/runtime typecheck— bothDone.tsconfig.jsoncarryexclude: ["**/*.test.ts"], verified withtsc --noEmit --listFiles—engine.tsandexpected-read-refusal-noise.tsare in the programs (1 hit each), the three test files are not (0 hits each). Their exercise is the vitest runs above.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no output.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the merged tree, then run. Green:check:nul-bytes,check:durability-log-level,check:logger-receiver-detach,check:engine-double-contract,check:objectql-double-limit,check:where-matcher,check:stack-collection-maps,check:dispatcher-error-vocabulary,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:objectui-changeset,check:page-declaration-shape,check:pm-half-states,check:published-files,check:slot-lookup,check:type-check-coverage,check:dual-build-cjs-loads,check:type-check-debt(--re-measure, 30 entries, none above its recorded number),check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-keyed-text-bounds,check-comment-mask-adoption,check-undeclared-dep-imports,check-plugin-teardown-shape,check-ci-filter-parity,check-shard-attestation,docs-audit/check-affected-docs,docs-audit/check-drift-comment,release-rehearsal-clone --self-test.check-engine-split-ratiofirst refused (shallow clone … oldest visible commit sits INSIDE the window, exit 2). Re-run aftergit fetch --shallow-since=2026-05-25 origin: exit 0, ratio 97.3%.scripts/pm/check-half-states.mjs— exit 3,Nothing was swept … it is no reading at all; the container'sGITHUB_TOKENis the proxy placeholder and the anonymous quota is spent on the shared egress IP.scripts/check-test-completeness.mjs— exit 3,PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named; CI tees it and passes the path.Every gate result above is its own exit code, captured before any pipe.
Changeset
Yes —
.changeset/migrate-plan-missing-table-log-level.md,@objectstack/objectqlpatch. The change is user-visible:os migrate plan, and any first boot against an unprovisioned database, stops printing these stack traces.Files
The two runtime edits are the blast radius of moving the channel, and both are the narrow repair rather than a relaxation. The shared helper counts the demoted frame into the same
engineFramestally its ~20 consuming fixtures assert, sosilentChannels()still names a channel that stopped firing. The asymmetry file's instrument now patchesstdoutas well asstderr(ObjectLoggersendserror/fatalto stderr and everything else to stdout) and runs its positive control at the level that admits the frame; its claim — "the engine channel is only as loud as the fixture's own kernel logger" — is unchanged, only the rank it compares against moved.Found but not fixed
isMissingTableErrornever checks WHICH table the "no such table" phrase names, so a view over a missing base table is read as "not provisioned yet". Measured on a real database. Pre-existing, shared by every consumer of the predicate; this PR adds one more reader of it and changes none of the verdicts.errorchannel — one of them can no longer fire since #13273 moved the frame todebug#13325 — two inline copies of the expected-read-refusal capture wrap the engineerrorchannel only; one of them can no longer fire now that the frame moved. Nothing red — dead suppression plus stale prose.Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
Generated by Claude Code
Generated by Claude Code