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
63 changes: 63 additions & 0 deletions .changeset/17502-served-schema-drops-unauthorable-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol): `GET /meta/types` stops publishing properties no instance can satisfy (#17502)

The served JSON Schema advertised the `retiredKey()` tombstones alongside the
live keys. `retiredKey()` keeps a removed authorable key declared on purpose —
the removal has to be audible — and `z.toJSONSchema` renders that tombstone as
a property node, `{ "description": "[REMOVED] <prescription>", "not": {} }`.

`not: {}` is the JSON Schema spelling of "no instance validates", so a consumer
that reads the subschema is told the truth. A consumer that reads the KEY SET is
not: Studio builds a repeater's column headers from
`items.properties[k].title ?? k`, so a tombstone inside a row shape became a
column an author was invited to fill and `saveMetaItem` then refused.

`toJsonSchemaSafe` now drops every property whose subschema admits no instance
before it serves or caches the document — structurally, by asking the JSON
Schema question, never by matching the `[REMOVED] ` description prefix, which
would put a second hand-written spelling of "this is a tombstone" in a consumer.
A property that admits nothing and is `required` is kept: dropping it would turn
"this object admits nothing" into "this object admits anything".

Measured over the whole served registry at `74eaab8614`, this change's merge
base (`@objectstack/spec` SOURCE at 17.4.0, plus the retirements unreleased at
that sha — not the published release): 80 such nodes across 16 types — a
reading taken at that tree, not a standing invariant; it moves as retired keys
land or age out.

**Nothing is un-retired, and no prescription CHANNEL is destroyed.** The removal is a
property of ONE emitter. `tsc` still types the key `never`, the parse still
refuses it with the prescription byte for byte, `packages/spec`'s
`authorable-surface/` ratchet still lists every retired key as `[RETIRED]`, and
the generated reference pages still print the full prescription in the
description column of a `never`-typed row. What this drops is a fourth copy, on
the one surface whose documented job is to describe what an author MAY write.

**What an author stops being offered, stated as a class.** A tombstone became
visible wherever a renderer derives its field or column list from the served KEY
SET and reads the subschema for nothing but a label — so the retired key arrived
as an editable input, or as a repeater column, that the publish door then
refused. Three mechanisms put one in front of an author, and one retired key can
reach it through more than one of them:

- **the flat, schema-driven fallback**, for a served type that carries no
`*.form.ts` layout: its field list *is* the served `properties` map, and a
nested object renders recursively, so a tombstone at any depth becomes a field
with the `[REMOVED] ` prescription as its help text;
- **repeater rows**, whose column headers are `items.properties[k].title ?? k` —
the carrier this card was filed on;
- **server-field grafting**, where an inspector merges the server's top-level
properties into a trailing "More fields" section: a key the UI's own bundled
spec predates is offered *because* the served document is the only place it is
known from.

No count of the affected sites is given, on purpose. Which nodes reach an author
depends on the renderer and on the Console build this repo pins, so any number
written here would be false at the next pin bump. The invariant is the class: the
served document stops offering what the publish door refuses, and every retired
key keeps the full prescription on its generated reference page. A repeater
column loses no text either way — the row-cell renderer has no `description`
branch — so there the removal only withdraws the offer.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@
* instrument agrees with the card wherever the card actually measured, and the
* canonicalising comparison is what tells content apart from key ordering.
*
* ## [#17502] Why the baseline is now STRIPPED before it is compared
*
* There are two declared reasons a served payload may differ from the raw
* derivation, and this suite owns exactly one of them. #17502 made
* `toJsonSchemaSafe` drop every property whose subschema admits no instance —
* a `retiredKey()` tombstone — so 15 of the served types legitimately differ
* from their raw derivation for a reason that has nothing to do with the
* degeneracy retry. Comparing against the raw document would make this pin red
* for that reason and blind to its own: a later blanket widening to
* `io: 'input'` would arrive inside an already-red assertion nobody could read.
*
* So the baseline has the SAME strip applied — through the emitter's own
* `stripUnauthorableProperties`, never a second spelling — and what remains on
* the two sides of the comparison is exactly the retry's blast radius. The
* assertion is unchanged in strength: widen the retry to every type and 24
* types move, not one.
*
* The property-count controls keep the CARD's original numbers as their
* authority and add back what the strip removed, so the constant still fails
* when a live property appears or disappears, and the subtraction is derived
* rather than a second hand-maintained table.
*
* Harness: the real `getMetaTypes()` on one protocol instance over a stub
* engine, so the assertions are about what the endpoint SERVES. A pin taken on
* a derivation chosen for convenience would not cover the served path at all —
Expand All @@ -55,6 +77,10 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
import { ObjectStackProtocolImplementation } from './protocol.js';
// [#17502] The emitter's OWN strip and its predicate — the baseline below is
// stripped with the same code the server runs, so this pin can never drift
// into measuring a second, hand-written idea of "admits nothing".
import { acceptsNothing, stripUnauthorableProperties } from './unauthorable-nodes.js';

/**
* The whole served surface: every declared metadata type plus every
Expand Down Expand Up @@ -109,6 +135,41 @@ function preFixDerivation(type: string): Record<string, unknown> | undefined {
}
}

/**
* [#17502] The pre-fix derivation with this card's strip applied — the baseline
* the blast-radius pin compares against, so the only difference left to find is
* the degeneracy retry's.
*/
function preFixServedBaseline(type: string): Record<string, unknown> | undefined {
return stripUnauthorableProperties(preFixDerivation(type));
}

/**
* [#17502] How many TOP-LEVEL properties the strip removes from this type's
* served document.
*
* Counted on whichever derivation the server can actually use: `action` has no
* properties at all on the default arm, so its three tombstones are visible
* only on the `io: 'input'` retry that #17501 gave it.
*/
function retiredTopLevelCount(type: string): number {
const schema = getMetadataTypeSchema(type);
if (!schema) return 0;
for (const io of ['output', 'input'] as const) {
let json: Record<string, unknown>;
try {
json = z.toJSONSchema(schema as z.ZodTypeAny, { unrepresentable: 'any', io }) as Record<string, unknown>;
} catch {
continue;
}
const properties = json.properties as Record<string, unknown> | undefined;
if (properties && Object.keys(properties).length > 0) {
return Object.values(properties).filter(acceptsNothing).length;
}
}
return 0;
}

/**
* Recursive key sort. Two documents that differ only in key ORDER canonicalise
* to the same string; anything still different after this is real content.
Expand Down Expand Up @@ -147,7 +208,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no

const properties = served!.properties as Record<string, unknown>;
expect(properties, '`action` must name its properties').toBeDefined();
expect(Object.keys(properties).length).toBe(48);
// [#17502] 48 is the key set `action` DECLARES — 45 accepted plus the
// three that admit no instance and are therefore refused — and that
// declared total stays the pinned authority. The served document no
// longer carries those three, so they are added back rather than the
// constant being lowered — a live key going missing is still red.
expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(48);
// A sample an author would actually address, and the one #17500's
// repeater titles need a node to sit on.
for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations']) {
Expand All @@ -161,7 +227,7 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
const moved: string[] = [];
for (const type of SERVED_TYPES) {
if (!getMetadataTypeSchema(type)) continue; // absence is not degeneracy — see below
const before = preFixDerivation(type);
const before = preFixServedBaseline(type);
const after = served.get(type);
if (canon(before) !== canon(after)) moved.push(type);
}
Expand All @@ -178,7 +244,7 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no

for (const type of SERVED_TYPES) {
if (type === 'action' || !getMetadataTypeSchema(type)) continue;
const before = preFixDerivation(type);
const before = preFixServedBaseline(type);
const after = served.get(type);
// Raw equality first: these must not move at all.
expect(JSON.stringify(after), `${type} served payload moved`).toBe(JSON.stringify(before));
Expand Down Expand Up @@ -212,7 +278,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
async (type, count) => {
const served = (await servedSchemas()).get(type as string);
expect(served, `${type} must be served`).toBeDefined();
expect(Object.keys(served!.properties as Record<string, unknown>).length).toBe(count);
// [#17502] The card's count is the authority; what the strip
// removed is added back, derived, so this stays a control over
// LIVE properties rather than a number quietly rewritten.
expect(
Object.keys(served!.properties as Record<string, unknown>).length
+ retiredTopLevelCount(type as string),
).toBe(count);
},
);

Expand Down
Loading
Loading