fix(metadata): isMissingTableError no longer reads Postgres' write-path missing-COLUMN phrase as a missing TABLE (#6347) - #6613
Merged
Conversation
…e as a missing TABLE (#6347) `MISSING_TABLE`'s message test demands table/relation next to `does not exist`, but Postgres' write-path phrasing — `column "label" of relation "sys_team" does not exist` — carries a complete legal missing-table phrase as a SUBSTRING, so it matched. The function's own docblock names 42703 as a failure that must stay loud. A message regex can never exclude a superstring, so the repair is a front-exclusion evaluated before any positive test: the column-level SQLSTATEs the docblock already names (42703 / 42704 / 3D000) and the `"x" of relation "y"` sub-object phrasing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA
…ngeset (#6347) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 8, 2026
baozhoutao
marked this pull request as ready for review
August 8, 2026 07:31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6347
The premise, re-measured on
origin/main(82397b6)Confirmed, exactly as filed. Driving the real
MISSING_TABLE.messageregex against the two Postgres phrasings:The write-path phrase carries a complete, legal missing-table phrase —
relation "sys_team" does not exist— as a substring, so the table-scoped message test matched it. The function's own docblock namescolumn "x" does not exist(42703) as "a real failure that must stay loud … a case where 'start numbering at 1' would be the wrong answer against a table that may be full of rows". This is the restore-invariant: the code did not honour its own documented contract.Code-first does not rescue it —
matchesDriverErroris a sequential OR, so an error carryingcode: '42703'falls past both code lines and is decided by its message.One thing the issue did not name, found by the same probe: the superstring family is wider than columns. Postgres phrases every sub-object of a relation the same way, and
constraint "uq_x" of relation "sys_team" does not exist(SQLSTATE 42704) matched too — 42704 being the second of the three neighbours the same docblock already declares must stay loud. Closing it is the same invariant, in the same function, so it is in this PR rather than a follow-up.The repair
A message regex can never exclude a superstring: once a legal phrase for X appears inside a longer phrase meaning NOT-X, no widening of the X regex removes the match — the phrase really is in there. So the repair is a front-exclusion on the signature, evaluated before any positive test. Two channels:
codes —
42703(undefined_column),42704(undefined_object: constraint/trigger/role/type),3D000(invalid_catalog_name). Exactly the three the docblock already names. Postgres-shaped on purpose: measured, neither MySQL (Unknown column 'label' in 'field list') nor SQLite (no such column: bogus,table t has no column named label) phrases a sub-object failure so that a missing-table phrase falls out of it, so there is nothing there to exclude.message — the "something inside a relation" phrasing, which therefore says the relation itself is present:
/["'`][^"'`]+["'`]\s+of relation\s/iRecognising an exclusion ends the question with
falseand does not descend intocause: an error that identifies as "a column of an existing relation" is that error whatever it wraps, and stopping can only ever subtract benign verdicts, never add one — the direction this module already errs in.On the copied regex: the two in-repo siblings are
mapDataError(packages/rest, #5352) andMISSING_COLUMN_OF_RELATION(service-analytics, #6035 / PR #6346). Both are read-only references here, and neither is imported — a one-line phrase is not worth a cross-package edge. It is deliberately wider than theirs: they extract the column name to phrase a better error, so a miss costs a vaguer message; this one excludes, so a miss restores the corruption. It therefore drops theircolumn/ snake-case-identifier /does not existanchors.Tests — a phrase corpus, both directions
packages/metadata/src/utils/schema-sync-errors.test.tsgains 25 corpus rows plus 2 named cases, driving the realisMissingTableError:false) — write-path phrase in five shapes (bare message, with 42703, thrown as a string, wrapped ascause, single-quoted identifiers);constraint … of relation …(42704); both read-path forms; 42703 with an opaque message; 42703 whose message is a genuine missing-table phrase; role (42704) and database (3D000); MySQL and SQLite missing-column prose.true, verbatim) — the whole guarded surface: SQLite/libsql with and without a schema prefix and behind a driver prefix, PG message and PG 42P01-with-opaque-message, MySQL message /ER_NO_SUCH_TABLE/ errno 1146, a bare string, and a genuine missing table wrapped ascause. An exclusion is a subtraction; the guarded surface may not shrink.cause, and it does not leak intoisSchemaAlreadyExistsError(column "x" of relation "y" already existsis genuinely benign there — the column IS provisioned — and still matches).Reverse verification, direction predicted before running: deleting
MISSING_TABLE.excludesturns the write-path / sub-object rows red, while every BENIGN row and every read-path row stays green. Measured:8 failed | 48 passed. The direction held; the count came in two above the six predicted by counting phrases, because two rows go red through the code channel rather than the phrase (42703 whose message is a genuine missing-table phrase, and thecause-rescue case). Both are recorded in the test docblock as measured rather than trimmed to fit the prediction.The asymmetry question — does this close it for ALL consumers?
Yes, at one point, because there is exactly one point.
isMissingTableErroris re-exported atpackages/metadata/src/errors.ts:50and every consumer asks the shared predicate — no call site open-codes its own copy (metadata-protocoleven pins that it must import rather than re-implement, insys-metadata-repository.history-counters.test.ts). Grepped, all five:truepackages/metadata/src/loaders/database-loader.ts:310(nextEventSeq)return 1event_seqrestarts at 1 against a full history table — the one the issue namespackages/metadata-protocol/src/sys-metadata-repository.ts:1212(historyCounterVerdict)return 1event_seqandversionpackages/objectql/src/engine.ts:2173(autonumber seed, #5979)return 0packages/metadata/src/loaders/database-loader.ts:716packages/metadata-protocol/src/protocol.ts:3162, 10911All five get strictly louder; none gets quieter. No consumer needs a follow-up, and no consumer behaviour is edited in this PR —
return 1on a genuine missing table is untouched.Fixture sweep
Swept the predicate's consumption radius (
metadata,metadata-protocol,objectql,metadata-core) for the affected phrases: no fixture anywhere spells… of relation …, and every genuine missing-table fixture is the plainrelation "x" does not exist/no such table:form, which is unchanged. Nothing to re-spell.Verification
pnpm --filter @objectstack/metadata test— 27 files, 568 passedpnpm --filter @objectstack/metadata-protocol exec vitest runon the three predicate-consuming suites — 55 passedpnpm --filter @objectstack/objectql exec vitest runon the two outage suites — 33 passedpnpm lint— clean; all 30check:*gates in the ESLint job, run one by one from.github/workflows/lint.yml— all PASS (incl.check:nul-bytes,check:engine-double-contract,check:route-envelope,check:error-code-casing,check:durability-log-level,check:startup-registry-verdict)check:type-check-coverage,check:driver-conformance,check:stall-guard,check:skill-frame-sync,check:skill-compatibility,check:empty-changeset,check:adr-0087-registration— PASStsc --noEmitoverpackages/metadata: zero errors in the two touched files (the package's ledgered DEBT count is unmoved)Changeset:
.changeset/missing-table-column-of-relation.md,@objectstack/metadata: patch. Not declared-breaking, so no ADR-0087 marker is owed (check:adr-0087-registrationgreen).Out-of-scope finding filed
#6615 — observation-class (
finding, nopm:queue, unassigned): the"x" of relation "y"phrase is now open-coded in three packages, while@objectstack/typesis the established home for exactly this class of question (#6250). Nothing broken today; recorded so the consolidation happens once and deliberately rather than as a rider.