Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/relation-sub-object-phrase-one-home.md
Original file line number Diff line number Diff line change
@@ -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 个携带同一条边。
49 changes: 33 additions & 16 deletions packages/metadata/src/utils/schema-sync-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -103,8 +108,15 @@ interface DriverErrorSignature {
readonly excludes?: {
/** SQLSTATEs / driver codes that positively mean "**not** this case". */
readonly codes: ReadonlySet<string>;
/** 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;
};
}

Expand Down Expand Up @@ -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,
},
};

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
16 changes: 11 additions & 5 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isMcpServerEnabled,
looksLikeInternalErrorLeak,
isUniqueViolationError,
matchMissingColumnOfRelation,
declaresServerFault,
INTERNAL_ERROR_MESSAGE,
} from '@objectstack/types';
Expand Down Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion packages/services/service-analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*"
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.1.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 18 additions & 6 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading