|
| 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