Skip to content

Commit e6db317

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): an unreadable sys_metadata is an outage, not a missing item (#5532) (#5705)
The customization-overlay reads in `getMetaItems`/`getMetaItem` each wrapped their sys_metadata access in a bare `catch {}` and answered with their own empty value, so a metadata store the protocol could not reach was indistinguishable from an item nobody ever customised. The emptiness then travelled the read chain and each consumer named it differently and wrongly: `getMetaItemCached` as `Metadata item <type>/<name> not found`, the `state='draft'` read as `NO_DRAFT`/404, `getMetaItems` as `items: []`. ADR-0110 D3: a miss and an outage are different facts with opposite meanings. #5108 fixed this in DatabaseLoader's plural read and #5089 in listForIndex; this is the same rule on the protocol's own overlay reads. Discrimination is by error TYPE through `isMissingTableError` — the predicate DatabaseLoader (#5108) and SysMetadataRepository (#4867) already ask, so a driver quirk is taught to the platform once. The one benign reason (the table is not provisioned yet) still falls through to the registry; everything else throws 503 + SERVICE_UNAVAILABLE with the driver error as `cause`, which the REST boundary's existing #5437/#5464 sanitising and logging already handle. The terminal miss in `getMetaItemCached` is structured too: 404 + RESOURCE_NOT_FOUND, so a plain miss stops falling out of `mapDataError`'s catch-all as an unattributable 500 (and, pre-#5489, as a 400 shipping the internal wording verbatim). Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b52bc8 commit e6db317

5 files changed

Lines changed: 697 additions & 14 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): 元数据存储读不到不再被讲成「这一项不存在」(#5532)
6+
7+
`sys_metadata` 整体不可达时,`GET /api/v1/meta/object/acct` 会回一个「不存在」——
8+
真相是「读不到」。两个事实的处置方向完全相反(去建一个 / 去修后端),而 Studio、
9+
Setup 在元数据库故障期就是照前者渲染的:每一个对象都显示成「不存在」。
10+
11+
根因在产出方:`getMetaItems` / `getMetaItem` 的四处 customization-overlay 读各自
12+
裹着一个裸 `catch {}`,注释写着 "DB not available" 然后照 miss 处理。空值一路穿过
13+
读链,每个消费方给它起了一个不同却同样错的名字:
14+
15+
- `getMetaItemCached``Metadata item <type>/<name> not found`
16+
- `?state=draft``NO_DRAFT` / 404「没有待发布的草稿」(发布流程读作「没什么可发的」)
17+
- `getMetaItems``items: []`「这个环境一个都没声明」
18+
19+
ADR-0110 D3 已经为这件事立过规矩:miss 与 outage 是两个不同的事实、安全含义相反。
20+
#5108 按这条修掉了 `DatabaseLoader` 的复数读,#5089 修掉了 `listForIndex`;本次是
21+
同一条规矩在协议自己的 overlay 读上,单数与复数一并覆盖。
22+
23+
**改了什么**
24+
25+
1. **区分按错误类型判定,不按异常猜。** 唯一良性的读失败是「`sys_metadata` 还没被
26+
创建」——那时确实没有 overlay 行,落回 registry 就是真相,首次启动也不该爆炸。
27+
判定走 `isMissingTableError`,与 `DatabaseLoader`(#5108)、本包
28+
`SysMetadataRepository`(#4867)同一个谓词,一个驱动怪癖只教给平台一次。其余
29+
一律视为故障。
30+
2. **故障照实上报。** 上抛 `status: 503` / `code: SERVICE_UNAVAILABLE`
31+
(`HttpStatusErrorCodeMap[503]`,ADR-0112 的标准目录码,不新造词汇),驱动原始
32+
错误挂在 `cause` 上。REST 层现有的 #5437 / #5464 消毒与日志口原样接住:客户端拿
33+
到 503 + code(文案按 5xx 规则withheld),运维在日志里拿到完整的驱动报文。
34+
3. **终末 not found 结构化。** 真 miss 现在带 `status: 404` /
35+
`code: RESOURCE_NOT_FOUND`
36+
37+
**wire 可见变化**(把错误答案改成对的答案):
38+
39+
| 场景 | 之前 | 之后 |
40+
|---|---|---|
41+
| 元数据存储不可达 | `404`/`400`/`500` 说「不存在」「没有草稿」「什么都没声明」 | `503` + `SERVICE_UNAVAILABLE`,可重试 |
42+
| 真的没有这一项 | `500` + `INTERNAL_ERROR`(#5489 之前是 `400` 且内部措辞逐字上线) | `404` + `RESOURCE_NOT_FOUND` |
43+
44+
`sys_metadata` 尚未建表这一路径行为不变:仍旧落回 registry / MetadataService,
45+
真查不到时回结构化 404。
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#5532] A `sys_metadata` read that FAILED is not a metadata item that does
4+
// not exist.
5+
//
6+
// ---------------------------------------------------------------------------
7+
// The defect
8+
// ---------------------------------------------------------------------------
9+
// Every customization-overlay read in `getMetaItems` / `getMetaItem` was
10+
// wrapped in a bare `catch {}` whose comment named the reason it was swallowing
11+
// ("DB not available") and then answered as if the row simply was not there.
12+
// The emptiness travelled the whole read chain unremarked and each consumer
13+
// gave it a different, equally wrong name:
14+
//
15+
// GET /meta/object/acct → "Metadata item object/acct not found"
16+
// GET /meta/object/acct?state=draft→ NO_DRAFT / 404 "no pending draft exists"
17+
// GET /meta/object → `items: []` — "this env declares none"
18+
//
19+
// Measured on `origin/main` before the fix, with an engine whose reads reject
20+
// with `connect ECONNREFUSED 10.0.0.5:5432`:
21+
//
22+
// RESOLVE getMetaItem(econnrefused) -> { type, name, ...no item }
23+
// THROW getMetaItemCached(econnrefused) status=undefined code=undefined
24+
// msg=Metadata item object/acct not found
25+
// THROW getMetaItem(state=draft, …) status=404 code=NO_DRAFT
26+
// RESOLVE getMetaItems(econnrefused) -> { items: [] }
27+
//
28+
// ADR-0110 D3 is the rule those answers break: a miss and an outage are
29+
// different facts with opposite meanings, and the dispositions they call for
30+
// are opposite too — "create it / fix your link" vs. "the backend is down,
31+
// retry". #5108 fixed exactly this in `DatabaseLoader`'s plural read and #5089
32+
// in `listForIndex`; this is the same rule one layer up, on the protocol's own
33+
// overlay reads, singular and plural.
34+
//
35+
// ---------------------------------------------------------------------------
36+
// The one benign reason, and why the discrimination is by error TYPE
37+
// ---------------------------------------------------------------------------
38+
// `sys_metadata` not provisioned yet: there are then genuinely no overlay rows,
39+
// so falling through to the registry IS the truth and first boot must not
40+
// explode. That is `isMissingTableError` — the same predicate `DatabaseLoader`
41+
// (#5108) and this package's `SysMetadataRepository` (#4867) ask, so a driver
42+
// quirk is taught to the platform once. Everything else is an outage.
43+
//
44+
// ---------------------------------------------------------------------------
45+
// Reverse verification, direction predicted BEFORE running
46+
// ---------------------------------------------------------------------------
47+
// Ordinary red, on both halves, and they fail differently — which is the point:
48+
//
49+
// * Restore `} catch { /* DB not available */ }` at the four overlay reads →
50+
// 7 red / 5 green, and they go red in exactly the shape the issue reported:
51+
// the singular and preview reads RESOLVE with no item, the plural reads
52+
// resolve `{ items: [] }`, the draft read throws 404. (Predicted 6 — the
53+
// six outage cases; the seventh is the miss-vs-outage comparison, whose
54+
// OUTAGE half is one of the same six. Recorded rather than rounded off.)
55+
// * Restore `throw new Error(\`Metadata item …/… not found\`)` → 3 red /
56+
// 9 green: the two "a real miss is a structured 404" cases plus the benign
57+
// first-boot miss, all on `status`/`code` being `undefined`, while every
58+
// 503 case stays GREEN. That separation is deliberate: it is what proves
59+
// the 404 is fix C's own contribution and not an artifact of the outage
60+
// split.
61+
//
62+
// The "benign / working store" describe is the opposite guard — it exists to
63+
// catch the overreach where a fix starts calling first boot, or a plain
64+
// unreferenced item, an outage.
65+
66+
import { describe, it, expect, vi } from 'vitest';
67+
import { ErrorCode } from '@objectstack/spec/api';
68+
import { ObjectStackProtocolImplementation } from './protocol.js';
69+
70+
/** A registry with nothing in it — the overlay read is the only source. */
71+
function emptyRegistry(items: Record<string, any> = {}) {
72+
return {
73+
getObject: () => undefined,
74+
getItem: (_type: string, name: string) => items[name],
75+
listItems: () => [],
76+
applyNavContributions: (x: any) => x,
77+
isPackageDisabled: () => false,
78+
getObjectOwner: () => undefined,
79+
};
80+
}
81+
82+
/**
83+
* An engine whose every read REJECTS with `error` — the shape of a metadata
84+
* store the protocol cannot reach.
85+
*/
86+
function engineThatCannotBeRead(error: () => unknown, registryItems: Record<string, any> = {}) {
87+
const reject = vi.fn(async () => { throw error(); });
88+
return {
89+
registry: emptyRegistry(registryItems),
90+
find: reject,
91+
findOne: reject,
92+
} as any;
93+
}
94+
95+
/** An engine that answers reads normally, from `rows`. */
96+
function engineWithRows(rows: any[] = [], registryItems: Record<string, any> = {}) {
97+
return {
98+
registry: emptyRegistry(registryItems),
99+
find: vi.fn(async () => rows),
100+
findOne: vi.fn(async () => rows[0] ?? null),
101+
} as any;
102+
}
103+
104+
/** The real driver phrasings for "the table has not been provisioned yet". */
105+
const missingTable = () =>
106+
Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR' });
107+
108+
/** An outage: the rows may well exist and simply were not seen. */
109+
const connectionRefused = () =>
110+
Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' });
111+
112+
/** Capture a rejection without letting a resolve pass silently. */
113+
async function rejection(run: () => Promise<unknown>): Promise<any> {
114+
let caught: any;
115+
let resolved: unknown;
116+
let didResolve = false;
117+
try {
118+
resolved = await run();
119+
didResolve = true;
120+
} catch (e) {
121+
caught = e;
122+
}
123+
expect(
124+
didResolve,
125+
`expected a rejection, but the call resolved with ${JSON.stringify(resolved)}`,
126+
).toBe(false);
127+
return caught;
128+
}
129+
130+
/** Every assertion the outage envelope owes a caller. */
131+
function expectStoreUnavailable(caught: any, cause: unknown) {
132+
expect(caught?.status).toBe(503);
133+
expect(caught?.code).toBe('SERVICE_UNAVAILABLE');
134+
// ADR-0112: the wire code must be in the declared vocabulary, or the
135+
// envelope fails `ApiErrorSchema.parse` at the boundary that ships it.
136+
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
137+
// The words a client reads say "unknown", never "does not exist".
138+
expect(caught.message).toContain('unknown');
139+
expect(caught.message.toLowerCase()).not.toContain('not found');
140+
// The driver's own error is not lost — it rides as `cause`, which is what
141+
// `logWithheldServerFault` prints for the operator (#5437).
142+
expect(caught.cause).toBe(cause);
143+
}
144+
145+
describe('[#5532] an unreadable sys_metadata is a 503, not "that item does not exist"', () => {
146+
it('the singular active read no longer answers a miss it never verified', async () => {
147+
const err = connectionRefused();
148+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
149+
150+
const caught = await rejection(() => p.getMetaItem({ type: 'object', name: 'acct' } as any));
151+
expectStoreUnavailable(caught, err);
152+
});
153+
154+
it('getMetaItemCached propagates the outage instead of relabelling it "not found"', async () => {
155+
const err = connectionRefused();
156+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
157+
158+
const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
159+
expectStoreUnavailable(caught, err);
160+
// The regression this replaces, verbatim.
161+
expect(caught.message).not.toContain('Metadata item object/acct not found');
162+
});
163+
164+
it('the draft read stops reporting an outage as "there is no pending draft"', async () => {
165+
const err = connectionRefused();
166+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
167+
168+
const caught = await rejection(
169+
() => p.getMetaItem({ type: 'object', name: 'acct', state: 'draft' } as any),
170+
);
171+
expectStoreUnavailable(caught, err);
172+
// NO_DRAFT is a lifecycle fact ("nobody is editing this"). A publish
173+
// flow reads it as "nothing to publish" and moves on.
174+
expect(caught.code).not.toBe('NO_DRAFT');
175+
});
176+
177+
it('the ?preview=draft overlay stops silently serving the published world', async () => {
178+
const err = connectionRefused();
179+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
180+
181+
const caught = await rejection(
182+
() => p.getMetaItem({ type: 'object', name: 'acct', previewDrafts: true } as any),
183+
);
184+
expectStoreUnavailable(caught, err);
185+
});
186+
187+
it('the PLURAL read stops answering "this environment declares none of these"', async () => {
188+
const err = connectionRefused();
189+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
190+
191+
const caught = await rejection(() => p.getMetaItems({ type: 'object' } as any));
192+
expectStoreUnavailable(caught, err);
193+
});
194+
195+
it('the plural draft-preview overlay is held to the same rule', async () => {
196+
const err = connectionRefused();
197+
// The active overlay read must succeed so control actually reaches the
198+
// draft-preview block: only its own read fails.
199+
const engine = engineWithRows([]);
200+
let call = 0;
201+
engine.find = vi.fn(async (_o: string, opts: any) => {
202+
call += 1;
203+
if (opts?.where?.state === 'draft') throw err;
204+
return [];
205+
});
206+
207+
const p = new ObjectStackProtocolImplementation(engine);
208+
const caught = await rejection(
209+
() => p.getMetaItems({ type: 'object', previewDrafts: true } as any),
210+
);
211+
expectStoreUnavailable(caught, err);
212+
expect(call).toBeGreaterThan(1); // the active read really did run first
213+
});
214+
});
215+
216+
describe('[#5532 / fix C] a REAL miss is a structured 404, not an unattributable throw', () => {
217+
it('getMetaItemCached carries status 404 + the catalog code', async () => {
218+
const p = new ObjectStackProtocolImplementation(engineWithRows([]));
219+
220+
const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
221+
expect(caught.status).toBe(404);
222+
expect(caught.code).toBe('RESOURCE_NOT_FOUND');
223+
expect(ErrorCode.safeParse(caught.code).success).toBe(true);
224+
expect(caught.message).toBe('Metadata item object/ghost not found');
225+
});
226+
227+
it('is distinguishable from the outage by code alone — which is the whole point', async () => {
228+
const missP = new ObjectStackProtocolImplementation(engineWithRows([]));
229+
const outageP = new ObjectStackProtocolImplementation(
230+
engineThatCannotBeRead(connectionRefused),
231+
);
232+
233+
const miss = await rejection(() => missP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
234+
const outage = await rejection(() => outageP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
235+
236+
expect([miss.status, miss.code]).toEqual([404, 'RESOURCE_NOT_FOUND']);
237+
expect([outage.status, outage.code]).toEqual([503, 'SERVICE_UNAVAILABLE']);
238+
});
239+
});
240+
241+
describe('[#5532] the benign case and the healthy case are untouched', () => {
242+
it('an unprovisioned sys_metadata still falls through to the registry', async () => {
243+
// First boot: the table does not exist, so "no overlay row" IS the
244+
// truth and the code-authored item must still be served.
245+
const p = new ObjectStackProtocolImplementation(
246+
engineThatCannotBeRead(missingTable, { acct: { name: 'acct', label: 'Account' } }),
247+
);
248+
249+
const res: any = await p.getMetaItem({ type: 'object', name: 'acct' } as any);
250+
expect(res.item?.name).toBe('acct');
251+
expect(res.item?.label).toBe('Account');
252+
});
253+
254+
it('an unprovisioned sys_metadata + nothing anywhere is a 404 miss, not a 503', async () => {
255+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));
256+
257+
const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
258+
expect(caught.status).toBe(404);
259+
expect(caught.code).toBe('RESOURCE_NOT_FOUND');
260+
});
261+
262+
it('an unprovisioned sys_metadata still lists the registry items (plural)', async () => {
263+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));
264+
265+
const res: any = await p.getMetaItems({ type: 'object' } as any);
266+
expect(res.items).toEqual([]);
267+
});
268+
269+
it('a healthy store still serves the overlay row it holds', async () => {
270+
const p = new ObjectStackProtocolImplementation(
271+
engineWithRows([
272+
{ type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Overlaid' }) },
273+
]),
274+
);
275+
276+
const res: any = await p.getMetaItem({ type: 'object', name: 'acct' } as any);
277+
expect(res.item?.label).toBe('Overlaid');
278+
});
279+
});

0 commit comments

Comments
 (0)