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
43 changes: 43 additions & 0 deletions .changeset/metadata-get-diagnosed-outage-vs-miss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/metadata': patch
'@objectstack/metadata-protocol': patch
'@objectstack/objectql': patch
'@objectstack/spec': patch
---

metadata: `getDiagnosed` — a metadata read that FAILED stops arriving as "nobody declared this"

`MetadataManager.loadDiagnosed` computes the ADR-0110 D3 verdict (a MISS and an OUTAGE
are different facts with opposite security meanings) and `get()` discarded it two hops
later: `load()` kept only `.data`, `get()` turned that `null` into `undefined`. Every
consumer of `get()` therefore received one `undefined` for two opposite facts and could
not have told them apart even if it had wanted to.

**New read.** `MetadataManager.getDiagnosed(type, name)` returns
`{ data, degraded, errors }` — the registry-first counterpart of `loadDiagnosed`, declared
as an optional member of `IMetadataService`. A registry hit is never degraded (it
consulted no loader); a clean miss is never degraded (every loader answered).

**`get()` is unchanged — zero breaking.** Same signature, same answer, same behaviour for
every existing caller, including the microtask-level ordering `register()`'s watchers
depend on. Only callers that ASK for the verdict pay for it. Making `get()` throw on
`degraded` was deliberately not done: the boot path degrades on purpose.

**Consumers switched**, each with a disposition argued for its own context rather than one
blanket rule:

- `getMetaItem` / `getMetaItemCached` — a degraded MetadataService read with nothing in
the registry now raises `503 SERVICE_UNAVAILABLE` instead of falling through to
`404 RESOURCE_NOT_FOUND`. This is the half that made the existing `#5532` comment ("
reaching here now means a real miss") untrue.
- `getMetaItemLayered` — the `code` layer joins the rule its `overlay` layer already
followed. `code: null` is a positive claim, and `lockSource = code ?? overlay ?? {}`
derives from it, so an outage could render an item the packager locked
(`_lock: 'full'`) as `editable: true, deletable: true`.
- `ObjectQLPlugin`'s `object` metadata-event refresh — logs `warn` naming the consequence
(the registry keeps the previous definition; nothing retries) and the fix, instead of
`debug` "metadata service has no fresh body". `warn` and not `error` because the write
already landed; only a re-read failed.

Hosts whose `metadata` slot is a shim that predates `getDiagnosed` are read as
"not degraded" — exactly what they could express before — so their behaviour is unchanged.
194 changes: 194 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 @@ -406,3 +406,197 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust
function p_layered(engine: any, request: Record<string, unknown>): Promise<any> {
return new ObjectStackProtocolImplementation(engine).getMetaItemLayered(request as any);
}

// ---------------------------------------------------------------------------
// [#5840] The OTHER read in these same two methods — the MetadataService one
// ---------------------------------------------------------------------------
// Everything above is about the `sys_metadata` overlay read, whose failure the
// protocol can see because it arrives as a throw. The second source each of
// these methods consults — the `metadata` SERVICE, i.e. the loader chain
// (filesystem, database, attached repository) — failed silently: a loader that
// throws is warn-logged and skipped inside `MetadataManager`, and its `get()`
// dropped the `degraded` verdict `loadDiagnosed` had already computed. So an
// unreachable metadata database arrived here as the ordinary `undefined` of a
// name nobody declared, and the SAME two methods that now refuse to guess on
// their overlay half went on guessing on this one:
//
// getMetaItem → falls through, item stays undefined → 404 "not found"
// getMetaItemLayered → `code: null` → `lockSource = code ?? overlay ?? {}`
// → `editable: true, deletable: true` on an item whose
// code layer may declare `_lock: 'full'`
//
// The second is the sharper one, and it is the shape ADR-0110 D3 names
// outright: an availability failure widening an affordance. `getDiagnosed`
// (#5840) is the seam that makes the failure visible at all; these cases pin
// what each method does with it.
//
// Reverse verification, direction predicted BEFORE running: ordinary red, and
// it must be taken on the CONSUMER, not the producer. These doubles feed the
// return contract directly, so reverting `MetadataManager.getDiagnosed` cannot
// turn them red — only deleting the two `if (… degraded)` branches in
// `protocol.ts` can. Two laps therefore prove two different halves, and
// neither substitutes for the other.
//
// Predicted for the consumer lap: 5 red / 3 green across the two describes
// below — every case that expects a 503, and only those, with all three
// narrowness/back-compat cases green (they assert the branch does NOT fire).
// Measured: exactly that, and the 18 #5532/#5707 cases above stayed green,
// which is what shows this is the third read joining the rule rather than a
// blanket "these methods now throw".

/** A services registry holding one `metadata` service — what the protocol probes. */
function servicesWith(metadata: unknown): () => Map<string, any> {
const registry = new Map<string, any>([['metadata', metadata]]);
return () => registry;
}

const LOADER_FAILURE = 'database: connect ECONNREFUSED 10.0.0.5:5432';

/**
* A `metadata` service whose loader chain is DOWN. `get()` answers exactly what
* it answered before this issue — `undefined`, indistinguishable from a miss —
* and `getDiagnosed()` reports the verdict that was being computed and thrown
* away all along.
*/
const metadataServiceInOutage = () => ({
get: vi.fn(async () => undefined),
getDiagnosed: vi.fn(async () => ({ data: undefined, degraded: true, errors: [LOADER_FAILURE] })),
});

/** A `metadata` service that answered, and simply does not hold the item. */
const metadataServiceWithMiss = () => ({
get: vi.fn(async () => undefined),
getDiagnosed: vi.fn(async () => ({ data: undefined, degraded: false, errors: [] })),
});

/** A `metadata` service that holds `body`. */
const metadataServiceHolding = (body: unknown) => ({
get: vi.fn(async () => body),
getDiagnosed: vi.fn(async () => ({ data: body, degraded: false, errors: [] })),
});

/** A service that predates #5840: `get` only, no way to report the difference. */
const legacyMetadataService = (body?: unknown) => ({ get: vi.fn(async () => body) });

/** The outage envelope, for a cause built from loader messages rather than a driver error. */
function expectLoaderOutage(caught: any) {
expect(caught?.status).toBe(503);
expect(caught?.code).toBe('SERVICE_UNAVAILABLE');
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
expect(caught.message).toContain('unknown');
expect(caught.message.toLowerCase()).not.toContain('not found');
// The failing loaders' own words reach the operator on `cause`, which is
// what `logWithheldServerFault` prints (#5437) — the protocol never sees a
// driver error here, because `MetadataManager` already absorbed it.
expect(String((caught.cause as Error)?.message)).toContain(LOADER_FAILURE);
}

/** A protocol whose overlay store is healthy and empty — only the SERVICE half varies. */
function protocolWithService(metadata: unknown, registryItems: Record<string, any> = {}) {
return new ObjectStackProtocolImplementation(
engineWithRows([], registryItems),
servicesWith(metadata),
);
}

describe('[#5840] a MetadataService outage stops arriving as "nobody declared this"', () => {
it('the singular read throws 503 instead of falling through to a 404', async () => {
const p = protocolWithService(metadataServiceInOutage());

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

it('getMetaItemCached stops relabelling that outage "Metadata item object/acct not found"', async () => {
const p = protocolWithService(metadataServiceInOutage());

const caught = await rejection(() => p.getMetaItemCached({ type: 'object', name: 'acct' } as any));
expectLoaderOutage(caught);
// The comment above that 404 claims "reaching here now means a real
// miss". This is the half that used to make it untrue.
expect(caught.message).not.toContain('Metadata item object/acct not found');
});

it('a miss and an outage are told apart by code alone — the whole point', async () => {
const missP = protocolWithService(metadataServiceWithMiss());
const outageP = protocolWithService(metadataServiceInOutage());

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']);
});

it('the layered read refuses to publish a `code: null` it never verified', async () => {
const p = protocolWithService(metadataServiceInOutage());

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

it('and that is what stops an outage from unlocking a locked artifact', async () => {
// The concrete widening. `lockSource = code ?? overlay ?? {}`, so a
// code layer that never arrived resolves the protection envelope from
// `{}` — `editable: true, deletable: true` on an item the packager
// locked. Left column: what the truth looks like. Right column: what
// the outage used to render, and now cannot.
const locked = { name: 'acct', label: 'Account', _lock: 'full' };

const healthy: any = await protocolWithService(
metadataServiceHolding(locked),
).getMetaItemLayered({ type: 'object', name: 'acct' } as any);
expect(healthy.lock).toBe('full');
expect([healthy.editable, healthy.deletable]).toEqual([false, false]);

const caught = await rejection(
() => protocolWithService(metadataServiceInOutage())
.getMetaItemLayered({ type: 'object', name: 'acct' } as any),
);
expectLoaderOutage(caught);
// Never the silently-permissive envelope.
expect(caught.editable).toBeUndefined();
});
});

describe('[#5840] the narrowness is the design — three things it deliberately does not do', () => {
it('a registry hit still answers, degraded service or not', async () => {
// A registry item IS a real declaration, so the answer contains no
// unfounded claim and is served exactly as before. The 503 fires only
// where the alternative would have been "this does not exist".
const p = protocolWithService(metadataServiceInOutage(), {
acct: { name: 'acct', label: 'Account (packaged)' },
});

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

const layered: any = await p.getMetaItemLayered({ type: 'object', name: 'acct' } as any);
expect(layered.code).toMatchObject({ label: 'Account (packaged)' });
});

it('a clean MISS still renders the all-null layered envelope, not a 503', async () => {
const p = protocolWithService(metadataServiceWithMiss());

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 service that predates `getDiagnosed` behaves exactly as it did', async () => {
// It cannot report the distinction, so it is read as "not degraded" —
// precisely what it could express before, unchanged. The alternative
// (treating an un-probeable service as suspect) would 503 every host
// whose `metadata` slot is a shim.
const holding = protocolWithService(legacyMetadataService({ name: 'acct', label: 'From shim' }));
const res: any = await holding.getMetaItem({ type: 'object', name: 'acct' } as any);
expect(res.item?.label).toBe('From shim');

const empty = protocolWithService(legacyMetadataService(undefined));
const caught = await rejection(() => empty.getMetaItemCached({ type: 'object', name: 'ghost' } as any));
expect([caught.status, caught.code]).toEqual([404, 'RESOURCE_NOT_FOUND']);
});
});
Loading
Loading