From 193ef84c380005ed0fcedc7a657a9f3126d2ce19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:40:59 +0000 Subject: [PATCH] fix(metadata-protocol): getMetaItemLayered reports an unreadable overlay instead of "no overlay" (#5707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .../layered-read-outage-is-not-no-overlay.md | 45 ++++++ .../protocol.metadata-store-outage.test.ts | 129 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 36 ++++- 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 .changeset/layered-read-outage-is-not-no-overlay.md diff --git a/.changeset/layered-read-outage-is-not-no-overlay.md b/.changeset/layered-read-outage-is-not-no-overlay.md new file mode 100644 index 0000000000..10d8188d75 --- /dev/null +++ b/.changeset/layered-read-outage-is-not-no-overlay.md @@ -0,0 +1,45 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): 分层读的 overlay 读失败不再被画成「这一项没有定制」(#5707) + +`getMetaItemLayered` 是 Studio「code / overlay / effective」对比视图背后的那次读 +(`GET /api/v1/meta/:type/:name?layers=true`)。它的 `sys_metadata` overlay 读裹着一个 +裸 `catch`,注释写着 "DB unavailable — overlay stays null" 然后照「没有 overlay 行」 +返回。 + +那不是一个中性的兜底值。这个信封在**同一次响应里同时给出三个正面断言**,而且是 200: + +- `overlay: null` —— 「这一项从来没有被定制过」; +- `overlayScope: null` —— 「org 和 env 两个作用域都没有行」; +- `effective === code` —— 「现在生效的就是打包件原样」。 + +对比视图存在的意义正是回答作者「我改过什么」。故障期它回答「什么都没改过」—— +和 #5532 同一个错误(可用性故障被讲成作者的声明事实),只是落在 diff 视图而不是 404 上。 +本次沿用 #5532 / PR #5705 的判定,补上该 PR 按 scope 刻意没有覆盖到的这一处读。 + +**改了什么**:这一处 `catch` 改为调用同文件的 `rethrowUnlessMetadataStoreUnprovisioned` +—— `isMissingTableError`(表尚未建 → 确实没有 overlay 行)良性放行,其余上抛 +`status: 503` / `code: SERVICE_UNAVAILABLE`,驱动原始错误挂在 `cause` 上。没有新增 +判定逻辑,也没有新的返回形状:分层信封仍是 code / overlay / effective 三**层**,而不是 +每层三**态** —— 「读不到」不是一层,所以照失败上报,不再冒充某一层的取值。 + +**wire 可见变化** + +| 场景 | 之前 | 之后 | +|---|---|---| +| `sys_metadata` 不可达 | `200` + `overlay: null` / `overlayScope: null` / `effective = code` | `503` + `SERVICE_UNAVAILABLE`(`cause` 带驱动报文),可重试 | +| org 作用域读失败、env 行本可读 | `200`,连那行 env overlay 也一并报告为「没有」 | `503`,同上 | +| `sys_metadata` 尚未建表 | `200` + 只有 code 层 | 不变 | +| 存储正常 | 不变 | 不变 | + +REST 侧无需改动:`?layers=true` 与普通读共用同一个 `handleRouteError`,#5437 / #5464 +的消毒与日志口原样接住。已测量的消费方处置也都已就位:objectui 的 +`MetadataClient.layered()` 对非 2xx 一律 `throw`(只有 404 映射为空信封), +ResourceEditPage 的加载 `try/catch` 把它渲染成错误态而不是空白页; +`plugin-security` 的三个消费点里,两处本就有 `catch` 兜底,唯一没有的 +`projectPermissionMutation` 在 503 化后反而更安全 —— 此前的静默 `null` 会让权限集 +投影悄悄退回打包基线(`customized: false`),没有 declared body 时甚至会把记录 +retire,而协议的 `runMutationProjector` 契约是 never throws,会把 503 收敛成 +`projectionApplied: { success: false }`。 diff --git a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts index 98982752c4..5188c2f4b7 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -277,3 +277,132 @@ describe('[#5532] the benign case and the healthy case are untouched', () => { expect(res.item?.label).toBe('Overlaid'); }); }); + +// --------------------------------------------------------------------------- +// [#5707] The same rule on the LAYERED read — kept in this file on purpose +// --------------------------------------------------------------------------- +// `getMetaItemLayered` is a different method and a different read, which is why +// PR #5705 did not reach it (scope = the issue). It is the same DEFECT, so its +// coverage lives next to the four reads above: a future edit that re-widens one +// catch and not the others is then a diff in one file, and the shared +// `expectStoreUnavailable` keeps the envelope from drifting per-method. +// +// What the swallow answered is worse-shaped here than on the singular read. A +// 404 at least says "I have no item for you"; the layered envelope makes three +// POSITIVE claims at once, all of them about what the author declared: +// +// overlay: null → "this item has never been customised" +// overlayScope: null → "no org and no env scope holds a row" +// effective === code → "what runs today is the packaged artifact, verbatim" +// +// and it makes them with HTTP 200, so no client can tell. The Studio diff tab +// exists to answer "what did I change?" — during an outage it answered +// "nothing", which is #5532's error (an availability failure told as an +// authorship fact) landing in the diff view instead of on a 404. +// +// Reverse verification, direction predicted BEFORE running: ordinary red. +// Restoring `} catch { /* DB unavailable — overlay stays null */ }` turns the +// three outage cases below red (they resolve instead of throwing) and leaves +// the three benign/healthy cases green — that separation is what shows the +// change is the outage split and not a blanket "layered now throws". + +describe('[#5707] the layered read stops painting an outage as "nothing was customised"', () => { + /** The registry's artifact baseline — the layer an outage used to promote to `effective`. */ + const codeBaseline = { name: 'acct', label: 'Account (packaged)' }; + + it('throws the same 503 envelope instead of resolving a fabricated 3-layer view', async () => { + const err = connectionRefused(); + const p = new ObjectStackProtocolImplementation( + engineThatCannotBeRead(() => err, { acct: codeBaseline }), + ); + + const caught = await rejection( + () => p.getMetaItemLayered({ type: 'object', name: 'acct' } as any), + ); + expectStoreUnavailable(caught, err); + }); + + it('the two failures that used to render IDENTICALLY are now told apart', async () => { + // Same registry, same request, same all-reads-fail engine — only the + // error TYPE differs, and before the fix both produced byte-identical + // envelopes (`overlay: null`, `overlayScope: null`, `effective = code`, + // HTTP 200). That indistinguishability IS the defect: one of them means + // "nothing was ever customised", the other means "I could not look". + const benign = new ObjectStackProtocolImplementation( + engineThatCannotBeRead(missingTable, { acct: codeBaseline }), + ); + const outage = new ObjectStackProtocolImplementation( + engineThatCannotBeRead(connectionRefused, { acct: codeBaseline }), + ); + + const firstBoot: any = await benign.getMetaItemLayered({ type: 'object', name: 'acct' } as any); + expect([firstBoot.overlay, firstBoot.overlayScope]).toEqual([null, null]); + expect(firstBoot.effective).toBe(firstBoot.code); + + const caught = await rejection( + () => outage.getMetaItemLayered({ type: 'object', name: 'acct' } as any), + ); + expect([caught.status, caught.code]).toEqual([503, 'SERVICE_UNAVAILABLE']); + }); + + it('a failed ORG-scope read does not silently demote to the env row it never got to', async () => { + // The org lookup runs first and the env lookup is inside the same + // `try`, so the swallow hid BOTH: an env-wide overlay row that was + // perfectly readable was reported as "no overlay" because the org read + // failed ahead of it. + const err = connectionRefused(); + const engine = engineWithRows([]); + engine.findOne = vi.fn(async (_o: string, opts: any) => { + if (opts?.where?.organization_id === 'org_acme') throw err; + return { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }; + }); + + const caught = await rejection( + () => p_layered(engine, { type: 'object', name: 'acct', organizationId: 'org_acme' }), + ); + expectStoreUnavailable(caught, err); + }); + + it('an unprovisioned sys_metadata still renders the code layer (benign, unchanged)', async () => { + // First boot: no overlay row EXISTS, so `overlay: null`, + // `overlayScope: null` and `effective === code` are the truth — and the + // diff tab must keep rendering rather than 503 on every fresh install. + const p = new ObjectStackProtocolImplementation( + engineThatCannotBeRead(missingTable, { acct: codeBaseline }), + ); + + const res: any = await p.getMetaItemLayered({ type: 'object', name: 'acct' } as any); + expect(res.code).toMatchObject({ label: 'Account (packaged)' }); + expect(res.overlay).toBeNull(); + expect(res.overlayScope).toBeNull(); + expect(res.effective).toBe(res.code); + }); + + it('an unprovisioned sys_metadata + nothing anywhere is still an all-null envelope, not a 503', async () => { + const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable)); + + const res: any = await p.getMetaItemLayered({ type: 'object', name: 'ghost' } as any); + expect(res.code).toBeNull(); + expect(res.overlay).toBeNull(); + expect(res.effective).toBeNull(); + }); + + it('a healthy store still reports the overlay layer and its scope', async () => { + const engine = engineWithRows([], { acct: codeBaseline }); + engine.findOne = vi.fn(async (_o: string, opts: any) => + (opts?.where?.organization_id === null + ? { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) } + : null), + ); + + const res: any = await p_layered(engine, { type: 'object', name: 'acct' }); + expect(res.overlay).toMatchObject({ label: 'Env overlay' }); + expect(res.overlayScope).toBe('env'); + expect(res.effective).toBe(res.overlay); + }); +}); + +/** `getMetaItemLayered` on a protocol built over `engine` — the call is 3 lines otherwise. */ +function p_layered(engine: any, request: Record): Promise { + return new ObjectStackProtocolImplementation(engine).getMetaItemLayered(request as any); +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fd5fbd860b..4e3647e16d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3701,6 +3701,19 @@ export class ObjectStackProtocolImplementation implements * `code` is null if no artifact baseline exists; `overlay` is null if * no sys_metadata row exists for the requested scope; `effective` is * never null when either layer exists. + * + * [#5707] Those three sentences are ASSERTIONS about what the author + * declared, so the method may only make them from a read that happened. + * The layers are a 3-LAYER shape (code / overlay / effective), not a + * 3-VALUE one: there is no "unknown" spelling for a layer, and the null + * that would have to stand in for it already means "not customised". So + * an overlay read that failed is reported as a failure, never as a layer. + * + * @throws {@link metadataStoreUnavailableError} — 503 / + * `SERVICE_UNAVAILABLE`, driver error on `cause`, when the + * `sys_metadata` overlay read fails for any reason other than the + * table not being provisioned yet (which genuinely means "no + * overlay row" and still returns normally). */ async getMetaItemLayered(request: { type: string; @@ -3823,8 +3836,27 @@ export class ObjectStackProtocolImplementation implements overlayScope = 'env'; } } - } catch { - // DB unavailable — overlay stays null + } catch (error) { + // [#5707] The same rule as the four overlay reads in + // `getMetaItems` / `getMetaItem` (#5532), on the one overlay read + // PR #5705 deliberately did not reach. + // + // Swallowing here does not answer 404 — it answers something this + // method states positively in THREE fields at once: `overlay: null` + // ("nothing was ever customised"), `overlayScope: null` ("no scope + // holds a row"), and `effective = code` ("what runs today is the + // packaged artifact, verbatim"). The whole point of the layered + // read is to show an author what they changed; during an outage it + // told them they had changed nothing, which is the #5532 error — + // an availability failure reported as an authorship fact — landing + // in the diff view instead of on a 404. + // + // The benign case is unchanged and is why this is not a bare + // rethrow: an unprovisioned `sys_metadata` genuinely holds no + // overlay row, so `overlay: null` / `effective = code` IS the truth + // and first boot still renders the code layer. + // See {@link rethrowUnlessMetadataStoreUnprovisioned}. + this.rethrowUnlessMetadataStoreUnprovisioned(error); } const effective: unknown | null = overlay ?? code;