From 568a141adf939911101f8e92dfff533f73411395 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:37:18 +0000 Subject: [PATCH] fix(rest): a missing relation is an unknown OBJECT only when it is the object asked for (#5462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapDataError`'s unknown-object heuristic asked whether a driver error mentioned `no such table` / `relation ... does not exist` — never WHICH table was missing. A business object that was never registered and the metadata plane collapsing entirely are the same two words to that regex, so `sys_metadata` becoming unreachable came back as `404 {"error":"Object not found","code": "OBJECT_NOT_FOUND"}`: the caller was told to check the object name they typed. And 404 is an `isExpectedDataStatus`, so `handleRouteError` printed no "[REST] Unhandled error" — a total outage of the metadata plane left not one line in the server log. Reproduced in process on a real ObjectQL + ObjectStackProtocolImplementation whose driver fails every access with `SQLITE_ERROR: no such table: sys_metadata`: `PUT /api/v1/meta/object/acct` answered 404 with zero log lines. The rule now: a missing-relation message is an unknown-object verdict only when the relation it names is the object the request named. Attribution takes both halves — a request object, and a relation name the phrasing actually carries (schema qualifier stripped, compare case-insensitive). Prime Directive #6 (object name IS table name, no `tableName` mapping) is what makes the comparison sound rather than a guess. Anything unattributable is the sanitised data-store fault the SQL-leak branch has always emitted: 500 DATABASE_ERROR, which sits outside `isExpectedDataStatus` and so buys back the log line. Unchanged on purpose: a genuine unknown object is still a quiet 404 OBJECT_NOT_FOUND from both producers (#3770); the engine-authored limbs (`unknown object`, `no driver available`, the quoted-name catch-all) keep the old reading; and the declared-status band (#5437/#5464, #5423/#5436) answers in `resolveErrorResponse` before the heuristic is reached at all. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .../unknown-object-heuristic-attribution.md | 58 +++ packages/rest/src/rest-server.ts | 104 +++- .../src/rest-unknown-object-heuristic.test.ts | 459 ++++++++++++++++++ 3 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 .changeset/unknown-object-heuristic-attribution.md create mode 100644 packages/rest/src/rest-unknown-object-heuristic.test.ts diff --git a/.changeset/unknown-object-heuristic-attribution.md b/.changeset/unknown-object-heuristic-attribution.md new file mode 100644 index 0000000000..6693ce46ef --- /dev/null +++ b/.changeset/unknown-object-heuristic-attribution.md @@ -0,0 +1,58 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): a missing relation is only an unknown OBJECT when it IS the object asked for (#5462) + +`mapDataError`'s unknown-object heuristic asked whether a driver error mentioned +`no such table` / `relation … does not exist` — never **which** table was +missing. A business object that was never registered and the metadata plane +collapsing entirely produce the same two words, so `sys_metadata` becoming +unreachable came back to the caller as: + +``` +404 {"error":"Object not found","code":"OBJECT_NOT_FOUND"} +``` + +The caller was told to check the object name they typed while the real answer +was "the metadata store is gone". And because 404 is an `isExpectedDataStatus`, +`handleRouteError` printed no `[REST] Unhandled error` — so a total outage of +the metadata plane left **not one line** in the server log. Reproduced in +process on a real `ObjectQL` + `ObjectStackProtocolImplementation` whose driver +fails every access with `SQLITE_ERROR: no such table: sys_metadata`: +`PUT /api/v1/meta/object/acct` answered 404 with zero log lines. + +**The rule now: a missing-relation message is an unknown-object verdict only +when the relation it names is the object the request named.** Attribution takes +both halves — a request object, and a relation name the phrasing actually +carries (`no such table: main.acct`, `relation "public.acct" does not exist`; +the schema qualifier is stripped and the compare is case-insensitive). Prime +Directive #6 is what makes that comparison sound rather than a guess: the object +`name` **is** the table name, with no `tableName` mapping to launder it. + +Anything unattributable — a different table than the one asked for, an auxiliary +table, no request object at all (which is every metadata / UI / discovery route, +since they call `handleRouteError(res, error)` without one), or a phrasing that +names no relation — is now the sanitised data-store fault the SQL-leak branch +has always emitted: `500 { "error": "Internal data error", "code": +"DATABASE_ERROR" }`. 500 sits outside `isExpectedDataStatus`, which is what buys +back the log line the silent 404 never had; the driver's own words still never +reach the client. + +Deliberately unchanged: + +- **A genuine unknown object is still a quiet `404 OBJECT_NOT_FOUND`.** Both + producers still land on one envelope (#3770): the protocol's registry gate, + and the driver limb when the missing table is the requested object. It still + logs nothing — an unknown object is a client mistake, not a fault (#4886). +- **The engine-authored limbs.** `unknown object`, `object not found`, + `[ObjectQL] No driver available for object ''` and the quoted-name + catch-all are ObjectStack's own vocabulary about a named object; they mean + what they say. Only the DATABASE-authored limbs, which cannot know which table + the caller wanted, needed attribution. +- **The declared-status band.** #5437/#5464 (a declared 5xx is withheld and + logged) and #5423/#5436 (a 4xx is truncated, not erased) answer in + `resolveErrorResponse` before the heuristic is reached at all. That fix + covered producers that declare `status: 500`; this path never reached it, + because `saveMetaItem` rethrows the driver's `Error` with no `status` and no + `code` — which is why the message text was judging it. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 0348bb755b..69d72b781d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -419,6 +419,55 @@ function truncateClientMessage(message: string): string { : `${message.slice(0, CLIENT_MESSAGE_MAX - 1)}…`; } +/** + * [#5462] The envelope for "the data store failed and the client cannot fix + * it": a sanitised 500 carrying the catalog's `DATABASE_ERROR`. + * + * The SQL-leak branch has emitted exactly this for as long as it has existed; + * it is a function now only so the missing-relation branch above it cannot + * drift into a second spelling of the same verdict. 500 is deliberately outside + * `isExpectedDataStatus`, which is what buys the log line the silent 404 never + * had — `handleRouteError` prints `[REST] Unhandled error` and `sendError`'s + * `logWithheldServerFault` (#5437) covers the routes that bypass it, so the + * withheld driver text always lands somewhere an operator can read it. + */ +const DATA_STORE_FAULT = (): { status: number; body: Record } => ({ + status: 500, + body: { error: 'Internal data error', code: 'DATABASE_ERROR' }, +}); + +/** + * [#5462] Does a driver's missing-relation message name the very object this + * request asked for? + * + * Both halves must hold. `object` is the object the ROUTE named (`undefined` on + * every metadata / UI / discovery route — they call `handleRouteError(res, + * error)`), and the relation name is whatever the driver's phrasing carries: + * + * SQLite `SQLITE_ERROR: no such table: acct` → `acct` + * SQLite `no such table: main.acct` → `acct` (schema stripped) + * Postgres `relation "public.acct" does not exist` → `acct` + * generic `table not found` → nothing to attribute + * + * Prime Directive #6 is what makes the comparison sound rather than a guess: + * the object `name` IS the table name, always, with no `tableName` mapping to + * launder it. So "the missing table is not the object you asked for" really + * does mean the failure is somewhere other than the caller's object — an + * auxiliary table, a system table, or the metadata plane itself. + * + * A message that names NO relation is unattributable and therefore not a + * match: the fail-loud direction is what this issue asked for, and there is no + * producer of the bare `table not found` phrasing in this repo to regress. + */ +function missingRelationIsObject(raw: string, object: string | undefined): boolean { + if (!object) return false; + const named = + /no such table:?\s*["'`[]?([a-z0-9_.$]+)/i.exec(raw) || + /relation\s+["'`]?([a-z0-9_.$]+)["'`]?\s+does not exist/i.exec(raw); + const relation = named?.[1]?.toLowerCase().split('.').pop(); + return relation !== undefined && relation === object.toLowerCase(); +} + export function mapDataError(error: any, object?: string): { status: number; body: Record } { // Referential-integrity restrict on delete → 409 with the dependent count. // Surfaced FIRST so the structured fields survive the generic catch-alls. @@ -763,10 +812,54 @@ export function mapDataError(error: any, object?: string): { status: number; bod }; } - const looksLikeUnknownObject = + // [#5462] A driver saying "that relation is missing" is an unknown-OBJECT + // verdict only when the missing relation IS the object the request named. + // + // These three limbs are the only ones in the heuristic below whose text is + // written by the DATABASE rather than by ObjectStack, and the database has + // no idea which of its tables the caller asked for. `sys_metadata` going + // away produces exactly the same words as a business object that was never + // registered — so the whole metadata plane collapsing came back as + // `404 {"error":"Object not found","code":"OBJECT_NOT_FOUND"}`, telling the + // caller to check their spelling, and 404 is an `isExpectedDataStatus`, so + // the infrastructure fault left NOT ONE LINE in the server log. Reproduced + // in process on the real engine + protocol: `PUT /api/v1/meta/object/acct` + // against a driver that fails every access with `SQLITE_ERROR: no such + // table: sys_metadata` answered 404 with zero log lines (see + // `rest-unknown-object-heuristic.test.ts`). + // + // #5437/#5464 fixed the sibling half — a producer that DECLARES `status: + // 5xx` is sanitised and logged. It deliberately did not touch the heuristic, + // and this path never reaches that branch: `saveMetaItem` rethrows the raw + // driver `Error` with no `status` and no `code` at all, so the whole + // message-text machinery below is what judges it. + // + // The criterion is attribution, and it takes BOTH halves: a request object + // to attribute to, and a relation name the phrasing actually carries. When + // either is missing the message cannot be shown to be about the object the + // caller asked for, and per the direction on this issue the safe way to be + // wrong is LOUD — a 500 that is sanitised and logged — never a silent 404. + // That covers the metadata/UI/discovery routes for free: they call + // `handleRouteError(res, error)` with no object at all, which is the exact + // shape this issue was raised on. + // + // The engine-authored limbs keep the old reading. `unknown object`, + // `object not found`, `[ObjectQL] No driver available for object ''` + // and the quoted-object-name catch-all are OUR vocabulary about a named + // object — they mean what they say, and #3770's registry gate (which throws + // `code: 'OBJECT_NOT_FOUND'` and is matched far above) is the primary + // producer of this 404 anyway; the driver-string limb has been a legacy + // safety net since. + const looksLikeMissingRelation = lower.includes('no such table') || - lower.includes('relation') && lower.includes('does not exist') || - lower.includes('table not found') || + (lower.includes('relation') && lower.includes('does not exist')) || + lower.includes('table not found'); + if (looksLikeMissingRelation && !missingRelationIsObject(raw, object)) { + return DATA_STORE_FAULT(); + } + + const looksLikeUnknownObject = + looksLikeMissingRelation || lower.includes('unknown object') || lower.includes('object not found') || lower.includes('no driver available') || @@ -804,10 +897,7 @@ export function mapDataError(error: any, object?: string): { status: number; bod }, }; } - return { - status: 500, - body: { error: 'Internal data error', code: 'DATABASE_ERROR' }, - }; + return DATA_STORE_FAULT(); } return { status: 400, body: { error: raw || 'Bad request' } }; } diff --git a/packages/rest/src/rest-unknown-object-heuristic.test.ts b/packages/rest/src/rest-unknown-object-heuristic.test.ts new file mode 100644 index 0000000000..243fce89b5 --- /dev/null +++ b/packages/rest/src/rest-unknown-object-heuristic.test.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5462] `sys_metadata` being unavailable is an infrastructure fault, not a +// missing object. +// +// `mapDataError`'s `looksLikeUnknownObject` asked only whether the message +// contained `no such table` / `relation … does not exist` — never WHICH table +// was missing. A business object that was never registered and the metadata +// plane collapsing entirely are the same two words to that regex, and they call +// for opposite responses: the first says "check the object name you typed", the +// second says "page whoever owns the database". +// +// The second failure was quieter and worse. 404 is an `isExpectedDataStatus`, +// so `handleRouteError` printed no `[REST] Unhandled error`, and the raw driver +// error — the only evidence the metadata store was gone — reached neither the +// client (correctly) nor the log (a blind spot). A total outage of the metadata +// plane left the server with nothing to show for it. +// +// #5437/#5464 closed the sibling half of this: a producer that DECLARES a 5xx +// now has its message withheld and logged. It deliberately did not touch the +// heuristic, and the path here never reaches that branch — `saveMetaItem` +// rethrows the driver's own `Error` with no `status` and no `code`, so the +// message text is all `mapDataError` has to go on. That is why this needed its +// own fix rather than falling out of the last one. +// +// --------------------------------------------------------------------------- +// The rule this file pins +// --------------------------------------------------------------------------- +// A missing-relation message is an unknown-object verdict ONLY when the +// relation it names is the object the request named. Attribution needs both +// halves — a request object and an extractable relation name — and Prime +// Directive #6 (object name IS table name, no `tableName` mapping) is what +// makes the comparison sound rather than a guess. Anything unattributable is a +// sanitised, logged 500; per the direction on this issue the safe way to be +// wrong is loud, never silently 404. +// +// --------------------------------------------------------------------------- +// Reverse verification, direction predicted BEFORE running +// --------------------------------------------------------------------------- +// Restoring the old expression (`looksLikeUnknownObject` starting from the +// three missing-relation limbs unconditionally, and no attribution branch) is +// the ordinary RED direction, not one of the inverted families: +// +// § 1 + § 2 (the fault cases) RED — they assert 500 + a log line on +// responses that revert to a silent +// 404 OBJECT_NOT_FOUND +// § 3 (the genuine unknown object) GREEN — `no such table: ghost` with +// `object: 'ghost'` matched the old +// heuristic and matches the new one +// § 4 (#5464 / #5436 pins) GREEN — untouched band; those errors carry +// an explicit `status` and never +// reach the heuristic at all +// +// Measured after predicting it; the run is quoted in the PR. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { mapDataError, RestServer } from './rest-server'; + +const META_ITEM = '/api/v1/meta/:type/:name'; + +/** The driver line a missing `sys_metadata` produces on each dialect. */ +const SQLITE_NO_TABLE = 'SQLITE_ERROR: no such table: sys_metadata'; +const PG_NO_RELATION = 'relation "sys_metadata" does not exist'; + +// --------------------------------------------------------------------------- +// Harness — #5464's, reused verbatim (the issue was reproduced on it) +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function mountRest(protocol: any) { + const rest = new RestServer( + createMockServer() as any, + protocol, + { api: { requireAuth: false, enableBatch: true } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +async function callRoute(rest: any, method: string, path: string, req: Record = {}) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`${method} ${path} route not registered`); + const res = makeRes(); + await route.handler({ method, params: {}, query: {}, body: {}, headers: {}, ...req }, res); + return res; +} + +/** Every access fails the way a missing table does — the outage under test. */ +function failingDriver(dbError: string) { + const boom = () => { throw new Error(dbError); }; + const driver: any = { + name: 'memory-broken', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find() { boom(); }, async findOne() { boom(); }, + async create() { boom(); }, async update() { boom(); }, async delete() { boom(); }, + async upsert() { boom(); }, async count() { boom(); }, + async bulkCreate() { boom(); }, async bulkUpdate() { boom(); }, async bulkDelete() { boom(); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +async function bootRealProtocol(dbError: string) { + const engine = new ObjectQL(); + engine.registerDriver(failingDriver(dbError), true); + await engine.init(); + const protocol = new ObjectStackProtocolImplementation(engine as any); + return mountRest(protocol as any); +} + +/** A spec-valid object body, so the PUT reaches persistence rather than a 422. */ +const ACCT = { name: 'acct', label: 'Acct', fields: { name: { type: 'text', label: 'Name' } } }; + +let logged: unknown[][] = []; +let spy: ReturnType; + +beforeEach(() => { + logged = []; + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { logged.push(args); }); +}); +afterEach(() => { spy.mockRestore(); }); + +/** Did ANY log line carry the given text (in its message or its arguments)? */ +function loggedText(needle: string): boolean { + return logged.some((args) => args.some((a) => { + if (a instanceof Error) return a.message.includes(needle); + return typeof a === 'string' && a.includes(needle); + })); +} + +// --------------------------------------------------------------------------- +// 1. The reported repro, walked in process on the real engine + protocol +// --------------------------------------------------------------------------- +// +// Nothing is hand-built here: a REAL `ObjectQL`, a REAL +// `ObjectStackProtocolImplementation`, and the route a client actually calls. +// The error `saveMetaItem` lets escape is the driver's own `Error` — verified +// below to carry NEITHER `status` NOR `code`, which is precisely why #5464's +// declared-5xx branch cannot see it and the text heuristic judged it instead. + +describe('[#5462] sys_metadata unavailable is a fault, not a missing object', () => { + it('PUT /meta/object/acct answers 5xx, not 404 OBJECT_NOT_FOUND', async () => { + const rest = await bootRealProtocol(SQLITE_NO_TABLE); + + const res = await callRoute(rest, 'PUT', META_ITEM, { + params: { type: 'object', name: 'acct' }, body: ACCT, + }); + + // The reported wire answer, gone: the client is no longer told to go + // check the spelling of an object name. + expect(res.statusCode).toBe(500); + expect(res.body.code).not.toBe('OBJECT_NOT_FOUND'); + expect(String(res.body.error)).not.toContain('not registered'); + expect(String(res.body.error)).not.toContain('Object not found'); + }, 60_000); + + it('the fault leaves exactly one log line, carrying the driver text', async () => { + // The other half of the defect, and the more dangerous one: 404 is an + // `isExpectedDataStatus`, so the whole outage used to leave the server + // with NOTHING. Reproduced as `logged.length === 0` before this change. + const rest = await bootRealProtocol(SQLITE_NO_TABLE); + + await callRoute(rest, 'PUT', META_ITEM, { + params: { type: 'object', name: 'acct' }, body: ACCT, + }); + + expect(logged.length).toBe(1); + expect(loggedText('no such table')).toBe(true); + expect(loggedText('sys_metadata')).toBe(true); + }, 60_000); + + it('the driver text still does not reach the client', async () => { + // Becoming loud must not become leaky: this is the same boundary #3867 + // and #5437 defend, so the 500 is the sanitised envelope, not the raw + // driver line wearing a new status. + const rest = await bootRealProtocol(SQLITE_NO_TABLE); + + const res = await callRoute(rest, 'PUT', META_ITEM, { + params: { type: 'object', name: 'acct' }, body: ACCT, + }); + + const wire = JSON.stringify(res.body); + expect(wire).not.toContain('sys_metadata'); + expect(wire).not.toContain('no such table'); + expect(wire).not.toContain('SQLITE_ERROR'); + expect(res.body).toEqual({ error: 'Internal data error', code: 'DATABASE_ERROR' }); + }, 60_000); + + it('Postgres phrasing lands identically — this is not a SQLite-shaped guard', async () => { + // `relation "sys_metadata" does not exist` is the limb the issue named + // second, and it is also the one that could NOT have been caught by + // falling through to `looksLikeInternalErrorLeak`: it contains no + // `sqlite_`, no SQLSTATE, no statement prefix and no constraint dump, + // so the terminal fallback would have shipped it verbatim as a 400. + const rest = await bootRealProtocol(PG_NO_RELATION); + + const res = await callRoute(rest, 'PUT', META_ITEM, { + params: { type: 'object', name: 'acct' }, body: ACCT, + }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('DATABASE_ERROR'); + expect(JSON.stringify(res.body)).not.toContain('does not exist'); + expect(loggedText('does not exist')).toBe(true); + }, 60_000); + + it('the escaping error really does carry no status and no code (premise guard)', async () => { + // If `saveMetaItem` ever starts declaring a status, this path moves to + // #5464's branch and the cases above stop proving anything about the + // heuristic. Pinned so that migration is a red test, not a silent + // change of meaning. + const engine = new ObjectQL(); + engine.registerDriver(failingDriver(SQLITE_NO_TABLE), true); + await engine.init(); + const protocol: any = new ObjectStackProtocolImplementation(engine as any); + + let seen: any; + const traced = new Proxy(protocol, { + get(target, key) { + const value = (target as any)[key]; + if (key !== 'saveMetaItem' || typeof value !== 'function') return value; + return (...args: any[]) => (value.apply(target, args) as Promise) + .catch((e: unknown) => { seen = e; throw e; }); + }, + }); + + await callRoute(mountRest(traced), 'PUT', META_ITEM, { + params: { type: 'object', name: 'acct' }, body: ACCT, + }); + + expect(seen).toBeInstanceOf(Error); + expect(seen.message).toContain('no such table: sys_metadata'); + expect(seen.status).toBeUndefined(); + expect(seen.code).toBeUndefined(); + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// 2. The rule at the unit boundary +// --------------------------------------------------------------------------- + +const driverError = (message: string) => Object.assign(new Error(message), { code: 'SQLITE_ERROR' }); + +describe('[#5462] a missing relation is attributed, or it is a fault', () => { + it('no request object at all → fault (every metadata / UI / discovery route)', () => { + // These routes call `handleRouteError(res, error)` with no object, so + // there is nothing to attribute the missing table to. This is the exact + // shape the issue was raised on. + for (const message of [SQLITE_NO_TABLE, PG_NO_RELATION]) { + const r = mapDataError(driverError(message)); + expect(r.status, message).toBe(500); + expect(r.body.code, message).toBe('DATABASE_ERROR'); + } + }); + + it('a DIFFERENT table than the requested object → fault', () => { + // The data-route form: the caller asked for `acct`, which exists; what + // is missing is the metadata plane underneath it. + const r = mapDataError(driverError(SQLITE_NO_TABLE), 'acct'); + expect(r.status).toBe(500); + expect(r.body.code).toBe('DATABASE_ERROR'); + }); + + it('an auxiliary table of the requested object is still a fault, not a 404', () => { + // `acct` is registered and its own table is fine; a join/aux table has + // drifted. Answering "Object 'acct' is not registered" would be a lie + // about an object the caller can see working elsewhere. + const r = mapDataError(driverError('no such table: acct_shares'), 'acct'); + expect(r.status).toBe(500); + expect(r.body.code).toBe('DATABASE_ERROR'); + }); + + it('an empty-string object cannot be attributed to → fault', () => { + // Several routes pass `String(req.params?.object || '')`. An empty + // string is not a name; it must not act as a wildcard match. + const r = mapDataError(driverError(SQLITE_NO_TABLE), ''); + expect(r.status).toBe(500); + }); + + it('a phrasing that names no relation cannot be attributed to → fault', () => { + // `table not found` carries no name on any dialect this repo produces + // (nothing in the repo produces it at all — it is a third-party-driver + // net). Unattributable, so it takes the loud side by construction. + const r = mapDataError(driverError('table not found'), 'acct'); + expect(r.status).toBe(500); + expect(r.body.code).toBe('DATABASE_ERROR'); + }); + + it('a qualified relation name still attributes: schema prefixes are stripped', () => { + // Prime Directive #6 — object name IS table name — is what makes the + // comparison sound; the schema/catalog qualifier is not part of it. + for (const message of [ + 'no such table: main.acct', + 'relation "public.acct" does not exist', + 'SQLITE_ERROR: no such table: "acct"', + ]) { + const r = mapDataError(driverError(message), 'acct'); + expect(r.status, message).toBe(404); + expect(r.body.code, message).toBe('OBJECT_NOT_FOUND'); + } + }); + + it('attribution is case-insensitive, matching object-name resolution', () => { + const r = mapDataError(driverError('no such table: ACCT'), 'acct'); + expect(r.status).toBe(404); + expect(r.body.code).toBe('OBJECT_NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. The genuine unknown object must stay a quiet 404 (③) +// --------------------------------------------------------------------------- +// +// The half of the semantics this change is not allowed to break. Both producers +// of the verdict are checked, because #3770's whole point is that they land on +// ONE wire envelope. + +describe('[#5462] a real unknown object is still 404 OBJECT_NOT_FOUND, still silent', () => { + const registryGate = (object: string) => + Object.assign(new Error(`Object '${object}' not found`), { + code: 'OBJECT_NOT_FOUND', status: 404, object, + }); + + it('the driver limb: the missing table IS the object asked for', () => { + const r = mapDataError(driverError('no such table: ghost'), 'ghost'); + expect(r.status).toBe(404); + expect(r.body.code).toBe('OBJECT_NOT_FOUND'); + expect(r.body.error).toBe("Object 'ghost' is not registered"); + }); + + it('gate and driver still produce the identical envelope (#3770)', () => { + expect(mapDataError(registryGate('ghost'), 'ghost')) + .toEqual(mapDataError(driverError('no such table: ghost'), 'ghost')); + }); + + it('the Postgres spelling of the same condition also stays 404', () => { + const r = mapDataError(driverError('relation "ghost" does not exist'), 'ghost'); + expect(r.status).toBe(404); + expect(r.body.code).toBe('OBJECT_NOT_FOUND'); + }); + + it('the engine-authored limbs are untouched', () => { + // Our own vocabulary about a named object — these mean what they say, + // so they keep the old reading. Only the DATABASE-authored limbs, which + // cannot know which table the caller wanted, needed attribution. + for (const message of [ + "[ObjectQL] No driver available for object 'ghost'", + 'unknown object ghost', + 'object not found', + ]) { + const r = mapDataError(driverError(message), 'ghost'); + expect(r.status, message).toBe(404); + expect(r.body.code, message).toBe('OBJECT_NOT_FOUND'); + } + }); + + it('404 stays an expected status: an unknown object logs nothing', async () => { + // "Do not make the real 404 loud" is a requirement in its own right — + // an unknown object is a client mistake and must not print a stack + // trace per request (#4886). + const rest = mountRest({ + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + findData: vi.fn().mockRejectedValue(driverError('no such table: ghost')), + }); + + const res = await callRoute(rest, 'GET', '/api/v1/data/:object', { params: { object: 'ghost' } }); + + expect(res.statusCode).toBe(404); + expect(res.body.code).toBe('OBJECT_NOT_FOUND'); + expect(logged).toHaveLength(0); + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// 4. #5464 and #5436 must not regress (③, the declared-status band) +// --------------------------------------------------------------------------- +// +// Those errors carry an explicit `status` and are answered by +// `resolveErrorResponse` before the heuristic is reached at all — so the claim +// under test is that this change did not disturb that ordering. Full coverage +// stays in `rest-5xx-message-sanitization.test.ts` / +// `rest-4xx-message-truncation.test.ts`. + +describe('[#5462] the declared-status band is untouched', () => { + it('a declared 500 whose text says "no such table" keeps its 500 and its code', async () => { + const err = Object.assign( + new Error(`Failed to persist customization overlay to sys_metadata: ${SQLITE_NO_TABLE}.`), + { code: 'OVERLAY_PERSISTENCE_FAILED', status: 500 }, + ); + const rest = mountRest({ + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + saveMetaItem: vi.fn().mockRejectedValue(err), + }); + + const res = await callRoute(rest, 'PUT', META_ITEM, { + params: { type: 'object', name: 'showcase_account' }, body: { name: 'showcase_account' }, + }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('OVERLAY_PERSISTENCE_FAILED'); + expect(res.body.error).toBe('Internal server error'); + }, 60_000); + + it('a declared 4xx is still verbatim even when its text trips the relation limbs', () => { + const msg = 'no such table: pick one that exists'; + const r = mapDataError(Object.assign(new Error(msg), { status: 400, code: 'INVALID_QUERY' }), 'acct'); + expect(r.status).toBe(400); + expect(r.body.error).toBe(msg); + }); + + it('the SQL-leak branch below still answers with the same fault envelope', () => { + // Routed through one helper by this change, so the two spellings cannot + // drift. `UNIQUE constraint` keeps its own 409 above it, untouched. + const leak = mapDataError(driverError('SQLITE_IOERR: disk I/O error'), 'acct'); + expect(leak).toEqual({ status: 500, body: { error: 'Internal data error', code: 'DATABASE_ERROR' } }); + + const unique = mapDataError(driverError('UNIQUE constraint failed: acct.name'), 'acct'); + expect(unique.status).toBe(409); + expect(unique.body.code).toBe('UNIQUE_VIOLATION'); + }); + + it('the column-level branches still win over the relation limbs', () => { + // Postgres spells an unknown column with `relation … does not exist` + // inside it. That branch is matched earlier and must stay that way, or + // a fixable client mistake would become a 500. + const r = mapDataError( + driverError('column "label" of relation "acct" does not exist'), 'acct', + ); + expect(r.status).toBe(400); + expect(r.body.code).toBe('INVALID_FIELD'); + expect(r.body.field).toBe('label'); + }); +});