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
13 changes: 13 additions & 0 deletions .changeset/20870-page-requires-load-and-promote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': minor
---

A stored page whose `requires` names a plugin the deployment's console does not load is reported when the page loads, and a draft → active promotion re-stamps an html page's `requires` with the save door's own computation (ADR-0080 §5: `requires` is validated at save and load, and derived from the source).

Clause-②: no

**At load.** The boot hydration of stored metadata (`loadMetaFromDb`) prints one `warn` line for each stored page whose `requires` lists a namespace no component in the deployment's SDUI component manifest carries, naming the page and every such namespace under the marker `[page_requires_plugin_absent]`. It is a report, never a refusal: the page has already loaded when the line is printed, and it is served. The manifest is read through the same `SDUI_MANIFEST_SERVICE` key the save door reads; `os serve` registers it before any plugin initialises, so it is there when stored pages load. A host that registers no manifest judges no page at load, exactly as it judges none at save.

**At promotion.** `publishMetaItem` and `publishPackageDrafts` now store the promoted body with the `requires` that an active save of the same body would store, computed against the manifest registered at the moment of the publish. Before, the draft's `requires` was carried into the active row as it was. A draft saved before the host had a manifest reached `active` with no `requires`, and an agreeing list kept the draft's own spelling (its order and any repeats). Nothing is newly refused: wherever the runtime authoring gate runs, it already refused a draft whose source does not compile, or whose `requires` disagrees with its source, and it still does. A host with no manifest promotes the draft as written.

`SysMetadataRepository.promoteDraft` takes an optional `deriveActiveBody(draftBody)` that derives the active row's body from the draft row it promotes. When it is omitted, the draft body is promoted unchanged, as before.
164 changes: 164 additions & 0 deletions packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { validateSemanticRoles } from '@objectstack/lint';
import { ObjectStackProtocolImplementation } from './protocol.js';
import type { MetadataAuthoringChannel } from './protocol.js';
import { SDUI_MANIFEST_SERVICE } from './index.js';
import { stampHtmlPageRequires } from './runtime-authoring-gate.js';

/** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */
const brokenApprovalFlow = () => ({
Expand Down Expand Up @@ -1232,3 +1233,166 @@ describe('html page source compiled at the save door against the SDUI manifest (
expect(lines).toHaveLength(1);
});
});

/**
* [#20312] ADR-0080 §5 — `requires` is "validated at save and load", and it is
* derived from the source, never carried. The save door's half is pinned in
* the block above; this block pins the other two moments a stored page's
* `requires` meets the deployment's manifest:
*
* - **At load** (`loadMetaFromDb`, the boot hydration of stored rows): a page
* whose `requires` names a namespace no component in the manifest carries —
* a plugin this deployment's console does not load — is reported with the
* page and the namespace named, and the page still loads. It reads the same
* `SDUI_MANIFEST_SERVICE` key the save door reads; with no manifest
* registered nothing is judged, exactly as at the save door.
* - **At draft → active promotion** (`publishMetaItem` and
* `publishPackageDrafts`, both through `promoteDraftForPublish`): the
* promoted body carries the `requires` the save door computes for it
* (`stampHtmlPageRequires`), not the draft's.
*/
describe('stored html page `requires` at load and at draft promotion (#20312)', () => {
const slot = { name: 'children', type: 'slot' };
const manifest = (withKanban = true) => ({
components: {
flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [slot] },
box: { type: 'box', namespace: 'ui', isContainer: true, inputs: [slot] },
...(withKanban ? { kanban: { type: 'kanban', namespace: 'plugin-kanban', inputs: [] } } : {}),
},
});
const htmlPage = (source: string, extra: Record<string, unknown> = {}) => ({
name: 'landing', label: 'Landing', kind: 'html', source, ...extra,
});
const WITH_KANBAN = '<flex><box>a</box><kanban /></flex>';

/** A protocol whose services table the test holds, plus the registry writes boot hydration makes. */
function hostWith(services: Map<string, unknown>) {
const { engine, rows } = makeStubEngine();
const registered: Array<{ type: string; name: unknown }> = [];
engine.registry.registerItem = (type: string, item: { name?: unknown }) => {
registered.push({ type, name: item?.name });
};
const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_test') as any;
return { protocol, rows, registered };
}
const storedPage = (rows: Map<string, Row>, state = 'active') => {
const row = Array.from(rows.values()).find((r) => r.type === 'page' && r.state === state);
return row ? JSON.parse(row.metadata) : undefined;
};
const savePage = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
protocol.saveMetaItem({ type: 'page', name: 'landing', item, ...extra });
const loadReports = (warn: ReturnType<typeof vi.spyOn>) =>
(warn.mock.calls as unknown[][]).map((c) => String(c[0])).filter((m) => m.includes('[page_requires_plugin_absent]'));

let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
});
afterEach(() => {
warn.mockRestore();
});

// ── At load ──────────────────────────────────────────────────────────

it('at load, a stored page naming a plugin the manifest does not carry is reported — page and plugin named — and still loads', async () => {
const services = new Map<string, unknown>();
const { protocol, rows, registered } = hostWith(services);
// Stored on a host with no manifest, so stored as written…
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-kanban'] }));
expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']);
// …and the deployment's console now carries no `plugin-kanban` component.
services.set('sdui-manifest', manifest(false));

const result = await protocol.loadMetaFromDb();

expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false });
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
const lines = loadReports(warn);
expect(lines, JSON.stringify(warn.mock.calls)).toHaveLength(1);
expect(lines[0]).toContain('page/landing');
expect(lines[0]).toContain(`'plugin-kanban'`);
expect(lines[0]).not.toContain(`'ui'`);
});

it('at load, a stored page whose every plugin is present is not reported (the control)', async () => {
const services = new Map<string, unknown>();
const { protocol, registered } = hostWith(services);
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-kanban'] }));
services.set('sdui-manifest', manifest(true));

const result = await protocol.loadMetaFromDb();

expect(result).toMatchObject({ loaded: 1, errors: 0 });
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
expect(loadReports(warn)).toEqual([]);
});

it('at load, a host with no manifest judges nothing — the save door\'s posture', async () => {
const { protocol, registered } = hostWith(new Map());
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-absent'] }));

const result = await protocol.loadMetaFromDb();

expect(result).toMatchObject({ loaded: 1, errors: 0 });
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
expect(loadReports(warn)).toEqual([]);
});

// ── At draft → active promotion ──────────────────────────────────────

it('a draft saved before the manifest arrived is promoted with the `requires` the save door computes', async () => {
const services = new Map<string, unknown>();
const { protocol, rows } = hostWith(services);
const body = htmlPage(WITH_KANBAN);
await savePage(protocol, body, { mode: 'draft' });
expect(storedPage(rows, 'draft')?.requires).toBeUndefined();
services.set('sdui-manifest', manifest());

await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
.resolves.toMatchObject({ success: true });

const saveDoor = stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown };
expect(saveDoor.requires).toEqual(['ui', 'plugin-kanban']);
expect(storedPage(rows)?.requires).toEqual(saveDoor.requires);
});

it('an agreeing draft `requires` is promoted as the save door spells it, not as the draft carried it', async () => {
const services = new Map<string, unknown>();
const { protocol, rows } = hostWith(services);
const body = htmlPage(WITH_KANBAN, { requires: ['plugin-kanban', 'ui', 'ui'] });
await savePage(protocol, body, { mode: 'draft' });
services.set('sdui-manifest', manifest());

await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
.resolves.toMatchObject({ success: true });

expect(storedPage(rows)?.requires)
.toEqual((stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown }).requires);
expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']);
});

it('the package batch promotion re-stamps too', async () => {
const services = new Map<string, unknown>();
const { protocol, rows } = hostWith(services);
const body = htmlPage(WITH_KANBAN);
await savePage(protocol, body, { mode: 'draft', packageId: 'com.example.pages' });
services.set('sdui-manifest', manifest());

const result = await protocol.publishPackageDrafts({ packageId: 'com.example.pages' });

expect(result, JSON.stringify(result.failed)).toMatchObject({ success: true, publishedCount: 1 });
expect(storedPage(rows)?.requires)
.toEqual((stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown }).requires);
});

it('a promotion on a host with no manifest stores the draft as written — the save door\'s posture', async () => {
const { protocol, rows } = hostWith(new Map());
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['plugin-absent'] }), { mode: 'draft' });

await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
.resolves.toMatchObject({ success: true });

expect(storedPage(rows)?.requires).toEqual(['plugin-absent']);
});
});
Loading
Loading