Skip to content

Commit 9f403be

Browse files
committed
refactor(metadata-protocol): move getMetaDiagnostics's body out of protocol.ts into protocol-meta-diagnostics.ts
Move-only. The class keeps getMetaDiagnostics with its docblock, signature and position; its body moves unedited into the new module and is handed the five members it reads through a host literal (MetaDiagnosticsHost). ShippingPackages gains export in place; index.ts exports protocol.js by name, so it stays unpublished. Claude-Session: https://claude.ai/code/session_01J8pUyVs5eas1AjMdReJM7C Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5188e7f commit 9f403be

2 files changed

Lines changed: 263 additions & 172 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 2] The metadata diagnostics sweep of
5+
* `ObjectStackProtocolImplementation` — `getMetaDiagnostics`, which sweeps the
6+
* metadata types a caller asks about, reports the entries that fail spec
7+
* validation and the per-type stats beside them (`GET /api/v1/meta/diagnostics`)
8+
* — moved out of `protocol.ts` whole.
9+
*
10+
* A MOVE, not a redesign. The function below is the body of the
11+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
12+
* re-indented one level and otherwise unedited: the same reads, the same
13+
* findings, the same counts.
14+
*
15+
* ## How the moved body reaches the class
16+
*
17+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
18+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
19+
* The class keeps the method — the published `dist/*.d.ts` lists its name,
20+
* docblock, signature and position — and its body is one literal of exactly
21+
* the {@link MetaDiagnosticsHost} members it reads, plus a `.call`. The method
22+
* is no longer `async`: it returns the moved function's own promise, so a
23+
* caller awaits the same promise the body settles, with no extra wrapper. Each
24+
* member is handed over as a closure that calls it on the protocol at call
25+
* time, so a test double or spy set on the instance is still the one the body
26+
* reaches.
27+
*
28+
* The one module-level declaration the body reads, `ShippingPackages`, stays in
29+
* `protocol.ts`, where code that did not move reads it too, and is imported
30+
* from there as a type. `index.ts` names what it publishes from `protocol.ts`
31+
* one by one, so the `export` it gained for this keeps it off the published
32+
* surface, and nothing re-exports this module, so the published surface does
33+
* not move.
34+
*/
35+
36+
import type { ObjectStackProtocolImplementation, ShippingPackages } from './protocol.js';
37+
import { resolveItemLock } from './item-lock.js';
38+
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel';
39+
import { computeMetadataDiagnostics } from './metadata-diagnostics.js';
40+
import type { MetadataDiagnostics } from './metadata-diagnostics.js';
41+
42+
/**
43+
* The `ObjectStackProtocolImplementation` members the function below reads —
44+
* every one of them, and no other; its `this` is the `Pick` of them. Each is
45+
* typed AS the class member (an indexed access on the class), so a change to a
46+
* member's signature on the class is a change here too, and naming a member
47+
* the class does not have is a compile error.
48+
*/
49+
export interface MetaDiagnosticsHost {
50+
artifactLockLayerAt: ObjectStackProtocolImplementation['artifactLockLayerAt'];
51+
getMetaItems: ObjectStackProtocolImplementation['getMetaItems'];
52+
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
53+
servedLockState: ObjectStackProtocolImplementation['servedLockState'];
54+
shippingPackagesOf: ObjectStackProtocolImplementation['shippingPackagesOf'];
55+
}
56+
57+
/** The body of `ObjectStackProtocolImplementation#getMetaDiagnostics`; the method's docblock there is its contract. */
58+
export async function getMetaDiagnostics(
59+
this: Pick<MetaDiagnosticsHost, 'artifactLockLayerAt' | 'getMetaItems' | 'isArtifactBacked' | 'servedLockState' | 'shippingPackagesOf'>,
60+
request: {
61+
type?: string;
62+
severity?: 'error' | 'warning';
63+
packageId?: string;
64+
} = {}): Promise<{
65+
entries: Array<{ type: string; name: string; diagnostics: MetadataDiagnostics }>;
66+
total: number;
67+
scannedTypes: number;
68+
scannedItems: number;
69+
/**
70+
* Per-type aggregate stats — count of items and the list of
71+
* packages contributing to each type. Computed in the same
72+
* sweep so the Studio directory page can render tile counts
73+
* and a package filter in one round-trip. `locked` is the number
74+
* of items whose read envelope reports `lock` other than `'none'`
75+
* — the same derivation `getMetaItem` publishes (#21694).
76+
*/
77+
stats: Record<string, { count: number; locked: number; packages: string[] }>;
78+
}> {
79+
const includeWarnings = request.severity === 'warning';
80+
const targetTypes = request.type
81+
? [request.type]
82+
: DEFAULT_METADATA_TYPE_REGISTRY
83+
.filter((e) => getMetadataTypeSchema(e.type))
84+
.map((e) => e.type);
85+
86+
const entries: Array<{ type: string; name: string; diagnostics: MetadataDiagnostics }> = [];
87+
const stats: Record<string, { count: number; locked: number; packages: string[] }> = {};
88+
let scannedItems = 0;
89+
90+
for (const t of targetTypes) {
91+
let listed: any;
92+
try {
93+
listed = await this.getMetaItems({
94+
type: t,
95+
packageId: request.packageId,
96+
} as any);
97+
} catch (error) {
98+
// [#8855] TWO different failures used to leave through this
99+
// `continue`, and only one of them is benign.
100+
//
101+
// BENIGN, and the reason the comment named — it is real, which
102+
// is why this `catch` is narrowed rather than deleted: the type
103+
// is not listable in THIS kernel scope. Skipping it is the
104+
// truth, and a scope that cannot enumerate one type must not
105+
// fail the whole governance sweep.
106+
//
107+
// NOT BENIGN: the read already ran the platform's single
108+
// discrimination — `getMetaItems` classifies by error TYPE
109+
// through {@link rethrowUnlessMetadataStoreUnprovisioned},
110+
// returns normally for the one benign driver reason
111+
// (`isMissingTableError`: `sys_metadata` not provisioned yet,
112+
// so `items: []` IS the truth), and throws
113+
// {@link metadataStoreUnavailableError} — a 503 — for every
114+
// other read failure. That 503 says "whether rows exist is
115+
// UNKNOWN". Swallowing it here put the emptiness straight back
116+
// one layer up, and then published it as a NUMBER:
117+
//
118+
// * `stats[t]` was never written, so the type is ABSENT from
119+
// the response — not zero, absent — byte-shaped like an
120+
// environment that declares none of it, and the Studio
121+
// directory tile the field exists to feed simply loses it;
122+
// * `total` counts entries that FAILED validation, and a
123+
// store nobody can read contributes none — so a diagnostics
124+
// endpoint whose whole job is reporting problems answered
125+
// `0 problems` precisely when it could not read anything.
126+
//
127+
// ADR-0110 D3 is the rule: a miss and an outage are different
128+
// facts with opposite dispositions ("nothing to fix" vs "the
129+
// backend is down, retry"), and a consumer must never read one
130+
// as the other. #5108 restored it in `DatabaseLoader`, #5532 in
131+
// `getMetaItems` itself — this is the same rule one layer up,
132+
// on the consumer that turns the read into a published count.
133+
//
134+
// Classification is by the envelope the PRODUCER already built,
135+
// not by a second reading of the driver error. Re-running
136+
// `isMissingTableError` / `rethrowUnlessMetadataStoreUnprovisioned`
137+
// on what arrives here would re-wrap an already-shaped 503 in
138+
// another one and displace the driver error riding as `cause` —
139+
// the object `logWithheldServerFault` prints for the operator
140+
// (#5437). The original is rethrown UNCHANGED, so the envelope
141+
// that reaches the REST boundary is the one #5532 shaped.
142+
//
143+
// The test is `status === 503` rather than the narrower
144+
// `code === 'SERVICE_UNAVAILABLE'` on purpose, and in the
145+
// direction `rethrowUnlessMetadataStoreUnprovisioned`'s own doc
146+
// argues: a false "benign" silently mis-answers a question
147+
// nobody can re-ask, while a false "real" costs one 503 the
148+
// caller can retry.
149+
if ((error as { status?: unknown } | null | undefined)?.status === 503) throw error;
150+
// [#8924] TWO error classes leave through this catch — the 503
151+
// above and the 400 below — under ONE rule: an error the
152+
// producer already stamped with a status is a CLASSIFIED
153+
// VERDICT, and this sweep may not overrule a verdict into
154+
// silence. Everything else is a genuine listing failure nobody
155+
// classified, and for those the benign skip below is the ruled
156+
// disposition (maintainer, 2026-08-20, option 1).
157+
//
158+
// * 503 (#8855, above): the store outage — "whether rows
159+
// exist is UNKNOWN". Swallowing it published an outage as
160+
// "0 problems".
161+
// * 400 (#8924, here): the request-classification refusal
162+
// `canonicalizeMetaRequestType` raises INSIDE `getMetaItems`
163+
// (`status: 400`, `code: 'INVALID_REQUEST'`) for an
164+
// unrecognised spelling of a DECLARED type (`fieldes`,
165+
// `viewes`, `capabilitys`, …). Only the `request.type` arm
166+
// can produce it — the full-sweep arm's `targetTypes` come
167+
// canonical out of the registry — so rethrowing it cannot
168+
// fail a whole-corpus sweep. Swallowing it answered
169+
// `200 {"scannedTypes":1,"stats":{}}` — "scanned 1 type,
170+
// no issues" — to a spelling every sibling `/meta` door
171+
// refuses with a 400 that names both accepted spellings
172+
// (measured: 27 such spellings on a booted kernel, while
173+
// objectui's DiagnosticsPage paints that payload as "All
174+
// clear"). The producer classified the caller's mistake;
175+
// the caller gets to hear it. Declared = enforced.
176+
//
177+
// What still takes the `continue`: a listing failure with no
178+
// status, or any status that is not one of the two verdicts
179+
// above (the registry listing is not a guarded read, so its
180+
// failures arrive unclassified). That skip is genuine — a
181+
// kernel scope that cannot enumerate one type must not fail
182+
// the whole governance sweep — and measured to have NO
183+
// producer in kernel scope today (six booted scopes, zero
184+
// skips), which is exactly why it stays a guard rather than
185+
// becoming a payload field (`skippedTypes` was considered and
186+
// ruled out as motiveless).
187+
//
188+
// By `status` and not by `code`, mirroring the 503 test above
189+
// and its direction argument: a false "real" is loud and
190+
// correctable at the caller; a false "benign" silently
191+
// mis-answers a question nobody re-asks.
192+
if ((error as { status?: unknown } | null | undefined)?.status === 400) throw error;
193+
continue;
194+
}
195+
const items: any[] = Array.isArray(listed?.items)
196+
? listed.items
197+
: Array.isArray(listed)
198+
? listed
199+
: [];
200+
const pkgSet = new Set<string>();
201+
let lockedCount = 0;
202+
let shipping: ShippingPackages | undefined;
203+
for (const item of items) {
204+
scannedItems += 1;
205+
const pkg = (item?._packageId ?? null) as string | null;
206+
if (pkg) pkgSet.add(pkg);
207+
// [#21694] Counted from the envelope's own derivation
208+
// ({@link servedLockState}), never from the declared `_lock`
209+
// alone: a packaged base the write doors refuse reads locked on
210+
// its item envelope, so the per-type tile counts it too. The
211+
// derivation reads the registry only — no store read per item.
212+
// [#21738] Its lock is the one item-lock resolution
213+
// ({@link resolveItemLock}) over the item's artifact layer —
214+
// [#21803] every installed package that ships the name, from
215+
// the address the list's own merge used (ADR-0048 package
216+
// scope) — and the document the list serves for it.
217+
const itemName = typeof item?.name === 'string' ? item.name : '';
218+
shipping ??= this.shippingPackagesOf(t);
219+
const itemLock = resolveItemLock({
220+
artifact: this.artifactLockLayerAt({
221+
type: t,
222+
name: itemName,
223+
packageId: request.packageId ?? (item?._packageId as string | undefined),
224+
}, shipping),
225+
overlay: item,
226+
});
227+
const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName), itemLock);
228+
if (served.lock !== 'none') lockedCount += 1;
229+
const diag: MetadataDiagnostics | undefined =
230+
item?._diagnostics ?? computeMetadataDiagnostics(t, item);
231+
if (!diag) continue;
232+
if (diag.valid && !includeWarnings) continue;
233+
if (diag.valid && includeWarnings && !diag.warnings?.length) continue;
234+
entries.push({
235+
type: t,
236+
name: typeof item?.name === 'string' ? item.name : '<unknown>',
237+
diagnostics: diag,
238+
});
239+
}
240+
stats[t] = { count: items.length, locked: lockedCount, packages: [...pkgSet].sort() };
241+
}
242+
243+
return {
244+
entries,
245+
total: entries.length,
246+
scannedTypes: targetTypes.length,
247+
scannedItems,
248+
stats,
249+
};
250+
}

0 commit comments

Comments
 (0)