Skip to content

Commit eeb01c7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19333-top-level-zodonly-wiring
2 parents 47ecd08 + 9449512 commit eeb01c7

16 files changed

Lines changed: 1153 additions & 184 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown`
6+
7+
**BREAKING for TypeScript code that writes a view filter rule through a published type**: `ViewFilterRule`, and every carrier of it — `ListView.filter`, a view tab's `filter`, `InterfacePageConfig.filterBy`, and the related-list, record-picker and `object-*` block filter doors. A narrowing of a published TYPE, landing as `minor` (the bump level is not the carrier; this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes.
8+
9+
`operator` is a `z.preprocess` over the alias fold, and zod types a preprocess's input from its function's parameter. That parameter was `unknown`, so `ViewFilterRule['operator']` was `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule on every carrier, and was refused only when the schema parsed it. The input type is now `ViewFilterOperator`, the vocabulary the alias table's own contract says new producers emit, so an alias spelling or a non-string is refused by the compiler.
10+
11+
What does not change:
12+
13+
- **The runtime.** `ViewFilterRuleSchema` still folds every legacy spelling it folded before (`eq`, `gt`, `notIn`, `isNull`, …) to its canonical id, and still refuses a non-string at `operator` with the enum's own issue. Stored `sys_metadata` rows, YAML and JSON bodies and plain-JS producers that carry an alias parse exactly as before, and `os validate` answers as before.
14+
- **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design.
15+
- **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum.
16+
17+
## FROM → TO
18+
19+
| Wrote (TypeScript) | Write instead |
20+
| --- | --- |
21+
| `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` |
22+
| `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` |
23+
| `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` |
24+
| `operator: someString` (a value typed `string`) | type the unvalidated rule `unknown` and `ViewFilterRuleSchema.safeParse` it, or fold it with `normalizeFilterOperator` and check it against `VIEW_FILTER_OPERATORS` first |
25+
26+
The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id.
27+
28+
Clause-②: no (narrowing)
29+
30+
<!-- adr-0087: registered view-filter-rule-operator-input-canonical -->
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478)
7+
8+
Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`.
9+
10+
A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any
11+
adapter written on the public `HttpDispatcher` API) serves `/meta` through the
12+
runtime dispatcher. Until now, on such a host:
13+
14+
- **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was
15+
`{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or
16+
`effective` read `undefined`. There was no `Deprecation` header and no `Link`
17+
to the successor. An author (a caller the item's save door admits) was served
18+
the app pruned, where the layered view serves them every layer whole.
19+
- **`GET /meta/:type/:name/layers` was no route.** It answered a located
20+
`404 ROUTE_NOT_FOUND`.
21+
22+
Both spellings now answer what `RestServer` answers: the three layers, each
23+
judged by the per-caller read gate under the stored-version doors' policy
24+
(whole for a caller who may save the item, pruned as the plain read prunes it
25+
for everyone else), each projected through the object-schema field mask, and
26+
`private, no-store` when the caller's field visibility could not be determined.
27+
The read is scoped to the caller's vetted organization and to `?package=`. The
28+
flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to
29+
`/layers` built from the request's own URL (every `createHonoApp` request
30+
carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no
31+
layered read, and the flag is then the plain read, on both transports.
32+
33+
**What changed.** Everything `RestServer`'s layered helper does after the store
34+
read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and
35+
headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The
36+
dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are
37+
unchanged: every existing REST test passes unedited.

‎packages/rest/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,21 +106,31 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
106106
// caller's `/meta` request is scoped to — the VETTED one on its execution
107107
// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read
108108
// of one type).
109+
//
110+
// [#20478] …and the layered view's, on both of its spellings: its post-read
111+
// chain (`createMetaLayeredAnswer` — the per-caller gate on every layer under
112+
// the stored-version doors' policy, the object mask and its cache posture), the
113+
// deprecated `?layers=` flag's parse (`wantsMetaItemLayers`) and the headers it
114+
// is served under (`metaItemLayersDeprecationHeaders`). The read itself is each
115+
// transport's, scoped by `metaReadOrganizationId`.
109116
export {
110117
createMetaBookTreeAnswer,
111118
createMetaItemAnswer,
112119
createMetaItemReadGate,
120+
createMetaLayeredAnswer,
113121
createMetaListReadGate,
114122
createMetaListAnswer,
115123
isPublicAudienceRead,
116124
metaCallerOrganizationId,
125+
metaItemLayersDeprecationHeaders,
117126
metaReadOrganizationId,
118127
metaRequestLocale,
119128
projectMetaObjectSchema,
120129
refuseUnknownMetaListType,
121130
STORED_VERSION_DOOR_POLICY,
122131
translateMetaEnvelope,
123132
translateMetaList,
133+
wantsMetaItemLayers,
124134
} from './meta-item-read-gate.js';
125135
export type {
126136
MetaBookTreeAnswer,
@@ -131,6 +141,8 @@ export type {
131141
MetaItemReadRefusal,
132142
MetaItemReadVerdict,
133143
MetaItemRequest,
144+
MetaLayeredAnswer,
145+
MetaLayeredRequest,
134146
MetaListAnswer,
135147
MetaListAnswerSources,
136148
MetaListRequest,

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
* the list's unknown-type refusal ({@link refuseUnknownMetaListType}), the
5050
* object mask's cache posture ({@link projectMetaObjectSchema}) and the
5151
* 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}).
5255
* `meta-list-projection-parity.test.ts` and `meta-read-org-scope-parity.test.ts`
5356
* in `@objectstack/runtime` drive both transports over the same fixtures and
5457
* hold the answers equal.
@@ -2448,6 +2451,180 @@ export function createMetaItemAnswer(
24482451
};
24492452
}
24502453

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+
24512628
// ── THE book tree ─────────────────────────────────────────────────────────────
24522629

24532630
/** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */

0 commit comments

Comments
 (0)