|
49 | 49 | * the list's unknown-type refusal ({@link refuseUnknownMetaListType}), the |
50 | 50 | * object mask's cache posture ({@link projectMetaObjectSchema}) and the |
51 | 51 | * organization a caller's read is scoped to ({@link metaReadOrganizationId}). |
| 52 | + * [#20478] So does the layered view, on both of its spellings |
| 53 | + * ({@link createMetaLayeredAnswer}, {@link wantsMetaItemLayers}, |
| 54 | + * {@link metaItemLayersDeprecationHeaders}). |
52 | 55 | * `meta-list-projection-parity.test.ts` and `meta-read-org-scope-parity.test.ts` |
53 | 56 | * in `@objectstack/runtime` drive both transports over the same fixtures and |
54 | 57 | * hold the answers equal. |
@@ -2448,6 +2451,180 @@ export function createMetaItemAnswer( |
2448 | 2451 | }; |
2449 | 2452 | } |
2450 | 2453 |
|
| 2454 | +// ── THE layered answer ──────────────────────────────────────────────────────── |
| 2455 | + |
| 2456 | +/** |
| 2457 | + * [#5882 · #20478] Does this query ask for the layered view through its |
| 2458 | + * DEPRECATED spelling, `GET /meta/:type/:name?layers=<value>`? Any value but |
| 2459 | + * the empty string does (`?layers=true`, `?layers=1`, `?layers=false` alike); |
| 2460 | + * `?layers=` alone, and no `layers` at all, are the plain read. |
| 2461 | + * |
| 2462 | + * The one parse both transports ask, so a value cannot be the layered view on |
| 2463 | + * one and the plain read on the other. A caller that asks is served the layered |
| 2464 | + * view only where the protocol has one (`getMetaItemLayered`); elsewhere the |
| 2465 | + * flag is the plain read, as it always was on `RestServer`. |
| 2466 | + */ |
| 2467 | +export function wantsMetaItemLayers(query: Readonly<Record<string, unknown>> | undefined): boolean { |
| 2468 | + return query?.layers !== undefined && query?.layers !== ''; |
| 2469 | +} |
| 2470 | + |
| 2471 | +/** |
| 2472 | + * [#5882 · #20478] The headers every answer of the deprecated |
| 2473 | + * `?layers=` spelling carries: RFC 9745 `Deprecation`, and RFC 8288 `Link` |
| 2474 | + * naming the successor `GET /meta/:type/:name/layers` — the pairing |
| 2475 | + * `versioning.zod.ts` describes for retiring API versions, applied to a |
| 2476 | + * retiring query flag. No `Sunset` date: the hard cut-off is a maintainer call, |
| 2477 | + * and an invented date is worse than none. |
| 2478 | + * |
| 2479 | + * `itemPath` is the path the transport serves this item read at — the successor |
| 2480 | + * is that path plus `/layers`. A transport that cannot say where it is mounted |
| 2481 | + * passes `undefined` and advertises the deprecation alone: a `Link` naming a |
| 2482 | + * path this host may not serve is a machine-readable surface that lies (AGENTS.md |
| 2483 | + * 〈Route & surface ownership〉 rule 4). |
| 2484 | + */ |
| 2485 | +export function metaItemLayersDeprecationHeaders( |
| 2486 | + itemPath: string | undefined, |
| 2487 | +): { Deprecation: 'true'; Link?: string } { |
| 2488 | + return itemPath === undefined |
| 2489 | + ? { Deprecation: 'true' } |
| 2490 | + : { Deprecation: 'true', Link: `<${itemPath}/layers>; rel="successor-version"` }; |
| 2491 | +} |
| 2492 | + |
| 2493 | +/** The layers a layered answer carries, in the order the gate judges them: `effective` first — it is what the plain read serves, so its refusal is the plain read's own. */ |
| 2494 | +const META_ITEM_LAYERS = ['effective', 'code', 'overlay'] as const; |
| 2495 | + |
| 2496 | +/** The layers in the order the ADR-0106 mask projects them. */ |
| 2497 | +const META_ITEM_MASKED_LAYERS = ['code', 'overlay', 'effective'] as const; |
| 2498 | + |
| 2499 | +/** The request facts the layered chain reads — no transport shape. */ |
| 2500 | +export interface MetaLayeredRequest { |
| 2501 | + /** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */ |
| 2502 | + readonly metaType: string; |
| 2503 | + readonly name: string; |
| 2504 | + /** |
| 2505 | + * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, |
| 2506 | + * resolved by the transport BEFORE the read, the not-applicable passthrough |
| 2507 | + * for every type but `object`. A tier-3 fault is the transport's to answer |
| 2508 | + * before it gets here. |
| 2509 | + */ |
| 2510 | + readonly maskPosture: ObjectSchemaMaskPosture; |
| 2511 | +} |
| 2512 | + |
| 2513 | +/** |
| 2514 | + * What the layered chain answers: |
| 2515 | + * |
| 2516 | + * - `serve` — send `layered`, under `cacheControl` when it owes one |
| 2517 | + * ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2). No `Vary`: |
| 2518 | + * the layered view is not translated; |
| 2519 | + * - `refuse` — the gate's {@link MetaItemReadRefusal} for a layer the caller |
| 2520 | + * may not read (`absent` included: an unpublished app to a non-builder); |
| 2521 | + * - `mask-fault` — a layer's projection left the schema with no field (D6), |
| 2522 | + * which the transport answers as its field-visibility fault. |
| 2523 | + */ |
| 2524 | +export type MetaLayeredAnswer = |
| 2525 | + | { kind: 'serve'; layered: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } |
| 2526 | + | { kind: 'refuse'; refusal: MetaItemReadRefusal } |
| 2527 | + | { kind: 'mask-fault'; object: string }; |
| 2528 | + |
| 2529 | +/** |
| 2530 | + * [#5882 · #20156 · #20478] THE answer of the layered view — the three-layer |
| 2531 | + * diagnostic projection (`code` / `overlay` / `effective`) declared by |
| 2532 | + * `GetMetaItemLayeredResponseSchema` — after the store read, on both of its |
| 2533 | + * spellings (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag |
| 2534 | + * on the item read), on both transports. |
| 2535 | + * |
| 2536 | + * ## Why one chain |
| 2537 | + * |
| 2538 | + * `RestServer` served both spellings through one private helper, and the |
| 2539 | + * runtime dispatcher — the only answer on a host that mounts just the |
| 2540 | + * `${prefix}/*` catch-all — served neither: the route answered a located |
| 2541 | + * `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's |
| 2542 | + * `{ type, name, item }` with a `200`, so a client reading `overlay` or |
| 2543 | + * `effective` there read `undefined`, and an author was served the app pruned |
| 2544 | + * where ruling 5856774816 serves it whole. Everything the helper did after the |
| 2545 | + * read moved here, unchanged, and each transport hands its read's answer to |
| 2546 | + * THIS function. ⛔ A step is added HERE, never in a transport — one added in |
| 2547 | + * one of them is the defect this closed, reopened. |
| 2548 | + * `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both |
| 2549 | + * spellings through both transports. |
| 2550 | + * |
| 2551 | + * The read stays each transport's, in the caller's VETTED partition |
| 2552 | + * ({@link metaReadOrganizationId} over the folded type — the partition the plain |
| 2553 | + * read reads, [#9454] so an author who has just saved an org overlay is not |
| 2554 | + * shown `overlay: null`) and its `?package=` scope (ADR-0048), exactly as the |
| 2555 | + * item read's does ({@link createMetaItemAnswer}). |
| 2556 | + * |
| 2557 | + * Not translated and not cached, both deliberately: this is a diagnostic view of |
| 2558 | + * what is STORED at each layer, so locale-collapsing it (or serving it from the |
| 2559 | + * published-value cache) would misreport the thing being diagnosed. |
| 2560 | + * |
| 2561 | + * ## The steps, in `RestServer`'s order (unchanged) |
| 2562 | + * |
| 2563 | + * 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first |
| 2564 | + * (it is what the plain read serves, so its refusal is the plain read's |
| 2565 | + * own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only |
| 2566 | + * (these are STORED versions, which Studio's designer loads and saves |
| 2567 | + * back), and ruling 5856774816 — a caller who may write the item reads |
| 2568 | + * every layer whole, and any other caller who may open it reads each layer |
| 2569 | + * pruned, exactly as the plain read prunes it. ⚠️ So the transport's |
| 2570 | + * {@link MetaReadGateAudienceSources.resolveCaller} MUST carry |
| 2571 | + * {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission; |
| 2572 | + * absent reads as `false` (every caller pruned). Every layer is judged |
| 2573 | + * before any is served, so a refusal sends nothing of the others. |
| 2574 | + * 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema — |
| 2575 | + * through {@link projectMetaObjectSchema} under the posture resolved before |
| 2576 | + * the read, and the `private, no-store` an undetermined posture owes. |
| 2577 | + * |
| 2578 | + * The protocol's answer is never mutated. A layer the gate or the mask leaves as |
| 2579 | + * it was is served as it was, and every other key of the answer |
| 2580 | + * (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides |
| 2581 | + * through untouched. With nothing behind the name the protocol answers every |
| 2582 | + * layer `null`, and so does this chain: no layer is present to judge. A gate |
| 2583 | + * input that cannot be read REJECTS: the transport answers that fault, ⛔ never |
| 2584 | + * a layered view with a layer missing. |
| 2585 | + */ |
| 2586 | +export function createMetaLayeredAnswer( |
| 2587 | + sources: MetaItemReadGateSources, |
| 2588 | + request: MetaLayeredRequest, |
| 2589 | +): (layered: unknown) => Promise<MetaLayeredAnswer> { |
| 2590 | + const { metaType, name, maskPosture } = request; |
| 2591 | + return async (raw) => { |
| 2592 | + const layered = raw as Record<string, unknown> | null | undefined; |
| 2593 | + |
| 2594 | + // 1. [#20156] THE per-caller gate, on every present layer. |
| 2595 | + const served = new Map<string, unknown>(); |
| 2596 | + { |
| 2597 | + const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); |
| 2598 | + const judge = createMetaItemReadGate( |
| 2599 | + sources, metaType, name, present.map((layer) => layered![layer]), STORED_VERSION_DOOR_POLICY, |
| 2600 | + ); |
| 2601 | + for (const layer of present) { |
| 2602 | + const verdict = await judge(layered![layer]); |
| 2603 | + if (verdict.kind === 'refuse') return verdict; |
| 2604 | + served.set(layer, verdict.document); |
| 2605 | + } |
| 2606 | + } |
| 2607 | + |
| 2608 | + // 2. [ADR-0106 D5(4)] The mask, on every layer. |
| 2609 | + let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; |
| 2610 | + for (const layer of META_ITEM_MASKED_LAYERS) { |
| 2611 | + const document = served.has(layer) ? served.get(layer) : layered?.[layer]; |
| 2612 | + const masked = projectMetaObjectSchema(maskPosture, document); |
| 2613 | + if (!masked.ok) return { kind: 'mask-fault', object: name }; |
| 2614 | + cacheControl ??= masked.cacheControl; |
| 2615 | + if (masked.document !== document) served.set(layer, masked.document); |
| 2616 | + } |
| 2617 | + |
| 2618 | + let answer: unknown = raw; |
| 2619 | + if (layered && typeof layered === 'object') { |
| 2620 | + const replaced: Record<string, unknown> = { ...layered }; |
| 2621 | + for (const [layer, document] of served) replaced[layer] = document; |
| 2622 | + answer = replaced; |
| 2623 | + } |
| 2624 | + return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer }; |
| 2625 | + }; |
| 2626 | +} |
| 2627 | + |
2451 | 2628 | // ── THE book tree ───────────────────────────────────────────────────────────── |
2452 | 2629 |
|
2453 | 2630 | /** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */ |
|
0 commit comments