Skip to content

Commit 1c625ca

Browse files
baozhoutaoclaude
andauthored
fix(metadata,metadata-protocol,objectql): getDiagnosed —— get() 不再把 loader outage 答成「这一项没声明」 (#5840) (#6051)
* wip: pre-merge snapshot * fix(metadata,metadata-protocol,objectql): getDiagnosed —— get() 不再把 loader outage 答成「没声明」 MetadataManager.loadDiagnosed 算出的 ADR-0110 D3 判定,在两跳内被丢掉: load() 只取 .data,get() 再把 null 变 undefined。六个消费点因此对 「读不到」与「这一项没声明」拿到同一个 undefined。 新增 getDiagnosed(type, name) -> { data, degraded, errors },即 loadDiagnosed 的 registry-first 对应物,并在 IMetadataService 上声明为可选成员。get() 本体 逐字不变(含 register() 观察者依赖的 microtask 时序),零破坏。 本车道内被测量为 gating 的消费点按各自语境处置: - getMetaItem / getMetaItemCached:degraded 且 registry 也无 -> 503,不再落到 404 - getMetaItemLayered 的 code 层:与它 overlay 层同规矩(code: null 会派生 lockSource,outage 可把 _lock:'full' 渲染成 editable) - ObjectQLPlugin 的 object 事件重读:warn(写已落地、只是重读失败)而非 error Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b434304 commit 1c625ca

8 files changed

Lines changed: 995 additions & 36 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/metadata': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
metadata: `getDiagnosed` — a metadata read that FAILED stops arriving as "nobody declared this"
9+
10+
`MetadataManager.loadDiagnosed` computes the ADR-0110 D3 verdict (a MISS and an OUTAGE
11+
are different facts with opposite security meanings) and `get()` discarded it two hops
12+
later: `load()` kept only `.data`, `get()` turned that `null` into `undefined`. Every
13+
consumer of `get()` therefore received one `undefined` for two opposite facts and could
14+
not have told them apart even if it had wanted to.
15+
16+
**New read.** `MetadataManager.getDiagnosed(type, name)` returns
17+
`{ data, degraded, errors }` — the registry-first counterpart of `loadDiagnosed`, declared
18+
as an optional member of `IMetadataService`. A registry hit is never degraded (it
19+
consulted no loader); a clean miss is never degraded (every loader answered).
20+
21+
**`get()` is unchanged — zero breaking.** Same signature, same answer, same behaviour for
22+
every existing caller, including the microtask-level ordering `register()`'s watchers
23+
depend on. Only callers that ASK for the verdict pay for it. Making `get()` throw on
24+
`degraded` was deliberately not done: the boot path degrades on purpose.
25+
26+
**Consumers switched**, each with a disposition argued for its own context rather than one
27+
blanket rule:
28+
29+
- `getMetaItem` / `getMetaItemCached` — a degraded MetadataService read with nothing in
30+
the registry now raises `503 SERVICE_UNAVAILABLE` instead of falling through to
31+
`404 RESOURCE_NOT_FOUND`. This is the half that made the existing `#5532` comment ("
32+
reaching here now means a real miss") untrue.
33+
- `getMetaItemLayered` — the `code` layer joins the rule its `overlay` layer already
34+
followed. `code: null` is a positive claim, and `lockSource = code ?? overlay ?? {}`
35+
derives from it, so an outage could render an item the packager locked
36+
(`_lock: 'full'`) as `editable: true, deletable: true`.
37+
- `ObjectQLPlugin`'s `object` metadata-event refresh — logs `warn` naming the consequence
38+
(the registry keeps the previous definition; nothing retries) and the fix, instead of
39+
`debug` "metadata service has no fresh body". `warn` and not `error` because the write
40+
already landed; only a re-read failed.
41+
42+
Hosts whose `metadata` slot is a shim that predates `getDiagnosed` are read as
43+
"not degraded" — exactly what they could express before — so their behaviour is unchanged.

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

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,197 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust
406406
function p_layered(engine: any, request: Record<string, unknown>): Promise<any> {
407407
return new ObjectStackProtocolImplementation(engine).getMetaItemLayered(request as any);
408408
}
409+
410+
// ---------------------------------------------------------------------------
411+
// [#5840] The OTHER read in these same two methods — the MetadataService one
412+
// ---------------------------------------------------------------------------
413+
// Everything above is about the `sys_metadata` overlay read, whose failure the
414+
// protocol can see because it arrives as a throw. The second source each of
415+
// these methods consults — the `metadata` SERVICE, i.e. the loader chain
416+
// (filesystem, database, attached repository) — failed silently: a loader that
417+
// throws is warn-logged and skipped inside `MetadataManager`, and its `get()`
418+
// dropped the `degraded` verdict `loadDiagnosed` had already computed. So an
419+
// unreachable metadata database arrived here as the ordinary `undefined` of a
420+
// name nobody declared, and the SAME two methods that now refuse to guess on
421+
// their overlay half went on guessing on this one:
422+
//
423+
// getMetaItem → falls through, item stays undefined → 404 "not found"
424+
// getMetaItemLayered → `code: null` → `lockSource = code ?? overlay ?? {}`
425+
// → `editable: true, deletable: true` on an item whose
426+
// code layer may declare `_lock: 'full'`
427+
//
428+
// The second is the sharper one, and it is the shape ADR-0110 D3 names
429+
// outright: an availability failure widening an affordance. `getDiagnosed`
430+
// (#5840) is the seam that makes the failure visible at all; these cases pin
431+
// what each method does with it.
432+
//
433+
// Reverse verification, direction predicted BEFORE running: ordinary red, and
434+
// it must be taken on the CONSUMER, not the producer. These doubles feed the
435+
// return contract directly, so reverting `MetadataManager.getDiagnosed` cannot
436+
// turn them red — only deleting the two `if (… degraded)` branches in
437+
// `protocol.ts` can. Two laps therefore prove two different halves, and
438+
// neither substitutes for the other.
439+
//
440+
// Predicted for the consumer lap: 5 red / 3 green across the two describes
441+
// below — every case that expects a 503, and only those, with all three
442+
// narrowness/back-compat cases green (they assert the branch does NOT fire).
443+
// Measured: exactly that, and the 18 #5532/#5707 cases above stayed green,
444+
// which is what shows this is the third read joining the rule rather than a
445+
// blanket "these methods now throw".
446+
447+
/** A services registry holding one `metadata` service — what the protocol probes. */
448+
function servicesWith(metadata: unknown): () => Map<string, any> {
449+
const registry = new Map<string, any>([['metadata', metadata]]);
450+
return () => registry;
451+
}
452+
453+
const LOADER_FAILURE = 'database: connect ECONNREFUSED 10.0.0.5:5432';
454+
455+
/**
456+
* A `metadata` service whose loader chain is DOWN. `get()` answers exactly what
457+
* it answered before this issue — `undefined`, indistinguishable from a miss —
458+
* and `getDiagnosed()` reports the verdict that was being computed and thrown
459+
* away all along.
460+
*/
461+
const metadataServiceInOutage = () => ({
462+
get: vi.fn(async () => undefined),
463+
getDiagnosed: vi.fn(async () => ({ data: undefined, degraded: true, errors: [LOADER_FAILURE] })),
464+
});
465+
466+
/** A `metadata` service that answered, and simply does not hold the item. */
467+
const metadataServiceWithMiss = () => ({
468+
get: vi.fn(async () => undefined),
469+
getDiagnosed: vi.fn(async () => ({ data: undefined, degraded: false, errors: [] })),
470+
});
471+
472+
/** A `metadata` service that holds `body`. */
473+
const metadataServiceHolding = (body: unknown) => ({
474+
get: vi.fn(async () => body),
475+
getDiagnosed: vi.fn(async () => ({ data: body, degraded: false, errors: [] })),
476+
});
477+
478+
/** A service that predates #5840: `get` only, no way to report the difference. */
479+
const legacyMetadataService = (body?: unknown) => ({ get: vi.fn(async () => body) });
480+
481+
/** The outage envelope, for a cause built from loader messages rather than a driver error. */
482+
function expectLoaderOutage(caught: any) {
483+
expect(caught?.status).toBe(503);
484+
expect(caught?.code).toBe('SERVICE_UNAVAILABLE');
485+
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
486+
expect(caught.message).toContain('unknown');
487+
expect(caught.message.toLowerCase()).not.toContain('not found');
488+
// The failing loaders' own words reach the operator on `cause`, which is
489+
// what `logWithheldServerFault` prints (#5437) — the protocol never sees a
490+
// driver error here, because `MetadataManager` already absorbed it.
491+
expect(String((caught.cause as Error)?.message)).toContain(LOADER_FAILURE);
492+
}
493+
494+
/** A protocol whose overlay store is healthy and empty — only the SERVICE half varies. */
495+
function protocolWithService(metadata: unknown, registryItems: Record<string, any> = {}) {
496+
return new ObjectStackProtocolImplementation(
497+
engineWithRows([], registryItems),
498+
servicesWith(metadata),
499+
);
500+
}
501+
502+
describe('[#5840] a MetadataService outage stops arriving as "nobody declared this"', () => {
503+
it('the singular read throws 503 instead of falling through to a 404', async () => {
504+
const p = protocolWithService(metadataServiceInOutage());
505+
506+
const caught = await rejection(() => p.getMetaItem({ type: 'object', name: 'acct' } as any));
507+
expectLoaderOutage(caught);
508+
});
509+
510+
it('getMetaItemCached stops relabelling that outage "Metadata item object/acct not found"', async () => {
511+
const p = protocolWithService(metadataServiceInOutage());
512+
513+
const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
514+
expectLoaderOutage(caught);
515+
// The comment above that 404 claims "reaching here now means a real
516+
// miss". This is the half that used to make it untrue.
517+
expect(caught.message).not.toContain('Metadata item object/acct not found');
518+
});
519+
520+
it('a miss and an outage are told apart by code alone — the whole point', async () => {
521+
const missP = protocolWithService(metadataServiceWithMiss());
522+
const outageP = protocolWithService(metadataServiceInOutage());
523+
524+
const miss = await rejection(() => missP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
525+
const outage = await rejection(() => outageP.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
526+
527+
expect([miss.status, miss.code]).toEqual([404, 'RESOURCE_NOT_FOUND']);
528+
expect([outage.status, outage.code]).toEqual([503, 'SERVICE_UNAVAILABLE']);
529+
});
530+
531+
it('the layered read refuses to publish a `code: null` it never verified', async () => {
532+
const p = protocolWithService(metadataServiceInOutage());
533+
534+
const caught = await rejection(
535+
() => p.getMetaItemLayered({ type: 'object', name: 'acct' } as any),
536+
);
537+
expectLoaderOutage(caught);
538+
});
539+
540+
it('and that is what stops an outage from unlocking a locked artifact', async () => {
541+
// The concrete widening. `lockSource = code ?? overlay ?? {}`, so a
542+
// code layer that never arrived resolves the protection envelope from
543+
// `{}` — `editable: true, deletable: true` on an item the packager
544+
// locked. Left column: what the truth looks like. Right column: what
545+
// the outage used to render, and now cannot.
546+
const locked = { name: 'acct', label: 'Account', _lock: 'full' };
547+
548+
const healthy: any = await protocolWithService(
549+
metadataServiceHolding(locked),
550+
).getMetaItemLayered({ type: 'object', name: 'acct' } as any);
551+
expect(healthy.lock).toBe('full');
552+
expect([healthy.editable, healthy.deletable]).toEqual([false, false]);
553+
554+
const caught = await rejection(
555+
() => protocolWithService(metadataServiceInOutage())
556+
.getMetaItemLayered({ type: 'object', name: 'acct' } as any),
557+
);
558+
expectLoaderOutage(caught);
559+
// Never the silently-permissive envelope.
560+
expect(caught.editable).toBeUndefined();
561+
});
562+
});
563+
564+
describe('[#5840] the narrowness is the design — three things it deliberately does not do', () => {
565+
it('a registry hit still answers, degraded service or not', async () => {
566+
// A registry item IS a real declaration, so the answer contains no
567+
// unfounded claim and is served exactly as before. The 503 fires only
568+
// where the alternative would have been "this does not exist".
569+
const p = protocolWithService(metadataServiceInOutage(), {
570+
acct: { name: 'acct', label: 'Account (packaged)' },
571+
});
572+
573+
const res: any = await p.getMetaItem({ type: 'object', name: 'acct' } as any);
574+
expect(res.item?.label).toBe('Account (packaged)');
575+
576+
const layered: any = await p.getMetaItemLayered({ type: 'object', name: 'acct' } as any);
577+
expect(layered.code).toMatchObject({ label: 'Account (packaged)' });
578+
});
579+
580+
it('a clean MISS still renders the all-null layered envelope, not a 503', async () => {
581+
const p = protocolWithService(metadataServiceWithMiss());
582+
583+
const res: any = await p.getMetaItemLayered({ type: 'object', name: 'ghost' } as any);
584+
expect(res.code).toBeNull();
585+
expect(res.overlay).toBeNull();
586+
expect(res.effective).toBeNull();
587+
});
588+
589+
it('a service that predates `getDiagnosed` behaves exactly as it did', async () => {
590+
// It cannot report the distinction, so it is read as "not degraded" —
591+
// precisely what it could express before, unchanged. The alternative
592+
// (treating an un-probeable service as suspect) would 503 every host
593+
// whose `metadata` slot is a shim.
594+
const holding = protocolWithService(legacyMetadataService({ name: 'acct', label: 'From shim' }));
595+
const res: any = await holding.getMetaItem({ type: 'object', name: 'acct' } as any);
596+
expect(res.item?.label).toBe('From shim');
597+
598+
const empty = protocolWithService(legacyMetadataService(undefined));
599+
const caught = await rejection(() => empty.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
600+
expect([caught.status, caught.code]).toEqual([404, 'RESOURCE_NOT_FOUND']);
601+
});
602+
});

0 commit comments

Comments
 (0)