Found while working #13273 (log-level classification at ObjectQL.find); out of that card's scope, filed unassigned for triage. Pre-existing — the predicate is untouched by that card, which only reads it.
The predicate, and the gap
isMissingTableError (packages/metadata/src/utils/schema-sync-errors.ts) answers "did this READ fail because the table has not been provisioned yet?". Its MISSING_TABLE signature matches on a table-scoped SQLSTATE, an errno, or this message regex:
/no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
The regex recognises the shape of the phrase. It never compares the name inside the phrase against the object the caller was reading. So any fault whose message says "no such table: X" is answered benign for a read of Y — and every consumer of the predicate takes that as a licence to treat the empty answer as the truth ("there are no rows, so there is nothing to be inconsistent with").
Measured
Driven against a real sqlite database, os migrate plan from examples/app-todo, NODE_ENV=production, on claude/issue-13273-migrate-plan-error-noise at 75e44212:
CREATE VIEW sys_metadata AS SELECT id, type, state FROM table_that_does_not_exist;
os migrate plan --database-url file:<that db>
The read of sys_metadata fails, and the dialect reason names the view's missing BASE relation, not sys_metadata:
(libsql phrases the failed view resolution that way; the phrase still matches the regex.) The command exits 0. sys_metadata is a real object in the database — it just cannot answer — and every fail-soft consumer on that path reads the failure as "not provisioned yet".
Why this is worth a card rather than a comment
The module's own docblock states the invariant it is defending:
Classification is deliberately conservative — anything not positively recognised as "already exists" is treated as a real failure, because the cost of a false "benign" (silent data loss) is far higher than the cost of a false "real" (one extra error line).
and, for this predicate:
The only failure that licenses a caller to treat an empty table as the truth — there are no rows, so there is nothing to be inconsistent with.
A view (or a read whose failure names some other relation) breaks the premise the licence rests on: the table exists, it may be backed by rows, and the caller is now computing from data it never read. That is the shape #6347 already closed for one neighbouring case — Postgres' column "x" of relation "y" does not exist, excluded because the relation is right there in the message because it exists. This is the same family, one step out: the relation in the message is not the one that was read.
The consumers this reaches are the fail-soft ones that already exist: ObjectQL.probeInstallOrganizations (memoises "no organizations"), ObjectQL.resolveFileReferences (passes file ids through un-hydrated, silently), DatabaseLoader, seedAutonumber ("start numbering at 1"), SeedLoaderService.resolveSoleOrganizationId.
Not caused by #13273
#13273 adds one more reader of the same predicate — it picks the find failure's LOG LEVEL from it — so a fault of this shape also becomes quieter there. That is a consequence of the gap, not its cause: every verdict listed above was already reached the same way before that card, and reverting it changes none of them.
Options for triage (not decided here)
- Pass the object being read into the predicate and require the phrase to name it (or name nothing extractable). Narrow, and it keeps every current true positive: sqlite/PG/MySQL all put the read's own table in the phrase for a genuine missing table.
- Exclude only the measured "the phrase names something else" shapes, as
excludes already does for the column phrasings. - Rule it acceptable and say so in the docblock, so the next reader does not re-derive it.
Option 1 changes a signature every caller shares, so it wants a maintainer decision rather than a dev's pick.
Reproduce
cd examples/app-todo
node -e "…createClient({url:'file:/tmp/view.db'}).execute('CREATE VIEW sys_metadata AS SELECT id, type, state FROM table_that_does_not_exist')"
NODE_ENV=production node ../../packages/cli/bin/run.js migrate plan --database-url file:/tmp/view.db
Found while working #13273 (log-level classification at
ObjectQL.find); out of that card's scope, filed unassigned for triage. Pre-existing — the predicate is untouched by that card, which only reads it.The predicate, and the gap
isMissingTableError(packages/metadata/src/utils/schema-sync-errors.ts) answers "did this READ fail because the table has not been provisioned yet?". ItsMISSING_TABLEsignature matches on a table-scoped SQLSTATE, an errno, or this message regex:The regex recognises the shape of the phrase. It never compares the name inside the phrase against the object the caller was reading. So any fault whose message says "no such table: X" is answered benign for a read of Y — and every consumer of the predicate takes that as a licence to treat the empty answer as the truth ("there are no rows, so there is nothing to be inconsistent with").
Measured
Driven against a real sqlite database,
os migrate planfromexamples/app-todo,NODE_ENV=production, onclaude/issue-13273-migrate-plan-error-noiseat75e44212:The read of
sys_metadatafails, and the dialect reason names the view's missing BASE relation, notsys_metadata:(libsql phrases the failed view resolution that way; the phrase still matches the regex.) The command exits 0.
sys_metadatais a real object in the database — it just cannot answer — and every fail-soft consumer on that path reads the failure as "not provisioned yet".Why this is worth a card rather than a comment
The module's own docblock states the invariant it is defending:
and, for this predicate:
A view (or a read whose failure names some other relation) breaks the premise the licence rests on: the table exists, it may be backed by rows, and the caller is now computing from data it never read. That is the shape #6347 already closed for one neighbouring case — Postgres'
column "x" of relation "y" does not exist, excluded because the relation is right there in the message because it exists. This is the same family, one step out: the relation in the message is not the one that was read.The consumers this reaches are the fail-soft ones that already exist:
ObjectQL.probeInstallOrganizations(memoises "no organizations"),ObjectQL.resolveFileReferences(passes file ids through un-hydrated, silently),DatabaseLoader,seedAutonumber("start numbering at 1"),SeedLoaderService.resolveSoleOrganizationId.Not caused by #13273
#13273 adds one more reader of the same predicate — it picks the
findfailure's LOG LEVEL from it — so a fault of this shape also becomes quieter there. That is a consequence of the gap, not its cause: every verdict listed above was already reached the same way before that card, and reverting it changes none of them.Options for triage (not decided here)
excludesalready does for the column phrasings.Option 1 changes a signature every caller shares, so it wants a maintainer decision rather than a dev's pick.
Reproduce