diff --git a/.changeset/relation-sub-object-phrase-one-home.md b/.changeset/relation-sub-object-phrase-one-home.md new file mode 100644 index 0000000000..8028e389f8 --- /dev/null +++ b/.changeset/relation-sub-object-phrase-one-home.md @@ -0,0 +1,21 @@ +--- +'@objectstack/types': minor +'@objectstack/rest': patch +'@objectstack/metadata': patch +'@objectstack/service-analytics': patch +--- + +refactor(types,rest,metadata,analytics): Postgres 的 `"x" of relation "y"` 短语收归一处,三个包不再各修一遍同一个超串洞(#6615) + +Postgres 把「关系内部某个子对象」的失败写成 `column "label" of relation "sys_team" does not exist`——里面**逐字包含**一句合法的「表不存在」短语 `relation "sys_team" does not exist`,含义却相反:关系正因为存在才被点名。任何对「这句话是不是在说表没了」的正则收紧都消不掉这个匹配,短语确实在里面;唯一的修法是**先问更具体的问题**。所以修的是**顺序**,不是模式。 + +正因为如此,这个短语被分三次教给了这个仓库,分属三个包、三个 PR,其中两次是在别处已经踩过同一个洞之后:`@objectstack/rest` 的 `mapDataError`(#5352)、`@objectstack/service-analytics` 的缺列扣除(#6035 / PR #6346)、`@objectstack/metadata` 的 `MISSING_TABLE.excludes`(#6347 / PR #6613)。本次把它收进 `@objectstack/types`,与 `isUniqueViolationError`(#6250)和 `isModuleNotFoundError`(framework#3265)同一个理由与同一个位置。 + +**两种宽度,故意保留成两个导出。** 三个消费者要的并不是同一条正则,差别也不是随手写的,而是**每个站点哪个方向的误差是安全的**: + +- `matchMissingColumnOfRelation(message)` —— 严格提取器,锚定 Postgres 的 errmsg 模板 `column "%s" of relation "%s" does not exist`,返回列名。`rest` 用它把 42703 答成 `400 INVALID_FIELD` 而不是 `404`;`service-analytics` 用它在分类前扣除缺列。这两处**过宽**会把真正缺失的表变成硬失败、回退 #5033 刻意保留的宽容,**漏匹配**只是让消息含糊一点——所以必须严格。 +- `isRelationSubObjectPhrase(message)` —— 宽检测器,丢掉 `column` / `[a-z0-9_]+` / `does not exist` 三个锚点:任意子对象、任意带引号标识符、任意判词。`metadata` 用它做排除。这一处**过宽**只会把良性判定变成响亮判定,**漏匹配**却会让 `event_seq` 从 1 重新开始、撞进一张已有行的历史表——方向正好相反。 + +把两者合并成一条正则,无论哪种宽度胜出都会对其中一个调用方是错的;这是卡片记录在案的风险,两个导出即为此而设,理由是承重的而非风格的。仓库里第四份拷贝(`service-analytics` 测试内用于守护 fixture 的那条正则)同时收编:它本是为「两张面孔别对不上」而写,却把断言打在其中一面的私有复述上,因而正是它要防的漂移。 + +行为逐字保持不变:搬进来的两条模式与原站点逐字节相同。`@objectstack/service-analytics` 因此新增一条对 `@objectstack/types` 的依赖边——这是本次唯一的依赖变化,构造上无环(`@objectstack/types` 只依赖 `@objectstack/spec`,后者无仓内依赖),且仓库 73 个包中已有 25 个、16 个 service 中已有 5 个携带同一条边。 diff --git a/packages/metadata/src/utils/schema-sync-errors.ts b/packages/metadata/src/utils/schema-sync-errors.ts index 8a11cf4ee0..663bc62d2e 100644 --- a/packages/metadata/src/utils/schema-sync-errors.ts +++ b/packages/metadata/src/utils/schema-sync-errors.ts @@ -83,6 +83,11 @@ * ``` */ +// [#6615] The Postgres `"x" of relation "y"` phrase, owned once — see the +// module docblock in `@objectstack/types` for the superstring hole it closes +// and for why the exclusion's width deliberately differs from the extractor's. +import { isRelationSubObjectPhrase } from '@objectstack/types'; + /** One "which errors mean X?" vocabulary, in the three forms drivers use. */ interface DriverErrorSignature { /** `error.code` — Postgres SQLSTATE, or mysql2's symbolic name. */ @@ -103,8 +108,15 @@ interface DriverErrorSignature { readonly excludes?: { /** SQLSTATEs / driver codes that positively mean "**not** this case". */ readonly codes: ReadonlySet; - /** Message shapes that carry a legal match for this case as a substring. */ - readonly message: RegExp; + /** + * Message shapes that carry a legal match for this case as a substring. + * + * A predicate rather than a `RegExp` since #6615, so this channel can be + * satisfied by a shared, named question from `@objectstack/types` instead + * of a pattern this file owns alone. The phrase it tests is the same one + * `@objectstack/rest` and `@objectstack/service-analytics` read. + */ + readonly matchesMessage: (message: string) => boolean; }; } @@ -205,19 +217,24 @@ const MISSING_TABLE: DriverErrorSignature = { /** * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a * failure about something *inside* a relation, which therefore says the - * relation itself is present. The two in-repo siblings that already - * carry this phrase are `mapDataError` (`packages/rest`, #5352) and - * `MISSING_COLUMN_OF_RELATION` (`service-analytics`, #6035/PR #6346); - * this is a deliberate one-line copy rather than a cross-package import, - * and deliberately **wider** than theirs. Both of those *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 their `column`/`[a-z0-9_]+`/`does not exist` anchors: - * any sub-object, any quoted identifier, any verdict. Over-matching here - * only ever converts a benign verdict into a loud one, which is the - * direction this whole module already errs in. + * relation itself is present. The two in-repo siblings that carry this + * phrase are `mapDataError` (`packages/rest`, #5352) and + * `service-analytics`'s missing-column subtraction (#6035/PR #6346). + * + * [#6615] All three now read one home — `@objectstack/types` — instead + * of three hand-kept copies, so the phrase can no longer be taught to + * the repo a fourth time or drift in one package only. The **width** + * difference that used to justify the copy is preserved and is the + * reason the home exports two functions rather than one: those two + * *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. {@link isRelationSubObjectPhrase} is therefore the wider + * question — it drops their `column`/`[a-z0-9_]+`/`does not exist` + * anchors: any sub-object, any quoted identifier, any verdict. + * Over-matching here only ever converts a benign verdict into a loud + * one, which is the direction this whole module already errs in. */ - message: /["'`][^"'`]+["'`]\s+of relation\s/i, + matchesMessage: isRelationSubObjectPhrase, }, }; @@ -246,7 +263,7 @@ function matchesDriverError( if (error === null || error === undefined || depth > MAX_CAUSE_DEPTH) return false; if (typeof error === 'string') { - if (signature.excludes?.message.test(error)) return false; + if (signature.excludes?.matchesMessage(error)) return false; return signature.message.test(error); } if (typeof error !== 'object') return false; @@ -261,7 +278,7 @@ function matchesDriverError( const excludes = signature.excludes; if (excludes) { if (typeof err.code === 'string' && excludes.codes.has(err.code)) return false; - if (typeof err.message === 'string' && excludes.message.test(err.message)) return false; + if (typeof err.message === 'string' && excludes.matchesMessage(err.message)) return false; } if (typeof err.code === 'string' && signature.codes.has(err.code)) return true; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c6daae931d..d2e5e810d8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8,6 +8,7 @@ import { isMcpServerEnabled, looksLikeInternalErrorLeak, isUniqueViolationError, + matchMissingColumnOfRelation, declaresServerFault, INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; @@ -903,13 +904,18 @@ export function mapDataError(error: any, object?: string): { status: number; bod // NOTE: this is a last-resort safety net — the validation layer should // ideally reject these before they reach the driver (see follow-ups on // unknown-field rejection + provenance-aware required checks). + // [#6615] The Postgres limb is the shared `matchMissingColumnOfRelation` + // rather than a fourth open-coded copy of that phrase: its message contains + // a legal missing-TABLE phrase as a substring, and `service-analytics` and + // `metadata` each had to repair the same superstring hole. Same regex as + // before, same position last in the chain — only its owner moved. const unknownColumn = - /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw) || - /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw) || - /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || - /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i.exec(raw); + /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw)?.[1] ?? + /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw)?.[1] ?? + /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw)?.[1] ?? + matchMissingColumnOfRelation(raw); if (unknownColumn) { - const field = unknownColumn[1]?.split('.').pop(); + const field = unknownColumn.split('.').pop(); return { status: 400, body: { diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index e34cfbb33d..ebb05e5205 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -19,7 +19,8 @@ }, "dependencies": { "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts index c3e9431ae6..23231506c1 100644 --- a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts +++ b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts @@ -92,6 +92,7 @@ import { describe, it, expect, vi } from 'vitest'; import { DatasetSchema } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { matchMissingColumnOfRelation } from '@objectstack/types'; import { AnalyticsService } from '../analytics-service.js'; const EMPTY = { rows: [], fields: [], totals: [] }; @@ -196,9 +197,15 @@ describe('[#6035] postgres’s missing-COLUMN wording is a hard failure, not a m // answer `400 INVALID_FIELD` instead of a `404`, pinned in `rest.test.ts`. // If these two stop being the same sentence, the two faces have started // disagreeing about what postgres says — which is what this fix removed. - expect(MISSING_COLUMN_JOINED).toMatch( - /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i, - ); + // + // [#6615] Asked through the SHARED parser rather than a fourth open-coded + // copy of the regex. The copy this replaces was itself the drift this test + // set out to prevent: it could be edited without `mapDataError` moving, so + // "the two faces agree" was pinned against a private restatement of one of + // them. Now the fixture is checked against the one definition both read, + // and the column name it yields is asserted too — `mapDataError` puts + // exactly that string in the `field` of its `400 INVALID_FIELD`. + expect(matchMissingColumnOfRelation(MISSING_COLUMN_JOINED)).toBe('label'); // …and it does contain a well-formed missing-TABLE wording, which is the // whole reason a subtraction is needed rather than a tighter anchor. expect(MISSING_COLUMN_JOINED).toMatch(/relation\s+["'`]?[A-Za-z0-9_$.]+["'`]?\s+does not exist/i); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index efd72da116..bc4644893f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -17,6 +17,10 @@ import type { Dataset } from '@objectstack/spec/ui'; import { resolveI18nLabel } from '@objectstack/spec/ui'; import type { Logger } from '@objectstack/spec/contracts'; import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from '@objectstack/core'; +// [#6615] The Postgres `"x" of relation "y"` phrase, owned once. This is the +// only reason this package depends on `@objectstack/types` — see the module's +// docblock for why the edge is acyclic and why it was worth adding. +import { matchMissingColumnOfRelation } from '@objectstack/types'; import { CubeRegistry } from './cube-registry.js'; import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; @@ -141,9 +145,17 @@ function hasDeclaredErrorEnvelope(err: unknown): boolean { * them is the safe direction of error: a wording this misses merely keeps * today's verdict, while one it over-matches would turn a genuinely missing * table into a hard failure and regress #5033's deliberate leniency. + * + * [#6615] "Deliberately that regex rather than a second dialect of it" is now + * enforced rather than asserted: the phrase moved to + * {@link matchMissingColumnOfRelation} in `@objectstack/types`, which + * `rest-server.ts`'s `mapDataError` and `metadata`'s `MISSING_TABLE.excludes` + * also read. The two faces can no longer disagree about what postgres says by + * one of them being edited. Same pattern, byte for byte — only its owner moved. */ -const MISSING_COLUMN_OF_RELATION = - /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i; +function isMissingColumnOfRelation(message: string): boolean { + return matchMissingColumnOfRelation(message) !== undefined; +} /** * Detect the "backing object/table isn't present in this kernel" class of @@ -178,7 +190,7 @@ const MISSING_COLUMN_OF_RELATION = * behaviour change for #5033's leniency. * * [#6035] The residue #5717 left and named here is now closed by - * {@link MISSING_COLUMN_OF_RELATION}, subtracted BEFORE any limb below runs. + * {@link isMissingColumnOfRelation}, subtracted BEFORE any limb below runs. * The anchor above cannot do it alone, for a reason worth stating plainly: the * missing-COLUMN wording literally CONTAINS a well-formed missing-relation * wording, so no tightening of "does this say a relation is missing" can ever @@ -189,7 +201,7 @@ function isMissingSourceError(err: unknown): boolean { const raw = String((err as { message?: unknown })?.message ?? err ?? ''); // [#6035] Missing COLUMN is not missing SOURCE — the paragraph above promises // column errors stay hard failures, and this is where that promise is kept. - if (MISSING_COLUMN_OF_RELATION.test(raw)) return false; + if (isMissingColumnOfRelation(raw)) return false; const msg = raw.toLowerCase(); return ( msg.includes('no such table') || // sqlite / libsql @@ -218,7 +230,7 @@ function isMissingSourceError(err: unknown): boolean { * `undefined` when the driver's phrasing carries no name. Unparseable ⇒ the * caller keeps today's degradation, never a louder guess. * - * [#6035] It subtracts {@link MISSING_COLUMN_OF_RELATION} for the same reason + * [#6035] It subtracts {@link isMissingColumnOfRelation} for the same reason * its sibling does, and the reason is CONSISTENCY rather than a second bug: * measured on `origin/main`, the column wording made this function answer * `sys_team`, so fixing only "is something missing" would leave the pair @@ -232,7 +244,7 @@ function isMissingSourceError(err: unknown): boolean { */ function missingSourceRelation(err: unknown): string | undefined { const msg = String((err as { message?: unknown })?.message ?? err ?? ''); - if (MISSING_COLUMN_OF_RELATION.test(msg)) return undefined; + if (isMissingColumnOfRelation(msg)) return undefined; const patterns = [ /no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i, // sqlite / libsql /relation\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+does not exist/i, // postgres diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8e6402c016..d8d3542878 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,10 @@ export * from './error-leak.js'; export * from './keyset-walk.js'; export * from './module-not-found.js'; export * from './response-envelope.js'; +// [#6615] The one home for Postgres' `«sub-object» "x" of relation "y"` phrase, +// whose missing-COLUMN spelling contains a legal missing-TABLE phrase as a +// substring. Three packages had each repaired that superstring hole separately. +export * from './relation-sub-object.js'; // [#6250] The one named "is this a unique-constraint violation?" predicate. // Four hand-written vocabularies used to answer it and disagreed about MySQL, // which is why every MySQL conflict came back 500 instead of 409. diff --git a/packages/types/src/relation-sub-object.test.ts b/packages/types/src/relation-sub-object.test.ts new file mode 100644 index 0000000000..48f0d26f1d --- /dev/null +++ b/packages/types/src/relation-sub-object.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#6615] The shared home for Postgres' `«sub-object» "x" of relation "y" …` + * phrase. + * + * The defect class this pins is a **substring** one, so no single phrase can + * express it: Postgres' write-path missing-COLUMN message contains a complete, + * legal missing-TABLE phrase inside it. That is pinned first and explicitly + * ({@link SUPERSTRING}) rather than left implicit in a corpus row, because it + * is the thing all three consumers separately grew a repair for and the only + * reason this module exists. + * + * The corpus is then driven as a table across **both** exported widths at once. + * That shape is the point: the two functions must disagree on exactly the rows + * where the width difference is deliberate, and agree everywhere else. A test + * per function could not see that, and collapsing the two into one regex — + * the recorded risk on the card — would show up here as a column that stopped + * differing. + */ + +import { describe, it, expect } from 'vitest'; +import { matchMissingColumnOfRelation, isRelationSubObjectPhrase } from './relation-sub-object.js'; + +/** + * The hole itself, stated as an assertion rather than as prose. + * + * The left string means "the column is misspelled, the table is fine"; the + * right string is the phrasing Postgres uses for "the table is gone", and it + * sits inside the left one verbatim. A consumer classifying by "does this say a + * relation is missing" therefore CANNOT get this right by tightening that + * question — which is why every consumer asks the narrower question first. + */ +const SUPERSTRING = 'column "label" of relation "sys_team" does not exist'; +const MISSING_TABLE_PHRASE = 'relation "sys_team" does not exist'; + +describe('[#6615] the superstring hole every consumer repairs', () => { + it('the missing-COLUMN phrase literally contains a legal missing-TABLE phrase', () => { + expect(SUPERSTRING).toContain(MISSING_TABLE_PHRASE); + // …and the missing-table phrase is well-formed on its own terms: the + // anchored postgres limb `service-analytics` and `metadata` both use + // matches it. So the containment is not a near-miss to be regexed away. + expect(MISSING_TABLE_PHRASE).toMatch( + /relation\s+["'`]?[A-Za-z0-9_$.]+["'`]?\s+does not exist/i, + ); + }); + + it('both exports recognise it, which is what lets a consumer subtract it first', () => { + expect(matchMissingColumnOfRelation(SUPERSTRING)).toBe('label'); + expect(isRelationSubObjectPhrase(SUPERSTRING)).toBe(true); + }); + + it('neither export claims the bare missing-TABLE phrase — subtraction must not eat it', () => { + // The direction that matters: if either width matched this, every + // genuinely missing table would become a hard failure and #5033's + // deliberate leniency would be gone. + expect(matchMissingColumnOfRelation(MISSING_TABLE_PHRASE)).toBeUndefined(); + expect(isRelationSubObjectPhrase(MISSING_TABLE_PHRASE)).toBe(false); + }); +}); + +/** + * One row per wording this repo actually carries, both widths in one place. + * + * `column` is the strict extractor's expected answer (`undefined` = no match); + * `wide` is the exclusion predicate's. Rows where they differ are the width + * difference being deliberate, and are commented as such. + */ +const CORPUS: ReadonlyArray< + readonly [name: string, message: string, column: string | undefined, wide: boolean] +> = [ + // ── both widths agree: postgres' write-path missing column (42703) ── + ['PG write-path missing column', SUPERSTRING, 'label', true], + [ + 'PG write-path missing column, single-quoted identifiers', + "column 'label' of relation 'sys_team' does not exist", + 'label', + true, + ], + [ + 'PG write-path missing column, backtick-quoted identifiers', + 'column `label` of relation `sys_team` does not exist', + 'label', + true, + ], + [ + 'PG write-path missing column, bare (unquoted) relation', + 'column "label" of relation sys_team does not exist', + 'label', + // The extractor's relation limb is `\S+` — quoted or bare. The wide + // detector needs no relation quoting either: it stops at `of relation`. + true, + ], + [ + 'PG write-path missing column, schema-qualified relation', + 'column "label" of relation "public"."sys_team" does not exist', + 'label', + true, + ], + + // ── the width difference, on purpose: other sub-objects of a LIVE relation ── + [ + 'PG missing constraint of a relation (42704)', + 'constraint "uq_sys_team_name" of relation "sys_team" does not exist', + // Not a COLUMN, so the extractor has nothing to extract and says so. + // The exclusion still must fire: the relation is present, so this is + // not a missing table, and calling it one restarts `event_seq` at 1. + undefined, + true, + ], + [ + 'PG missing trigger of a relation (42704)', + 'trigger "trg_audit" of relation "sys_team" does not exist', + undefined, + true, + ], + [ + 'PG column ALREADY EXISTS on a relation (42701)', + 'column "environment_id" of relation "sys_metadata" already exists', + // The extractor is anchored to `does not exist` — a different verdict + // is a different error and it must not be reported as a missing column. + // The exclusion is verdict-blind by design: whatever this error is, the + // relation is present, so `isMissingTableError` must not claim it. + undefined, + true, + ], + [ + 'a quoted identifier the extractor deliberately cannot read', + // Postgres permits arbitrary quoted identifiers. The extractor's + // `[a-z0-9_]+` misses this and that miss is the CHEAP direction (a + // vaguer message); the exclusion must not miss it, because its miss is + // the EXPENSIVE one (a corruption verdict returns). + 'column "first name" of relation "sys_team" does not exist', + undefined, + true, + ], + + // ── both widths stay out: nothing here is about a sub-object of a relation ── + ['PG missing table', MISSING_TABLE_PHRASE, undefined, false], + [ + 'PG read-path missing column — no relation named at all', + 'column "bogus" does not exist', + undefined, + false, + ], + ['sqlite missing table', 'no such table: sys_team', undefined, false], + ['sqlite missing column', 'no such column: bogus', undefined, false], + ['sqlite unknown column, other spelling', 'table sys_team has no column named label', undefined, false], + ['mysql missing table', "Table 'app.sys_team' doesn't exist", undefined, false], + ['mysql unknown column', "Unknown column 'label' in 'field list'", undefined, false], + [ + 'PG not-null violation — names a column AND a relation, but is neither question', + 'null value in column "organization_id" of relation "sys_team" violates not-null constraint', + // The extractor wants `does not exist`; this says `violates`. Correct: + // the column is right there, it was just left empty, and `mapDataError` + // routes this to its `notNull` limb for a different message entirely. + undefined, + // The exclusion fires, and must: the relation exists. This wording is + // already the reason a verdict-blind exclusion is the right width. + true, + ], + [ + 'the dataset-compiler refusal that #5717 anchored the postgres limb away from', + 'Dataset includes relationship "owner" which does not exist on object "account"', + undefined, + false, + ], + ['empty message', '', undefined, false], +]; + +describe('[#6615] one phrase, two widths, one corpus', () => { + it.each(CORPUS)('extractor: %s', (_name, message, column) => { + expect(matchMissingColumnOfRelation(message)).toBe(column); + }); + + it.each(CORPUS)('wide detector: %s', (_name, message, _column, wide) => { + expect(isRelationSubObjectPhrase(message)).toBe(wide); + }); + + it('the two widths genuinely differ — collapsing them would be caught here', () => { + // The card's recorded risk is that a shared home quietly becomes ONE + // regex, wrong for one caller whichever width wins. This asserts the + // gap is non-empty in the one direction it can be: every message the + // extractor claims is also a sub-object phrase, and strictly more + // messages are sub-object phrases than are missing columns. + const extractorHits = CORPUS.filter(([, , column]) => column !== undefined); + const wideHits = CORPUS.filter(([, , , wide]) => wide); + expect(extractorHits.length).toBeGreaterThan(0); + expect(wideHits.length).toBeGreaterThan(extractorHits.length); + for (const [name, , column, wide] of CORPUS) { + if (column !== undefined) { + expect(wide, `${name}: extracted a column but is not a sub-object phrase`).toBe(true); + } + } + }); + + it('the extractor never returns a relation name, only a column name', () => { + // Guards against the one misuse the export shape cannot prevent by + // itself: reading this answer as "which table". + expect(matchMissingColumnOfRelation(SUPERSTRING)).not.toBe('sys_team'); + }); +}); diff --git a/packages/types/src/relation-sub-object.ts b/packages/types/src/relation-sub-object.ts new file mode 100644 index 0000000000..38c729dc8b --- /dev/null +++ b/packages/types/src/relation-sub-object.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one home for Postgres' `«sub-object» "x" of relation "y" …` phrasing + * (#6615). + * + * ## The superstring hole, stated once + * + * Postgres phrases a failure about something *inside* a relation by naming the + * relation too: + * + * ``` + * column "label" of relation "sys_team" does not exist (42703) + * constraint "uq_sys_team_name" of relation "sys_team" does not exist (42704) + * column "environment_id" of relation "sys_metadata" already exists (42701) + * ``` + * + * Every one of those **contains a complete, legal missing-TABLE phrase** — + * `relation "sys_team" does not exist` — as a substring, while meaning the + * opposite: the relation is right there, which is precisely why it could be + * named. No amount of tightening a "does this say a relation is missing?" + * regex can remove that match, because the phrase really is in there. The only + * repair is to ask the more specific question FIRST. That makes the ORDER the + * fix, not the pattern — and it is why three packages each grew their own copy + * of this phrase (#5352, #6035/PR #6346, #6347/PR #6613) before it was given a + * home. + * + * ## Two widths, on purpose — never collapse them + * + * The three consumers do not want the same regex, and the difference is not + * sloppiness: it is **which direction of error is safe** at each site. + * + * | consumer | asks | uses | a MISS costs | + * |:---|:---|:---|:---| + * | `@objectstack/rest` `mapDataError` (#5352) | which column? | {@link matchMissingColumnOfRelation} | a vaguer message (`404` instead of `400 INVALID_FIELD`) | + * | `@objectstack/service-analytics` `isMissingSourceError` / `missingSourceRelation` (#6035) | is this a missing COLUMN, so keep it hard? | {@link matchMissingColumnOfRelation} | a mistyped column degrades to a confident empty chart | + * | `@objectstack/metadata` `MISSING_TABLE.excludes` (#6347) | is this about a sub-object, so not a missing table? | {@link isRelationSubObjectPhrase} | a corruption verdict returns (`event_seq` restarts at 1) | + * + * The first two **extract**, so they must be strict: over-matching there would + * turn a genuinely missing table into a hard failure and regress #5033's + * deliberate leniency, while under-matching merely keeps today's verdict. The + * third **excludes**, so it is deliberately wider — any sub-object, any quoted + * identifier, any verdict — because over-matching there only ever converts a + * benign verdict into a loud one, and a miss restores data corruption. + * + * Collapsing the two into one regex would therefore be wrong for one caller + * whichever width won. They are two exports for that reason, and the reason is + * load-bearing rather than stylistic. + * + * ## Home + * + * `@objectstack/types`, following `isUniqueViolationError`'s move + * (#6250 — four hand-written answers to one question) and + * `isModuleNotFoundError`'s (framework#3265 — "single shared owner … so the + * parallel loaders cannot drift apart"). This module deliberately imports + * nothing. + * + * ⚠️ Unlike #6250, adopting this **does** add one dependency edge: + * `@objectstack/service-analytics` did not depend on `@objectstack/types` + * before #6615. It is acyclic by construction — `@objectstack/types` depends + * only on `@objectstack/spec`, which depends on nothing in-repo, so no package + * except `spec` itself can form a cycle by consuming it — and 25 of the repo's + * 73 packages (5 of 16 services) already carry the same edge. Recorded here + * rather than left for a reader to rediscover. + */ + +/** + * Postgres' missing-COLUMN template, strictly. Returns the column name, or + * `undefined` when the message is not that phrase. + * + * Anchored to `column "%s" of relation "%s" does not exist` — the exact errmsg + * template Postgres emits for SQLSTATE 42703 on the write path + * (`INSERT` / `UPDATE` / `ALTER`). Both quotes are required because Postgres + * always emits them here, and requiring them is the safe direction of error for + * the two consumers that call this. + * + * Deliberately narrow in two further ways, both preserved verbatim from the + * open-coded copies this replaces: + * + * - the identifier is `[a-z0-9_]+` (case-insensitive), so a quoted identifier + * carrying a space or punctuation is NOT matched. Postgres can quote such + * names; the consumers accept the miss because a miss is the cheap direction. + * - the relation is `\S+` — quoted or bare, unparsed. This function answers + * "which COLUMN", never "which relation". + * + * The read-path phrasing `column "bogus" does not exist` is a different + * sentence with no relation in it, so it does not match — and it does not need + * to: it carries no missing-table substring, which is the whole hole this + * module exists for. + */ +export function matchMissingColumnOfRelation(message: string): string | undefined { + return MISSING_COLUMN_OF_RELATION.exec(message)?.[1]; +} + +/** + * The same quirk, **wider**: does this message talk about any sub-object of a + * relation, in any verdict? + * + * Drops all three of {@link matchMissingColumnOfRelation}'s anchors — the + * literal `column`, the `[a-z0-9_]+` identifier shape, and the trailing + * `does not exist` — so it also recognises `constraint "uq_x" of relation "y" + * does not exist` (42704), `column "x" of relation "y" already exists` (42701), + * and every other sub-object Postgres phrases this way. + * + * For **exclusion** callers only. A `true` here means "the relation is present, + * so whatever else this error is, it is not a missing table"; it does not mean + * the error is benign and it names nothing. Using it to extract would be a + * category error — there is no capture group precisely so that it cannot be. + */ +export function isRelationSubObjectPhrase(message: string): boolean { + return RELATION_SUB_OBJECT.test(message); +} + +/** + * The strict extractor's pattern. Module-private: exported behaviour is the two + * functions above, so a consumer cannot read the wrong capture group, re-flag + * the regex, or quietly widen one width toward the other. + */ +const MISSING_COLUMN_OF_RELATION = + /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i; + +/** The wide detector's pattern. Module-private for the same reason. */ +const RELATION_SUB_OBJECT = /["'`][^"'`]+["'`]\s+of relation\s/i; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 981971c9df..e60169f479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2021,6 +2021,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types devDependencies: '@types/node': specifier: ^26.1.2