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/metadata-store-outage-is-not-a-miss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): 元数据存储读不到不再被讲成「这一项不存在」(#5532)

`sys_metadata` 整体不可达时,`GET /api/v1/meta/object/acct` 会回一个「不存在」——
真相是「读不到」。两个事实的处置方向完全相反(去建一个 / 去修后端),而 Studio、
Setup 在元数据库故障期就是照前者渲染的:每一个对象都显示成「不存在」。

根因在产出方:`getMetaItems` / `getMetaItem` 的四处 customization-overlay 读各自
裹着一个裸 `catch {}`,注释写着 "DB not available" 然后照 miss 处理。空值一路穿过
读链,每个消费方给它起了一个不同却同样错的名字:

- `getMetaItemCached` → `Metadata item <type>/<name> not found`
- `?state=draft` → `NO_DRAFT` / 404「没有待发布的草稿」(发布流程读作「没什么可发的」)
- `getMetaItems` → `items: []`「这个环境一个都没声明」

ADR-0110 D3 已经为这件事立过规矩:miss 与 outage 是两个不同的事实、安全含义相反。
#5108 按这条修掉了 `DatabaseLoader` 的复数读,#5089 修掉了 `listForIndex`;本次是
同一条规矩在协议自己的 overlay 读上,单数与复数一并覆盖。

**改了什么**

1. **区分按错误类型判定,不按异常猜。** 唯一良性的读失败是「`sys_metadata` 还没被
创建」——那时确实没有 overlay 行,落回 registry 就是真相,首次启动也不该爆炸。
判定走 `isMissingTableError`,与 `DatabaseLoader`(#5108)、本包
`SysMetadataRepository`(#4867)同一个谓词,一个驱动怪癖只教给平台一次。其余
一律视为故障。
2. **故障照实上报。** 上抛 `status: 503` / `code: SERVICE_UNAVAILABLE`
(`HttpStatusErrorCodeMap[503]`,ADR-0112 的标准目录码,不新造词汇),驱动原始
错误挂在 `cause` 上。REST 层现有的 #5437 / #5464 消毒与日志口原样接住:客户端拿
到 503 + code(文案按 5xx 规则withheld),运维在日志里拿到完整的驱动报文。
3. **终末 not found 结构化。** 真 miss 现在带 `status: 404` /
`code: RESOURCE_NOT_FOUND`。

**wire 可见变化**(把错误答案改成对的答案):

| 场景 | 之前 | 之后 |
|---|---|---|
| 元数据存储不可达 | `404`/`400`/`500` 说「不存在」「没有草稿」「什么都没声明」 | `503` + `SERVICE_UNAVAILABLE`,可重试 |
| 真的没有这一项 | `500` + `INTERNAL_ERROR`(#5489 之前是 `400` 且内部措辞逐字上线) | `404` + `RESOURCE_NOT_FOUND` |

`sys_metadata` 尚未建表这一路径行为不变:仍旧落回 registry / MetadataService,
真查不到时回结构化 404。
279 changes: 279 additions & 0 deletions packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#5532] A `sys_metadata` read that FAILED is not a metadata item that does
// not exist.
//
// ---------------------------------------------------------------------------
// The defect
// ---------------------------------------------------------------------------
// Every customization-overlay read in `getMetaItems` / `getMetaItem` was
// wrapped in a bare `catch {}` whose comment named the reason it was swallowing
// ("DB not available") and then answered as if the row simply was not there.
// The emptiness travelled the whole read chain unremarked and each consumer
// gave it a different, equally wrong name:
//
// GET /meta/object/acct → "Metadata item object/acct not found"
// GET /meta/object/acct?state=draft→ NO_DRAFT / 404 "no pending draft exists"
// GET /meta/object → `items: []` — "this env declares none"
//
// Measured on `origin/main` before the fix, with an engine whose reads reject
// with `connect ECONNREFUSED 10.0.0.5:5432`:
//
// RESOLVE getMetaItem(econnrefused) -> { type, name, ...no item }
// THROW getMetaItemCached(econnrefused) status=undefined code=undefined
// msg=Metadata item object/acct not found
// THROW getMetaItem(state=draft, …) status=404 code=NO_DRAFT
// RESOLVE getMetaItems(econnrefused) -> { items: [] }
//
// ADR-0110 D3 is the rule those answers break: a miss and an outage are
// different facts with opposite meanings, and the dispositions they call for
// are opposite too — "create it / fix your link" vs. "the backend is down,
// retry". #5108 fixed exactly this in `DatabaseLoader`'s plural read and #5089
// in `listForIndex`; this is the same rule one layer up, on the protocol's own
// overlay reads, singular and plural.
//
// ---------------------------------------------------------------------------
// The one benign reason, and why the discrimination is by error TYPE
// ---------------------------------------------------------------------------
// `sys_metadata` not provisioned yet: there are then genuinely no overlay rows,
// so falling through to the registry IS the truth and first boot must not
// explode. That is `isMissingTableError` — the same predicate `DatabaseLoader`
// (#5108) and this package's `SysMetadataRepository` (#4867) ask, so a driver
// quirk is taught to the platform once. Everything else is an outage.
//
// ---------------------------------------------------------------------------
// Reverse verification, direction predicted BEFORE running
// ---------------------------------------------------------------------------
// Ordinary red, on both halves, and they fail differently — which is the point:
//
// * Restore `} catch { /* DB not available */ }` at the four overlay reads →
// 7 red / 5 green, and they go red in exactly the shape the issue reported:
// the singular and preview reads RESOLVE with no item, the plural reads
// resolve `{ items: [] }`, the draft read throws 404. (Predicted 6 — the
// six outage cases; the seventh is the miss-vs-outage comparison, whose
// OUTAGE half is one of the same six. Recorded rather than rounded off.)
// * Restore `throw new Error(\`Metadata item …/… not found\`)` → 3 red /
// 9 green: the two "a real miss is a structured 404" cases plus the benign
// first-boot miss, all on `status`/`code` being `undefined`, while every
// 503 case stays GREEN. That separation is deliberate: it is what proves
// the 404 is fix C's own contribution and not an artifact of the outage
// split.
//
// The "benign / working store" describe is the opposite guard — it exists to
// catch the overreach where a fix starts calling first boot, or a plain
// unreferenced item, an outage.

import { describe, it, expect, vi } from 'vitest';
import { ErrorCode } from '@objectstack/spec/api';
import { ObjectStackProtocolImplementation } from './protocol.js';

/** A registry with nothing in it — the overlay read is the only source. */
function emptyRegistry(items: Record<string, any> = {}) {
return {
getObject: () => undefined,
getItem: (_type: string, name: string) => items[name],
listItems: () => [],
applyNavContributions: (x: any) => x,
isPackageDisabled: () => false,
getObjectOwner: () => undefined,
};
}

/**
* An engine whose every read REJECTS with `error` — the shape of a metadata
* store the protocol cannot reach.
*/
function engineThatCannotBeRead(error: () => unknown, registryItems: Record<string, any> = {}) {
const reject = vi.fn(async () => { throw error(); });
return {
registry: emptyRegistry(registryItems),
find: reject,
findOne: reject,
} as any;
}

/** An engine that answers reads normally, from `rows`. */
function engineWithRows(rows: any[] = [], registryItems: Record<string, any> = {}) {
return {
registry: emptyRegistry(registryItems),
find: vi.fn(async () => rows),
findOne: vi.fn(async () => rows[0] ?? null),
} as any;
}

/** The real driver phrasings for "the table has not been provisioned yet". */
const missingTable = () =>
Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR' });

/** An outage: the rows may well exist and simply were not seen. */
const connectionRefused = () =>
Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' });

/** Capture a rejection without letting a resolve pass silently. */
async function rejection(run: () => Promise<unknown>): Promise<any> {
let caught: any;
let resolved: unknown;
let didResolve = false;
try {
resolved = await run();
didResolve = true;
} catch (e) {
caught = e;
}
expect(
didResolve,
`expected a rejection, but the call resolved with ${JSON.stringify(resolved)}`,
).toBe(false);
return caught;
}

/** Every assertion the outage envelope owes a caller. */
function expectStoreUnavailable(caught: any, cause: unknown) {
expect(caught?.status).toBe(503);
expect(caught?.code).toBe('SERVICE_UNAVAILABLE');
// ADR-0112: the wire code must be in the declared vocabulary, or the
// envelope fails `ApiErrorSchema.parse` at the boundary that ships it.
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
// The words a client reads say "unknown", never "does not exist".
expect(caught.message).toContain('unknown');
expect(caught.message.toLowerCase()).not.toContain('not found');
// The driver's own error is not lost — it rides as `cause`, which is what
// `logWithheldServerFault` prints for the operator (#5437).
expect(caught.cause).toBe(cause);
}

describe('[#5532] an unreadable sys_metadata is a 503, not "that item does not exist"', () => {
it('the singular active read no longer answers a miss it never verified', async () => {
const err = connectionRefused();
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));

const caught = await rejection(() => p.getMetaItem({ type: 'object', name: 'acct' } as any));
expectStoreUnavailable(caught, err);
});

it('getMetaItemCached propagates the outage instead of relabelling it "not found"', async () => {
const err = connectionRefused();
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));

const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
expectStoreUnavailable(caught, err);
// The regression this replaces, verbatim.
expect(caught.message).not.toContain('Metadata item object/acct not found');
});

it('the draft read stops reporting an outage as "there is no pending draft"', async () => {
const err = connectionRefused();
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));

const caught = await rejection(
() => p.getMetaItem({ type: 'object', name: 'acct', state: 'draft' } as any),
);
expectStoreUnavailable(caught, err);
// NO_DRAFT is a lifecycle fact ("nobody is editing this"). A publish
// flow reads it as "nothing to publish" and moves on.
expect(caught.code).not.toBe('NO_DRAFT');
});

it('the ?preview=draft overlay stops silently serving the published world', async () => {
const err = connectionRefused();
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));

const caught = await rejection(
() => p.getMetaItem({ type: 'object', name: 'acct', previewDrafts: true } as any),
);
expectStoreUnavailable(caught, err);
});

it('the PLURAL read stops answering "this environment declares none of these"', async () => {
const err = connectionRefused();
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));

const caught = await rejection(() => p.getMetaItems({ type: 'object' } as any));
expectStoreUnavailable(caught, err);
});

it('the plural draft-preview overlay is held to the same rule', async () => {
const err = connectionRefused();
// The active overlay read must succeed so control actually reaches the
// draft-preview block: only its own read fails.
const engine = engineWithRows([]);
let call = 0;
engine.find = vi.fn(async (_o: string, opts: any) => {
call += 1;
if (opts?.where?.state === 'draft') throw err;
return [];
});

const p = new ObjectStackProtocolImplementation(engine);
const caught = await rejection(
() => p.getMetaItems({ type: 'object', previewDrafts: true } as any),
);
expectStoreUnavailable(caught, err);
expect(call).toBeGreaterThan(1); // the active read really did run first
});
});

describe('[#5532 / fix C] a REAL miss is a structured 404, not an unattributable throw', () => {
it('getMetaItemCached carries status 404 + the catalog code', async () => {
const p = new ObjectStackProtocolImplementation(engineWithRows([]));

const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
expect(caught.status).toBe(404);
expect(caught.code).toBe('RESOURCE_NOT_FOUND');
expect(ErrorCode.safeParse(caught.code).success).toBe(true);
expect(caught.message).toBe('Metadata item object/ghost not found');
});

it('is distinguishable from the outage by code alone — which is the whole point', async () => {
const missP = new ObjectStackProtocolImplementation(engineWithRows([]));
const outageP = new ObjectStackProtocolImplementation(
engineThatCannotBeRead(connectionRefused),
);

const miss = await rejection(() => missP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
const outage = await rejection(() => outageP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));

expect([miss.status, miss.code]).toEqual([404, 'RESOURCE_NOT_FOUND']);
expect([outage.status, outage.code]).toEqual([503, 'SERVICE_UNAVAILABLE']);
});
});

describe('[#5532] the benign case and the healthy case are untouched', () => {
it('an unprovisioned sys_metadata still falls through to the registry', async () => {
// First boot: the table does not exist, so "no overlay row" IS the
// truth and the code-authored item must still be served.
const p = new ObjectStackProtocolImplementation(
engineThatCannotBeRead(missingTable, { acct: { name: 'acct', label: 'Account' } }),
);

const res: any = await p.getMetaItem({ type: 'object', name: 'acct' } as any);
expect(res.item?.name).toBe('acct');
expect(res.item?.label).toBe('Account');
});

it('an unprovisioned sys_metadata + nothing anywhere is a 404 miss, not a 503', async () => {
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));

const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
expect(caught.status).toBe(404);
expect(caught.code).toBe('RESOURCE_NOT_FOUND');
});

it('an unprovisioned sys_metadata still lists the registry items (plural)', async () => {
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));

const res: any = await p.getMetaItems({ type: 'object' } as any);
expect(res.items).toEqual([]);
});

it('a healthy store still serves the overlay row it holds', async () => {
const p = new ObjectStackProtocolImplementation(
engineWithRows([
{ type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Overlaid' }) },
]),
);

const res: any = await p.getMetaItem({ type: 'object', name: 'acct' } as any);
expect(res.item?.label).toBe('Overlaid');
});
});
Loading
Loading