Skip to content

Commit 03cc871

Browse files
fix(types): a write to a registered object whose table is missing answers 500 DATABASE_ERROR, as the read does, not 404 OBJECT_NOT_FOUND (#27771)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DJwKZqSF8gKyXHHSquun4GK)_ Fixes #27702 ## What changes The shared data-error classification (`packages/types/src/data-error-classification.ts`, `classifyDataError`) no longer answers a driver's missing-relation line (`no such table: …`, Postgres `relation "…" does not exist`) with `404 OBJECT_NOT_FOUND` "Object '…' is not registered" when the relation names the request's object. Every missing-relation line is now the sanitised, logged `500 DATABASE_ERROR` (`DATA_STORE_FAULT`), the verdict the SQL driver already declares at its read exits for the same table. The attribution helper `missingRelationIsObject` had no other reader and is removed. `404 OBJECT_NOT_FOUND` for an unregistered object is unchanged: its source is the structured `code: 'OBJECT_NOT_FOUND'` that the protocol's `assertObjectRegistered` and the engine's name resolution (`resolveObjectName`) throw before any driver sees the name, and the classification matches that arm far above the text limbs. ## Why Measured on `origin/main` 81d00a4 with a real `ObjectQL` + `SqlDriver` + protocol + `RestServer`, an object registered after the DDL (its table never created), on better-sqlite3 and on a live PostgreSQL 16: | door | producer of the answer | before | after | |---|---|---|---| | `GET /data/:object`, `GET /data/:object/:id` | driver-sql's declared read-exit fault (`backendStatementFault`) | `500 DATABASE_ERROR` | `500 DATABASE_ERROR` | | `POST /data/:object` | the boundary's text classification of the raw driver error | `404 OBJECT_NOT_FOUND` | `500 DATABASE_ERROR` | | `PATCH` / `DELETE /data/:object/:id` | — | — | `500 DATABASE_ERROR` | | unregistered object, `POST` and `GET` | registry gate | `404 OBJECT_NOT_FOUND` | `404 OBJECT_NOT_FOUND` | The PM's second hypothesis (the objectql insert path re-throwing the registry's "is not registered" error) was falsified: the insert leaves the dialect error undeclared (driver-sql's `backendStatementFault` docblock says the write paths are classified at the boundary on purpose), and the 404 came from the classifier's missing-relation limb. The fix is that one place, so no second copy of the rule exists and `packages/objectql/src/engine.ts` is untouched. No new error code: `DATABASE_ERROR` is an existing catalog code. ## Verification - New pin `packages/rest/src/data-door-missing-relation-registered-object.test.ts` (SQLite cell always; live Postgres cell under `OS_TEST_POSTGRES_URL`, a named skip otherwise): POST, PATCH and DELETE answer what GET answers, never `OBJECT_NOT_FOUND`; control: an unregistered object is still `404` on both doors. Run first against the unfixed classifier: **4 red** (the insert cell and the agreement cell, on both SQLite and live PG 16; the POST answered `404 OBJECT_NOT_FOUND`), 6 green. With the fix: 12/12 green on both cells. - Pins that asserted the old 404 for a driver missing-table line were rewritten to the new verdict: `rest-unknown-object-heuristic.test.ts` (§2 attribution cells, §3 driver limb, the quiet-404 route cell now uses the gate's error, §5d), `rest.test.ts` (gate vs. driver envelope), `core/src/utils/import-runner-sandbox-fault-row.test.ts` (the #22868 control). A stale comment in `qa/http-conformance` was corrected. - `@objectstack/types` 809/809, `@objectstack/rest` full run (local + repo projects) green after the `rest.test.ts` rewrite, `@objectstack/core` 2962/2962. - `node scripts/pm/dispatch-gates.mjs --commands` derived 62 families; all ran plus `pnpm lint` (results in the report comment). `check:dual-build-cjs-loads` exits 3 (needs every package's dist) — NOT MEASURED. Clause-②: no Metadata-protocol: none _Keep one value on each of the two lines above, each line starting with its key. [Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum) item 5 says what they mean._ ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1GY2PJ12VgyKNvfDK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5cce09 commit 03cc871

8 files changed

Lines changed: 292 additions & 95 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
fix(types): a write to a registered object whose table is missing answers `500 DATABASE_ERROR`, as the read does, instead of `404 OBJECT_NOT_FOUND` "is not registered"
7+
8+
Clause-②: no (an existing refusal is re-classified to an existing catalog code; nothing accepted today is refused)
9+
Metadata-protocol: none
10+
11+
A registered object whose table was never created (a datasource that was never provisioned, schema drift) answered its two data doors differently. `GET /api/v1/data/:object` answered `500 DATABASE_ERROR`, which the SQL driver declares at its read exits. `POST /api/v1/data/:object` answered `404 OBJECT_NOT_FOUND` with "Object '…' is not registered", because the write paths leave the driver's own error undeclared and the shared data-error classification read its missing-relation line (`no such table: …`, Postgres `42P01 relation "…" does not exist`) as an unregistered object whenever the relation named the request's object.
12+
13+
No unregistered name reaches a driver: the protocol's registry gate and the engine's name resolution both refuse one first, with the structured `OBJECT_NOT_FOUND` that the classification still answers `404`. So a driver's missing-relation line always means a registered object's storage is missing. The classification now answers it the sanitised, logged `500 DATABASE_ERROR` in every case, and the insert, update and delete doors agree with the read.
14+
15+
- **What changes for a caller.** A write to such an object gets `500 {"error":"Internal data error","code":"DATABASE_ERROR"}` instead of a `404`. A bulk-import row that fails this way carries the same verdict. The driver text is withheld from the client and written to the server log, where the `404` used to leave no line.
16+
- **Unchanged.** An unregistered object is still `404 OBJECT_NOT_FOUND` on every door. A missing relation that names some other table was already `500 DATABASE_ERROR`.

‎packages/core/src/utils/import-runner-sandbox-fault-row.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,10 +485,13 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
485485
it.each([
486486
['SQLite', `SQLITE_ERROR: no such table: ${OBJECT}`],
487487
['Postgres', `relation "${OBJECT}" does not exist`],
488-
])('CONTROL [#22868] — a real %s unknown-object text on the requested object still answers 404', (_driver, text) => {
488+
])('CONTROL [#22868] — a real %s missing-relation text on the requested object keeps the heuristic\'s reading', (_driver, text) => {
489+
// [#27702] That reading is a store fault, not `OBJECT_NOT_FOUND`: no
490+
// unregistered name reaches a driver, so the relation is a registered
491+
// object's missing storage.
489492
const door = mapDataError(new Error(text), OBJECT);
490-
expect(door.status).toBe(404);
491-
expect(door.body.code).toBe('OBJECT_NOT_FOUND');
493+
expect(door.status).toBe(500);
494+
expect(door.body.code).toBe('DATABASE_ERROR');
492495
});
493496

494497
it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => {

‎packages/qa/http-conformance/src/conformance.integration.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,9 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean
171171
// Registering an object after bootstrap misses the boot-time schema sync,
172172
// so nothing has issued this table's DDL. The mingo driver this fixture used
173173
// before #4065 created a table on first touch and hid that; on SQL the
174-
// insert fails with `no such table`, which the REST error mapper turns into
175-
// a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a DDL-shaped cause.
174+
// insert fails with `no such table`, which the REST error mapper used to
175+
// turn into a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a
176+
// DDL-shaped cause (a 500 DATABASE_ERROR since #27702).
176177
await ql.syncObjectSchema('task');
177178
for (const o of authzResolverObjects) ql.registry.registerObject(o.def as never, o.owner);
178179
// A real `single` install's Default Organization: the writes below derive
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27702] A registered object whose table is missing is answered the same
5+
* classified error on its insert as on its read — never `OBJECT_NOT_FOUND`.
6+
*
7+
* ## What was measured
8+
*
9+
* The object is registered after the DDL ran, so the engine resolves it and
10+
* the driver addresses a table that was never created — the card's setup (a
11+
* code object bound to a datasource whose table was never provisioned).
12+
*
13+
* | door | producer of the answer | before |
14+
* |---|---|---|
15+
* | `GET /data/:object`, `GET /data/:object/:id` | driver-sql's declared read-exit fault (`backendStatementFault`) | `500 DATABASE_ERROR` |
16+
* | `POST /data/:object` | the boundary's text classification of the raw driver error (`classifyDataError`) | `404 OBJECT_NOT_FOUND` "Object '…' is not registered" |
17+
*
18+
* The write paths leave the driver as the dialect's raw error by design (the
19+
* boundary turns a unique violation into a 409, an unknown column into a 400),
20+
* so the boundary's missing-relation limb is what answered the insert. It read
21+
* "the relation the caller named is missing" as "the object is not
22+
* registered" — but no unregistered name reaches a driver: the protocol's
23+
* registry gate and the engine's own name resolution refuse it first, with the
24+
* structured `OBJECT_NOT_FOUND` that the classifier matches above every text
25+
* limb. A missing relation is therefore always a registered object whose
26+
* storage is missing, and the read's verdict for it is the classified one.
27+
*
28+
* ## What is pinned
29+
*
30+
* - each door answers the same status and code for the missing table;
31+
* - that answer is the read's `500 DATABASE_ERROR`;
32+
* - the control: an unregistered object is still `404 OBJECT_NOT_FOUND` on
33+
* both doors — the gate's answer, untouched.
34+
*
35+
* The SQLite cell always runs. The live Postgres cell (`42P01 relation "…"
36+
* does not exist`, the card's dialect) runs when `OS_TEST_POSTGRES_URL` is
37+
* set and is a named skip otherwise.
38+
*
39+
* Ablation: restoring the attributed missing-relation 404 in
40+
* `@objectstack/types`' `classifyDataError` turns the agreement cells and the
41+
* insert's own cell red (the POST answers 404 again), while the control stays
42+
* green.
43+
*/
44+
45+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
46+
import { ObjectQL } from '@objectstack/objectql';
47+
import { SqlDriver } from '@objectstack/driver-sql';
48+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
49+
import { RestServer } from './rest-server';
50+
import { installDefaultOrganization } from './default-organization.testkit.js';
51+
52+
/** Registered after the DDL, so its table never exists. */
53+
const OBJECT = 'wh_note_27702';
54+
/** Never registered. */
55+
const GHOST = 'wh_ghost_27702';
56+
57+
const NOTE = {
58+
name: OBJECT,
59+
label: 'Warehouse note',
60+
sharingModel: 'public_read_write',
61+
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
62+
};
63+
64+
interface Cell {
65+
id: 'sqlite' | 'pg';
66+
label: string;
67+
env: string | null;
68+
config: () => Record<string, unknown> | null;
69+
}
70+
71+
const CELLS: readonly Cell[] = [
72+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
73+
{
74+
id: 'pg',
75+
label: 'live postgres',
76+
env: 'OS_TEST_POSTGRES_URL',
77+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
78+
},
79+
];
80+
81+
function createMockServer() {
82+
const noop = () => {};
83+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
84+
}
85+
86+
function makeRes() {
87+
const res: any = {
88+
write: () => true, end: () => {}, setHeader: () => {},
89+
header: () => res,
90+
status: (code: number) => { res._status = code; return res; },
91+
json: (body: any) => { res._json = body; return res; },
92+
};
93+
return res;
94+
}
95+
96+
interface Answer {
97+
status: number;
98+
code: unknown;
99+
}
100+
101+
for (const cell of CELLS) {
102+
const config = cell.config();
103+
describe.skipIf(!config)(
104+
`[#27702] a registered object whose table is missing — insert and read agree — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
105+
() => {
106+
let engine: ObjectQL;
107+
let driver: any;
108+
let call: (method: string, path: string, req: Record<string, unknown>) => Promise<Answer>;
109+
110+
const dropTable = async () => {
111+
if (cell.id === 'sqlite') return;
112+
await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
113+
};
114+
115+
beforeAll(async () => {
116+
driver = new SqlDriver(config as any);
117+
await dropTable();
118+
engine = new ObjectQL();
119+
engine.registerDriver(driver, true);
120+
await engine.init();
121+
await installDefaultOrganization(engine);
122+
// After the DDL: the registry holds the object, the database has no table for it.
123+
engine.registry.registerObject(NOTE as any, '@objectstack/rest');
124+
125+
const protocol = new ObjectStackProtocolImplementation(engine as any);
126+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
127+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
128+
rest.registerRoutes();
129+
call = async (method, path, req) => {
130+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
131+
expect(route, `${method} ${path}`).toBeDefined();
132+
const res = makeRes();
133+
await route!.handler({ method, params: {}, query: {}, body: {}, headers: {}, ...req } as any, res);
134+
return { status: res._status ?? 200, code: res._json?.code };
135+
};
136+
}, 60_000);
137+
138+
afterAll(async () => {
139+
await dropTable();
140+
await engine?.destroy().catch(() => {});
141+
}, 60_000);
142+
143+
const insert = (object: string) =>
144+
call('POST', '/api/v1/data/:object', { params: { object }, body: { title: 'first' } });
145+
const list = (object: string) => call('GET', '/api/v1/data/:object', { params: { object } });
146+
const read = (object: string) => call('GET', '/api/v1/data/:object/:id', { params: { object, id: 'rec_1' } });
147+
148+
it('premise: the object is registered and its table is missing', async () => {
149+
expect(engine.getSchema(OBJECT)).toBeDefined();
150+
await expect(driver.find(OBJECT, {})).rejects.toBeTruthy();
151+
}, 60_000);
152+
153+
it('the read answers the classified store fault: 500 DATABASE_ERROR', async () => {
154+
expect(await list(OBJECT)).toEqual({ status: 500, code: 'DATABASE_ERROR' });
155+
expect(await read(OBJECT)).toEqual({ status: 500, code: 'DATABASE_ERROR' });
156+
}, 60_000);
157+
158+
it('the insert answers the same, never OBJECT_NOT_FOUND', async () => {
159+
const answer = await insert(OBJECT);
160+
expect(answer.code).not.toBe('OBJECT_NOT_FOUND');
161+
expect(answer).toEqual({ status: 500, code: 'DATABASE_ERROR' });
162+
}, 60_000);
163+
164+
it('insert and read agree', async () => {
165+
expect(await insert(OBJECT)).toEqual(await list(OBJECT));
166+
}, 60_000);
167+
168+
it('the update and delete doors answer the same', async () => {
169+
const update = await call('PATCH', '/api/v1/data/:object/:id', { params: { object: OBJECT, id: 'rec_1' }, body: { title: 'second' } });
170+
const remove = await call('DELETE', '/api/v1/data/:object/:id', { params: { object: OBJECT, id: 'rec_1' } });
171+
expect(update).toEqual({ status: 500, code: 'DATABASE_ERROR' });
172+
expect(remove).toEqual({ status: 500, code: 'DATABASE_ERROR' });
173+
}, 60_000);
174+
175+
it('control: an unregistered object is still 404 OBJECT_NOT_FOUND on both doors', async () => {
176+
expect(await insert(GHOST)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' });
177+
expect(await list(GHOST)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' });
178+
}, 60_000);
179+
},
180+
);
181+
}

‎packages/rest/src/error-response.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
* What the file owns, in the order a thrown error meets it:
2121
*
2222
* classification {@link mapDataError} — the error → `{ status, body }` table,
23-
* with {@link declaredHttpStatus}, {@link isScriptFaultMessage}
24-
* and {@link missingRelationIsObject} as its judgements and
23+
* with {@link declaredHttpStatus} and {@link isScriptFaultMessage}
24+
* as its judgements and
2525
* {@link DATA_STORE_FAULT} / {@link UNCLASSIFIED_FAULT} as its
2626
* two sanitised 5xx terminals.
2727
* resolution {@link resolveErrorResponse} — the same answer WITHOUT

‎packages/rest/src/rest-unknown-object-heuristic.test.ts‎

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434
// sanitised, logged 500; per the direction on this issue the safe way to be
3535
// wrong is loud, never silently 404.
3636
//
37+
// [#27702] The attributed half is gone too: a missing relation is a sanitised,
38+
// logged 500 even when it names the request's own object. No unregistered name
39+
// reaches a driver (the registry gates refuse it first, with a structured
40+
// `OBJECT_NOT_FOUND`), so that relation belongs to a registered object whose
41+
// storage is missing, and its read already answers `500 DATABASE_ERROR`.
42+
// § 2's last cell and § 3's driver cell pin it; the reverse-verification table
43+
// below is #5462's, measured before this change.
44+
//
3745
// ---------------------------------------------------------------------------
3846
// Reverse verification, direction predicted BEFORE running
3947
// ---------------------------------------------------------------------------
@@ -337,57 +345,58 @@ describe('[#5462] a missing relation is attributed, or it is a fault', () => {
337345
expect(r.body.code).toBe('DATABASE_ERROR');
338346
});
339347

340-
it('a qualified relation name still attributes: schema prefixes are stripped', () => {
341-
// Prime Directive #6 — object name IS table name — is what makes the
342-
// comparison sound; the schema/catalog qualifier is not part of it.
348+
it('[#27702] the requested object\'s OWN relation is a fault too, in every spelling', () => {
349+
// These used to attribute to a 404 "Object 'acct' is not registered".
350+
// The registry gates refuse an unregistered name before any driver
351+
// sees it, so a driver missing the object's own relation is reporting
352+
// a REGISTERED object whose storage is missing — the read of it
353+
// answers this same `500 DATABASE_ERROR`
354+
// (`data-door-missing-relation-registered-object.test.ts`).
343355
for (const message of [
356+
'no such table: acct',
344357
'no such table: main.acct',
358+
'relation "acct" does not exist',
345359
'relation "public.acct" does not exist',
346360
'SQLITE_ERROR: no such table: "acct"',
361+
'no such table: ACCT',
347362
]) {
348363
const r = mapDataError(driverError(message), 'acct');
349-
expect(r.status, message).toBe(404);
350-
expect(r.body.code, message).toBe('OBJECT_NOT_FOUND');
364+
expect(r.status, message).toBe(500);
365+
expect(r.body.code, message).toBe('DATABASE_ERROR');
351366
}
352367
});
353-
354-
it('attribution is case-insensitive, matching object-name resolution', () => {
355-
const r = mapDataError(driverError('no such table: ACCT'), 'acct');
356-
expect(r.status).toBe(404);
357-
expect(r.body.code).toBe('OBJECT_NOT_FOUND');
358-
});
359368
});
360369

361370
// ---------------------------------------------------------------------------
362371
// 3. The genuine unknown object must stay a quiet 404 (③)
363372
// ---------------------------------------------------------------------------
364373
//
365-
// The half of the semantics this change is not allowed to break. Both producers
366-
// of the verdict are checked, because #3770's whole point is that they land on
367-
// ONE wire envelope.
374+
// The half of the semantics this change is not allowed to break. Since #27702
375+
// the registry gate is the verdict's only producer: the driver's missing-table
376+
// line no longer is, because no unregistered name reaches a driver.
368377

369378
describe('[#5462] a real unknown object is still 404 OBJECT_NOT_FOUND, still silent', () => {
370379
const registryGate = (object: string) =>
371380
Object.assign(new Error(`Object '${object}' not found`), {
372381
code: 'OBJECT_NOT_FOUND', status: 404, object,
373382
});
374383

375-
it('the driver limb: the missing table IS the object asked for', () => {
376-
const r = mapDataError(driverError('no such table: ghost'), 'ghost');
384+
it('the registry gate is the 404\'s source (#3770)', () => {
385+
const r = mapDataError(registryGate('ghost'), 'ghost');
377386
expect(r.status).toBe(404);
378387
expect(r.body.code).toBe('OBJECT_NOT_FOUND');
379388
expect(r.body.error).toBe("Object 'ghost' is not registered");
380389
});
381390

382-
it('gate and driver still produce the identical envelope (#3770)', () => {
383-
expect(mapDataError(registryGate('ghost'), 'ghost'))
384-
.toEqual(mapDataError(driverError('no such table: ghost'), 'ghost'));
385-
});
386-
387-
it('the Postgres spelling of the same condition also stays 404', () => {
388-
const r = mapDataError(driverError('relation "ghost" does not exist'), 'ghost');
389-
expect(r.status).toBe(404);
390-
expect(r.body.code).toBe('OBJECT_NOT_FOUND');
391+
it('[#27702] the driver limb no longer answers it: a missing table behind a name is a store fault', () => {
392+
// An unregistered name never reaches a driver — the gate above refuses
393+
// it — so the driver's missing-relation line, in either dialect, is
394+
// about a registered object whose storage is missing.
395+
for (const message of ['no such table: ghost', 'relation "ghost" does not exist']) {
396+
const r = mapDataError(driverError(message), 'ghost');
397+
expect(r.status, message).toBe(500);
398+
expect(r.body.code, message).toBe('DATABASE_ERROR');
399+
}
391400
});
392401

393402
it('the engine-authored limbs are untouched', () => {
@@ -414,7 +423,7 @@ describe('[#5462] a real unknown object is still 404 OBJECT_NOT_FOUND, still sil
414423
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
415424
}),
416425
getMetaTypes: vi.fn().mockResolvedValue([]),
417-
findData: vi.fn().mockRejectedValue(driverError('no such table: ghost')),
426+
findData: vi.fn().mockRejectedValue(registryGate('ghost')),
418427
});
419428

420429
const res = await callRoute(rest, 'GET', '/api/v1/data/:object', { params: { object: 'ghost' } });
@@ -567,10 +576,9 @@ describe('[#8264] anchored on the driver quoted template, not a bare conjunction
567576

568577
it('§5b decision 2 (the 404 limb): an unquoted "relation <name> does not exist" no longer silently 404s', () => {
569578
// Crafted to isolate decision 2, which the card's own example cannot
570-
// reach: `missingRelationIsObject`'s Postgres branch tolerates
571-
// UNQUOTED names (it answers a different, narrower question — which
572-
// relation, once one is already suspected), so it still extracts
573-
// `acct` and matches it to the object. Under the OLD predicate this
579+
// reach: the attribution helper of the time (removed by #27702)
580+
// tolerated UNQUOTED names, so it still extracted `acct` and matched
581+
// it to the object. Under the OLD predicate this
574582
// fell through to the `looksLikeMissingRelation` OR-limb of
575583
// `looksLikeUnknownObject` and silently answered 404 — the exact
576584
// second consequence the card's own text never measured.
@@ -588,9 +596,9 @@ describe('[#8264] anchored on the driver quoted template, not a bare conjunction
588596
}
589597
});
590598

591-
it('§5d the quoted form still attributes to 404 when the relation IS the object — decision 2, real case, unchanged', () => {
599+
it('§5d [#27702] the quoted form names the object itself and is a store fault, not a 404', () => {
592600
const r = mapDataError(driverError('relation "ghost" does not exist'), 'ghost');
593-
expect(r.status).toBe(404);
594-
expect(r.body.code).toBe('OBJECT_NOT_FOUND');
601+
expect(r.status).toBe(500);
602+
expect(r.body.code).toBe('DATABASE_ERROR');
595603
});
596604
});

0 commit comments

Comments
 (0)