Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/layered-read-outage-is-not-no-overlay.md
Original file line number Diff line number Diff line change
@@ -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 }`。
129 changes: 129 additions & 0 deletions packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<any> {
return new ObjectStackProtocolImplementation(engine).getMetaItemLayered(request as any);
}
36 changes: 34 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading