Skip to content

Commit 65159ae

Browse files
baozhoutaoclaude
andauthored
fix(metadata-protocol): getMetaItemLayered reports an unreadable overlay instead of "no overlay" (#5707) (#5843)
The layered read's `sys_metadata` overlay lookup was wrapped in a bare `catch` that fell through to `overlay: null`. That is not a neutral placeholder: the envelope then makes three positive claims at once, with HTTP 200 — `overlay: null` ("never customised"), `overlayScope: null` ("no scope holds a row") and `effective === code` ("what runs today is the packaged artifact, verbatim"). The Studio diff tab exists to answer "what did I change?"; during an outage it answered "nothing". Same defect and same discrimination as #5532 / PR #5705, on the one overlay read that PR deliberately did not reach (scope = the issue): `rethrowUnlessMetadataStoreUnprovisioned` lets `isMissingTableError` through (an unprovisioned `sys_metadata` genuinely holds no overlay row, so first boot still renders the code layer) and turns every other error into `503` / `SERVICE_UNAVAILABLE` with the driver error on `cause`. No new return shape: the envelope stays a 3-LAYER shape, not a 3-VALUE one. Coverage joins the same-family outage file rather than a new isolated one. Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2779911 commit 65159ae

3 files changed

Lines changed: 208 additions & 2 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): 分层读的 overlay 读失败不再被画成「这一项没有定制」(#5707)
6+
7+
`getMetaItemLayered` 是 Studio「code / overlay / effective」对比视图背后的那次读
8+
(`GET /api/v1/meta/:type/:name?layers=true`)。它的 `sys_metadata` overlay 读裹着一个
9+
`catch`,注释写着 "DB unavailable — overlay stays null" 然后照「没有 overlay 行」
10+
返回。
11+
12+
那不是一个中性的兜底值。这个信封在**同一次响应里同时给出三个正面断言**,而且是 200:
13+
14+
- `overlay: null` —— 「这一项从来没有被定制过」;
15+
- `overlayScope: null` —— 「org 和 env 两个作用域都没有行」;
16+
- `effective === code` —— 「现在生效的就是打包件原样」。
17+
18+
对比视图存在的意义正是回答作者「我改过什么」。故障期它回答「什么都没改过」——
19+
#5532 同一个错误(可用性故障被讲成作者的声明事实),只是落在 diff 视图而不是 404 上。
20+
本次沿用 #5532 / PR #5705 的判定,补上该 PR 按 scope 刻意没有覆盖到的这一处读。
21+
22+
**改了什么**:这一处 `catch` 改为调用同文件的 `rethrowUnlessMetadataStoreUnprovisioned`
23+
—— `isMissingTableError`(表尚未建 → 确实没有 overlay 行)良性放行,其余上抛
24+
`status: 503` / `code: SERVICE_UNAVAILABLE`,驱动原始错误挂在 `cause` 上。没有新增
25+
判定逻辑,也没有新的返回形状:分层信封仍是 code / overlay / effective 三****,而不是
26+
每层三**** —— 「读不到」不是一层,所以照失败上报,不再冒充某一层的取值。
27+
28+
**wire 可见变化**
29+
30+
| 场景 | 之前 | 之后 |
31+
|---|---|---|
32+
| `sys_metadata` 不可达 | `200` + `overlay: null` / `overlayScope: null` / `effective = code` | `503` + `SERVICE_UNAVAILABLE`(`cause` 带驱动报文),可重试 |
33+
| org 作用域读失败、env 行本可读 | `200`,连那行 env overlay 也一并报告为「没有」 | `503`,同上 |
34+
| `sys_metadata` 尚未建表 | `200` + 只有 code 层 | 不变 |
35+
| 存储正常 | 不变 | 不变 |
36+
37+
REST 侧无需改动:`?layers=true` 与普通读共用同一个 `handleRouteError`,#5437 / #5464
38+
的消毒与日志口原样接住。已测量的消费方处置也都已就位:objectui
39+
`MetadataClient.layered()` 对非 2xx 一律 `throw`(只有 404 映射为空信封),
40+
ResourceEditPage 的加载 `try/catch` 把它渲染成错误态而不是空白页;
41+
`plugin-security` 的三个消费点里,两处本就有 `catch` 兜底,唯一没有的
42+
`projectPermissionMutation` 在 503 化后反而更安全 —— 此前的静默 `null` 会让权限集
43+
投影悄悄退回打包基线(`customized: false`),没有 declared body 时甚至会把记录
44+
retire,而协议的 `runMutationProjector` 契约是 never throws,会把 503 收敛成
45+
`projectionApplied: { success: false }`

packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,132 @@ describe('[#5532] the benign case and the healthy case are untouched', () => {
277277
expect(res.item?.label).toBe('Overlaid');
278278
});
279279
});
280+
281+
// ---------------------------------------------------------------------------
282+
// [#5707] The same rule on the LAYERED read — kept in this file on purpose
283+
// ---------------------------------------------------------------------------
284+
// `getMetaItemLayered` is a different method and a different read, which is why
285+
// PR #5705 did not reach it (scope = the issue). It is the same DEFECT, so its
286+
// coverage lives next to the four reads above: a future edit that re-widens one
287+
// catch and not the others is then a diff in one file, and the shared
288+
// `expectStoreUnavailable` keeps the envelope from drifting per-method.
289+
//
290+
// What the swallow answered is worse-shaped here than on the singular read. A
291+
// 404 at least says "I have no item for you"; the layered envelope makes three
292+
// POSITIVE claims at once, all of them about what the author declared:
293+
//
294+
// overlay: null → "this item has never been customised"
295+
// overlayScope: null → "no org and no env scope holds a row"
296+
// effective === code → "what runs today is the packaged artifact, verbatim"
297+
//
298+
// and it makes them with HTTP 200, so no client can tell. The Studio diff tab
299+
// exists to answer "what did I change?" — during an outage it answered
300+
// "nothing", which is #5532's error (an availability failure told as an
301+
// authorship fact) landing in the diff view instead of on a 404.
302+
//
303+
// Reverse verification, direction predicted BEFORE running: ordinary red.
304+
// Restoring `} catch { /* DB unavailable — overlay stays null */ }` turns the
305+
// three outage cases below red (they resolve instead of throwing) and leaves
306+
// the three benign/healthy cases green — that separation is what shows the
307+
// change is the outage split and not a blanket "layered now throws".
308+
309+
describe('[#5707] the layered read stops painting an outage as "nothing was customised"', () => {
310+
/** The registry's artifact baseline — the layer an outage used to promote to `effective`. */
311+
const codeBaseline = { name: 'acct', label: 'Account (packaged)' };
312+
313+
it('throws the same 503 envelope instead of resolving a fabricated 3-layer view', async () => {
314+
const err = connectionRefused();
315+
const p = new ObjectStackProtocolImplementation(
316+
engineThatCannotBeRead(() => err, { acct: codeBaseline }),
317+
);
318+
319+
const caught = await rejection(
320+
() => p.getMetaItemLayered({ type: 'object', name: 'acct' } as any),
321+
);
322+
expectStoreUnavailable(caught, err);
323+
});
324+
325+
it('the two failures that used to render IDENTICALLY are now told apart', async () => {
326+
// Same registry, same request, same all-reads-fail engine — only the
327+
// error TYPE differs, and before the fix both produced byte-identical
328+
// envelopes (`overlay: null`, `overlayScope: null`, `effective = code`,
329+
// HTTP 200). That indistinguishability IS the defect: one of them means
330+
// "nothing was ever customised", the other means "I could not look".
331+
const benign = new ObjectStackProtocolImplementation(
332+
engineThatCannotBeRead(missingTable, { acct: codeBaseline }),
333+
);
334+
const outage = new ObjectStackProtocolImplementation(
335+
engineThatCannotBeRead(connectionRefused, { acct: codeBaseline }),
336+
);
337+
338+
const firstBoot: any = await benign.getMetaItemLayered({ type: 'object', name: 'acct' } as any);
339+
expect([firstBoot.overlay, firstBoot.overlayScope]).toEqual([null, null]);
340+
expect(firstBoot.effective).toBe(firstBoot.code);
341+
342+
const caught = await rejection(
343+
() => outage.getMetaItemLayered({ type: 'object', name: 'acct' } as any),
344+
);
345+
expect([caught.status, caught.code]).toEqual([503, 'SERVICE_UNAVAILABLE']);
346+
});
347+
348+
it('a failed ORG-scope read does not silently demote to the env row it never got to', async () => {
349+
// The org lookup runs first and the env lookup is inside the same
350+
// `try`, so the swallow hid BOTH: an env-wide overlay row that was
351+
// perfectly readable was reported as "no overlay" because the org read
352+
// failed ahead of it.
353+
const err = connectionRefused();
354+
const engine = engineWithRows([]);
355+
engine.findOne = vi.fn(async (_o: string, opts: any) => {
356+
if (opts?.where?.organization_id === 'org_acme') throw err;
357+
return { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) };
358+
});
359+
360+
const caught = await rejection(
361+
() => p_layered(engine, { type: 'object', name: 'acct', organizationId: 'org_acme' }),
362+
);
363+
expectStoreUnavailable(caught, err);
364+
});
365+
366+
it('an unprovisioned sys_metadata still renders the code layer (benign, unchanged)', async () => {
367+
// First boot: no overlay row EXISTS, so `overlay: null`,
368+
// `overlayScope: null` and `effective === code` are the truth — and the
369+
// diff tab must keep rendering rather than 503 on every fresh install.
370+
const p = new ObjectStackProtocolImplementation(
371+
engineThatCannotBeRead(missingTable, { acct: codeBaseline }),
372+
);
373+
374+
const res: any = await p.getMetaItemLayered({ type: 'object', name: 'acct' } as any);
375+
expect(res.code).toMatchObject({ label: 'Account (packaged)' });
376+
expect(res.overlay).toBeNull();
377+
expect(res.overlayScope).toBeNull();
378+
expect(res.effective).toBe(res.code);
379+
});
380+
381+
it('an unprovisioned sys_metadata + nothing anywhere is still an all-null envelope, not a 503', async () => {
382+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));
383+
384+
const res: any = await p.getMetaItemLayered({ type: 'object', name: 'ghost' } as any);
385+
expect(res.code).toBeNull();
386+
expect(res.overlay).toBeNull();
387+
expect(res.effective).toBeNull();
388+
});
389+
390+
it('a healthy store still reports the overlay layer and its scope', async () => {
391+
const engine = engineWithRows([], { acct: codeBaseline });
392+
engine.findOne = vi.fn(async (_o: string, opts: any) =>
393+
(opts?.where?.organization_id === null
394+
? { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }
395+
: null),
396+
);
397+
398+
const res: any = await p_layered(engine, { type: 'object', name: 'acct' });
399+
expect(res.overlay).toMatchObject({ label: 'Env overlay' });
400+
expect(res.overlayScope).toBe('env');
401+
expect(res.effective).toBe(res.overlay);
402+
});
403+
});
404+
405+
/** `getMetaItemLayered` on a protocol built over `engine` — the call is 3 lines otherwise. */
406+
function p_layered(engine: any, request: Record<string, unknown>): Promise<any> {
407+
return new ObjectStackProtocolImplementation(engine).getMetaItemLayered(request as any);
408+
}

packages/metadata-protocol/src/protocol.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3701,6 +3701,19 @@ export class ObjectStackProtocolImplementation implements
37013701
* `code` is null if no artifact baseline exists; `overlay` is null if
37023702
* no sys_metadata row exists for the requested scope; `effective` is
37033703
* never null when either layer exists.
3704+
*
3705+
* [#5707] Those three sentences are ASSERTIONS about what the author
3706+
* declared, so the method may only make them from a read that happened.
3707+
* The layers are a 3-LAYER shape (code / overlay / effective), not a
3708+
* 3-VALUE one: there is no "unknown" spelling for a layer, and the null
3709+
* that would have to stand in for it already means "not customised". So
3710+
* an overlay read that failed is reported as a failure, never as a layer.
3711+
*
3712+
* @throws {@link metadataStoreUnavailableError} — 503 /
3713+
* `SERVICE_UNAVAILABLE`, driver error on `cause`, when the
3714+
* `sys_metadata` overlay read fails for any reason other than the
3715+
* table not being provisioned yet (which genuinely means "no
3716+
* overlay row" and still returns normally).
37043717
*/
37053718
async getMetaItemLayered(request: {
37063719
type: string;
@@ -3823,8 +3836,27 @@ export class ObjectStackProtocolImplementation implements
38233836
overlayScope = 'env';
38243837
}
38253838
}
3826-
} catch {
3827-
// DB unavailable — overlay stays null
3839+
} catch (error) {
3840+
// [#5707] The same rule as the four overlay reads in
3841+
// `getMetaItems` / `getMetaItem` (#5532), on the one overlay read
3842+
// PR #5705 deliberately did not reach.
3843+
//
3844+
// Swallowing here does not answer 404 — it answers something this
3845+
// method states positively in THREE fields at once: `overlay: null`
3846+
// ("nothing was ever customised"), `overlayScope: null` ("no scope
3847+
// holds a row"), and `effective = code` ("what runs today is the
3848+
// packaged artifact, verbatim"). The whole point of the layered
3849+
// read is to show an author what they changed; during an outage it
3850+
// told them they had changed nothing, which is the #5532 error —
3851+
// an availability failure reported as an authorship fact — landing
3852+
// in the diff view instead of on a 404.
3853+
//
3854+
// The benign case is unchanged and is why this is not a bare
3855+
// rethrow: an unprovisioned `sys_metadata` genuinely holds no
3856+
// overlay row, so `overlay: null` / `effective = code` IS the truth
3857+
// and first boot still renders the code layer.
3858+
// See {@link rethrowUnlessMetadataStoreUnprovisioned}.
3859+
this.rethrowUnlessMetadataStoreUnprovisioned(error);
38283860
}
38293861

38303862
const effective: unknown | null = overlay ?? code;

0 commit comments

Comments
 (0)