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
18 changes: 18 additions & 0 deletions .changeset/20101-page-type-default-served.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): a page saved without `type` is stored and served with the `type` default that `PageSchema` declares (#20101)

Clause-②: no

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

The declared default now reaches the served body at two points:

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

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.

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.
78 changes: 78 additions & 0 deletions packages/metadata-protocol/src/protocol.read-decorations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,81 @@ describe('draft preview diagnostics do not judge the injected `_draft` badge (#7
});
});
});

/**
* [#20101] The WRITE half of the served page-type default, pinned against the
* #4326 invariant this file exists for.
*
* `PageSchema` declares `type: PageTypeSchema.default('record')`. The save gate
* parsed with that default and then stored the authored body verbatim, so a
* page authored without `type` was stored without it, served without it, and
* never picked as the object's record page. The read seam now fills the
* declared default for such rows (`protocol.stored-conversions.test.ts` pins
* that half). The write half is what keeps THIS file's invariant: if the read
* seam were the only fix, the served document would carry a key its row did
* not, and the first GET → PUT of every such page would write a one-key change
* and a history row that nobody authored.
*
* The expected value is read from the registered schema, the same source the
* implementation reads, never spelled as a second literal.
*/
describe('[#20101] a page saved without `type` stores and serves the declared default', () => {
const DECLARED = (getMetadataTypeSchema('page') as any).shape.type.parse(undefined);
const typelessPage = (name: string) => ({ name, label: 'Invoice', object: 'crm_invoice' });
const rowOf = (rows: Map<string, Row>, name: string, state = 'active') =>
Array.from(rows.values()).find((r) => r.name === name && r.state === state)!;

it('precondition: the declared default is `record`', () => {
expect(DECLARED).toBe('record');
});

it('publish and draft saves store it, and the list and the single read serve it', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);

await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: typelessPage('invoice_record') });
await protocol.saveMetaItem({
type: 'page', name: 'invoice_record_next', item: typelessPage('invoice_record_next'), mode: 'draft',
});

expect(JSON.parse(rowOf(rows, 'invoice_record').metadata)).toEqual({ ...typelessPage('invoice_record'), type: DECLARED });
expect(JSON.parse(rowOf(rows, 'invoice_record_next', 'draft').metadata).type).toBe(DECLARED);

const listed = (await protocol.getMetaItems({ type: 'page' })).items as any[];
expect(listed.find((i) => i.name === 'invoice_record')?.type).toBe(DECLARED);
const single: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
expect(single.item.type).toBe(DECLARED);
const draft: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record_next', state: 'draft' });
expect(draft.item.type).toBe(DECLARED);
});

it('an explicit non-record `type` is stored and served unchanged', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
const appPage = { name: 'launchpad', label: 'Launchpad', type: 'app' };

await protocol.saveMetaItem({ type: 'page', name: 'launchpad', item: appPage });

expect(JSON.parse(rowOf(rows, 'launchpad').metadata)).toEqual(appPage);
const single: any = await protocol.getMetaItem({ type: 'page', name: 'launchpad' });
expect(single.item.type).toBe('app');
});

it('GET → PUT of the served page is byte-identical: same stored body, same checksum, same version', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);

await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: typelessPage('invoice_record') });
const before = { ...rowOf(rows, 'invoice_record') };

const served: any = (await protocol.getMetaItem({ type: 'page', name: 'invoice_record' })).item;
expect(served.type).toBe(DECLARED); // precondition — the served body carries the default
await protocol.saveMetaItem({ type: 'page', name: 'invoice_record', item: served });

const after = rowOf(rows, 'invoice_record');
expect(after.metadata).toBe(before.metadata);
expect(after.checksum).toBe(before.checksum);
// The repository's identical-body short-circuit: no write happened at all.
expect(after.version).toBe(before.version);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

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

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

it("[#20101] a stored page with no `type` is hit with `pageType` = PageSchema's declared default", async () => {
// The sweep reads the SERVED set, so it inherits the read seam's fill:
// a page authored without `type` is a record page, and the hit says so
// instead of omitting `pageType`. The value is read from the schema.
const declared = (PageSchema as unknown as { shape: { type: { parse(v: unknown): unknown } } })
.shape.type.parse(undefined);
const { p } = makeProtocol({
storedRows: [storedPage({ body: { name: 'invoice_record', label: 'Invoice record', object: 'invoice' } })],
});

const result = await p.searchAll({ q: 'invoice' });

expect(declared).toBe('record');
expect(result.pages).toEqual([{ kind: 'page', name: 'invoice_record', title: 'Invoice record', pageType: declared }]);
});

it('a DRAFT row of the same shape does not surface — the sweep reads the published state only', async () => {
const body = {
name: 'pending_dashboard',
Expand Down
84 changes: 84 additions & 0 deletions packages/metadata-protocol/src/protocol.stored-conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { describe, expect, it } from 'vitest';
// of this package's (file, verb) pairs sat in the gate's DEBT ledger until
// #5619 sank the two predicates into a package both sides already depend on.
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { PageSchema } from '@objectstack/spec/ui';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface Row {
Expand Down Expand Up @@ -255,3 +256,86 @@ describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3
expect(registered.some((r) => r.kind === 'object' && r.body?.name === 'corrupt_thing')).toBe(true);
});
});

// ── [#20101] a declared DEFAULT is part of the canonical served shape ─────────
//
// `PageSchema` declares `type: PageTypeSchema.default('record')`: a page
// authored without `type` IS a record page. Every row a save wrote before
// #20101 is stored without the key (the save path persisted the authored body
// verbatim), and a consumer that selects an object's record page by
// `type === 'record'` never picked one. The rehydration seam now serves the
// declared default — and ONLY serves it: the row at rest is not rewritten, and
// the stored-migration pass finds nothing to do (no stored-row rewrite is part
// of this fix).
//
// The expected value is READ from the schema, like the implementation reads
// it, so a pin here cannot quietly become a second spelling of the default.
const DECLARED_PAGE_TYPE = (PageSchema as unknown as { shape: { type: { parse(v: unknown): unknown } } })
.shape.type.parse(undefined);

const typelessPageBody = { name: 'invoice_record', label: 'Invoice', object: 'crm_invoice' };
const typelessPageRow = { type: 'page', name: 'invoice_record', metadata: typelessPageBody };
const appPageRow = { type: 'page', name: 'launchpad', metadata: { name: 'launchpad', label: 'Launchpad', type: 'app' } };
const typelessDraftRow = {
type: 'page', name: 'invoice_record_next', state: 'draft',
metadata: { name: 'invoice_record_next', label: 'Invoice (next)', object: 'crm_invoice' },
};

describe('[#20101] a stored page row without `type` is served with PageSchema\'s declared default', () => {
it('precondition: the declared default the pins below read is `record`', () => {
expect(DECLARED_PAGE_TYPE).toBe('record');
});

it('the list and the single read serve the declared default; an explicit non-record type is untouched', async () => {
const { engine } = makeStubEngine([typelessPageRow, appPageRow]);
const protocol = new ObjectStackProtocolImplementation(engine);

const listed = (await protocol.getMetaItems({ type: 'page' })).items as any[];
expect(listed.find((i) => i.name === 'invoice_record')?.type).toBe(DECLARED_PAGE_TYPE);
expect(listed.find((i) => i.name === 'launchpad')?.type).toBe('app');

const single: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
expect(single.item.type).toBe(DECLARED_PAGE_TYPE);
expect(single.item._diagnostics?.valid).toBe(true);
const control: any = await protocol.getMetaItem({ type: 'page', name: 'launchpad' });
expect(control.item.type).toBe('app');
});

it('the draft reads serve it too — the pinned `?state=draft` read and the list preview', async () => {
const { engine } = makeStubEngine([typelessDraftRow]);
const protocol = new ObjectStackProtocolImplementation(engine);

const draft: any = await protocol.getMetaItem({ type: 'page', name: 'invoice_record_next', state: 'draft' });
expect(draft.item.type).toBe(DECLARED_PAGE_TYPE);
const preview = (await protocol.getMetaItems({ type: 'page', previewDrafts: true })).items as any[];
expect(preview.find((i) => i.name === 'invoice_record_next')?.type).toBe(DECLARED_PAGE_TYPE);
});

it('boot hydration registers the defaulted body', async () => {
const { engine, registered } = makeStubEngine([typelessPageRow]);
const protocol = new ObjectStackProtocolImplementation(engine);
const res = await protocol.loadMetaFromDb();
expect(res).toEqual({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false });
const page = registered.find((r) => r.kind === 'item' && r.type === 'page')!;
expect(page.body.type).toBe(DECLARED_PAGE_TYPE);
});

it('the row at rest is NOT rewritten: its bytes survive the reads, and the migration pass reports it canonical', async () => {
const { engine, rows } = makeStubEngine([typelessPageRow, typelessDraftRow]);
const protocol = new ObjectStackProtocolImplementation(engine);
const before = rows.map((r) => r.metadata);

await protocol.getMetaItems({ type: 'page', previewDrafts: true });
await protocol.getMetaItem({ type: 'page', name: 'invoice_record' });
expect(rows.map((r) => r.metadata)).toEqual(before);
expect(JSON.parse(rows[0]!.metadata)).toEqual(typelessPageBody);

// The fill is a READ-side fact, not an ADR-0087 conversion: it emits no
// notice, so `os migrate meta --stored` has no row to rewrite.
const preview = await protocol.migrateStoredMetadata({ types: ['page'] });
expect(preview).toMatchObject({ scanned: 2, canonical: 2, pending: 0, failed: 0, rows: [] });
const applied = await protocol.migrateStoredMetadata({ types: ['page'], apply: true });
expect(applied).toMatchObject({ scanned: 2, canonical: 2, rewritten: 0, failed: 0, rows: [] });
expect(rows.map((r) => r.metadata)).toEqual(before);
});
});
69 changes: 67 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,9 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
* (e.g. `isPinned`, `isDefault`, `sortOrder`) survive the round-trip.
* The one exception is filter `operator` spellings, which are grafted back
* from `parsed.data` so a save stops minting new legacy-alias rows — see
* {@link graftNormalizedOperators}.
* {@link graftNormalizedOperators}. [#20101] A page that omits `type` is
* stored with the default `PageSchema` declares for it — see
* {@link withDeclaredPageTypeDefault}.
* - Types without a registered schema (the wiring-layer types
* `function`/`service`/`router`, and any plugin types that have not
* yet called `registerMetadataTypeSchema()`) fall through unvalidated.
Expand Down Expand Up @@ -1324,6 +1326,54 @@ export function graftFoldedFormSections(authored: unknown, parsed: unknown): unk
return rest.sections !== undefined ? rest : { ...rest, sections: groups };
}

/**
* [#20101] Give a page body that omits `type` the default `PageSchema`
* declares for it, and change nothing else.
*
* `PageSchema` declares `type: PageTypeSchema.default('record')`, so a page
* authored without `type` IS a record page. The save gate parses with that
* default and then persists the authored body verbatim (ADR-0005
* §"Validation", {@link resolveOverlaySchema}), and every read serves a stored
* row without parsing it. A record page written the natural way, with the
* defaulted key left out, therefore reached every reader of the served body
* with no `type` at all. A consumer that picks an object's record page by
* `type === 'record'` never picked it. The parse applied the declared default
* and nothing the server sends carried it.
*
* One function, applied at both seams:
* - the WRITE (`saveMetaItem`, once the schema gate has accepted the body),
* so a new row stores the key and a GET → PUT round-trip of the served
* document stays byte-identical (#4326's invariant). A fix on the read
* side alone breaks that round-trip: the first re-save of every such page
* would write a one-key change and a history row that nobody authored;
* - the READ ({@link ObjectStackProtocolImplementation.convertStoredItem},
* the rehydration seam every stored row passes through), so a row stored
* before this fix is served with the default and never rewritten. The row
* at rest keeps its bytes and there is no migration.
*
* The value is READ from the registered `page` schema, the one the save gate
* validates with, and is never spelled here. A literal would be a second,
* silent spelling of the default: the consumer-side `?? 'record'` this
* replaces, moved up one layer.
*
* ⛔ The scope is `type` on `page` and nothing wider. It is not a general
* "fill every declared default" pass. Other stored types declare top-level
* defaults in the same position, and each of those is its own decision.
*
* Only an ABSENT key is filled (zod applies a default to `undefined`, not to
* `null`), and an explicit value is left as it is. Returns the input itself
* when nothing is filled.
*/
function withDeclaredPageTypeDefault(type: string, item: unknown): unknown {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'page') return item;
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
if ((item as Record<string, unknown>).type !== undefined) return item;
const pageSchema = getMetadataTypeSchema('page') as { shape?: Record<string, z.ZodTypeAny> } | undefined;
const declared = pageSchema?.shape?.type?.safeParse(undefined);
if (!declared?.success || declared.data === undefined) return item;
return { ...(item as Record<string, unknown>), type: declared.data };
}

/**
* #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view
* overlay is missing but the registry entry it shadows carries. The overlay's
Expand Down Expand Up @@ -4678,10 +4728,17 @@ export class ObjectStackProtocolImplementation implements
* executor registry (`reservedNodeTypes`), which this layer does not
* have. Flows canonicalize at `AutomationEngine.registerFlow` — the
* execution seam — with the same full-chain policy.
*
* [#20101] …and a page that omits `type` is read with the default
* `PageSchema` declares ({@link withDeclaredPageTypeDefault}). That fill is
* applied HERE and not in {@link convertStoredItemDetailed}: it is not an
* ADR-0087 conversion and emits no notice, so the stored-migration pass
* (the Detailed caller) has nothing to rewrite, and the row at rest keeps
* its bytes.
*/
private convertStoredItem(type: string, data: unknown): unknown {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
return this.convertStoredItemDetailed(type, data, (n) => {
const converted = this.convertStoredItemDetailed(type, data, (n) => {
const name = (data as { name?: unknown } | null | undefined)?.name;
const key = `${n.conversionId}|${singular}|${String(name ?? '')}`;
if (this.storedConversionWarned.has(key)) return;
Expand All @@ -4692,6 +4749,7 @@ export class ObjectStackProtocolImplementation implements
`"os migrate meta --stored --apply") to persist the canonical shape.`,
);
}).item;
return withDeclaredPageTypeDefault(singular, converted);
}

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

Expand Down
Loading