Skip to content

Commit 586934e

Browse files
fix(metadata-protocol): a page saved without type is stored and served with PageSchema's declared default (#20101) (#20133)
Fixes #20101 Clause-②: no ## What this changes `PageSchema` declares `type: PageTypeSchema.default('record')` (`packages/spec/src/ui/page.zod.ts`), so a page authored without `type` is a record page. `saveMetaItem` parsed the body with that default, then stored the authored body verbatim, and every `/meta` read serves a stored row unparsed. A page saved without `type` was therefore stored and served without it. One helper, `withDeclaredPageTypeDefault` in `packages/metadata-protocol/src/protocol.ts`, reads the default from the registered `page` schema (`getMetadataTypeSchema('page').shape.type`, the schema the save gate validates with). It fills the default only when `type` is absent, so no `'record'` literal is added. It runs at two seams: - **Save.** It runs in `saveMetaItem` after the schema gate accepts the body, beside the two existing grafts. New rows store the key, on draft and publish saves alike. - **Read.** It runs in `convertStoredItem`, the rehydration seam every stored row passes through. Rows stored before this change are served with the default. The row itself is never rewritten. The read fill sits in `convertStoredItem` and not in `convertStoredItemDetailed`. It is not an ADR-0087 conversion and emits no notice, so `migrateStoredMetadata` (the Detailed caller) has nothing to rewrite. Scope is `PageSchema.type` only. The regions of `protocol.ts` that draft PR #20125 edits are not touched: this diff's hunks are at the `resolveOverlaySchema` docblock, after `graftFoldedFormSections`, in `convertStoredItem` and in the `saveMetaItem` schema-gate block. ## Why both seams (H2, measured) - **Read alone** covers old rows, but it breaks #4326's invariant that a GET → PUT round-trip is byte-identical. The served body would carry a key the row does not have. The first re-save of every such page would then write a one-key change, a new checksum and a history row that nobody authored. Ablation leg B below shows this: with the save fill removed, the round-trip pin goes red. - **Save alone** leaves every row stored before this change typeless on every read. Covering those rows would need a stored-row rewrite, which triage point 2 forbids. Ablation leg A below shows this. - **Both together** is the smallest fix that is correct. Measured below: a new row round-trips with the same bytes, checksum and version, and an old row is served typed without being rewritten. ## Serve paths: base `226e00c038` vs head (H1) The measurement used a typeless page, an explicit `type: 'app'` control, a typeless draft, and a typeless row seeded directly into the store (the "old row"). It ran at three levels: the real `RestServer` over the real protocol, the protocol alone, and the real composition (`bootStack(showcase)` plus `MetadataPlugin` over a build-shaped artifact, over real HTTP). | Path | base: typeless | base: `app` control | head: typeless | head: `app` control | |:---|:---|:---|:---|:---| | `saveMetaItem` publish: stored body | absent | `app` | `record` | `app` | | `saveMetaItem` draft: stored draft body | absent | n/a | `record` | n/a | | `publishMetaItem` of that draft: stored and served | absent | n/a | `record` | n/a | | Registry write-through on save (unscoped kernel) | absent | `app` | `record` | `app` | | `GET /meta/page` (`getMetaItems`), cache on and off, new and old row | absent | `app` | `record` | `app` | | `GET /meta/page?preview=draft` | absent | n/a | `record` | n/a | | `GET /meta/page/:name`, cached arm (`getMetaItemCached`) | absent, ETag `7811092d` | `app`, ETag `6164defa` | `record`, ETag `69c54eb6` | `app`, ETag `6164defa` (unchanged) | | `GET /meta/page/:name`, uncached arm (`getMetaItem`) | absent | `app` | `record` | `app` | | `GET /meta/page/:name?state=draft` | absent | n/a | `record` | n/a | | `getMetaItem` with `previewDrafts` | absent | n/a | `record` | n/a | | `getMetaItemLayered`, old row (overlay / effective) | absent / absent | n/a | `record` / `record` | n/a | | `searchAll` page hit `pageType` | absent | `app` | `record` | `app` | | `loadMetaFromDb` boot hydration into the registry | absent | `app` | `record` | `app` | | Old row at rest, after all the reads above | absent | n/a | absent (not rewritten) | n/a | | `migrateStoredMetadata` preview | scanned 4, canonical 4, pending 0 | | scanned 4, canonical 4, pending 0 | | | Real composition over HTTP: list, single, `?state=draft`, published draft | absent | `app` (ETag `100766d6`) | `record` | `app` (ETag `100766d6`, unchanged) | ## Consumers of the served page body (H3) Every measured reading below used the explicit `app` page as its control. - **Server-side record-page selection by `type`: none in this repo.** A grep of non-test `packages/**` for reads of a page's `type` finds two readers. One is the `searchAll` page sweep (`pageType`, measured). The other is the lint rule in `validate-page-visualization-bindings.ts`, which asks `page.type !== 'list'`; its verdict cannot change, because `record` is not `list`. Record-page selection lives in objectui's `usePageAssignment`, per the card, and that is UNMEASURED here because objectui is not reachable from this session. - **Measured to carry the fill:** the list, single, cached, draft-state, preview and layered reads; boot hydration; the registry write-through; and the `searchAll` sweep. See the table above. - **Carry the fill by code reading, not separately exercised:** these read through `convertStoredItem` or `getMetaItems`. They are the runtime authoring gate's stored universe (`foldStoredCollection`), the `duplicatePackage` copy (which is then saved through `saveMetaItem`), and the runtime package-export sweep (`getMetaItems`). - **Deliberately raw:** `historyMetaItem` and `diffMetaItem` history bodies, which are a record of what was written, and `getEffectiveLock`, which reads protection only. - **Outside this card's surface, not filled:** the `metadata` service's `DatabaseLoader.rowToData` (`packages/metadata`). In the composition measured (showcase plus `MetadataPlugin`), every read of the test pages served the typed body. - **Hashes.** The row checksum (the `sha256:` token `If-Match` compares) is computed over the stored body, so a read cannot move it. The cached read's ETag is a hash of the served content. It changes once for a typeless page and is unchanged for the `app` control. - **Round-trip.** Serving the new row, then PUTting it back, stores the same bytes, checksum and version: the repository's identical-body short-circuit fires and nothing is written. Serving the old row and PUTting it back stores `type` once, and the served body is identical before and after. That is the author's re-save, not a migration. ## Tests All runs are at head `e3b83df825`. The probes ran at `c2a29b4573`, and `protocol.ts` is byte-identical between the two commits. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 188 files passed, 3 skipped; 2686 tests passed, 19 skipped; exit 0. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` includes all 191 test files, among them the three edited here. - REST `/meta` route tests: every `packages/rest/src/*meta*.test.ts`, 47 files, 667 tests passed, exit 0. They read the rebuilt `metadata-protocol` `dist/`, which contains `withDeclaredPageTypeDefault` (4 hits). - Dogfood metadata round-trip tests: `dashboard-designer-roundtrip`, `meta-published-and-state-routes`, `package-first-authoring`, `meta-types-create-seed`, `showcase-object-extension-meta-read` and `showcase-object-extension-scalar-divergence`. 6 files, 33 tests passed, exit 0. New pins: - `protocol.stored-conversions.test.ts`: a stored typeless page row is served with the declared default by the list and single reads, the draft read and the list preview, and boot hydration. An explicit `app` row is untouched. The row's bytes survive the reads, and `migrateStoredMetadata` preview and apply report it canonical, with `rewritten: 0`. - `protocol.read-decorations.test.ts`: publish and draft saves store the default. An explicit `app` is stored and served unchanged. GET → PUT gives the same stored body, checksum and version. - `protocol.search-published-pages.test.ts`: a typeless page's hit carries `pageType` equal to the declared default. Every pin reads the expected value from the schema. A precondition asserts that value is `record`. **Ablation.** Each leg ran through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed) with a trap restore, and restore was proven each time (blob `1df09c2eb4` equals HEAD, `git diff HEAD` empty). These suites import `./protocol.js` from source, so no rebuild was involved. `ablation-dist-preflight --absent` confirms the markers never reached `dist/`. - Leg A, read fill removed: 4 failed of 48. These are the three stored-conversions read pins and the search pin. - Leg B, save fill removed: 2 failed of 48. These are the stored-body pin and the GET → PUT byte-identity pin. - Restored: 48 of 48 passed. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived at `e3b83df825`: 61 commands. All 61 were run with exit codes recorded, and `--ran` reports 61 run, 0 NOT-MEASURED (a derived zero). - `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist/`). It passed after those were built. - `node scripts/check-issue-citations.mjs --base 226e00c`: 5 citations, all resolve. - Lint, narrowed: `eslint --no-inline-config --format json` over the 4 changed `.ts` files reports 4 files, 0 errors, 0 warnings. `isPathIgnored` is false for them. `calculateConfigForFile` shows neither `parserOptions.project` nor `projectService` set, so linting is not type-aware and this diff cannot change the verdict on any file it does not touch. The full `pnpm lint` is left to CI. ## Acceptance notes - **Other declared defaults (triage point 3, census, not in this diff).** Read from the built spec at `226e00c038`: 22 stored metadata types were walked (types with `allowRuntimeCreate` or `allowOrgOverride`). 15 of them declare 39 top-level defaults. The positive control `page.type` is found, and the negative control `page.object` (optional, no default) is not. `external_catalog` has no registered schema. The save path stores all of these verbatim. Only `page.type` was measured through the serve paths. The 38 besides `page.type`: - `object`: `isSystem`, `datasource` - `hook`: `priority`, `async`, `onError`, `runAs` - `seed`: `externalId`, `mode`, `env` - `mapping`: `sourceFormat`, `mode` - `view`: `type` (`simple`) - `page`: `template`, `regions`, `isDefault`, `kind` (`full`) - `app`: `active`, `isDefault` - `action`: `type` (`script`), `refreshAfter` - `report`: `type` (`tabular`), `drilldown` - `flow`: `version`, `status`, `runAs` - `datasource`: `active`, `autoConnect`, `schemaMode`, `origin` - `email_template`: `category`, `locale`, `variables`, `active`, `isSystem` - `permission`: `isDefault` - `position`: `delegatable` - `skill`: `surface`, `active` - The renderer half of triage point 4 ("is picked as the object's record page") is objectui's `usePageAssignment`, and it is UNMEASURED from this session. What this PR pins is the served body: `type` equal to the declared default. - The cached single read's ETag for a page stored without `type` changes once, because the served content changed. The `app` control's ETag is unchanged. - The first re-save of an old typeless page stores `type` once, with one history row. From then on its round-trip writes nothing. - A stored row that spells the page type under a key `PageSchema` refuses (for example the `pageType` alias key, which is reachable only by a write that went around the save gate) is now read with `type: 'record'` beside that key. Such a row fails the schema either way and carries `_diagnostics`. No such row was measured in any data. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a44ce7 commit 586934e

5 files changed

Lines changed: 264 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a page saved without `type` is stored and served with the `type` default that `PageSchema` declares (#20101)
6+
7+
Clause-②: no
8+
9+
`PageSchema` declares `type: PageTypeSchema.default('record')`, so a page authored without `type` is a record page. `saveMetaItem` parsed the body with that default and then stored the body as authored, and the `/meta` reads serve stored rows without parsing them. A record page authored without `type` was therefore served with no `type` at all. A renderer that picks an object's record page by `type === 'record'` never picked it.
10+
11+
The declared default now reaches the served body at two points:
12+
13+
- **Save.** When the schema gate accepts a page that omits `type`, the stored body gets the declared default, on draft and publish saves alike. A page with an explicit `type` is stored unchanged. Because the stored row and the served document are the same bytes, a client that re-saves the page it just read writes nothing: the checksum and the version stay the same.
14+
- **Read.** A page row stored before this change is served with the declared default. This covers the list and single `/meta/page` reads, the cached read, draft reads and the `?preview=draft` list, the layered read, boot hydration into the registry, and the `searchAll` page sweep, whose hit now carries `pageType: 'record'` for such a page. The row itself is not rewritten, and `os migrate meta --stored` reports it canonical.
15+
16+
The value is read from the registered `page` schema, never written out a second time. Only an absent `type` is filled: an explicit value, of any page type, is served as stored.
17+
18+
What moves for a client: the served body of such a page gains `type: 'record'`, a key `PageSchema` already declares. The set of accepted bodies is unchanged. The ETag of the cached single read for such a page changes once, because the served content changed. Saving that page again after reading it stores `type` once. From then on a read-then-save round-trip writes nothing.

‎packages/metadata-protocol/src/protocol.read-decorations.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,81 @@ describe('draft preview diagnostics do not judge the injected `_draft` badge (#7
513513
});
514514
});
515515
});
516+
517+
/**
518+
* [#20101] The WRITE half of the served page-type default, pinned against the
519+
* #4326 invariant this file exists for.
520+
*
521+
* `PageSchema` declares `type: PageTypeSchema.default('record')`. The save gate
522+
* parsed with that default and then stored the authored body verbatim, so a
523+
* page authored without `type` was stored without it, served without it, and
524+
* never picked as the object's record page. The read seam now fills the
525+
* declared default for such rows (`protocol.stored-conversions.test.ts` pins
526+
* that half). The write half is what keeps THIS file's invariant: if the read
527+
* seam were the only fix, the served document would carry a key its row did
528+
* not, and the first GET → PUT of every such page would write a one-key change
529+
* and a history row that nobody authored.
530+
*
531+
* The expected value is read from the registered schema, the same source the
532+
* implementation reads, never spelled as a second literal.
533+
*/
534+
describe('[#20101] a page saved without `type` stores and serves the declared default', () => {
535+
const DECLARED = (getMetadataTypeSchema('page') as any).shape.type.parse(undefined);
536+
const typelessPage = (name: string) => ({ name, label: 'Invoice', object: 'crm_invoice' });
537+
const rowOf = (rows: Map<string, Row>, name: string, state = 'active') =>
538+
Array.from(rows.values()).find((r) => r.name === name && r.state === state)!;
539+
540+
it('precondition: the declared default is `record`', () => {
541+
expect(DECLARED).toBe('record');
542+
});
543+
544+
it('publish and draft saves store it, and the list and the single read serve it', async () => {
545+
const { engine, rows } = makeStubEngine();
546+
const protocol = new ObjectStackProtocolImplementation(engine);
547+
548+
await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: typelessPage('invoice_record') });
549+
await protocol.saveMetaItem({
550+
type: 'page', name: 'invoice_record_next', item: typelessPage('invoice_record_next'), mode: 'draft',
551+
});
552+
553+
expect(JSON.parse(rowOf(rows, 'invoice_record').metadata)).toEqual({ ...typelessPage('invoice_record'), type: DECLARED });
554+
expect(JSON.parse(rowOf(rows, 'invoice_record_next', 'draft').metadata).type).toBe(DECLARED);
555+
556+
const listed = (await protocol.getMetaItems({ type: 'page' })).items as any[];
557+
expect(listed.find((i) => i.name === 'invoice_record')?.type).toBe(DECLARED);
558+
const single: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
559+
expect(single.item.type).toBe(DECLARED);
560+
const draft: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record_next', state: 'draft' });
561+
expect(draft.item.type).toBe(DECLARED);
562+
});
563+
564+
it('an explicit non-record `type` is stored and served unchanged', async () => {
565+
const { engine, rows } = makeStubEngine();
566+
const protocol = new ObjectStackProtocolImplementation(engine);
567+
const appPage = { name: 'launchpad', label: 'Launchpad', type: 'app' };
568+
569+
await protocol.saveMetaItem({ type: 'page', name: 'launchpad', item: appPage });
570+
571+
expect(JSON.parse(rowOf(rows, 'launchpad').metadata)).toEqual(appPage);
572+
const single: any = await protocol.getMetaItem({ type: 'page', name: 'launchpad' });
573+
expect(single.item.type).toBe('app');
574+
});
575+
576+
it('GET → PUT of the served page is byte-identical: same stored body, same checksum, same version', async () => {
577+
const { engine, rows } = makeStubEngine();
578+
const protocol = new ObjectStackProtocolImplementation(engine);
579+
580+
await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: typelessPage('invoice_record') });
581+
const before = { ...rowOf(rows, 'invoice_record') };
582+
583+
const served: any = (await protocol.getMetaItem({ type: 'page', name: 'invoice_record' })).item;
584+
expect(served.type).toBe(DECLARED); // precondition — the served body carries the default
585+
await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: served });
586+
587+
const after = rowOf(rows, 'invoice_record');
588+
expect(after.metadata).toBe(before.metadata);
589+
expect(after.checksum).toBe(before.checksum);
590+
// The repository's identical-body short-circuit: no write happened at all.
591+
expect(after.version).toBe(before.version);
592+
});
593+
});

‎packages/metadata-protocol/src/protocol.search-published-pages.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import { describe, it, expect, vi } from 'vitest';
3535
import { SearchAllPageHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api';
36+
import { PageSchema } from '@objectstack/spec/ui';
3637
import { ObjectStackProtocolImplementation } from './protocol.js';
3738

3839
const text = (name: string) => ({ name, type: 'text' });
@@ -133,6 +134,22 @@ describe('[#13216] searchAll sweeps published pages into `pages`', () => {
133134
expect(body.error?.issues ?? []).toEqual([]);
134135
});
135136

137+
it("[#20101] a stored page with no `type` is hit with `pageType` = PageSchema's declared default", async () => {
138+
// The sweep reads the SERVED set, so it inherits the read seam's fill:
139+
// a page authored without `type` is a record page, and the hit says so
140+
// instead of omitting `pageType`. The value is read from the schema.
141+
const declared = (PageSchema as unknown as { shape: { type: { parse(v: unknown): unknown } } })
142+
.shape.type.parse(undefined);
143+
const { p } = makeProtocol({
144+
storedRows: [storedPage({ body: { name: 'invoice_record', label: 'Invoice record', object: 'invoice' } })],
145+
});
146+
147+
const result = await p.searchAll({ q: 'invoice' });
148+
149+
expect(declared).toBe('record');
150+
expect(result.pages).toEqual([{ kind: 'page', name: 'invoice_record', title: 'Invoice record', pageType: declared }]);
151+
});
152+
136153
it('a DRAFT row of the same shape does not surface — the sweep reads the published state only', async () => {
137154
const body = {
138155
name: 'pending_dashboard',

‎packages/metadata-protocol/src/protocol.stored-conversions.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { describe, expect, it } from 'vitest';
2424
// of this package's (file, verb) pairs sat in the gate's DEBT ledger until
2525
// #5619 sank the two predicates into a package both sides already depend on.
2626
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
27+
import { PageSchema } from '@objectstack/spec/ui';
2728
import { ObjectStackProtocolImplementation } from './protocol.js';
2829

2930
interface Row {
@@ -255,3 +256,86 @@ describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3
255256
expect(registered.some((r) => r.kind === 'object' && r.body?.name === 'corrupt_thing')).toBe(true);
256257
});
257258
});
259+
260+
// ── [#20101] a declared DEFAULT is part of the canonical served shape ─────────
261+
//
262+
// `PageSchema` declares `type: PageTypeSchema.default('record')`: a page
263+
// authored without `type` IS a record page. Every row a save wrote before
264+
// #20101 is stored without the key (the save path persisted the authored body
265+
// verbatim), and a consumer that selects an object's record page by
266+
// `type === 'record'` never picked one. The rehydration seam now serves the
267+
// declared default — and ONLY serves it: the row at rest is not rewritten, and
268+
// the stored-migration pass finds nothing to do (no stored-row rewrite is part
269+
// of this fix).
270+
//
271+
// The expected value is READ from the schema, like the implementation reads
272+
// it, so a pin here cannot quietly become a second spelling of the default.
273+
const DECLARED_PAGE_TYPE = (PageSchema as unknown as { shape: { type: { parse(v: unknown): unknown } } })
274+
.shape.type.parse(undefined);
275+
276+
const typelessPageBody = { name: 'invoice_record', label: 'Invoice', object: 'crm_invoice' };
277+
const typelessPageRow = { type: 'page', name: 'invoice_record', metadata: typelessPageBody };
278+
const appPageRow = { type: 'page', name: 'launchpad', metadata: { name: 'launchpad', label: 'Launchpad', type: 'app' } };
279+
const typelessDraftRow = {
280+
type: 'page', name: 'invoice_record_next', state: 'draft',
281+
metadata: { name: 'invoice_record_next', label: 'Invoice (next)', object: 'crm_invoice' },
282+
};
283+
284+
describe('[#20101] a stored page row without `type` is served with PageSchema\'s declared default', () => {
285+
it('precondition: the declared default the pins below read is `record`', () => {
286+
expect(DECLARED_PAGE_TYPE).toBe('record');
287+
});
288+
289+
it('the list and the single read serve the declared default; an explicit non-record type is untouched', async () => {
290+
const { engine } = makeStubEngine([typelessPageRow, appPageRow]);
291+
const protocol = new ObjectStackProtocolImplementation(engine);
292+
293+
const listed = (await protocol.getMetaItems({ type: 'page' })).items as any[];
294+
expect(listed.find((i) => i.name === 'invoice_record')?.type).toBe(DECLARED_PAGE_TYPE);
295+
expect(listed.find((i) => i.name === 'launchpad')?.type).toBe('app');
296+
297+
const single: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
298+
expect(single.item.type).toBe(DECLARED_PAGE_TYPE);
299+
expect(single.item._diagnostics?.valid).toBe(true);
300+
const control: any = await protocol.getMetaItem({ type: 'page', name: 'launchpad' });
301+
expect(control.item.type).toBe('app');
302+
});
303+
304+
it('the draft reads serve it too — the pinned `?state=draft` read and the list preview', async () => {
305+
const { engine } = makeStubEngine([typelessDraftRow]);
306+
const protocol = new ObjectStackProtocolImplementation(engine);
307+
308+
const draft: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record_next', state: 'draft' });
309+
expect(draft.item.type).toBe(DECLARED_PAGE_TYPE);
310+
const preview = (await protocol.getMetaItems({ type: 'page', previewDrafts: true })).items as any[];
311+
expect(preview.find((i) => i.name === 'invoice_record_next')?.type).toBe(DECLARED_PAGE_TYPE);
312+
});
313+
314+
it('boot hydration registers the defaulted body', async () => {
315+
const { engine, registered } = makeStubEngine([typelessPageRow]);
316+
const protocol = new ObjectStackProtocolImplementation(engine);
317+
const res = await protocol.loadMetaFromDb();
318+
expect(res).toEqual({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false });
319+
const page = registered.find((r) => r.kind === 'item' && r.type === 'page')!;
320+
expect(page.body.type).toBe(DECLARED_PAGE_TYPE);
321+
});
322+
323+
it('the row at rest is NOT rewritten: its bytes survive the reads, and the migration pass reports it canonical', async () => {
324+
const { engine, rows } = makeStubEngine([typelessPageRow, typelessDraftRow]);
325+
const protocol = new ObjectStackProtocolImplementation(engine);
326+
const before = rows.map((r) => r.metadata);
327+
328+
await protocol.getMetaItems({ type: 'page', previewDrafts: true });
329+
await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
330+
expect(rows.map((r) => r.metadata)).toEqual(before);
331+
expect(JSON.parse(rows[0]!.metadata)).toEqual(typelessPageBody);
332+
333+
// The fill is a READ-side fact, not an ADR-0087 conversion: it emits no
334+
// notice, so `os migrate meta --stored` has no row to rewrite.
335+
const preview = await protocol.migrateStoredMetadata({ types: ['page'] });
336+
expect(preview).toMatchObject({ scanned: 2, canonical: 2, pending: 0, failed: 0, rows: [] });
337+
const applied = await protocol.migrateStoredMetadata({ types: ['page'], apply: true });
338+
expect(applied).toMatchObject({ scanned: 2, canonical: 2, rewritten: 0, failed: 0, rows: [] });
339+
expect(rows.map((r) => r.metadata)).toEqual(before);
340+
});
341+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,9 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
693693
* (e.g. `isPinned`, `isDefault`, `sortOrder`) survive the round-trip.
694694
* The one exception is filter `operator` spellings, which are grafted back
695695
* from `parsed.data` so a save stops minting new legacy-alias rows — see
696-
* {@link graftNormalizedOperators}.
696+
* {@link graftNormalizedOperators}. [#20101] A page that omits `type` is
697+
* stored with the default `PageSchema` declares for it — see
698+
* {@link withDeclaredPageTypeDefault}.
697699
* - Types without a registered schema (the wiring-layer types
698700
* `function`/`service`/`router`, and any plugin types that have not
699701
* yet called `registerMetadataTypeSchema()`) fall through unvalidated.
@@ -1324,6 +1326,54 @@ export function graftFoldedFormSections(authored: unknown, parsed: unknown): unk
13241326
return rest.sections !== undefined ? rest : { ...rest, sections: groups };
13251327
}
13261328

1329+
/**
1330+
* [#20101] Give a page body that omits `type` the default `PageSchema`
1331+
* declares for it, and change nothing else.
1332+
*
1333+
* `PageSchema` declares `type: PageTypeSchema.default('record')`, so a page
1334+
* authored without `type` IS a record page. The save gate parses with that
1335+
* default and then persists the authored body verbatim (ADR-0005
1336+
* §"Validation", {@link resolveOverlaySchema}), and every read serves a stored
1337+
* row without parsing it. A record page written the natural way, with the
1338+
* defaulted key left out, therefore reached every reader of the served body
1339+
* with no `type` at all. A consumer that picks an object's record page by
1340+
* `type === 'record'` never picked it. The parse applied the declared default
1341+
* and nothing the server sends carried it.
1342+
*
1343+
* One function, applied at both seams:
1344+
* - the WRITE (`saveMetaItem`, once the schema gate has accepted the body),
1345+
* so a new row stores the key and a GET → PUT round-trip of the served
1346+
* document stays byte-identical (#4326's invariant). A fix on the read
1347+
* side alone breaks that round-trip: the first re-save of every such page
1348+
* would write a one-key change and a history row that nobody authored;
1349+
* - the READ ({@link ObjectStackProtocolImplementation.convertStoredItem},
1350+
* the rehydration seam every stored row passes through), so a row stored
1351+
* before this fix is served with the default and never rewritten. The row
1352+
* at rest keeps its bytes and there is no migration.
1353+
*
1354+
* The value is READ from the registered `page` schema, the one the save gate
1355+
* validates with, and is never spelled here. A literal would be a second,
1356+
* silent spelling of the default: the consumer-side `?? 'record'` this
1357+
* replaces, moved up one layer.
1358+
*
1359+
* ⛔ The scope is `type` on `page` and nothing wider. It is not a general
1360+
* "fill every declared default" pass. Other stored types declare top-level
1361+
* defaults in the same position, and each of those is its own decision.
1362+
*
1363+
* Only an ABSENT key is filled (zod applies a default to `undefined`, not to
1364+
* `null`), and an explicit value is left as it is. Returns the input itself
1365+
* when nothing is filled.
1366+
*/
1367+
function withDeclaredPageTypeDefault(type: string, item: unknown): unknown {
1368+
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'page') return item;
1369+
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
1370+
if ((item as Record<string, unknown>).type !== undefined) return item;
1371+
const pageSchema = getMetadataTypeSchema('page') as { shape?: Record<string, z.ZodTypeAny> } | undefined;
1372+
const declared = pageSchema?.shape?.type?.safeParse(undefined);
1373+
if (!declared?.success || declared.data === undefined) return item;
1374+
return { ...(item as Record<string, unknown>), type: declared.data };
1375+
}
1376+
13271377
/**
13281378
* #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view
13291379
* overlay is missing but the registry entry it shadows carries. The overlay's
@@ -4678,10 +4728,17 @@ export class ObjectStackProtocolImplementation implements
46784728
* executor registry (`reservedNodeTypes`), which this layer does not
46794729
* have. Flows canonicalize at `AutomationEngine.registerFlow` — the
46804730
* execution seam — with the same full-chain policy.
4731+
*
4732+
* [#20101] …and a page that omits `type` is read with the default
4733+
* `PageSchema` declares ({@link withDeclaredPageTypeDefault}). That fill is
4734+
* applied HERE and not in {@link convertStoredItemDetailed}: it is not an
4735+
* ADR-0087 conversion and emits no notice, so the stored-migration pass
4736+
* (the Detailed caller) has nothing to rewrite, and the row at rest keeps
4737+
* its bytes.
46814738
*/
46824739
private convertStoredItem(type: string, data: unknown): unknown {
46834740
const singular = PLURAL_TO_SINGULAR[type] ?? type;
4684-
return this.convertStoredItemDetailed(type, data, (n) => {
4741+
const converted = this.convertStoredItemDetailed(type, data, (n) => {
46854742
const name = (data as { name?: unknown } | null | undefined)?.name;
46864743
const key = `${n.conversionId}|${singular}|${String(name ?? '')}`;
46874744
if (this.storedConversionWarned.has(key)) return;
@@ -4692,6 +4749,7 @@ export class ObjectStackProtocolImplementation implements
46924749
`"os migrate meta --stored --apply") to persist the canonical shape.`,
46934750
);
46944751
}).item;
4752+
return withDeclaredPageTypeDefault(singular, converted);
46954753
}
46964754

46974755
/**
@@ -16120,6 +16178,13 @@ export class ObjectStackProtocolImplementation implements
1612016178
graftFoldedFormSections(request.item, parsed.data),
1612116179
parsed.data,
1612216180
);
16181+
// [#20101] …and a page's declared `type` default, the one
16182+
// default that a reader of the served body selects on. It is
16183+
// stored so that the served document and the stored row are
16184+
// the same bytes, and a GET → PUT round-trip is a no-op. The
16185+
// read seam fills the same default for rows stored before this
16186+
// fix. See {@link withDeclaredPageTypeDefault}.
16187+
request.item = withDeclaredPageTypeDefault(request.type, request.item);
1612316188
}
1612416189
}
1612516190

0 commit comments

Comments
 (0)