@@ -268,3 +268,192 @@ describe('the two classifications are independent, not complementary', () => {
268268 expect ( isMissingTableError ( err ) ) . toBe ( false ) ;
269269 } ) ;
270270} ) ;
271+
272+ /**
273+ * #6347 — the phrase corpus.
274+ *
275+ * The defect this pins is a **substring** one, so it cannot be caught by
276+ * checking one phrase at a time: Postgres' write-path missing-COLUMN message,
277+ * `column "label" of relation "sys_team" does not exist`, contains a complete
278+ * legal missing-TABLE phrase (`relation "sys_team" does not exist`) inside it.
279+ * Measured on `origin/main` before the fix, straight against the signature's
280+ * message regex: write-path phrase => `true`, read-path phrase
281+ * (`column "bogus" does not exist`) => `false`. Only the second half was ever
282+ * pinned (see "rejects other 'does not exist' objects" above), which is why the
283+ * first half survived — the test suite asked about the phrasing the consumer's
284+ * own read path produces, and the hole is in the other one.
285+ *
286+ * So the corpus is driven as a table, both directions in one place:
287+ *
288+ * - `LOUD` — must NEVER be judged benign. A false benign here is not a wrong
289+ * log line: `DatabaseLoader.nextEventSeq` answers `return 1` on `true`, so a
290+ * misjudged column error restarts `event_seq` at 1 against a history table
291+ * full of rows, and the colliding insert SUCCEEDS.
292+ * - `BENIGN` — must STILL be judged benign, verbatim. An exclusion is a
293+ * subtraction, and the guarded surface may not shrink while it is applied.
294+ *
295+ * Reverse verification, direction predicted BEFORE running (2026-08-08):
296+ * deleting `MISSING_TABLE.excludes` turns the write-path / sub-object rows of
297+ * `LOUD` RED, while every `BENIGN` row and every read-path row stays green.
298+ * Measured: `8 failed | 48 passed` — the direction held, and the count came in
299+ * two ABOVE the six rows predicted by counting phrases, because two rows go red
300+ * through the code channel rather than the phrase: `42703 whose message is a
301+ * genuine missing-table phrase` and the `cause`-rescue case below. Both are
302+ * part of the same repair (a code is a fact, prose is a guess) and are recorded
303+ * here rather than trimmed to fit the prediction. The read-path rows are green
304+ * in both directions on purpose: they were never the hole, and a corpus that
305+ * could not tell the two apart would be reporting the wrong repair.
306+ */
307+ describe ( 'isMissingTableError — Postgres sub-object phrases are NOT missing tables (#6347)' , ( ) => {
308+ /** Errors that must stay LOUD — `isMissingTableError` must answer `false`. */
309+ const LOUD : ReadonlyArray < readonly [ name : string , error : unknown ] > = [
310+ // ── the reported hole: the write-path (INSERT/UPDATE/ALTER) phrasing ──
311+ [
312+ 'PG write-path missing column, bare message' ,
313+ new Error ( 'column "label" of relation "sys_team" does not exist' ) ,
314+ ] ,
315+ [
316+ 'PG write-path missing column, with SQLSTATE 42703' ,
317+ Object . assign ( new Error ( 'column "label" of relation "sys_team" does not exist' ) , {
318+ code : '42703' ,
319+ } ) ,
320+ ] ,
321+ [
322+ 'PG write-path missing column, thrown as a bare string' ,
323+ 'column "label" of relation "sys_team" does not exist' ,
324+ ] ,
325+ [
326+ 'PG write-path missing column, wrapped as `cause`' ,
327+ Object . assign ( new Error ( 'insert into "sys_team" failed' ) , {
328+ cause : Object . assign (
329+ new Error ( 'column "label" of relation "sys_team" does not exist' ) ,
330+ { code : '42703' } ,
331+ ) ,
332+ } ) ,
333+ ] ,
334+ [
335+ 'PG write-path missing column, single-quoted identifiers' ,
336+ new Error ( "column 'label' of relation 'sys_team' does not exist" ) ,
337+ ] ,
338+ // ── the same shape for other sub-objects of an EXISTING relation ──
339+ [
340+ 'PG missing constraint of a relation (42704)' ,
341+ Object . assign (
342+ new Error ( 'constraint "uq_sys_team_name" of relation "sys_team" does not exist' ) ,
343+ { code : '42704' } ,
344+ ) ,
345+ ] ,
346+ // ── the read-path phrasing: already excluded before #6347, kept pinned ──
347+ [
348+ 'PG read-path missing column, bare message' ,
349+ new Error ( 'column "bogus" does not exist' ) ,
350+ ] ,
351+ [
352+ 'PG read-path missing column, with SQLSTATE 42703' ,
353+ Object . assign ( new Error ( 'column "bogus" does not exist' ) , { code : '42703' } ) ,
354+ ] ,
355+ // ── code-first: 42703 means not-a-table whatever the prose says ──
356+ [
357+ '42703 with an opaque message' ,
358+ Object . assign ( new Error ( 'db error' ) , { code : '42703' } ) ,
359+ ] ,
360+ [
361+ '42703 whose message is a genuine missing-table phrase (code wins, loudly)' ,
362+ Object . assign ( new Error ( 'relation "sys_team" does not exist' ) , { code : '42703' } ) ,
363+ ] ,
364+ // ── the docblock's other named neighbours, still excluded ──
365+ [
366+ 'PG missing role (42704)' ,
367+ Object . assign ( new Error ( 'role "app_rw" does not exist' ) , { code : '42704' } ) ,
368+ ] ,
369+ [
370+ 'PG missing database (3D000)' ,
371+ Object . assign ( new Error ( 'database "objectstack" does not exist' ) , { code : '3D000' } ) ,
372+ ] ,
373+ // ── other dialects' missing-column prose, for completeness ──
374+ [
375+ 'MySQL unknown column (ER_BAD_FIELD_ERROR)' ,
376+ Object . assign ( new Error ( "Unknown column 'label' in 'field list'" ) , {
377+ code : 'ER_BAD_FIELD_ERROR' ,
378+ errno : 1054 ,
379+ } ) ,
380+ ] ,
381+ [
382+ 'SQLite unknown column on a read' ,
383+ Object . assign ( new Error ( 'no such column: bogus' ) , { code : 'SQLITE_ERROR' } ) ,
384+ ] ,
385+ [
386+ 'SQLite unknown column on a write' ,
387+ Object . assign ( new Error ( 'table sys_team has no column named label' ) , {
388+ code : 'SQLITE_ERROR' ,
389+ } ) ,
390+ ] ,
391+ ] ;
392+
393+ /** Errors that must STILL be benign — the guarded surface, verbatim. */
394+ const BENIGN : ReadonlyArray < readonly [ name : string , error : unknown ] > = [
395+ [ 'SQLite / libsql message' , new Error ( 'no such table: sys_metadata_history' ) ] ,
396+ [ 'SQLite message with a schema prefix' , new Error ( 'no such table: main.sys_metadata_history' ) ] ,
397+ [
398+ 'libsql remote, driver prefix in front of the phrase' ,
399+ new Error ( 'SQLITE_UNKNOWN: SQLite error: no such table: sys_metadata_history' ) ,
400+ ] ,
401+ [ 'PG message' , new Error ( 'relation "sys_metadata_history" does not exist' ) ] ,
402+ [
403+ 'PG SQLSTATE 42P01 with an opaque message' ,
404+ Object . assign ( new Error ( 'db error' ) , { code : '42P01' } ) ,
405+ ] ,
406+ [ 'MySQL message' , new Error ( "Table 'app.sys_metadata_history' doesn't exist" ) ] ,
407+ [
408+ 'MySQL ER_NO_SUCH_TABLE' ,
409+ Object . assign ( new Error ( 'opaque' ) , { code : 'ER_NO_SUCH_TABLE' } ) ,
410+ ] ,
411+ [ 'MySQL errno 1146' , Object . assign ( new Error ( 'opaque' ) , { errno : 1146 } ) ] ,
412+ [ 'a bare thrown string' , 'no such table: sys_metadata_history' ] ,
413+ [
414+ 'genuine missing table wrapped as `cause`' ,
415+ Object . assign ( new Error ( 'find failed' ) , {
416+ cause : Object . assign ( new Error ( 'relation "sys_metadata_history" does not exist' ) , {
417+ code : '42P01' ,
418+ } ) ,
419+ } ) ,
420+ ] ,
421+ ] ;
422+
423+ it . each ( LOUD ) ( 'stays loud: %s' , ( _name , error ) => {
424+ expect ( isMissingTableError ( error ) ) . toBe ( false ) ;
425+ } ) ;
426+
427+ it . each ( BENIGN ) ( 'still benign: %s' , ( _name , error ) => {
428+ expect ( isMissingTableError ( error ) ) . toBe ( true ) ;
429+ } ) ;
430+
431+ it ( 'does not let a sub-object phrase be rescued by a missing-table `cause`' , ( ) => {
432+ // Recognising "a column of an existing relation" ENDS the question: the
433+ // exclusion returns false without descending. The alternative — keep
434+ // walking and let a nested 42P01 win — would put the corrupting verdict
435+ // back one level down, reachable by any driver that wraps.
436+ const err = Object . assign (
437+ new Error ( 'column "label" of relation "sys_team" does not exist' ) ,
438+ {
439+ code : '42703' ,
440+ cause : Object . assign ( new Error ( 'relation "sys_team" does not exist' ) , {
441+ code : '42P01' ,
442+ } ) ,
443+ } ,
444+ ) ;
445+ expect ( isMissingTableError ( err ) ) . toBe ( false ) ;
446+ } ) ;
447+
448+ it ( 'leaves the DDL predicate alone — `already exists` on a column still matches' , ( ) => {
449+ // The exclusion is scoped to MISSING_TABLE. `ALREADY_EXISTS` documents
450+ // that Postgres' own `column "x" of relation "y" already exists` matches
451+ // its test, and it is genuinely benign there: the column IS provisioned.
452+ const err = Object . assign (
453+ new Error ( 'column "environment_id" of relation "sys_metadata" already exists' ) ,
454+ { code : '42701' } ,
455+ ) ;
456+ expect ( isSchemaAlreadyExistsError ( err ) ) . toBe ( true ) ;
457+ expect ( isMissingTableError ( err ) ) . toBe ( false ) ;
458+ } ) ;
459+ } ) ;
0 commit comments