diff --git a/.changeset/23904-installed-page-retired-declarations-convert-at-load.md b/.changeset/23904-installed-page-retired-declarations-convert-at-load.md new file mode 100644 index 00000000000..7cc02d3516b --- /dev/null +++ b/.changeset/23904-installed-page-retired-declarations-convert-at-load.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/metadata-core': minor +--- + +fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing + +Clause-②: yes (widening) + +**What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest: + +| Door | Before | Now | +| :--- | :--- | :--- | +| `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence | +| a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice | +| a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is | +| a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk | +| the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused | + +**The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them): + +- `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag. +- `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits. + +**`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`. + +**`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before. + +Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site. + + diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 9746e660190..9cd99398726 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -223,6 +223,30 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', () expect(grant).not.toHaveProperty('allowRestore'); }); + it('reads a FLAT package body\'s range off the body itself — the shape `registerApp` and the marketplace ledger hold', () => { + // The same bytes as `legacyPermissionDefinition`, flattened the way + // `normalizeBundle` and a package body inside `packages[]` spell them: the + // manifest half beside the collections, no `manifest` key. + const nested = legacyPermissionDefinition('^17.1.0'); + const { manifest, ...collections } = nested; + const flat = { ...manifest, ...collections }; + const fromFlat = applyArtifactForwardConversions(flat, { runtimeSpecVersion: '17.2.0' }); + const fromNested = applyArtifactForwardConversions(nested, { runtimeSpecVersion: '17.2.0' }); + expect(fromFlat.verdict).toBe('converted-forward'); + expect(fromFlat.authoredFloor).toBe('17.1.0'); + expect([fromFlat.verdict, fromFlat.authoredFloor]).toEqual([fromNested.verdict, fromNested.authoredFloor]); + expect((fromFlat.definition as typeof flat).permissions[0]!.objects.crm_ticket).not.toHaveProperty('allowPurge'); + // A flat body authored at a current-or-newer floor is NOT the undeclared + // amnesty: the strict parse stays its authority. + const current = applyArtifactForwardConversions({ ...flat, engines: { protocol: '>=99.0.0' } }, { runtimeSpecVersion: '17.2.0' }); + expect(current.verdict).toBe('authored-current'); + expect(current.authoredFloor).toBe('99.0.0'); + // A `manifest` object, when present, is still the only place the range is read. + const both = applyArtifactForwardConversions({ ...nested, engines: { protocol: '>=99.0.0' } }, { runtimeSpecVersion: '17.2.0' }); + expect(both.verdict).toBe('converted-forward'); + expect(both.authoredFloor).toBe('17.1.0'); + }); + it('closes the window when the runtime spec version cannot be resolved — amnesty needs positive version evidence', () => { const def = legacyPermissionDefinition('^17.1.0'); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: null }); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 7a1bd2df8c2..eae388f5e6b 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -89,6 +89,9 @@ * the next release — so the change that re-legalizes a retired key decides * that entry's fate at this door, where the default-flip refusal below * ({@link DEFAULT_FLIPS_NOT_REPLAYED_HERE}) is the existing mechanism. + * - **A flat package body** — the manifest half beside the collections, with + * no `manifest` key (the shape `registerApp` and the marketplace ledger + * hold) — declares its range on itself, and the window is read there. * - **No declared range** → replay the full chain. Same posture as the * protocol handshake (which grandfathers range-less packages with a warning, * ADR-0087 "never false-reject") and as the stored-row pass (whose rows @@ -498,11 +501,18 @@ export function applyArtifactForwardConversions( return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } + // WHERE the declared range is read from. A stack definition carries it on + // its `manifest`; a package BODY carries it on itself — the manifest half of + // `AssembledPackageBodySchema` sits beside the collections, which is the + // shape a flat `manifest.register` / `registerApp` payload and a marketplace + // ledger entry have, and the shape `packages[i].manifest` has inside an + // option-B artifact (ADR-0130 D4). A definition that carries a `manifest` + // object is read there and nowhere else, as before; only a definition with + // no `manifest` is read on its own keys, so a flat body's window is the + // body's own, versioned, instead of the undeclared amnesty. const manifest = (definition as { manifest?: unknown }).manifest; - const declared = - manifest && typeof manifest === 'object' - ? resolveDeclaredRange(manifest as ProtocolHandshakeManifest) - : null; + const rangeCarrier: unknown = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : definition; + const declared = resolveDeclaredRange(rangeCarrier as ProtocolHandshakeManifest); const floor = declared ? parseRangeFloor(declared.range) : null; const authoredFloor = floor ? floor.join('.') : null; diff --git a/packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json b/packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json new file mode 100644 index 00000000000..5cf1c611281 --- /dev/null +++ b/packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json @@ -0,0 +1,1380 @@ +{ + "manifest": { + "id": "app.objectstack.hotcrm", + "name": "HotCRM", + "version": "4.0.1", + "namespace": "crm", + "type": "app", + "engines": { + "protocol": "^17.7.0" + } + }, + "packages": [ + { + "manifest": { + "id": "app.objectstack.hotcrm.service", + "name": "HotCRM Service", + "version": "4.0.1", + "namespace": "crm", + "type": "module", + "engines": { + "protocol": "^17.7.0" + }, + "dependencies": { + "app.objectstack.hotcrm": "^4.0.1" + }, + "pages": [ + { + "name": "case_detail_page", + "label": "Case Detail", + "description": "Service-agent case record: highlights, SLA path, details and activity timeline.", + "type": "record", + "variables": [ + { + "name": "activeTab", + "type": "string", + "defaultValue": "details" + } + ], + "object": "crm_case", + "template": "full-width", + "regions": [ + { + "name": "header", + "width": "full", + "components": [ + { + "type": "page:header", + "id": "case_header", + "label": "Case Information", + "properties": { + "title": "{case_number} \u00b7 {subject}", + "subtitle": "{crm_account}", + "actions": [ + "escalate_case", + "close_case", + "log_call" + ] + } + }, + { + "type": "record:highlights", + "id": "case_highlights", + "label": "Key Information", + "properties": { + "fields": [ + "status", + "priority", + "sla_due_date", + "is_sla_violated", + "owner_id", + "crm_account" + ] + } + }, + { + "type": "record:path", + "id": "case_status_path", + "label": "Case Status Path", + "properties": { + "statusField": "status", + "stages": [ + { + "value": "new", + "label": "New" + }, + { + "value": "in_progress", + "label": "In Progress" + }, + { + "value": "waiting_customer", + "label": "Waiting on Customer" + }, + { + "value": "escalated", + "label": "Escalated" + }, + { + "value": "resolved", + "label": "Resolved" + }, + { + "value": "closed", + "label": "Closed" + } + ] + } + } + ] + }, + { + "name": "main", + "width": "large", + "components": [ + { + "type": "page:tabs", + "id": "case_main_tabs", + "properties": { + "tabStyle": "line", + "position": "top", + "items": [ + { + "value": "details", + "label": "Details", + "children": [ + { + "type": "record:details", + "id": "case_details", + "label": "Case Details", + "properties": { + "columns": "2", + "sections": [ + { + "group": "basic" + }, + { + "group": "origin", + "hideEmpty": false + }, + { + "group": "sla" + }, + { + "group": "escalation" + }, + { + "group": "resolution" + }, + { + "group": "system" + } + ] + } + } + ] + }, + { + "value": "related", + "label": "Related", + "children": [ + { + "type": "page:accordion", + "id": "case_related_accordion", + "properties": { + "items": [ + { + "label": "Open Tasks", + "children": [ + { + "type": "record:related_list", + "id": "case_tasks", + "properties": { + "objectName": "crm_task", + "relationshipField": "related_to_case", + "columns": [ + "subject", + "status", + "priority", + "due_date", + "owner_id" + ], + "filter": [ + { + "field": "status", + "operator": "not_equals", + "value": "completed" + } + ], + "limit": 10 + } + } + ] + } + ] + } + } + ] + }, + { + "value": "activity", + "label": "Activity", + "children": [ + { + "type": "record:activity", + "id": "case_activity", + "properties": { + "filterMode": "all", + "showFilterToggle": true, + "limit": 25, + "unifiedTimeline": true, + "showCommentInput": true, + "enableMentions": true, + "enableReactions": true, + "enableThreading": true + } + } + ] + } + ] + } + } + ] + } + ], + "isDefault": true, + "aria": { + "ariaLabel": "Case Detail Page", + "ariaDescribedBy": "Service-agent case detail view with SLA path, details, related items and activity timeline." + }, + "kind": "full" + } + ] + } + }, + { + "manifest": { + "id": "app.objectstack.hotcrm", + "name": "HotCRM", + "version": "4.0.1", + "namespace": "crm", + "type": "app", + "engines": { + "protocol": "^17.7.0" + }, + "pages": [ + { + "name": "account_detail_page", + "label": "Account Detail", + "description": "Slotted account record page \u2014 custom header + persistent discussion feed.", + "type": "record", + "object": "crm_account", + "template": "default", + "regions": [], + "isDefault": false, + "kind": "slotted", + "slots": { + "header": { + "type": "page:header", + "id": "account_header_slotted", + "label": "Account Header (slotted)", + "properties": { + "title": "{name}", + "subtitle": "{industry} \u00b7 {type}", + "actions": [ + "log_call", + "log_meeting", + "schedule_meeting" + ] + } + }, + "discussion": { + "type": "record:chatter", + "id": "account_chatter", + "label": "Discussion", + "properties": {} + } + } + }, + { + "name": "account_workbench", + "label": "Account Workbench", + "description": "Curated account list for the sales team: quick filters only, no view management.", + "type": "list", + "object": "crm_account", + "template": "default", + "regions": [], + "isDefault": false, + "interfaceConfig": { + "source": "crm_account", + "sourceView": "all_accounts", + "appearance": { + "showDescription": true, + "allowedVisualizations": [ + "grid" + ] + }, + "userFilters": { + "element": "dropdown", + "fields": [ + { + "field": "industry", + "showCount": true + }, + { + "field": "type" + }, + { + "field": "owner_id" + } + ] + }, + "userActions": { + "sort": true, + "search": true, + "filter": false, + "refresh": true, + "rowHeight": false, + "group": true, + "addRecordForm": false, + "editInline": false, + "hideFields": false, + "rowColor": false + }, + "showRecordCount": true + }, + "kind": "full" + }, + { + "name": "app_launcher_page", + "label": "App Launcher", + "description": "Central hub for accessing all applications", + "type": "app", + "template": "centered", + "regions": [ + { + "name": "header", + "width": "full", + "components": [ + { + "type": "global:search", + "id": "app_search", + "label": "Search Apps", + "properties": {} + } + ] + }, + { + "name": "main", + "width": "large", + "components": [ + { + "type": "page:header", + "id": "launcher_header", + "label": "App Launcher Header", + "properties": { + "title": "App Launcher", + "subtitle": "Select an app to get started" + } + }, + { + "type": "app:launcher", + "id": "app_grid", + "label": "Application Grid", + "properties": {} + } + ] + } + ], + "isDefault": false, + "aria": { + "ariaLabel": "App Launcher Page", + "ariaDescribedBy": "Central application launcher for accessing all apps" + }, + "kind": "full" + }, + { + "name": "lead_detail_page", + "label": "Lead Detail", + "description": "Comprehensive lead detail page with highlights, details, and related information", + "type": "record", + "variables": [ + { + "name": "showHistory", + "type": "boolean", + "defaultValue": false + }, + { + "name": "activeTab", + "type": "string", + "defaultValue": "details" + } + ], + "object": "crm_lead", + "template": "full-width", + "regions": [ + { + "name": "header", + "width": "full", + "components": [ + { + "type": "page:header", + "id": "lead_header", + "label": "Lead Information", + "properties": { + "title": "{first_name} {last_name}", + "subtitle": "{company}", + "actions": [ + "convert_lead", + "schedule_followup", + "log_call", + "log_meeting", + "schedule_meeting" + ] + } + }, + { + "type": "record:alert", + "id": "lead_duplicate_alert_suspected", + "label": "Suspected Duplicate Alert", + "properties": { + "severity": "warning", + "visible": { + "dialect": "cel", + "source": "has(record.duplicate_status) && record.duplicate_status == \"suspected\"" + }, + "title": { + "en": "Suspected duplicate \u2014 compare before you convert", + "zh-CN": "\u7591\u4f3c\u91cd\u590d\u2014\u2014\u8f6c\u6362\u524d\u8bf7\u5148\u6bd4\u5bf9", + "ja-JP": "\u91cd\u8907\u306e\u7591\u3044 \u2014 \u5909\u63db\u3059\u308b\u524d\u306b\u7167\u5408\u3057\u3066\u304f\u3060\u3055\u3044", + "es-ES": "Duplicado Sospechoso: compare antes de convertir" + }, + "body": { + "en": "Intake matched this lead to a record this app already has. Duplicate Management below links that record \u2014 open it and compare. You can still convert this lead: this is the match intake guessed at, not a reviewer's decision. But if it is the same buyer, disqualify it instead, because converting creates a second account, contact and opportunity for them.", + "zh-CN": "\u5f55\u5165\u65f6\u53d1\u73b0\u8be5\u7ebf\u7d22\u4e0e\u7cfb\u7edf\u4e2d\u5df2\u6709\u8bb0\u5f55\u5339\u914d\u3002\u4e0b\u65b9\u300c\u91cd\u590d\u7ebf\u7d22\u7ba1\u7406\u300d\u4e2d\u662f\u5b83\u91cd\u590d\u7684\u90a3\u6761\u8bb0\u5f55\uff0c\u8bf7\u5148\u6253\u5f00\u6bd4\u5bf9\u3002\u8be5\u7ebf\u7d22\u4ecd\u7136\u53ef\u4ee5\u8f6c\u6362\uff1a\u8fd9\u662f\u5f55\u5165\u65f6\u7684\u81ea\u52a8\u5224\u65ad\uff0c\u4e0d\u662f\u5ba1\u6838\u4eba\u7684\u7ed3\u8bba\u3002\u4f46\u82e5\u786e\u5c5e\u540c\u4e00\u5ba2\u6237\uff0c\u8bf7\u6539\u4e3a\u53d6\u6d88\u8d44\u683c\u2014\u2014\u8f6c\u6362\u4f1a\u4e3a\u540c\u4e00\u5ba2\u6237\u518d\u521b\u5efa\u4e00\u5957\u5ba2\u6237\u3001\u8054\u7cfb\u4eba\u548c\u5546\u673a\u3002", + "ja-JP": "\u767b\u9332\u6642\u306b\u3001\u3053\u306e\u30ea\u30fc\u30c9\u304c\u65e2\u5b58\u30ec\u30b3\u30fc\u30c9\u3068\u4e00\u81f4\u3057\u307e\u3057\u305f\u3002\u4e0b\u306e\u300c\u91cd\u8907\u7ba1\u7406\u300d\u306b\u91cd\u8907\u5148\u306e\u30ec\u30b3\u30fc\u30c9\u304c\u3042\u308a\u307e\u3059\u3002\u307e\u305a\u958b\u3044\u3066\u7167\u5408\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3053\u306e\u30ea\u30fc\u30c9\u306f\u307e\u3060\u5909\u63db\u3067\u304d\u307e\u3059\u3002\u3053\u308c\u306f\u767b\u9332\u6642\u306e\u81ea\u52d5\u5224\u5b9a\u3067\u3042\u308a\u3001\u62c5\u5f53\u8005\u306e\u7d50\u8ad6\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u305f\u3060\u3057\u540c\u3058\u76f8\u624b\u3067\u3042\u308c\u3070\u3001\u5909\u63db\u305b\u305a\u4e0d\u9069\u683c\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u5909\u63db\u3059\u308b\u3068\u540c\u3058\u76f8\u624b\u306b\u53d6\u5f15\u5148\u30fb\u53d6\u5f15\u5148\u8cac\u4efb\u8005\u30fb\u5546\u8ac7\u304c\u3082\u3046\u4e00\u7d44\u4f5c\u6210\u3055\u308c\u307e\u3059\u3002", + "es-ES": "La captura encontr\u00f3 que este prospecto coincide con un registro que ya existe. Gesti\u00f3n de Duplicados, m\u00e1s abajo, enlaza ese registro: \u00e1bralo y comp\u00e1relo. Todav\u00eda puede convertir este prospecto, porque se trata de una coincidencia autom\u00e1tica de la captura y no del veredicto de una persona. Pero si es el mismo comprador, descalif\u00edquelo en su lugar: convertirlo crea una segunda cuenta, contacto y oportunidad para \u00e9l." + } + } + }, + { + "type": "record:alert", + "id": "lead_duplicate_alert_confirmed", + "label": "Confirmed Duplicate Alert", + "properties": { + "severity": "error", + "visible": { + "dialect": "cel", + "source": "has(record.duplicate_status) && record.duplicate_status == \"confirmed\"" + }, + "title": { + "en": "Confirmed duplicate \u2014 conversion will be refused", + "zh-CN": "\u5df2\u786e\u8ba4\u91cd\u590d\u2014\u2014\u8f6c\u6362\u5c06\u88ab\u62d2\u7edd", + "ja-JP": "\u91cd\u8907\u78ba\u5b9a \u2014 \u5909\u63db\u306f\u62d2\u5426\u3055\u308c\u307e\u3059", + "es-ES": "Duplicado Confirmado: la conversi\u00f3n ser\u00e1 rechazada" + }, + "body": { + "en": "A reviewer checked this lead and recorded that it repeats a record this app already has, so Convert Lead refuses it \u2014 this banner is the only warning you get before you press the button. Disqualify this lead instead, naming the surviving record from Duplicate Management below. If the verdict is wrong, a reviewer revises Duplicate Status; there is no override here.", + "zh-CN": "\u5ba1\u6838\u4eba\u5df2\u6838\u5b9e\u8be5\u7ebf\u7d22\u4e0e\u7cfb\u7edf\u4e2d\u5df2\u6709\u8bb0\u5f55\u91cd\u590d\uff0c\u56e0\u6b64\u300c\u8f6c\u6362\u7ebf\u7d22\u300d\u4f1a\u62d2\u7edd\u6267\u884c\u2014\u2014\u672c\u63d0\u793a\u662f\u4f60\u6309\u4e0b\u6309\u94ae\u524d\u552f\u4e00\u7684\u9884\u8b66\u3002\u8bf7\u6539\u4e3a\u53d6\u6d88\u8be5\u7ebf\u7d22\u7684\u8d44\u683c\uff0c\u5e76\u5728\u4e0b\u65b9\u300c\u91cd\u590d\u7ebf\u7d22\u7ba1\u7406\u300d\u4e2d\u6ce8\u660e\u4fdd\u7559\u7684\u90a3\u6761\u8bb0\u5f55\u3002\u82e5\u5224\u5b9a\u6709\u8bef\uff0c\u5e94\u7531\u5ba1\u6838\u4eba\u4fee\u6539\u300c\u91cd\u590d\u72b6\u6001\u300d\uff0c\u6b64\u5904\u4e0d\u63d0\u4f9b\u5f3a\u5236\u8f6c\u6362\u7684\u5165\u53e3\u3002", + "ja-JP": "\u62c5\u5f53\u8005\u304c\u78ba\u8a8d\u3057\u3001\u3053\u306e\u30ea\u30fc\u30c9\u306f\u65e2\u5b58\u30ec\u30b3\u30fc\u30c9\u306e\u91cd\u8907\u3067\u3042\u308b\u3068\u8a18\u9332\u3055\u308c\u307e\u3057\u305f\u3002\u305d\u306e\u305f\u3081\u300c\u30ea\u30fc\u30c9\u5909\u63db\u300d\u306f\u62d2\u5426\u3055\u308c\u307e\u3059\u3002\u3053\u306e\u901a\u77e5\u304c\u3001\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u524d\u306b\u5f97\u3089\u308c\u308b\u552f\u4e00\u306e\u8b66\u544a\u3067\u3059\u3002\u3053\u306e\u30ea\u30fc\u30c9\u306f\u4e0d\u9069\u683c\u306b\u3057\u305f\u3046\u3048\u3067\u3001\u4e0b\u306e\u300c\u91cd\u8907\u7ba1\u7406\u300d\u3067\u6b8b\u3059\u30ec\u30b3\u30fc\u30c9\u3092\u660e\u8a18\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u5224\u5b9a\u304c\u8aa4\u3063\u3066\u3044\u308b\u5834\u5408\u306f\u62c5\u5f53\u8005\u304c\u300c\u91cd\u8907\u30b9\u30c6\u30fc\u30bf\u30b9\u300d\u3092\u4fee\u6b63\u3057\u307e\u3059\u3002\u3053\u3053\u306b\u5f37\u5236\u5909\u63db\u306e\u624b\u6bb5\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "es-ES": "Una persona verific\u00f3 que este prospecto repite un registro que ya existe, por lo que Convertir Prospecto lo rechazar\u00e1: este aviso es la \u00fanica advertencia antes de pulsar el bot\u00f3n. Descalifique el prospecto e indique el registro que sobrevive desde Gesti\u00f3n de Duplicados, m\u00e1s abajo. Si el veredicto es incorrecto, una persona debe cambiar el Estado del Duplicado; aqu\u00ed no hay forma de forzar la conversi\u00f3n." + } + } + }, + { + "type": "record:highlights", + "id": "lead_highlights", + "label": "Key Information", + "properties": { + "fields": [ + "status", + "rating", + "lead_source", + "owner_id", + "email", + "phone" + ] + } + }, + { + "type": "record:path", + "id": "lead_path", + "label": "Lead Status Path", + "properties": { + "statusField": "status", + "stages": [ + { + "value": "new", + "label": "New" + }, + { + "value": "contacted", + "label": "Contacted" + }, + { + "value": "qualified", + "label": "Qualified" + }, + { + "value": "converted", + "label": "Converted" + }, + { + "value": "unqualified", + "label": "Unqualified" + } + ] + } + } + ] + }, + { + "name": "main", + "width": "large", + "components": [ + { + "type": "page:tabs", + "id": "main_tabs", + "label": "Lead Information Tabs", + "properties": { + "tabStyle": "line", + "position": "top", + "items": [ + { + "label": "Details", + "icon": "info-circle", + "children": [ + { + "type": "record:details", + "id": "lead_details", + "label": "Lead Details", + "properties": { + "columns": "2", + "sections": [ + { + "group": "identity" + }, + { + "group": "company_info" + }, + { + "group": "contact_info", + "hideEmpty": false + }, + { + "group": "preferences" + }, + { + "group": "qualification" + }, + { + "group": "assignment" + }, + { + "group": "address", + "hideEmpty": false + }, + { + "group": "conversion" + }, + { + "group": "duplicates" + }, + { + "group": "additional", + "hideEmpty": false + } + ] + } + } + ] + }, + { + "label": "Related", + "icon": "link", + "children": [ + { + "type": "page:accordion", + "id": "related_accordion", + "label": "Related Records", + "properties": { + "allowMultiple": true, + "items": [ + { + "label": "Tasks", + "icon": "list-checks", + "collapsed": false, + "children": [ + { + "type": "record:related_list", + "id": "related_tasks", + "label": "Tasks", + "properties": { + "objectName": "crm_task", + "relationshipField": "related_to_lead", + "columns": [ + "subject", + "status", + "priority", + "due_date", + "owner_id" + ], + "sort": [ + { + "field": "due_date", + "order": "asc" + } + ], + "limit": 10, + "title": "Open Tasks", + "filter": [ + { + "field": "status", + "operator": "not_equals", + "value": "completed" + } + ], + "showViewAll": true + } + } + ] + } + ] + } + } + ] + }, + { + "label": "Activity", + "icon": "clock", + "children": [ + { + "type": "record:activity", + "id": "lead_activity", + "label": "Activity Timeline", + "properties": { + "types": [ + "task" + ], + "limit": 20, + "showCompleted": true + } + } + ] + }, + { + "label": "History", + "icon": "history", + "children": [ + { + "type": "record:history", + "id": "lead_history", + "label": "Field History", + "properties": { + "limit": 25 + } + } + ] + } + ] + } + } + ] + } + ], + "isDefault": true, + "aria": { + "ariaLabel": "Lead Detail Page", + "ariaDescribedBy": "Detailed view of lead information with related records and activity" + }, + "kind": "full" + }, + { + "name": "opportunity_detail_page", + "label": "Opportunity Detail", + "description": "Comprehensive opportunity detail page with path, highlights, details, and related lists", + "type": "record", + "variables": [ + { + "name": "activeTab", + "type": "string", + "defaultValue": "details" + } + ], + "object": "crm_opportunity", + "template": "full-width", + "regions": [ + { + "name": "header", + "width": "full", + "components": [ + { + "type": "page:header", + "id": "opp_header", + "label": "Opportunity Information", + "properties": { + "title": "{name}", + "subtitle": "{crm_account}", + "actions": [ + "generate_quote", + "clone_opportunity", + "log_call", + "log_meeting", + "schedule_meeting" + ] + } + }, + { + "type": "record:highlights", + "id": "opp_highlights", + "label": "Key Information", + "properties": { + "fields": [ + "amount", + "close_date", + "probability", + "expected_revenue", + "owner_id", + "crm_account" + ] + } + }, + { + "type": "record:path", + "id": "opp_stage_path", + "label": "Opportunity Stage Path", + "properties": { + "statusField": "stage", + "stages": [ + { + "value": "prospecting", + "label": "Prospecting" + }, + { + "value": "qualification", + "label": "Qualification" + }, + { + "value": "needs_analysis", + "label": "Needs Analysis" + }, + { + "value": "proposal", + "label": "Proposal" + }, + { + "value": "negotiation", + "label": "Negotiation" + }, + { + "value": "closed_won", + "label": "Closed Won" + }, + { + "value": "closed_lost", + "label": "Closed Lost" + } + ] + } + } + ] + }, + { + "name": "main", + "width": "large", + "components": [ + { + "type": "page:tabs", + "id": "opp_main_tabs", + "properties": { + "tabStyle": "line", + "position": "top", + "items": [ + { + "value": "details", + "label": "Details", + "children": [ + { + "type": "record:details", + "id": "opp_details", + "label": "Opportunity Details", + "properties": { + "columns": "2", + "sections": [ + { + "group": "basic" + }, + { + "group": "financials" + }, + { + "group": "classification", + "hideEmpty": false + }, + { + "group": "campaign", + "hideEmpty": false + }, + { + "group": "sales_process" + }, + { + "group": "qualification", + "hideEmpty": false + }, + { + "group": "narrative", + "hideEmpty": false + }, + { + "group": "crm_forecast" + }, + { + "group": "notes", + "hideEmpty": false + } + ] + } + } + ] + }, + { + "value": "related", + "label": "Related", + "children": [ + { + "type": "page:accordion", + "id": "opp_related_accordion", + "properties": { + "items": [ + { + "label": "Quotes", + "collapsed": false, + "children": [ + { + "type": "record:related_list", + "id": "opp_quotes", + "properties": { + "objectName": "crm_quote", + "relationshipField": "crm_opportunity", + "columns": [ + "quote_number", + "name", + "status", + "total_price", + "expiration_date" + ], + "limit": 10 + } + } + ] + }, + { + "label": "Products", + "children": [ + { + "type": "record:related_list", + "id": "opp_products", + "properties": { + "objectName": "crm_opportunity_line_item", + "relationshipField": "crm_opportunity", + "columns": [ + "crm_product", + "quantity", + "unit_price", + "total_price" + ], + "limit": 10, + "add": { + "picker": { + "object": "crm_product", + "filter": [ + { + "field": "is_active", + "operator": "equals", + "value": true + } + ] + }, + "linkField": "crm_product" + } + } + } + ] + }, + { + "label": "Open Tasks", + "children": [ + { + "type": "record:related_list", + "id": "opp_tasks", + "properties": { + "objectName": "crm_task", + "relationshipField": "related_to_opportunity", + "columns": [ + "subject", + "status", + "priority", + "due_date", + "owner_id" + ], + "filter": [ + { + "field": "status", + "operator": "not_equals", + "value": "completed" + } + ], + "limit": 10 + } + } + ] + } + ] + } + } + ] + }, + { + "value": "activity", + "label": "Activity", + "children": [ + { + "type": "record:activity", + "id": "opp_activity", + "properties": { + "limit": 25 + } + } + ] + } + ] + } + } + ] + }, + { + "name": "aside", + "width": "small", + "components": [ + { + "type": "record:reference_rail", + "id": "opp_reference_rail", + "properties": { + "entries": [ + { + "objectName": "crm_quote", + "relationshipField": "crm_opportunity", + "limit": 3 + }, + { + "objectName": "crm_opportunity_line_item", + "relationshipField": "crm_opportunity", + "limit": 3 + }, + { + "objectName": "crm_task", + "relationshipField": "related_to_opportunity", + "limit": 3 + } + ] + } + } + ] + } + ], + "isDefault": true, + "aria": { + "ariaLabel": "Opportunity Detail Page", + "ariaDescribedBy": "Detailed view of opportunity information with related records and activity" + }, + "kind": "full" + }, + { + "name": "sales_home_page", + "label": "Sales Home", + "description": "Sales team home page with key metrics and quick actions", + "type": "home", + "variables": [ + { + "name": "selectedPeriod", + "type": "string", + "defaultValue": "this_month" + } + ], + "template": "three-column", + "regions": [ + { + "name": "header", + "width": "full", + "components": [ + { + "type": "page:header", + "id": "home_header", + "label": "Sales Home Header", + "properties": { + "title": "Sales Dashboard", + "subtitle": "Welcome back" + } + } + ] + }, + { + "name": "left_sidebar", + "width": "small", + "components": [ + { + "type": "page:card", + "id": "quick_create", + "label": "Quick Create", + "properties": { + "title": "Quick Create", + "bordered": true, + "children": [ + { + "type": "nav:menu", + "id": "create_menu", + "properties": {} + } + ] + } + } + ] + }, + { + "name": "main", + "width": "large", + "components": [ + { + "type": "page:card", + "id": "key_metrics", + "label": "Key Metrics", + "properties": { + "title": "Key Performance Indicators", + "bordered": false, + "children": [ + { + "type": "object-metric", + "id": "kpi_revenue_won", + "properties": { + "objectName": "crm_opportunity", + "label": "Revenue (Won)", + "icon": "dollar-sign", + "aggregate": { + "field": "amount", + "function": "sum" + }, + "filter": [ + { + "field": "stage", + "operator": "equals", + "value": "closed_won" + } + ] + } + }, + { + "type": "object-metric", + "id": "kpi_deals_won", + "properties": { + "objectName": "crm_opportunity", + "label": "Deals Won", + "icon": "trophy", + "aggregate": { + "field": "id", + "function": "count" + }, + "filter": [ + { + "field": "stage", + "operator": "equals", + "value": "closed_won" + } + ] + } + }, + { + "type": "object-metric", + "id": "kpi_pipeline_value", + "properties": { + "objectName": "crm_opportunity", + "label": "Pipeline Value", + "icon": "briefcase", + "aggregate": { + "field": "amount", + "function": "sum" + }, + "filter": [ + { + "field": "stage", + "operator": "not_in", + "value": [ + "closed_won", + "closed_lost" + ] + } + ] + } + }, + { + "type": "object-metric", + "id": "kpi_open_leads", + "properties": { + "objectName": "crm_lead", + "label": "Open Leads", + "icon": "user-plus", + "aggregate": { + "field": "id", + "function": "count" + }, + "filter": [ + { + "field": "is_converted", + "operator": "equals", + "value": false + } + ] + } + } + ] + } + }, + { + "type": "page:tabs", + "id": "home_tabs", + "label": "Home Tabs", + "properties": { + "tabStyle": "card", + "position": "top", + "items": [ + { + "label": "My Leads", + "icon": "user-plus", + "children": [ + { + "type": "list-view", + "id": "home_my_leads", + "label": "My Leads", + "properties": { + "objectName": "crm_lead", + "viewType": "grid", + "columns": [ + "full_name", + "company", + "email", + "status", + "rating" + ], + "filter": [ + { + "field": "owner_id", + "operator": "equals", + "value": "{current_user_id}" + } + ], + "sort": [ + { + "field": "rating", + "order": "desc" + }, + { + "field": "created_at", + "order": "desc" + } + ] + } + } + ] + }, + { + "label": "My Open Deals", + "icon": "dollar-sign", + "children": [ + { + "type": "list-view", + "id": "home_my_deals", + "label": "My Open Deals", + "properties": { + "objectName": "crm_opportunity", + "viewType": "grid", + "columns": [ + "name", + "crm_account", + "stage", + "amount", + "close_date" + ], + "filter": [ + { + "field": "owner_id", + "operator": "equals", + "value": "{current_user_id}" + }, + { + "field": "stage", + "operator": "not_in", + "value": [ + "closed_won", + "closed_lost" + ] + } + ], + "sort": [ + { + "field": "close_date", + "order": "asc" + } + ] + } + } + ] + }, + { + "label": "My Open Tasks", + "icon": "list-checks", + "children": [ + { + "type": "list-view", + "id": "home_my_tasks", + "label": "My Open Tasks", + "properties": { + "objectName": "crm_task", + "viewType": "grid", + "columns": [ + "subject", + "priority", + "due_date", + "progress_percent" + ], + "filter": [ + { + "field": "owner_id", + "operator": "equals", + "value": "{current_user_id}" + }, + { + "field": "is_completed", + "operator": "equals", + "value": false + } + ], + "sort": [ + { + "field": "due_date", + "order": "asc" + } + ] + } + } + ] + } + ] + } + } + ] + }, + { + "name": "right_sidebar", + "width": "medium", + "components": [ + { + "type": "page:card", + "id": "ai_briefing", + "label": "Today with the AI Assistant", + "properties": { + "title": "Ask the AI Assistant", + "bordered": true, + "children": [ + { + "type": "element:text", + "id": "ai_briefing_text", + "properties": { + "content": { + "en": "Open the assistant panel from the right edge of the page and ask \"what should I focus on today?\" \u2014 it sees your live pipeline, schema, and accounts.", + "zh-CN": "\u4ece\u9875\u9762\u53f3\u4fa7\u6253\u5f00\u52a9\u624b\u9762\u677f\uff0c\u8be2\u95ee\"\u6211\u4eca\u5929\u5e94\u8be5\u5173\u6ce8\u4ec0\u4e48\uff1f\"\u2014\u2014\u5b83\u53ef\u4ee5\u5b9e\u65f6\u67e5\u770b\u60a8\u7684\u9500\u552e\u7ba1\u9053\u3001\u67b6\u6784\u4e0e\u5ba2\u6237\u4fe1\u606f\u3002", + "es-ES": "Abra el panel del asistente desde el borde derecho de la p\u00e1gina y pregunte \"\u00bfen qu\u00e9 deber\u00eda concentrarme hoy?\" \u2014 ve su flujo de ventas, esquema y cuentas en tiempo real.", + "ja-JP": "\u30da\u30fc\u30b8\u53f3\u7aef\u304b\u3089\u30a2\u30b7\u30b9\u30bf\u30f3\u30c8\u30d1\u30cd\u30eb\u3092\u958b\u304d\u3001\u300c\u4eca\u65e5\u306f\u4f55\u306b\u96c6\u4e2d\u3059\u3079\u304d\u304b\uff1f\u300d\u3068\u5c0b\u306d\u3066\u304f\u3060\u3055\u3044 \u2014 \u30ea\u30a2\u30eb\u30bf\u30a4\u30e0\u306e\u30d1\u30a4\u30d7\u30e9\u30a4\u30f3\u3001\u30b9\u30ad\u30fc\u30de\u3001\u53d6\u5f15\u5148\u60c5\u5831\u3092\u628a\u63e1\u3057\u3066\u3044\u307e\u3059\u3002" + } + } + } + ] + } + }, + { + "type": "page:card", + "id": "upcoming_events", + "label": "Upcoming Events", + "properties": { + "title": "Upcoming Events", + "bordered": true, + "children": [ + { + "type": "list-view", + "id": "home_upcoming_events", + "label": "\ud83d\udcc5 Upcoming \u00b7 Soonest First", + "properties": { + "objectName": "crm_event", + "viewType": "grid", + "columns": [ + "subject", + "type", + "start_datetime", + "location", + "related_to_type", + "owner_id" + ], + "filter": [ + { + "field": "status", + "operator": "equals", + "value": "planned" + } + ], + "sort": [ + { + "field": "start_datetime", + "order": "asc" + } + ] + } + } + ] + } + } + ] + } + ], + "isDefault": true, + "aria": { + "ariaLabel": "Sales Home Page", + "ariaDescribedBy": "Sales team home page with metrics, leads, and quick actions" + }, + "kind": "full" + }, + { + "name": "utility_bar_page", + "label": "Utility Bar", + "description": "Quick access utility bar with floating tools", + "type": "utility", + "template": "utility-bar", + "regions": [ + { + "name": "utilities", + "width": "full", + "components": [ + { + "type": "global:notifications", + "id": "notifications_panel", + "label": "Notifications", + "properties": {} + }, + { + "type": "page:card", + "id": "quick_notes", + "label": "Quick Notes", + "properties": { + "title": "Quick Notes", + "bordered": true + } + }, + { + "type": "global:search", + "id": "quick_search", + "label": "Quick Search", + "properties": {} + } + ] + } + ], + "isDefault": false, + "aria": { + "ariaLabel": "Utility Bar", + "ariaDescribedBy": "Quick access utility bar with floating tools and notifications" + }, + "kind": "full" + } + ] + } + } + ] +} diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index 5e8d4a9233d..4803c252b97 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -297,12 +297,18 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record // is the page parse's: a non-record page's `isDefault: true`, the retired // `full-width` template and a region the template does not place are each // refused 422 `INVALID_METADATA` at save. A row ALREADY stored in one of those -// shapes is read as it was (no conversion touches it and the read path does -// not refuse it); saving it again is refused until it is fixed. -const PLACEMENT_SHAPES: ReadonlyArray<[label: string, body: Record, says: string]> = [ - ['a home page with `isDefault: true`', { name: 'env_shape', label: 'Shape', type: 'home', isDefault: true, regions: [] }, '`isDefault: true` is refused'], - ['the retired `full-width` template', { name: 'env_shape', label: 'Shape', type: 'record', object: 'acme_task', template: 'full-width', regions: [{ name: 'main', components: [] }] }, "`template: 'full-width'` was removed"], - ['a `left_sidebar` region on `three-column`', { name: 'env_shape', label: 'Shape', type: 'home', template: 'three-column', regions: [{ name: 'left_sidebar', components: [] }, { name: 'main', components: [] }] }, 'Region `left_sidebar` is refused'], +// shapes is read back through the stored-row conversion chain (the read path +// never refuses it): the two shapes with a lossless rewrite come back +// CONVERTED — the retired D2 entries `page-is-default-off-record-page-removed` +// and `page-template-full-width-to-default` delete the flag and re-spell the +// template — while the unplaced region, which has no lossless rewrite, is +// served as stored. Saving the row as it was stored is refused until it is +// fixed, whichever way it reads back. `served` is what the read answers for +// the keys the shape varies. +const PLACEMENT_SHAPES: ReadonlyArray<[label: string, body: Record, says: string, served: Record]> = [ + ['a home page with `isDefault: true`', { name: 'env_shape', label: 'Shape', type: 'home', isDefault: true, regions: [] }, '`isDefault: true` is refused', { isDefault: undefined, regions: [] }], + ['the retired `full-width` template', { name: 'env_shape', label: 'Shape', type: 'record', object: 'acme_task', template: 'full-width', regions: [{ name: 'main', components: [] }] }, "`template: 'full-width'` was removed", { template: 'default', regions: [{ name: 'main', components: [] }] }], + ['a `left_sidebar` region on `three-column`', { name: 'env_shape', label: 'Shape', type: 'home', template: 'three-column', regions: [{ name: 'left_sidebar', components: [] }, { name: 'main', components: [] }] }, 'Region `left_sidebar` is refused', { template: 'three-column', regions: [{ name: 'left_sidebar', components: [] }, { name: 'main', components: [] }] }], ]; describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and rows stored before it (#23610)', (topology) => { @@ -316,15 +322,14 @@ describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and ro expect(storedPages()).toEqual([]); }); - it.each(PLACEMENT_SHAPES)('%s, ALREADY stored: the read serves the row as stored, and saving it again is refused', async (_label, body) => { + it.each(PLACEMENT_SHAPES)('%s, ALREADY stored: the read serves the row through the stored-row conversion chain, and saving it as stored is refused', async (_label, body, _says, served) => { const { protocol, save, storeRow } = await boot(topology); await storeRow('env_shape', body); const read: any = await protocol.getMetaItem({ type: 'page', name: 'env_shape' }); expect(read?.item?.name).toBe('env_shape'); - for (const key of ['isDefault', 'template'] as const) { - if (key in body) expect(read.item[key]).toEqual(body[key]); + for (const [key, value] of Object.entries(served)) { + expect(read.item[key], key).toEqual(value); } - expect(read.item.regions).toEqual(body.regions); const again = await save('env_shape', body); expect(again.ok, 'the re-save was refused').toBe(false); expect((again as { error: any }).error?.status).toBe(422); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b4c82f9f454..d45112dd186 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -184,6 +184,7 @@ import { resolveAllowDriverConnectFailure } from '@objectstack/types'; // vocabulary of "benign driver error" is the exact debt that module exists to // retire, and `check:durability-log-level` exempts only this declared name. import { isMissingTableError } from '@objectstack/metadata/errors'; +import { applyArtifactForwardConversions, resolveDeclaredRange } from '@objectstack/metadata-core'; // [#6806] The ONE shared "is this driver error a unique-constraint violation?" // predicate, and its narrower companion "which column conflicted" (#6250 / // #6544, both landed in `@objectstack/types`). The engine's autonumber @@ -7771,6 +7772,88 @@ export class ObjectQL implements IObjectQLEngine { } } + /** + * The (package id, conversion id) pairs this engine has already warned + * about at the registration door — one line per pair per process, as the + * artifact door prints its summaries (`MetadataPlugin`). + */ + private registrationConversionWarned = new Set(); + + /** + * ADR-0087 D2 at the kernel's package REGISTRATION door: a package body that + * declares its protocol range replays the versioned forward conversion + * before anything of it registers. + * + * ## Why this door converts + * + * A compiled artifact is data at rest with a version stamp, and the + * artifact FILE doors replay the conversion chain inside the window its + * `engines.protocol` opens (`loadArtifactBundle`, the metadata plugin's + * artifact door, `AppPlugin`). An INSTALLED package reaches this engine by + * other roads too: a hosted environment's kernel registers the package + * bodies it holds through `manifest.register` / {@link registerApp} with no + * parse and no file door in between, and a marketplace install hands the + * fetched bundle straight to `manifest.register`. On those roads the registry + * was the first and only judge, so a declaration a newer spec retires — + * `isDefault: true` on a `home` page, say — stopped the kernel build of every + * environment holding a package built before the retirement, with no operator + * remedy short of upgrading each one. This is the one door every package + * registration reaches (`SchemaRegistry.installPackage` and the per-item + * seam both sit behind it), so the window is opened here, once. + * + * ## Why only a body that declares a range + * + * The policy function reads the body's own `engines.protocol` (the manifest + * half of a package body carries it; every `os build` stamps it). A body + * with NO declared range is, at this door, a manifest a plugin built in code + * (`manifest.register({ … })` in its `init`), not data at rest: it has an + * author, and it meets the registry's refusals exactly as before — nothing + * is replayed for it. That keeps the registry's own refusals the authority + * for code-registered metadata while an installed body converts under the + * same versioned window every artifact door opens, decided by the same + * function (`applyArtifactForwardConversions`, `@objectstack/metadata-core`), + * never by a second table. + * + * Idempotent: a body an artifact door already converted comes back by + * reference with no notice, so the two doors never print one conversion + * twice. Copy-on-write: untouched branches (`plugins`, code) are shared. + * + * Loud: one `warn` per (package, conversion) names the conversion id, the + * sites and the remedy, the way the artifact door's summary does — the named + * warning an operator reads in the boot log. The notices themselves are the + * structured `OS_METADATA_CONVERTED` record every conversion emits. + */ + convertPackageBodyForward(body: any): any { + if (!body || typeof body !== 'object' || Array.isArray(body)) return body; + if (resolveDeclaredRange(body) === null) return body; + const result = applyArtifactForwardConversions(body); + if (result.notices.length === 0) return result.definition; + + const id = String(body.id || body.name || '(unnamed)'); + const byConversion = new Map(); + for (const n of result.notices) { + const existing = byConversion.get(n.conversionId); + if (existing) existing.count += 1; + else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message }); + } + const retiredAfter = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter])); + for (const [conversionId, agg] of byConversion) { + const key = `${id}|${conversionId}`; + if (this.registrationConversionWarned.has(key)) continue; + this.registrationConversionWarned.add(key); + const pastFloor = retiredAfter.get(conversionId); + this.logger.warn( + `[ObjectQL] package '${id}' registered with declared protocol floor ${result.authoredFloor ?? '(unreadable)'} ` + + `on @objectstack/spec ${result.runtimeSpecVersion ?? '(unknown)'} (${result.verdict}) — ADR-0087 conversion ` + + `'${conversionId}'${pastFloor !== undefined ? ` (a retirement this runtime enforces past the body's floor; the shape was last accepted by @objectstack/spec ${pastFloor})` : ''} ` + + `converted ${agg.count} site(s) forward (first at ${agg.firstPath}). ${agg.message} ` + + `The installed body itself is unchanged, so it converts again at every registration until the package ` + + `is rebuilt ('os build') and re-published with tooling from a release that ships this retirement.`, + ); + } + return result.definition; + } + /** * Register contribution (Manifest) * @@ -7781,7 +7864,10 @@ export class ObjectQL implements IObjectQLEngine { * Key: Package ≠ App. The manifest is the package. The apps[] array inside * the manifest contains UI navigation definitions (AppSchema). */ - registerApp(manifest: any, scope?: ArtifactInstallScope) { + registerApp(registeredBody: any, scope?: ArtifactInstallScope) { + // The CONVERTED body, for everything below — the package record, the + // live manifest, every item it registers (see {@link convertPackageBodyForward}). + const manifest = this.convertPackageBodyForward(registeredBody); const id = manifest.id || manifest.name; const namespace = manifest.namespace as string | undefined; // Stamped beside `id` on every item below ({@link stampPackage}), so an diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9417fcb1234..c4ec5086e02 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -667,7 +667,19 @@ export class ObjectQLPlugin implements Plugin { // register/bridge interleave would bridge a body that the very next // package is about to change. For the single-package artifact this is the // same one register followed by the same one bridge as before (D7). - register: (artifact: any) => { + register: (registeredArtifact: any) => { + // ADR-0087 D2 at the registration door, for an artifact that carries + // `packages[]`: its bodies are strictly parsed by the ordering below + // BEFORE `registerApp` could convert each one, so a bundle handed here + // as authored (a marketplace install, a hosted kernel's own loader) + // converts once as a whole first — the window is the artifact's, read + // off its own declared range, and every body replays under it. A + // single-body payload converts at `registerApp` (the body door) and is + // handed through untouched here; a bundle an artifact door already + // converted comes back by reference. See `ObjectQL.convertPackageBodyForward`. + const artifact = Array.isArray(registeredArtifact?.packages) + ? ql.convertPackageBodyForward(registeredArtifact) + : registeredArtifact; const ordered = resolveArtifactPackageOrder(artifact) as any[]; // [ADR-0130 D4] The objects of the stack's RESIDUAL — top-level objects // no package body declares — as the residual rule answers it. See diff --git a/packages/objectql/src/registry-installed-package-retired-page-declarations.test.ts b/packages/objectql/src/registry-installed-package-retired-page-declarations.test.ts new file mode 100644 index 00000000000..8bb7c617580 --- /dev/null +++ b/packages/objectql/src/registry-installed-package-retired-page-declarations.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An INSTALLED package carrying a page declaration a newer spec retires does + * not stop the kernel build: the registration door replays the versioned + * ADR-0087 forward conversion over a package body that declares its protocol + * range, and refuses what survives it (`ObjectQL.convertPackageBodyForward`, + * `engine.ts`). + * + * ## The artifact under test — HotCRM 4.0.1's published bundle + * + * `__fixtures__/hotcrm-4.0.1-published-pages.artifact.json` is + * `dist/objectstack.json` as the `v4.0.1` tag of objectstack-ai/hotcrm builds + * it with its own pinned toolchain (`@objectstack/cli` 17.7.0; the artifact + * declares `engines.protocol: ^17.7.0`), trimmed to the artifact's `manifest` + * and, per package body, the manifest fields and the FULL `pages` collection — + * every other collection removed, every page byte-identical. Its two bodies + * carry the shapes the #23610 retirements refuse: `sales_home_page` is a + * `type: 'home'` page with `isDefault: true` (the refusal that stopped every + * hosted environment's kernel build), three record pages spell + * `template: 'full-width'`, and — with no lossless rewrite — two pages name an + * unregistered template (`centered`, `utility-bar`) and the home page two + * regions `three-column` does not place (`left_sidebar`, `right_sidebar`). + * + * ## The door under test — the one a hosted kernel reaches + * + * The measured failure named `MisplacedDefaultPageError`, the registry's own + * refusal, and not a parse issue at `pages.N.template` — so the kernel that + * failed registered the package BODIES through the `manifest` service / + * `registerApp` with no strict body parse in between (a whole option-B + * artifact handed to the service is parsed body by body by + * `resolveArtifactPackageOrder` first, and HotCRM 4.0.1 fails that parse on + * the shapes no conversion rewrites). This file drives that door: each body, + * flat, through `manifest.register`. + * + * What is pinned: + * - the kernel builds: both bodies register, with a warning naming each + * conversion that fired, and the registry serves `sales_home_page` without + * the flag and the `full-width` pages as `default` — the converted copy is + * what registered, record and items alike; + * - the shapes with no lossless rewrite are left exactly as installed: the + * registry does not judge templates or regions (parse-only refusals), so + * they load as they always did; + * - CONTROL, the authoring posture: the same body with its declared range + * removed (a manifest a plugin builds in code) is still refused with the + * parse's sentence, and so is one authored against a floor newer than + * every retirement; + * - the ablation: with the D2 entry absent from the spec's step 18 the same + * boot is red again (run once at landing — the PR body carries the reading). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { MISPLACED_DEFAULT_PAGE_CODE } from './registry.js'; +import type { ObjectQL } from './engine.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(HERE, '__fixtures__/hotcrm-4.0.1-published-pages.artifact.json'); + +type Bag = Record; +type ManifestService = { register(m: unknown): void | Promise }; +type Refusal = Error & { code?: string; status?: number; name?: string }; + +const HOTCRM_ID = 'app.objectstack.hotcrm'; +const SERVICE_ID = 'app.objectstack.hotcrm.service'; +const IS_DEFAULT_CONVERSION = 'page-is-default-off-record-page-removed'; +const TEMPLATE_CONVERSION = 'page-template-full-width-to-default'; + +/** The artifact, parsed fresh per call so no test sees another's mutation. */ +const artifact = (): Bag => JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); +/** One package body as a hosted kernel registers it: flat, as `os build` assembled it. */ +const body = (id: string): Bag => { + const entry = artifact().packages.find((e: Bag) => e.manifest.id === id); + if (!entry) throw new Error(`fixture carries no package ${id}`); + return entry.manifest; +}; + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +const kernels: ObjectKernel[] = []; +const boot = async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const warnings: string[] = []; + (ql as unknown as { logger: { warn: (line: unknown) => void } }).logger.warn = (line: unknown) => { warnings.push(String(line)); }; + const manifest = kernel.getService('manifest'); + return { + registry: ql.registry, + warnings, + register: (m: unknown) => caught(() => manifest.register(m)), + /** The engine door itself, with the artifact's own package list (ADR-0130 D1) — what a loader that holds the whole artifact hands over. */ + registerBody: (m: unknown, packageIds: string[]) => caught(() => ql.registerApp(m, { packageIds })), + }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +describe('the fixture is HotCRM 4.0.1\'s published page shape', () => { + it('declares the 17.7.0 floor and carries every shape the #23610 retirements refuse', () => { + const a = artifact(); + expect(a.manifest).toMatchObject({ id: HOTCRM_ID, version: '4.0.1', engines: { protocol: '^17.7.0' } }); + expect(a.packages.map((e: Bag) => e.manifest.id)).toEqual([SERVICE_ID, HOTCRM_ID]); + const pages: Bag[] = a.packages.flatMap((e: Bag) => e.manifest.pages); + expect(pages).toHaveLength(8); + const home = pages.find((p) => p.name === 'sales_home_page')!; + expect(home).toMatchObject({ type: 'home', isDefault: true, template: 'three-column' }); + expect(home.regions.map((r: Bag) => r.name)).toEqual(['header', 'left_sidebar', 'main', 'right_sidebar']); + expect(pages.filter((p) => p.template === 'full-width').map((p) => p.name).sort()).toEqual([ + 'case_detail_page', 'lead_detail_page', 'opportunity_detail_page', + ]); + expect(pages.filter((p) => !['default', 'header-sidebar-main', 'three-column', 'dashboard', 'full-width'].includes(p.template)).map((p) => [p.name, p.template])).toEqual([ + ['app_launcher_page', 'centered'], + ['utility_bar_page', 'utility-bar'], + ]); + }); +}); + +describe('a kernel built from HotCRM 4.0.1\'s published bundle boots, with the named warning', () => { + it('registers both package bodies at the engine door, converting the retired declarations instead of refusing', async () => { + const { registry, warnings, registerBody } = await boot(); + + // The two bodies are co-owners of one artifact (both declare namespace + // `crm`, ADR-0130 D1), so each is handed over with the artifact's package + // list, as the `manifest` service hands every body of an artifact it + // ordered. The `manifest.register` door for ONE body is pinned below. + const scope = [SERVICE_ID, HOTCRM_ID]; + expect(await registerBody(body(SERVICE_ID), scope)).toBeUndefined(); + expect(await registerBody(body(HOTCRM_ID), scope)).toBeUndefined(); + + // The named warning: one line per (package, conversion), naming the + // conversion id, the body's floor and the first converted site. + const named = (id: string, conversion: string) => + warnings.filter((w) => w.includes(`package '${id}'`) && w.includes(`conversion '${conversion}'`)); + expect(named(HOTCRM_ID, IS_DEFAULT_CONVERSION)).toHaveLength(1); + expect(named(HOTCRM_ID, IS_DEFAULT_CONVERSION)[0]).toContain('declared protocol floor 17.7.0'); + expect(named(HOTCRM_ID, IS_DEFAULT_CONVERSION)[0]).toContain('first at pages[5].isDefault'); + expect(named(HOTCRM_ID, TEMPLATE_CONVERSION)).toHaveLength(1); + expect(named(SERVICE_ID, TEMPLATE_CONVERSION)).toHaveLength(1); + // The service body carries no misplaced default, so that conversion has nothing to say there. + expect(named(SERVICE_ID, IS_DEFAULT_CONVERSION)).toEqual([]); + + // Both packages are installed and every page registered. + expect(registry.getPackage(HOTCRM_ID)).toBeDefined(); + expect(registry.getPackage(SERVICE_ID)).toBeDefined(); + const pages = registry.listItems('page'); + expect(pages.map((p) => p.name).sort()).toEqual([ + 'account_detail_page', 'account_workbench', 'app_launcher_page', 'case_detail_page', + 'lead_detail_page', 'opportunity_detail_page', 'sales_home_page', 'utility_bar_page', + ]); + const page = (name: string) => pages.find((p) => p.name === name)!; + + // The converted copy registered: the flag is gone from the home page and + // the three `full-width` pages read `default`, items and record alike. + expect(page('sales_home_page')).not.toHaveProperty('isDefault'); + expect(page('sales_home_page')).toMatchObject({ type: 'home', template: 'three-column', _packageId: HOTCRM_ID }); + for (const name of ['case_detail_page', 'lead_detail_page', 'opportunity_detail_page']) { + expect(page(name).template, name).toBe('default'); + // A default record page bound to an object keeps the one meaning the flag has. + expect(page(name).isDefault, name).toBe(true); + } + const recordPages = (registry.getPackage(HOTCRM_ID)!.manifest as Bag).pages as Bag[]; + expect(recordPages.find((p) => p.name === 'sales_home_page')).not.toHaveProperty('isDefault'); + expect(recordPages.find((p) => p.name === 'lead_detail_page')!.template).toBe('default'); + + // The shapes with no lossless rewrite are served exactly as installed: the + // registry never judged templates or regions, and still does not. + expect(page('app_launcher_page').template).toBe('centered'); + expect(page('utility_bar_page').template).toBe('utility-bar'); + expect(page('sales_home_page').regions.map((r: Bag) => r.name)).toEqual(['header', 'left_sidebar', 'main', 'right_sidebar']); + }); + + it('the package whose page stopped the measured kernel build registers through `manifest.register`, converted, with the warning', async () => { + const { registry, warnings, register } = await boot(); + expect(await register(body(HOTCRM_ID))).toBeUndefined(); + expect(registry.getPackage(HOTCRM_ID)).toBeDefined(); + const home = registry.listItems('page').find((p) => p.name === 'sales_home_page')!; + expect(home).toBeDefined(); + expect(home).not.toHaveProperty('isDefault'); + expect(warnings.filter((w) => w.includes(`package '${HOTCRM_ID}'`) && w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`))).toHaveLength(1); + }); + + it('registers the same body again without a second warning — the converted copy is idempotent and the line is deduped', async () => { + const { warnings, register } = await boot(); + expect(await register(body(HOTCRM_ID))).toBeUndefined(); + const once = warnings.filter((w) => w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`)).length; + expect(once).toBe(1); + expect(await register(body(HOTCRM_ID))).toBeUndefined(); + expect(warnings.filter((w) => w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`))).toHaveLength(1); + }); +}); + +describe('CONTROL — the authoring posture at the same door is unchanged', () => { + const expectMisplaced = (err: Refusal | undefined) => { + expect(err, 'the registration was refused').toBeDefined(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: MISPLACED_DEFAULT_PAGE_CODE, status: 422 }); + expect(err!.name).toBe('MisplacedDefaultPageError'); + expect(err!.message.startsWith(`Page "sales_home_page" of package "${HOTCRM_ID}": \`isDefault: true\` is refused on a \`type: 'home'\` page`)).toBe(true); + }; + + it('the same body with NO declared range — a manifest built in code — is still refused with the parse\'s sentence', async () => { + const { registry, warnings, register } = await boot(); + const { engines: _engines, ...codeRegistered } = body(HOTCRM_ID); + expectMisplaced(await register(codeRegistered)); + expect(registry.getPackage(HOTCRM_ID)).toBeUndefined(); + expect(registry.listItems('page')).toEqual([]); + expect(warnings.filter((w) => w.includes('ADR-0087 conversion'))).toEqual([]); + }); + + it('the same body authored against a floor newer than every retirement is still refused — the window is versioned, not an amnesty', async () => { + const { registry, register } = await boot(); + expectMisplaced(await register({ ...body(HOTCRM_ID), engines: { protocol: '>=99.0.0' } })); + expect(registry.getPackage(HOTCRM_ID)).toBeUndefined(); + }); + + it('the item seam is the same door: a package-bound page registered directly, with no body to read a range off, is still refused', async () => { + const { registry } = await boot(); + const home = body(HOTCRM_ID).pages.find((p: Bag) => p.name === 'sales_home_page'); + const err = await caught(() => registry.registerItem('page', home, 'name', HOTCRM_ID)); + expectMisplaced(err); + }); +}); + +describe('a whole option-B artifact handed to the manifest service converts before its bodies are parsed', () => { + // The real 4.0.1 artifact also carries shapes no conversion rewrites + // (`centered`, `utility-bar`, the two unplaced regions), which the strict + // body parse of `resolveArtifactPackageOrder` refuses before any body + // registers — so this case carries only the convertible shapes, in the + // artifact's own `packages[]` form and under its own declared floor. + it('an artifact declaring `^17.7.0` whose body carries a misplaced default and a `full-width` page registers, converted', async () => { + const { registry, warnings, register } = await boot(); + const a = artifact(); + const home = body(HOTCRM_ID).pages.find((p: Bag) => p.name === 'sales_home_page'); + const lead = body(HOTCRM_ID).pages.find((p: Bag) => p.name === 'lead_detail_page'); + const payload = { + ...a.manifest, + packages: [{ + manifest: { + ...body(HOTCRM_ID), + pages: [ + { ...home, regions: home.regions.filter((r: Bag) => r.name === 'header' || r.name === 'main') }, + lead, + ], + }, + }], + }; + expect(await register(payload)).toBeUndefined(); + const pages = registry.listItems('page'); + expect(pages.find((p) => p.name === 'sales_home_page')).not.toHaveProperty('isDefault'); + expect(pages.find((p) => p.name === 'lead_detail_page')!.template).toBe('default'); + expect(warnings.filter((w) => w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`))).toHaveLength(1); + }); +}); diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 37780e2c728..428760d4df2 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -47,7 +47,7 @@ "verifiedAt": "2026-10-15", "evidenceScope": "cross-repo", "evidence": "objectui @0656b0e3c0 packages/components/src/renderers/layout/page.tsx#resolveTemplate resolves schema.template through #TEMPLATE_REGISTRY (default, full-width, header-sidebar-main, three-column, dashboard; default and full-width are the same #FullWidthTemplate) and the page renderer selects that layout ahead of the page-type layout — re-read 2026-10-15", - "note": "layout template selects the region arrangement. CLOSED 2026-10-15 (#23610, ruling 6190536588 Q2 A): PageTemplateSchema is the registered set less full-width (retired by name, a second name for default), and each template declares the regions it places in PAGE_TEMPLATE_SLOTS (page.zod.ts), which checkPageRegionPlacement enforces at the region name. RE-CITED 2026-08-10 (#7133/#7142): the old citation was split across 'page.tsx + containers.tsx' and the containers.tsx half is dead for this key — its only `template` occurrences (:240-248) are a `{field.path}` string-interpolation helper, an unrelated sense of the word. Verdict unchanged: LIVE." + "note": "layout template selects the region arrangement. CLOSED 2026-10-15 (#23610, ruling 6190536588 Q2 A): PageTemplateSchema is the registered set less full-width (retired by name, a second name for default), and each template declares the regions it places in PAGE_TEMPLATE_SLOTS (page.zod.ts), which checkPageRegionPlacement enforces at the region name. RE-CITED 2026-08-10 (#7133/#7142): the old citation was split across 'page.tsx + containers.tsx' and the containers.tsx half is dead for this key — its only `template` occurrences (:240-248) are a `{field.path}` string-interpolation helper, an unrelated sense of the word. Verdict unchanged: LIVE. LOAD PATH (P0 after #23610): a stored or installed page still spelling full-width replays the retired D2 entry page-template-full-width-to-default on read (both names drew FullWidthTemplate); an unregistered name has no lossless rewrite and stays with the parse." }, "regions": { "status": "live", @@ -63,7 +63,7 @@ }, "isDefault": { "status": "live", - "note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault." + "note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault. LOAD PATH (P0 after #23610): an installed or stored page still carrying the flag replays the retired D2 entry page-is-default-off-record-page-removed on read — stored rows, the artifact doors, and the kernel's package registration door for a body that declares its protocol range (ObjectQL.convertPackageBodyForward) — so the kernel builds with an OS_METADATA_CONVERTED notice; the authoring, save and publish doors keep refusing." }, "interfaceConfig": { "status": "live", diff --git a/packages/spec/src/conversions/page-is-default-off-record-page-removed.test.ts b/packages/spec/src/conversions/page-is-default-off-record-page-removed.test.ts new file mode 100644 index 00000000000..64db7c7a18e --- /dev/null +++ b/packages/spec/src/conversions/page-is-default-off-record-page-removed.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { PageSchema } from '../ui/page.zod.js'; +import { misplacedDefaultPageRefusal } from '../ui/default-record-page.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * `page-is-default-off-record-page-removed` — the data-at-rest half of the + * misplaced-`isDefault` refusal. + * + * The fixture pair in `conversions.test.ts` proves before → after over the + * whole table. What this file pins on top: + * + * - the entry strips EXACTLY the pages the parse's one judge refuses + * (`misplacedDefaultPageRefusal`): a `home`, `app`, `utility` or `list` + * page and a record page bound to no object lose the flag; a default + * record page bound to an object keeps it, as the SAME reference; + * - the authoring funnel never replays it (retired from the load path), so a + * live author still meets the parse's refusal at `isDefault`; + * - IDEMPOTENCE: the converted result replays to itself with no second notice; + * - the stored-row seam replays it, and the converted row parses against + * `PageSchema` where the stored one was refused. + */ +describe('page-is-default-off-record-page-removed (ADR-0087 D2)', () => { + const ID = 'page-is-default-off-record-page-removed'; + const HOME = { name: 'sales_home_page', label: 'Sales Home', type: 'home', isDefault: true, regions: [] as unknown[] }; + const BOUND = { name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', isDefault: true, regions: [] as unknown[] }; + + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.retiredAfter).toBe('17.7.0'); + }); + + it('strips the flag from every page the parse refuses it on, one notice per page, and from no other', () => { + const pages = [ + HOME, + { name: 'launcher', label: 'Launcher', type: 'app', isDefault: true }, + { name: 'utility_bar', label: 'Utilities', type: 'utility', isDefault: true }, + { name: 'account_list', label: 'Accounts', type: 'list', object: 'crm_account', isDefault: true }, + // A record page (the default type, spelled or omitted) bound to no object. + { name: 'draft_record', label: 'Draft', type: 'record', isDefault: true }, + { name: 'untyped_record', label: 'Untyped', isDefault: true }, + BOUND, + { name: 'team_home', label: 'Team Home', type: 'home', isDefault: false }, + ]; + // Every stripped page is one the judge refuses; every kept one it admits. + for (const page of pages) { + const refused = misplacedDefaultPageRefusal(page) !== undefined; + expect(refused, page.name).toBe(page.isDefault === true && page !== BOUND); + } + const { stack, notices } = collectConversionNotices({ pages }, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === ID); + expect(mine.map((n) => n.path)).toEqual([ + 'pages[0].isDefault', + 'pages[1].isDefault', + 'pages[2].isDefault', + 'pages[3].isDefault', + 'pages[4].isDefault', + 'pages[5].isDefault', + ]); + for (const n of mine) { + expect(n.from).toBe('isDefault'); + expect(n.to).toBe('(removed)'); + } + const out = (stack as { pages: Record[] }).pages; + for (let i = 0; i < 6; i++) expect(out[i], pages[i]!.name).not.toHaveProperty('isDefault'); + // Everything else on a stripped page survives verbatim. + expect(out[0]).toEqual({ name: 'sales_home_page', label: 'Sales Home', type: 'home', regions: [] }); + // The bound default record page and the `false` flag are untouched — the same references. + expect(out[6]).toBe(BOUND); + expect(out[7]).toBe(pages[7]); + }); + + it('control: a stack whose pages carry no misplaced flag is returned as the same reference, with no notice', () => { + const clean = { pages: [BOUND, { name: 'team_home', label: 'Team Home', type: 'home' }] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out, 'nothing to strip ⇒ copy-on-write returns the input').toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('⛔ never replays on the authoring funnel — the default load path leaves the flag for the parse to refuse', () => { + const { stack, notices } = collectConversionNotices({ pages: [structuredClone(HOME)] }); + expect((stack as { pages: Record[] }).pages[0]).toHaveProperty('isDefault', true); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + const parsed = PageSchema.safeParse(HOME); + expect(parsed.success).toBe(false); + expect(parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.'))).toEqual(['isDefault']); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions({ pages: [structuredClone(HOME)] }, { includeRetired: true }); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the stored-row seam replays it, and the converted row parses against `PageSchema`', () => { + const stored = structuredClone(HOME); + // Before: the flag is refused at parse — the row a pre-retirement author + // left behind would be badged invalid on every read without the replay. + expect(PageSchema.safeParse(stored).success).toBe(false); + const converted = applyConversionsToStoredItem('page', stored); + expect(converted).not.toHaveProperty('isDefault'); + expect(PageSchema.safeParse(converted).success).toBe(true); + // The row's identity survives: name, type and regions are as stored. + expect(converted).toMatchObject({ name: 'sales_home_page', type: 'home', regions: [] }); + }); +}); diff --git a/packages/spec/src/conversions/page-template-full-width-to-default.test.ts b/packages/spec/src/conversions/page-template-full-width-to-default.test.ts new file mode 100644 index 00000000000..314ed1d1bc6 --- /dev/null +++ b/packages/spec/src/conversions/page-template-full-width-to-default.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { PageSchema } from '../ui/page.zod.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * `page-template-full-width-to-default` — the data-at-rest half of the closed + * template set, for the one retired spelling that is a second name for a + * registered template. + * + * The fixture pair in `conversions.test.ts` proves before → after over the + * whole table. What this file pins on top: + * + * - only the retired SPELLING converts: a registered template and an + * UNREGISTERED name (`centered`) both come back as the same reference — + * an unregistered name is not a shape this entry positively recognizes, + * and stays with the parse; + * - the authoring funnel never replays it (retired from the load path), so a + * live author still meets the parse's one-word refusal at `template`; + * - IDEMPOTENCE: the converted result replays to itself with no second notice; + * - the stored-row seam replays it, and the converted row parses against + * `PageSchema` where the stored one was refused. + */ +describe('page-template-full-width-to-default (ADR-0087 D2)', () => { + const ID = 'page-template-full-width-to-default'; + const FULL_WIDTH = { + name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', template: 'full-width', + regions: [{ name: 'main', components: [] as unknown[] }], + }; + + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.retiredAfter).toBe('17.7.0'); + }); + + it('re-spells `full-width` as `default`, one notice per page, and leaves every other template alone', () => { + const registered = { name: 'sales_home_page', label: 'Sales Home', type: 'home', template: 'three-column' }; + const unregistered = { name: 'app_launcher_page', label: 'Launcher', type: 'app', template: 'centered' }; + const none = { name: 'team_home', label: 'Team Home', type: 'home' }; + const { stack, notices } = collectConversionNotices( + { pages: [FULL_WIDTH, registered, unregistered, none] }, + { includeRetired: true }, + ); + const mine = notices.filter((n) => n.conversionId === ID); + expect(mine.map((n) => [n.path, n.from, n.to])).toEqual([['pages[0].template', 'full-width', 'default']]); + const out = (stack as { pages: Record[] }).pages; + expect(out[0]).toEqual({ ...FULL_WIDTH, template: 'default' }); + expect(out[1]).toBe(registered); + expect(out[2]).toBe(unregistered); + expect(out[3]).toBe(none); + }); + + it('control: a stack with no `full-width` page is returned as the same reference, with no notice', () => { + const clean = { pages: [{ name: 'team_home', label: 'Team Home', type: 'home', template: 'default' }] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out).toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('⛔ never replays on the authoring funnel — the default load path leaves the name for the parse to refuse', () => { + const { stack, notices } = collectConversionNotices({ pages: [structuredClone(FULL_WIDTH)] }); + expect((stack as { pages: Record[] }).pages[0]).toHaveProperty('template', 'full-width'); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + const parsed = PageSchema.safeParse(FULL_WIDTH); + expect(parsed.success).toBe(false); + expect(parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.'))).toEqual(['template']); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions({ pages: [structuredClone(FULL_WIDTH)] }, { includeRetired: true }); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the stored-row seam replays it, and the converted row parses against `PageSchema`', () => { + const stored = structuredClone(FULL_WIDTH); + expect(PageSchema.safeParse(stored).success).toBe(false); + const converted = applyConversionsToStoredItem('page', stored) as Record; + expect(converted.template).toBe('default'); + expect(PageSchema.safeParse(converted).success).toBe(true); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7ab536dc825..2e91e26283e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -45,6 +45,7 @@ import { type ViewFilterOperator, } from '../ui/view.zod.js'; import { ASSEMBLED_VIEW_ITEMS_KEY } from '../ui/assembled-views.zod.js'; +import { misplacedDefaultPageRefusal } from '../ui/default-record-page.js'; import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; import { CONNECTOR_CONFIG_LOOSE_KEYS, WAIT_EVENT_CONFIG_LOOSE_KEYS } from '../automation/flow-sibling-block-loose-keys.js'; @@ -11191,6 +11192,84 @@ const pageHeaderShowStarRemoved: MetadataConversion = { }, }; +/** + * `page.isDefault` removed from a page that is not a record page bound to an + * object (protocol 18). The #23610 ruling (A on Q1, record 6190536588) refused + * the flag there at every door; this entry is the data-at-rest half the P0 that + * followed it added: an installed or stored page still carrying the flag + * converts at load instead of stopping the environment's kernel build. + * + * `isDefault` has one meaning — the default record page of the object a record + * page binds — and on every other page nothing read it: the console opens an + * app on its first navigation item and the root landing follows the app's own + * `isDefault`. Deleting the flag from a `home`, `app`, `utility` or `list` + * page, or from a record page with no `object`, therefore changes nothing a + * page does — a pure lossless delete. The ONE judge of "misplaced" is the + * parse's own, `misplacedDefaultPageRefusal` (`ui/default-record-page.ts`), so + * this conversion strips exactly the pages `checkPageDefaultPlacement` and the + * registry door refuse, and nothing else: a default record page bound to an + * object keeps its flag. + * + * **Retired from the authoring surface from the day it lands.** A live author + * meets the parse's refusal, which names where a landing is really set (the + * app's navigation) — that decision is the author's, and the refusal is how + * they are taught it. Data at rest has no author to teach: `sys_metadata` rows + * replay this on read, a compiled artifact replays it inside the window its + * manifest opens, and the kernel's package registration door + * (`ObjectQL.registerApp`, `@objectstack/objectql`) replays it for a package + * body that declares its protocol range — the seam a hosted environment's + * installed packages reach without a parse. The paired D3 entry is + * `page-is-default-off-record-page-refused`. + * + * ⚠️ Coverage boundary: this walks `stack.pages[]` ({@link mapPages}), the + * collection every door reads a package's pages from. A nested `plugins[]` + * entry's pages are code (`ObjectQL.registerPlugin`), never data at rest, and + * keep meeting the registry's refusal. + */ +const pageIsDefaultOffRecordPageRemoved: MetadataConversion = { + id: 'page-is-default-off-record-page-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.isDefault', + summary: + "page key 'isDefault' removed from a page that is not a record page bound to an object (a " + + 'home, app, utility or list page, or a record page with no object): it marks the default ' + + 'record page of an object and nothing read it on any other page — an app opens on its first ' + + 'navigation item, so the flag decided nothing', + apply(stack, emit) { + return mapPages(stack, (page, path) => { + if (page.isDefault !== true || misplacedDefaultPageRefusal(page) === undefined) return page; + return stripKeys(page, ['isDefault'], emit, path); + }); + }, + fixture: { + before: { + pages: [ + // The shipped shape: a home page marked as a landing that never landed. + { name: 'sales_home_page', label: 'Sales Home', type: 'home', isDefault: true, regions: [] }, + // A record page bound to no object — the flag places nothing here either. + { name: 'draft_record', label: 'Draft', type: 'record', isDefault: true }, + // A default record page bound to an object keeps its flag: that is the + // one meaning the key has. + { name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', isDefault: true }, + // `isDefault: false` says nothing and is left as authored. + { name: 'team_home', label: 'Team Home', type: 'home', isDefault: false }, + ], + }, + after: { + pages: [ + { name: 'sales_home_page', label: 'Sales Home', type: 'home', regions: [] }, + { name: 'draft_record', label: 'Draft', type: 'record' }, + { name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', isDefault: true }, + { name: 'team_home', label: 'Team Home', type: 'home', isDefault: false }, + ], + }, + // One notice per stripped flag: the home page and the unbound record page. + expectedNotices: 2, + }, +}; + /** * `page.requires` removed from the page kinds whose source is never compiled at * save — `react`, `full` and `slotted`, a page that omits `kind` included @@ -11262,6 +11341,67 @@ const pageRequiresNonCompiledKindRemoved: MetadataConversion = { }, }; +/** + * `page.template: 'full-width'` → `'default'` (protocol 18). The #23610 ruling + * (A on Q2, record 6190536588) closed the template set and retired `full-width` + * by name; this entry is the data-at-rest half the P0 that followed it added. + * + * `full-width` was a second name for `default`: the console's template + * registry mapped both names to the one `FullWidthTemplate` component + * (objectui `renderers/layout/page.tsx`, read at the `.objectui-sha` pin), so + * the re-spelling draws the page exactly as before — the enum re-spelling D2 + * was written for. The parse refuses the retired name with that prescription + * (`PageTemplateSchema`); an installed or stored page still carrying it + * converts here on read instead of failing the strict parse at an artifact + * door or on a stored-row read. + * + * **Retired from the authoring surface from the day it lands**, like its + * sibling `page-is-default-off-record-page-removed`: the authoring door keeps + * the one-word refusal, data at rest replays this. Only the retired SPELLING + * converts — an unregistered template name (`centered`, `two-column`) is not a + * shape this entry positively recognizes and stays with the parse, and a + * region a template does not place has no lossless rewrite at all (the D3 + * entry `page-template-closed-region-unplaced-refused` names the hand edit). + * + * ⚠️ Coverage boundary: this walks `stack.pages[]` ({@link mapPages}). + * `template` is a top-level `PageSchema` key with no nested spelling. + */ +const pageTemplateFullWidthToDefault: MetadataConversion = { + id: 'page-template-full-width-to-default', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.template', + summary: + "page template 'full-width' → 'default' (a second name for the same layout: the console drew " + + 'both with one template component, so the page renders exactly as before)', + apply(stack, emit) { + return mapPages(stack, (page, path) => { + if (page.template !== 'full-width') return page; + emit({ from: 'full-width', to: 'default', path: `${path}.template` }); + return { ...page, template: 'default' }; + }); + }, + fixture: { + before: { + pages: [ + { name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', template: 'full-width', regions: [] }, + // A registered template is left as authored; so is a page with none. + { name: 'sales_home_page', label: 'Sales Home', type: 'home', template: 'three-column', regions: [] }, + { name: 'team_home', label: 'Team Home', type: 'home' }, + ], + }, + after: { + pages: [ + { name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', template: 'default', regions: [] }, + { name: 'sales_home_page', label: 'Sales Home', type: 'home', template: 'three-column', regions: [] }, + { name: 'team_home', label: 'Team Home', type: 'home' }, + ], + }, + expectedNotices: 1, + }, +}; + /** * The delegated-admin scope's `manageBindings` and `authorEnvironmentSets` * removed (protocol 18, #23488 — ADR-0049 enforce-or-remove; ruling of record @@ -13971,13 +14111,13 @@ const pageAssignedProfilesRemoved: MetadataConversion = { // The authored shape: a page whose author believed the list gated it. { name: 'deal_desk', label: 'Deal Desk', assignedProfiles: ['admin', 'sales_manager'] }, // A page with nothing to strip keeps its identity (copy-on-write). - { name: 'team_home', label: 'Team Home', isDefault: true }, + { name: 'team_home', label: 'Team Home' }, ], }, after: { pages: [ { name: 'deal_desk', label: 'Deal Desk' }, - { name: 'team_home', label: 'Team Home', isDefault: true }, + { name: 'team_home', label: 'Team Home' }, ], }, expectedNotices: 1, @@ -16832,7 +16972,9 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: pageComponentResponsiveRemoved, order: 13 }, { conversion: pageHeaderBreadcrumbRemoved, order: 49 }, { conversion: pageHeaderShowStarRemoved, order: 66 }, + { conversion: pageIsDefaultOffRecordPageRemoved, order: 67 }, { conversion: pageRequiresNonCompiledKindRemoved, order: 58 }, + { conversion: pageTemplateFullWidthToDefault, order: 67 }, { conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 }, { conversion: permissionAllowRestorePurgeRemoved, order: 16 }, { conversion: permissionRlsTagsRemoved, order: 42 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts index d549ce705a5..aeee32ccbd4 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts @@ -7,11 +7,16 @@ import type { SemanticMigration } from '../../types.js'; // to an object, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the // parse refuses the flag elsewhere through `checkPageDefaultPlacement` // (ui/page.zod.ts), and the registry refuses it on a page a package registers -// without a parse. No D2 conversion and no grace window, per the ruling: the -// delete is lossless (nothing read the flag), but whether the page should be -// the app's landing — and so first in its navigation — is the author's call. -// No relevance question: the surface reaches pages a plugin registers in code -// and stored rows. +// without a parse. No grace window on the AUTHORING surface, per the ruling: +// the delete is lossless (nothing read the flag), but whether the page should +// be the app's landing — and so first in its navigation — is the author's +// call. Data at rest is the other half: an installed or stored page carrying +// the flag replays the retired D2 entry +// `page-is-default-off-record-page-removed` on read (the maintainer's P0 order +// after the refusal stopped kernel builds on environments holding an installed +// package built before it), so the kernel builds with a named notice instead +// of refusing. No relevance question: the surface reaches pages a plugin +// registers in code and stored rows. // // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it // inside a code span and a table cell. @@ -38,11 +43,13 @@ export const entry: SemanticMigration = { + 'the page parse (definePage; defineStack, so objectstack validate and objectstack build, with ' + 'STACK_SCHEMA_INVALID 422; the metadata save door with 422 INVALID_METADATA) and at package ' + 'registration (SchemaRegistry.installPackage and registerItem, 422 INVALID_METADATA). ' - + '⚠️ No D2 conversion: deleting the flag changes nothing at runtime, but where the app should open is the ' - + 'author\'s decision, made in its navigation. ' - + '⚠️ A page row already stored with the flag is served unchanged (no conversion touches it, and a stored ' - + 'row hydrates without a package, so the registry does not judge it), while saving it again is refused ' - + 'until the flag is deleted. ADR-0087.', + + '⚠️ No D2 conversion on the authoring surface: deleting the flag changes nothing at runtime, but where the ' + + 'app should open is the author\'s decision, made in its navigation. ' + + '⚠️ Data at rest converts instead of failing: a page row already stored with the flag, a compiled artifact ' + + 'inside the window its manifest opens, and a package body registered with a declared engines.protocol ' + + 'range (an installed package) replay the retired conversion page-is-default-off-record-page-removed on ' + + 'read, which deletes the flag with an OS_METADATA_CONVERTED notice, so the kernel builds; saving the page ' + + 'again is refused until the flag is deleted. ADR-0087.', acceptanceCriteria: 'Grep every page in defineStack pages sources, exported stacks, pages a plugin registers in code and every ' + 'page row in sys_metadata for isDefault: true on a page whose type is not record, or that binds no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts index a57de6829b2..cbcbd9f5d69 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts @@ -8,9 +8,14 @@ import type { SemanticMigration } from '../../types.js'; // `full-width` refused by name through the enum's error map), and a region // whose name the page's template does not place is refused through // `checkPageRegionPlacement` (ui/page.zod.ts), against the declared slot table -// `PAGE_TEMPLATE_SLOTS`. No key is removed, so no tombstone. No D2 conversion, -// per the ruling (no alias window): `full-width` → `default` is a one-word -// edit, and a region's rename is a choice of which slot the author meant. +// `PAGE_TEMPLATE_SLOTS`. No key is removed, so no tombstone. No alias window +// on the AUTHORING surface, per the ruling: `full-width` → `default` is a +// one-word edit, and a region's rename is a choice of which slot the author +// meant. Data at rest replays the retired D2 entry +// `page-template-full-width-to-default` on read (the two names drew the same +// layout), so an installed or stored page still spelling it keeps loading; an +// unregistered name and an unplaced region have no lossless rewrite and stay +// with the parse. // // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it // inside a code span and a table cell. @@ -46,8 +51,11 @@ export const entry: SemanticMigration = { + 'at its name too, naming the region that fills the slot. dashboard places every other name by design (two ' + 'regions of one other name are two grid cells); slotted, html and ' + 'react pages, which do not lay out regions by template, are not judged on region names. ' - + '⚠️ No D2 conversion: the ruling keeps no alias window for full-width, and which slot a renamed region ' - + 'fills is the author\'s call. ' + + '⚠️ No D2 conversion on the authoring surface: the ruling keeps no alias window for full-width, and which ' + + 'slot a renamed region fills is the author\'s call. Data at rest replays the retired conversion ' + + 'page-template-full-width-to-default on read (full-width and default were one layout component in the ' + + 'console, so nothing changes on screen); an unregistered template and an unplaced region have no lossless ' + + 'rewrite and are not converted. ' + '⚠️ A page row already stored with an unregistered template or an unplaced region is served unchanged ' + 'and renders as before, while saving it again is refused until it is renamed. ADR-0087.', acceptanceCriteria: diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 68acb129805..d94874f6526 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -334,6 +334,28 @@ const JOIN: Readonly> = { ], // #23650: retired after 17.7.0, so the v18 train's major. 'page-header-show-star-removed': [tombstone(PageHeaderProps, 'page:header', 'showStar')], + // #23904: the two data-at-rest halves of the #23610 page refusals, retired + // after 17.7.0; each prescription is the parse's own refusal at its key — the + // one sentence the registry door prints too — and names the release. + 'page-is-default-off-record-page-removed': [ + refusal( + "isDefault: true on a type 'home' page", + () => PageSchema.safeParse({ name: 'sales_home_page', label: 'Sales Home', type: 'home', isDefault: true, regions: [] }), + 'isDefault', + ), + refusal( + 'isDefault: true on a record page bound to no object', + () => PageSchema.safeParse({ name: 'draft_record', label: 'Draft', type: 'record', isDefault: true, regions: [] }), + 'isDefault', + ), + ], + 'page-template-full-width-to-default': [ + refusal( + "page template 'full-width'", + () => PageSchema.safeParse({ name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', template: 'full-width', regions: [{ name: 'main', components: [] }] }), + 'template', + ), + ], // #23543 F3: a loose `config` key under a sibling-block node type, refused by // the flow parse; the node's block is complete, so the key is the only issue. 'flow-node-wait-event-config-lift': [ diff --git a/packages/spec/src/ui/default-record-page.ts b/packages/spec/src/ui/default-record-page.ts index c4cee6a3b13..c13d1283b6b 100644 --- a/packages/spec/src/ui/default-record-page.ts +++ b/packages/spec/src/ui/default-record-page.ts @@ -89,7 +89,11 @@ export function defaultRecordPageObject(page: unknown): string | undefined { * without a parse. * * Reads a parsed page and an unparsed one alike, by the same declared keys - * {@link defaultRecordPageObject} reads. + * {@link defaultRecordPageObject} reads. The sentence names the release that + * first refused the flag (`@objectstack/spec 18`): the retired D2 entry + * `page-is-default-off-record-page-removed` converts data at rest, and the + * removal-version pin holds every such prescription to the release its + * `retiredAfter` derives. */ export function misplacedDefaultPageRefusal(page: unknown): string | undefined { if (!page || typeof page !== 'object' || Array.isArray(page)) return undefined; @@ -97,11 +101,11 @@ export function misplacedDefaultPageRefusal(page: unknown): string | undefined { if (p.isDefault !== true || defaultRecordPageObject(page) !== undefined) return undefined; const type = p.type === undefined ? PAGE_TYPE_DEFAULT : p.type; if (type === 'record') { - return '`isDefault: true` is refused on a record page bound to no `object`: it makes a record page the ' + return '`isDefault: true` is refused on a record page bound to no `object` since @objectstack/spec 18: it makes a record page the ' + 'default record page of the object it binds, the page that object\'s record view opens. Bind the ' + 'page (`object: \'\'`), or delete `isDefault`.'; } - return `\`isDefault: true\` is refused on a \`type: '${String(type)}'\` page: it marks the default record page ` + return `\`isDefault: true\` is refused on a \`type: '${String(type)}'\` page since @objectstack/spec 18: it marks the default record page ` + 'of an object, and nothing reads it on any other page. A page does not choose where an app opens — an ' + 'app\'s landing page is its first navigation item (by `order`), so to land on this page put its entry ' + 'first in the app\'s `navigation`; the root landing follows the app\'s own `isDefault`. Delete ' diff --git a/packages/spec/src/ui/page-default-and-region-placement.test.ts b/packages/spec/src/ui/page-default-and-region-placement.test.ts index 1e731c30ea6..ef4eb6e0f1f 100644 --- a/packages/spec/src/ui/page-default-and-region-placement.test.ts +++ b/packages/spec/src/ui/page-default-and-region-placement.test.ts @@ -17,8 +17,7 @@ * * Bookkeeping shapes, pinned below: both refusals are exported object-level * checks attached to `PageSchema` (parity in - * `object-refinement-check-exports.test.ts`); neither has a D2 conversion, so a - * stored row replays unchanged and the parse still refuses it; each has a D3 + * `object-refinement-check-exports.test.ts`); each has a D3 * entry. * * On the assertion set: a bare `PageSchema` parse raises zod issues, pinned by @@ -226,16 +225,37 @@ describe('Q2 — a region the template does not place', () => { }); }); -describe('the ledger — two D3 entries and no D2 conversion', () => { - it.each([Q1_D3, Q2_D3])('step 18 carries `%s`, judging no mechanical edit', (id) => { +describe('the ledger — two D3 entries, and the retired D2 entries data at rest replays', () => { + it.each([Q1_D3, Q2_D3])('step 18 carries `%s`, judging no mechanical edit on the authoring surface', (id) => { const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((e) => e.id === id); expect(entry, id).toBeDefined(); expect(entry!.conversionIds ?? []).toEqual([]); }); + // The shapes with a lossless rewrite: a STORED row converts on read (the + // retired D2 entries `page-is-default-off-record-page-removed` and + // `page-template-full-width-to-default`, replayed by every data-at-rest + // seam), with the named notice, and the converted row parses — while the + // authoring door, pinned above, still refuses the row as written. it.each([ - ['a non-record `isDefault`', { name: 'sales_home_page', label: 'Home', type: 'home', isDefault: true, regions: [] }, ['isDefault']], - ['the retired `full-width`', { name: 'pg_page', label: 'P', type: 'record', object: 'invoice', template: 'full-width', regions: [region('main')] }, ['template']], + ['a non-record `isDefault`', { name: 'sales_home_page', label: 'Home', type: 'home', isDefault: true, regions: [] }, 'page-is-default-off-record-page-removed', 'isDefault', { name: 'sales_home_page', label: 'Home', type: 'home', regions: [] }], + ['the retired `full-width`', { name: 'pg_page', label: 'P', type: 'record', object: 'invoice', template: 'full-width', regions: [region('main')] }, 'page-template-full-width-to-default', 'template', { name: 'pg_page', label: 'P', type: 'record', object: 'invoice', template: 'default', regions: [region('main')] }], + ])('a STORED row with %s converts on read, with the named notice, and the converted row parses', (_label, row, conversionId, path, converted) => { + expect(refusalOf(row).map((i) => i.path.join('.'))).toEqual([path]); + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(rehydrated).toEqual(converted); + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([[conversionId, `pages[0].${path}`]]); + const parsed = PageSchema.safeParse(rehydrated); + expect(parsed.success, JSON.stringify(parsed.success ? [] : parsed.error.issues)).toBe(true); + }); + + // The shapes with NO lossless rewrite: no conversion touches them, and the + // parse still refuses them — an unregistered template name is not a shape + // any entry positively recognizes, and which slot an unplaced region meant + // is the author's call (the D3 entries name the hand edits). + it.each([ + ['an unregistered template', { name: 'pg_page', label: 'P', type: 'app', template: 'centered', regions: [region('main')] }, ['template']], ['an unplaced region', { name: 'pg_page', label: 'P', type: 'home', template: 'three-column', regions: [region('main'), region('left_sidebar')] }, ['regions.1.name']], ])('a STORED row with %s replays unchanged — no conversion touches it — and the parse still refuses it', (_label, row, paths) => { const notices: ConversionNotice[] = [];