diff --git a/.changeset/23082-view-data-api-provider-retired.md b/.changeset/23082-view-data-api-provider-retired.md index 577a50fed2b..2f7dbaba967 100644 --- a/.changeset/23082-view-data-api-provider-retired.md +++ b/.changeset/23082-view-data-api-provider-retired.md @@ -22,7 +22,7 @@ Measured before removal: no producer wrote `provider: 'api'` on a view outside d **What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription: -> `view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. +> `view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. ## The retirement kit diff --git a/.changeset/23103-page-action-row-on-success-and-executor.md b/.changeset/23103-page-action-row-on-success-and-executor.md index e965ac35926..5a0fd28f215 100644 --- a/.changeset/23103-page-action-row-on-success-and-executor.md +++ b/.changeset/23103-page-action-row-on-success-and-executor.md @@ -33,6 +33,6 @@ A page's `properties` bag is outside the stack parse. `os validate` judges it th **The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call. -**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. +**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. **Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`. diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md new file mode 100644 index 00000000000..24f85895fb3 --- /dev/null +++ b/.changeset/23145-tombstone-removal-version.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': patch +--- + +Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now name 18 as the release that removed them + +Clause-②: no + +17.7.0, the newest 17.x release (npm `latest`), still accepts every shape in the two tables below. No 17.x release removes them: the 18 line does. `18.0.0-next.0` is the first release that refuses the shapes in this first table. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: + +| Surface | Was | Now | +| --- | --- | --- | +| `element:number` `object` / `filter`; `element:record_picker` and `element:repeater` `object` / `filter` / `sort` / `limit` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `object-grid` `defaultFilters` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `email_template.category`, `email_template.isSystem` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| a package manifest's `permissions` written as a list of permission strings | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | +| `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | + +The `18.0.0-next.0` release notes quoted the two `email_template` prescriptions with the earlier version, 17; from this release on they name 18. + +`18.0.0-next.0` still accepts the shapes in this second table, so the first release that refuses them is the one after it. Their refusals named 17, or no release at all: + +| Surface | Was | Now | +| --- | --- | --- | +| `view.data.provider: 'api'` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `'navigation'` as a page action row's executor (`actionType` on `action:button` / `action:icon`, member `type` on `action:group` / `action:menu`) | no release named | refused on page action rows since @objectstack/spec 18 | +| a `controlled_by_parent` object with no `master_detail` declared `required: true` | no release named | the one-required-master rule holds since @objectstack/spec 18 | + +One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". + +The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. diff --git a/.changeset/pre/22876-email-template-category-is-system-retired.md b/.changeset/pre/22876-email-template-category-is-system-retired.md index 06df26f98bb..ad063561b35 100644 --- a/.changeset/pre/22876-email-template-category-is-system-retired.md +++ b/.changeset/pre/22876-email-template-category-is-system-retired.md @@ -26,9 +26,9 @@ Measured before removal: no reader of either key in `@objectstack/plugin-email`, **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: -> `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +> `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. -> `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +> `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. ## The retirement kit diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index c6ad54d1042..631e9603a4e 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -269,7 +269,7 @@ const result = MetadataBulkResultSchema.parse(data); | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | | **supportsOverlay** | `boolean` | optional (default: `true`) | Whether a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for permission alone; it opens no save and it decides no read | | **allowEnvironmentOverlay** | `boolean` | optional (default: `false`) | Whether an environment may overlay an item of this type that a managed package ships (ADR-0131 D6 regime O). Decides the save door, the managed-content seal on write and on read, and Studio's write gate; the OS_METADATA_WRITABLE hatch does not reach a managed item. Default false. | -| **allowOrgOverride** | `never` | optional | [REMOVED] `MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` (ADR-0131 D6) — the per-organization overlay axis was retired, and the key now names the one scope it governs: whether an environment may overlay an item of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; the value is unchanged. | +| **allowOrgOverride** | `never` | optional | [REMOVED] `MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` in @objectstack/spec 18 (ADR-0131 D6) — the per-organization overlay axis was retired, and the key now names the one scope it governs: whether an environment may overlay an item of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; the value is unchanged. | | **allowRuntimeCreate** | `boolean` | optional (default: `true`) | Allow runtime creation via API | | **supportsVersioning** | `boolean` | optional (default: `false`) | Whether version history is tracked | | **executionPinned** | `boolean` | optional (default: `false`) | Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009) | diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 196bffc8d42..2aa1fa74d21 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -47,7 +47,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Template identifier (dotted snake_case) | | **label** | `string` | ✅ | Display label | -| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. | | **subject** | `string` | ✅ | Subject template | | **bodyHtml** | `string` | ✅ | HTML body template | @@ -56,7 +56,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **fromOverride** | `{ name?: string; address: string }` | optional | | | **replyTo** | `string` | optional | | | **active** | `boolean` | optional (default: `true`) | | -| **isSystem** | `never` | optional | [REMOVED] `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **isSystem** | `never` | optional | [REMOVED] `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string` | optional | | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this email template. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4d5a39b5ec1..23be42dbce1 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -355,10 +355,10 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | @@ -381,13 +381,13 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **labelField** | `string` | optional | Field rendered as each row's text (default `name`) | | **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) | | **label** | `string \| Record` | optional | Control label rendered above the select | -| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | @@ -413,12 +413,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -803,7 +803,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | diff --git a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts index 3c7bd4709d7..8c95ba7f7a6 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts @@ -309,7 +309,7 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { expect(parsed.success, granularity).toBe(false); if (parsed.success) return; const message = parsed.error.issues.map((i) => i.message).join(' '); - expect(message, granularity).toContain('retired in protocol 18'); + expect(message, granularity).toContain('removed in @objectstack/spec 17.5.0'); expect(message, granularity).toContain('os migrate meta --from 17'); }, ); diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 3e0cbaed042..8b427ca05cd 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -440,7 +440,7 @@ describe('lintLivenessProperties', () => { expect(refused.success, key).toBe(false); expect(refused.error?.issues[0]?.path, key).toEqual([key]); expect(refused.error?.issues[0]?.message, key).toMatch( - new RegExp(`^\`email_template\\.${key}\` was removed in @objectstack/spec 17`), + new RegExp(`^\`email_template\\.${key}\` was removed in @objectstack/spec 18`), ); } }); diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 95c902351de..2436995203d 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -758,7 +758,7 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem const refused = door!.safeParse({ ...item, category: 'workflow' }); expect(refused.success).toBe(false); expect(refused.error?.issues[0]?.path).toEqual(['category']); - expect(refused.error?.issues[0]?.message).toMatch(/^`email_template\.category` was removed in @objectstack\/spec 17/); + expect(refused.error?.issues[0]?.message).toMatch(/^`email_template\.category` was removed in @objectstack\/spec 18/); }); it('[#21127] a `mapping` write that DOES author `connectorSource` is judged silent — not thrown on', () => { diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts index b0e3547925f..1d479116114 100644 --- a/packages/lint/src/validate-component-props-row-on-success.test.ts +++ b/packages/lint/src/validate-component-props-row-on-success.test.ts @@ -67,7 +67,7 @@ describe("the props gate refuses `'navigation'` at a page action row's executor const findings = validateComponentProps(stackWith(node({ [key]: 'navigation', target: '/apps/crm/tasks' }))); expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.${key}`]]); expect(findings[0].message).toContain( - `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + `${subject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.`, ); }); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 6c29afcd19d..84643a886f0 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -379,7 +379,7 @@ describe('validateComponentProps — value verdicts', () => { 'pages[0].regions[0].components[0].properties.object', ]); const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; - expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 17.*`dataSource\.object`/s); + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); }); /** @@ -483,7 +483,7 @@ describe('validateComponentProps — value verdicts', () => { for (const key of ['object', 'filter', 'sort', 'limit']) { const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; expect(at.message).toMatch( - new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 17.*\`dataSource\\.${key}\``, 's'), + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), ); } expect(unknownKeys(findings)).toEqual([]); diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 214b37950d5..487d24bb65c 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -80,7 +80,7 @@ describe('TimeUpdateInterval', () => { if (parsed.success) continue; const [issue] = parsed.error.issues; expect(issue.message, retired).toContain(`'${retired}'`); - expect(issue.message, retired).toContain('retired in protocol 18'); + expect(issue.message, retired).toContain('removed in @objectstack/spec 17.5.0'); expect(issue.message, retired).toContain('day, week, month, quarter, year'); expect(issue.message, retired).toContain('os migrate meta --from 17'); } diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 2343320739a..be86656c847 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -101,7 +101,8 @@ export const DimensionType = z.enum([ export type DimensionType = z.input; /** - * The three sub-day names this enum declared until protocol 18 (#17296). + * The three sub-day names this enum declared through @objectstack/spec 17.4.0; + * 17.5.0 removed them (#17296). * * They were never a capability any backend could offer, and not because the * backends lag the contract — the rest of the contract never declared them: @@ -166,7 +167,7 @@ function timeUpdateIntervalRefusalMessage(input: unknown): string { const declared = DateGranularity.options.join(', '); if (typeof input === 'string' && (RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(input)) { return ( - `Time interval ${received} was retired in protocol 18 (ADR-0049 enforce-or-remove). ` + `Time interval ${received} was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove). ` + 'No backend ever bucketed it and none could advertise it: the canonical bucket-key ' + 'vocabulary and `supports.queryDateGranularity` both stop at ' + `${declared}, so the name resolved to a refusal or to one group per distinct timestamp. ` diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 0a2b751a751..6347359316f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2019,8 +2019,9 @@ function refuseControlledByParentWithoutOneMaster( path: ['sharingModel'], message: `Object '${name}' declares sharingModel 'controlled_by_parent' but no required \`master_detail\` field. ` - + 'ADR-0055 §1: a controlled-by-parent object derives its master from exactly one `master_detail` field ' - + 'declared `required: true`, and from nothing else, so as declared the platform derives no master ' + + 'ADR-0055 §1: since @objectstack/spec 18, a controlled-by-parent object derives its master from exactly ' + + 'one `master_detail` field declared `required: true`, and from nothing else, so as declared the ' + + 'platform derives no master ' + '(every read is denied, every write refused). ' + (found.length > 0 ? `${found.join('; ')}. ` : '') + 'If the rows must outlive their parent, this object is not a detail: give it its own sharingModel ' diff --git a/packages/spec/src/kernel/manifest-permissions-string-list.test.ts b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts index c53d6e5d9d4..9d74f6255d0 100644 --- a/packages/spec/src/kernel/manifest-permissions-string-list.test.ts +++ b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts @@ -59,7 +59,7 @@ const legal = () => ({ * two-clause `os migrate meta` sentence naming the case the conversion covers. */ const PRESCRIPTION = - /^Expected the plugin permission block `\{ services\?, hooks\?, network\?, fs\? \}`, received a flat list\..*removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\).*translate each one by hand, or delete `permissions` when the plugin needs none\. Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; `--write` applies the ones it can prove, and a granted-permission record is not a source it reads\.$/s; + /^Expected the plugin permission block `\{ services\?, hooks\?, network\?, fs\? \}`, received a flat list\..*removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\).*translate each one by hand, or delete `permissions` when the plugin needs none\. Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; `--write` applies the ones it can prove, and a granted-permission record is not a source it reads\.$/s; const permissionsIssue = (input: unknown) => { const result = ManifestSchema.safeParse(input); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 9c54d65a51c..06fbf3e2771 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -38,7 +38,7 @@ import { NavigationContributionSchema } from '../ui/app.zod'; const PLUGIN_PERMISSIONS_LIST_FORM = 'Expected the plugin permission block `{ services?, hooks?, network?, fs? }`, received a flat list. ' + 'A list of permission strings was the legacy form of a package manifest\'s `permissions`, removed in ' - + '@objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read it: what a package is ' + + '@objectstack/spec 18 (ADR-0049 enforce-or-remove) — no loader ever read it: what a package is ' + 'granted at load is the consented grant set, never the manifest\'s list. Name what the plugin may touch ' + 'in the four lists instead — platform services, lifecycle hooks, network hosts and filesystem paths, ' + 'e.g. `{ services: [\'object\'], network: [\'api.acme.com\'] }`. A permission string has no mechanical ' diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 86961718d34..b6f2a5ab24a 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -315,7 +315,7 @@ const MetadataTypeRegistryEntryBaseSchema = z.object({ */ allowOrgOverride: retiredKey( '`MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` ' - + '(ADR-0131 D6) — the per-organization overlay axis was retired, and ' + + 'in @objectstack/spec 18 (ADR-0131 D6) — the per-organization overlay axis was retired, and ' + 'the key now names the one scope it governs: whether an environment may overlay an item ' + 'of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; ' + 'the value is unchanged.', diff --git a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts index 64bc0561213..11cf08b4080 100644 --- a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts +++ b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts @@ -56,7 +56,7 @@ describe('MetadataTypeRegistryEntry.allowOrgOverride is renamed to allowEnvironm expect(issue!.path).toEqual(['allowOrgOverride']); // The prescription IS the migration doc for whoever hits it. expect(issue!.message).toMatch( - /`MetadataTypeRegistryEntry\.allowOrgOverride` was renamed to `allowEnvironmentOverlay` \(ADR-0131 D6\)/s, + /`MetadataTypeRegistryEntry\.allowOrgOverride` was renamed to `allowEnvironmentOverlay` in @objectstack\/spec 18 \(ADR-0131 D6\)/s, ); expect(issue!.message).toMatch(/Rename the key to `allowEnvironmentOverlay`; the value is unchanged/s); // `false` is refused exactly like `true`: the key is gone, not one value of it. 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 new file mode 100644 index 00000000000..a29e4a763db --- /dev/null +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Class pin: a retirement prescription names the release that removed it, and + * the pin DERIVES that release from the ADR-0087 conversion entry instead of + * trusting the sentence. + * + * Governing text, `retiredKey()`'s `@param guidance` (`./retired-key.ts`): + * "State what replaced the key, the version that removed it, and the one-line + * fix — this string IS the migration doc for anyone who hits it." A wrong + * version there is wrong release text: it ships in every refusal, in every + * JSON Schema `describe`, and in what `os validate` prints. + * + * ## The derivation + * + * `MetadataConversion.retiredAfter` is the last published `@objectstack/spec` + * release whose authoring surface still accepted the old shape + * (`../conversions/types.ts`). So the release that removed it is the first + * stable release after `retiredAfter`, and the prescription names that + * release's major: + * + * - `retiredAfter` is the newest published stable release ⇒ nothing published + * refuses the shape yet, and the release `main` cuts next does. Its major is + * `PROTOCOL_MAJOR`, which `../kernel/protocol-version.ts` keeps in lockstep + * with the package major and, in Changesets pre mode with a pending major, + * lets name the major that release is about to publish. On the v18 train + * that is 18. On the 17.x line, where a retirement shipped as a minor, + * the same rule gave 17. + * - `retiredAfter` is older ⇒ a published release refuses it: the next one on + * the same line when the tree records one, else the first release of the + * next line. + * + * "The newest published stable release" is read off the tree, with no version + * written here. It is the highest stable version the tree records: every + * census release, every `retiredAfter` stamp, and the package label when the + * label is stable. Under a stable label that is the label: `main` carries the + * last release's label until the next one is cut, and + * `retired-after.census.test.ts` holds every stamp and every census release at + * or below it. Under a prerelease label (`18.0.0-next.N`) it is the highest + * stamp: every retirement that lands then stamps the newest published stable + * release (`../conversions/types.ts`). The committed census alone cannot + * answer it, because the census trails the published line until it is + * refreshed after a stable publish. + * + * ## The join + * + * `JOIN` maps a retired conversion id to the prescriptions an author meets for + * its surface. A tombstone row reads the key's parse refusal and its + * `[REMOVED]` description, which are the two channels. A refusal row reads the + * refusal the surface answers, for a value or union-arm retirement that has no + * key to tombstone. Two guards keep the join honest: + * + * - every retired entry whose `retiredAfter` is the newest published stable + * release must have a row, so a future retirement cannot ship its + * prescription unjudged; + * - the rows must discriminate: at least one derives `PROTOCOL_MAJOR` and at + * least one derives an older major, so the derivation is exercised in both + * directions on real entries, never vacuously. + * + * The four older rows are entries a 17.x minor shipped. Each is `toMajor: 18` + * like the rest, so `toMajor` cannot be what tells them apart. One of them, + * the sub-day time interval, guards the opposite direction: a prescription for + * a shape a 17.x minor already refused must not name the next major. + * + * Out of reach by construction: a retirement with no D2 conversion carries no + * `retiredAfter`, so nothing here can derive its release (a retired-key-only + * tombstone, or a value retired with only a D3 entry). + * + * Each prescription's `os migrate meta --from N` sentence is derived the same + * way: `composeMigrationChain` keeps the majors above N, so the step that + * replays a `toMajor: T` entry is reached by `--from T-1`. + */ + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { TimeUpdateInterval } from '../data/analytics.zod'; +import { IndexSchema, ObjectSchema } from '../data/object.zod'; +import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; +import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; +import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; +import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, + ElementNumberPropsSchema, + ElementRecordPickerPropsSchema, + ElementRepeaterPropsSchema, + ObjectGridPropsSchema, + ObjectKanbanPropsSchema, + PageHeaderProps, +} from '../ui/component.zod'; + +// --------------------------------------------------------------------------- +// Versions +// --------------------------------------------------------------------------- + +type Triple = [number, number, number]; + +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; + +function triple(v: string): Triple { + const m = STABLE.exec(v); + if (!m) throw new Error(`not a stable x.y.z version: '${v}'`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function cmp(a: string, b: string): number { + const x = triple(a); + const y = triple(b); + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +const major = (v: string): number => triple(v)[0]; + +/** What the tree records about the published line, and the major `main` publishes next. */ +interface ReleaseFacts { + /** Every stable version the tree records: census releases, stamps, a stable label. */ + recorded: readonly string[]; + protocolMajor: number; +} + +function newestStable(facts: ReleaseFacts): string { + return facts.recorded.reduce((a, b) => (cmp(a, b) >= 0 ? a : b)); +} + +/** The major of the first stable release that refuses a shape last accepted by `retiredAfter`. */ +function removalMajor(retiredAfter: string, facts: ReleaseFacts): number { + if (cmp(retiredAfter, newestStable(facts)) >= 0) return facts.protocolMajor; + const line = major(retiredAfter); + const laterOnLine = facts.recorded.some((v) => major(v) === line && cmp(v, retiredAfter) > 0); + return laterOnLine ? line : line + 1; +} + +const LABEL = ( + JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string } +).version; + +const CENSUS = JSON.parse( + readFileSync(new URL('../conversions/retired-after.census.json', import.meta.url), 'utf8'), +) as { precedingRelease: string; releases: { version: string }[] }; + +const RETIRED = ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath === true); + +const FACTS: ReleaseFacts = { + recorded: [ + CENSUS.precedingRelease, + ...CENSUS.releases.map((r) => r.version), + ...RETIRED.map((c) => c.retiredAfter!), + ...(STABLE.test(LABEL) ? [LABEL] : []), + ], + protocolMajor: PROTOCOL_MAJOR, +}; + +const NEWEST_STABLE = newestStable(FACTS); + +// --------------------------------------------------------------------------- +// Prescriptions +// --------------------------------------------------------------------------- + +interface Prescription { + where: string; + text: string; +} + +type Renderer = () => Prescription[]; + +type Parse = { success: boolean; error?: { issues: { message: string; path?: readonly PropertyKey[] }[] } }; +type Shaped = { shape: Record Parse; description?: string }> }; + +/** A `retiredKey()` tombstone: its parse refusal and its `[REMOVED]` description. */ +const tombstone = (schema: unknown, surface: string, key: string): Renderer => () => { + const field = (schema as Shaped).shape[key]; + if (!field) throw new Error(`${surface}: no key \`${key}\` on the schema`); + const refused = field.safeParse(1); + expect(refused.success, `${surface}.${key} must refuse every value`).toBe(false); + const description = field.description ?? ''; + expect(description.startsWith('[REMOVED] '), `${surface}.${key}: the description is a tombstone's`).toBe(true); + return [ + { where: `${surface}.${key} (parse)`, text: refused.error!.issues[0]!.message }, + { where: `${surface}.${key} (describe)`, text: description.slice('[REMOVED] '.length) }, + ]; +}; + +/** + * The refusal a surface answers for a retired value or union arm. `at` keeps + * only the issues at that first path segment, for a surface whose parse can + * report other, unrelated issues. + */ +const refusal = (where: string, parse: () => Parse, at?: PropertyKey): Renderer => () => { + const refused = parse(); + expect(refused.success, `${where} must be refused`).toBe(false); + const issues = refused.error!.issues.filter((issue) => at === undefined || issue.path?.[0] === at); + expect(issues.length, `${where}: no issue at ${String(at)}`).toBeGreaterThan(0); + return issues.map((issue) => ({ where, text: issue.message })); +}; + +const ELEMENTS: readonly (readonly [unknown, string, readonly string[]])[] = [ + [ElementNumberPropsSchema, 'element:number', ['object', 'filter']], + [ElementRecordPickerPropsSchema, 'element:record_picker', ['object', 'filter', 'sort', 'limit']], + [ElementRepeaterPropsSchema, 'element:repeater', ['object', 'filter', 'sort', 'limit']], +]; + +/** Retired conversion id → the prescriptions an author meets for its surface. */ +const JOIN: Readonly> = { + 'element-flat-data-binding-to-data-source': ELEMENTS.flatMap(([schema, surface, keys]) => + keys.map((key) => tombstone(schema, surface, key)), + ), + 'object-grid-default-filters-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultFilters')], + 'email-template-category-is-system-removed': [ + tombstone(EmailTemplateDefinitionSchema, 'email_template', 'category'), + tombstone(EmailTemplateDefinitionSchema, 'email_template', 'isSystem'), + ], + 'manifest-permissions-string-list-removed': [ + refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), + ], + 'declared-index-unique-scope': [ + refusal('indexes[].unique: true', () => IndexSchema.safeParse({ fields: ['code'], unique: true })), + ], + 'page-action-row-navigation-executor-to-url': [ + refusal("action:button actionType 'navigation'", () => ActionButtonPropsSchema.safeParse({ label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), + refusal("action:icon actionType 'navigation'", () => ActionIconPropsSchema.safeParse({ icon: 'copy', label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), + refusal("action:group member type 'navigation'", () => ActionGroupPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), + refusal("action:menu member type 'navigation'", () => ActionMenuPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), + ], + 'cbp-master-detail-required-defaulted': [ + refusal( + 'a controlled_by_parent master_detail without `required`', + () => ObjectSchema.safeParse({ + name: 'cbp_line', + label: 'Line', + sharingModel: 'controlled_by_parent', + fields: { order: { type: 'master_detail', label: 'Order', reference: 'cbp_order' } }, + }), + 'sharingModel', + ), + ], + // Shipped by a 17.x minor; `toMajor: 18` like the rows above. + 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], + 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], + 'object-kanban-quick-add-removed': [tombstone(ObjectKanbanPropsSchema, 'object-kanban', 'quickAdd')], + 'cube-sub-day-granularities-removed': [ + refusal('a sub-day TimeUpdateInterval member', () => TimeUpdateInterval.safeParse('minute')), + ], +}; + +/** Every major a prescription names as a release: `@objectstack/spec N[.x.y]` or `protocol N`. */ +function majorsNamed(text: string): number[] { + return [...text.matchAll(/@objectstack\/spec (\d+)(?:\.\d+\.\d+)?\b|\bprotocol (\d+)\b/g)].map((m) => + Number(m[1] ?? m[2]), + ); +} + +const byId = new Map(RETIRED.map((c) => [c.id, c])); + +// --------------------------------------------------------------------------- + +describe('a retirement prescription names the release the registry entry derives', () => { + it('every joined entry is a retired conversion', () => { + const stale = Object.keys(JOIN).filter((id) => !byId.has(id)); + expect(stale, 'JOIN names an id that is not a retired conversion').toEqual([]); + }); + + it('every entry retired after the newest published stable release is joined to its prescriptions', () => { + const unjoined = RETIRED.filter((c) => cmp(c.retiredAfter!, NEWEST_STABLE) >= 0 && !(c.id in JOIN)).map( + (c) => `${c.id} (retiredAfter ${c.retiredAfter})`, + ); + expect( + unjoined, + `retired after ${NEWEST_STABLE}, the newest published stable release: add each to JOIN in this file, ` + + 'with the tombstones or refusals an author meets for its surface', + ).toEqual([]); + }); + + it('the joined rows exercise the derivation in both directions', () => { + const derived = Object.keys(JOIN).map((id) => removalMajor(byId.get(id)!.retiredAfter!, FACTS)); + expect(derived).toContain(PROTOCOL_MAJOR); + expect(derived.some((m) => m < PROTOCOL_MAJOR)).toBe(true); + }); + + it.each(Object.keys(JOIN))('%s', (id) => { + const entry = byId.get(id)!; + const want = removalMajor(entry.retiredAfter!, FACTS); + const prescriptions = JOIN[id]!.flatMap((render) => render()); + expect(prescriptions.length).toBeGreaterThan(0); + for (const { where, text } of prescriptions) { + const named = majorsNamed(text); + expect(named.length, `${where}: names no release — ${text}`).toBeGreaterThan(0); + expect( + named.every((m) => m === want), + `${where}: retiredAfter ${entry.retiredAfter} (newest published stable ${NEWEST_STABLE}) derives ` + + `@objectstack/spec ${want}, but the prescription names ${named.join(', ')} — ${text}`, + ).toBe(true); + for (const [, from] of text.matchAll(/os migrate meta --from (\d+)/g)) { + expect(Number(from), `${where}: toMajor ${entry.toMajor} is replayed by --from ${entry.toMajor - 1}`).toBe( + entry.toMajor - 1, + ); + } + } + }); +}); + +describe('the derivation under simulated release facts', () => { + const at = (recorded: string[], protocolMajor: number): ReleaseFacts => ({ recorded, protocolMajor }); + + it('a stable 17.x label on the v18 train: the newest stable is the next major\'s, older stamps keep 17', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0', '17.7.0'], 18); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + expect(removalMajor('17.2.0', facts)).toBe(17); + }); + + it('a prerelease label records no label, and the highest stamp answers the same way', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0'], 18); + expect(newestStable(facts)).toBe('17.7.0'); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + }); + + it('once 18.0.0 is the label, a 17.7.0 stamp still derives 18, and an 18.x stamp stays on 18', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0', '18.0.0', '18.1.0'], 18); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + expect(removalMajor('18.0.0', facts)).toBe(18); + expect(removalMajor('18.1.0', facts)).toBe(18); + }); + + it('on the 17.x line, where a retirement shipped as a minor, the newest stamp derives 17', () => { + const facts = at(['17.4.0', '17.5.0'], 17); + expect(removalMajor('17.5.0', facts)).toBe(17); + }); + + it('the tree\'s own reading: the newest published stable is at least every census release', () => { + for (const r of CENSUS.releases) expect(cmp(NEWEST_STABLE, r.version)).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/spec/src/system/email-template-category-is-system-retirement.test.ts b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts index 0faf811b5bc..0c0e0cc564c 100644 --- a/packages/spec/src/system/email-template-category-is-system-retirement.test.ts +++ b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts @@ -68,9 +68,9 @@ const TEMPLATE = { // Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; // the key-first house convention is asserted on the issue message itself below. const CATEGORY_PRESCRIPTION = - /`email_template\.category` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it.*Delete the key\. A template is found by its `name`, so group templates with a dotted namespace prefix instead.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + /`email_template\.category` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — nothing read it.*Delete the key\. A template is found by its `name`, so group templates with a dotted namespace prefix instead.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; const IS_SYSTEM_PRESCRIPTION = - /`email_template\.isSystem` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing enforced it.*Delete the key\. A built-in template is a code package's declaration.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + /`email_template\.isSystem` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — nothing enforced it.*Delete the key\. A built-in template is a code package's declaration.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; const RETIRED = [ { key: 'category', prescription: CATEGORY_PRESCRIPTION, values: ['auth', 'notification', 'workflow', 'marketing', 'custom'] }, diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index d72fca56e99..2df82881ed1 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -80,7 +80,7 @@ export const EMAIL_TEMPLATE_FLOOR_LOCALE = 'en-US'; * still owes. */ const EMAIL_TEMPLATE_CATEGORY_REMOVED = - '`email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + '`email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — ' + 'nothing read it: the Studio listing never offered the filter facet it promised, and delivery ' + 'never depended on it. Delete the key. A template is found by its `name`, so group templates ' + 'with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). ' @@ -88,7 +88,7 @@ const EMAIL_TEMPLATE_CATEGORY_REMOVED = + 'applies the ones it can prove, and you apply the rest by hand.'; const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = - '`email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + '`email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — ' + 'nothing enforced it: no door refused deleting a template that set it. Delete the key. A ' + "built-in template is a code package's declaration: a Studio save overrides it for the " + "environment, and discarding that save restores the package's copy. " diff --git a/packages/spec/src/system/object-storage.test.ts b/packages/spec/src/system/object-storage.test.ts index e6aef1edce8..e92f40f5123 100644 --- a/packages/spec/src/system/object-storage.test.ts +++ b/packages/spec/src/system/object-storage.test.ts @@ -57,6 +57,7 @@ describe('StorageScopeSchema', () => { expect(result.success).toBe(false); const message = result.error!.issues[0]!.message; expect(message).toMatch(/`public` was removed from `StorageScope`.*ADR-0049/s); + expect(message).toContain('in @objectstack/spec 18 (ADR-0049'); expect(message).toContain("acl: 'public_read'"); expect(message).toContain('ADR-0104'); }); diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index 1d6355eea64..9d117fd66c5 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -44,7 +44,7 @@ import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; // no authored source to rewrite. Module-private and written with `//`, never // `/** */`: prose an enum's error map consumes, not documented surface. const STORAGE_SCOPE_PUBLIC_RETIRED = - '`public` was removed from `StorageScope` in @objectstack/spec 17.8.0 (ADR-0049 ' + '`public` was removed from `StorageScope` in @objectstack/spec 18 (ADR-0049 ' + 'enforce-or-remove) — no storage scope ever made a file publicly readable. A file is ' + 'served without sign-in only when its stored file record carries `acl: \'public_read\'` ' + '(ADR-0104), the one opt-in for anonymous download. Name another scope, or omit `scope` ' diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts index e2084ef0129..dd572cd52fa 100644 --- a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -185,7 +185,7 @@ describe("a page action row's executor key refuses `'navigation'`, the runner's const issues = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: '/apps/crm/tasks' })); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); expect(firstSentence(issues[0].message)).toBe( - `${row.navigationSubject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + `${row.navigationSubject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.`, ); expect(issues[0].message).toContain(`Write \`${row.executorKey}: 'url'\``); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 34c8b368411..fb3ad73747a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3107,7 +3107,7 @@ const elementFlatBindingRetired = ( : type === 'element:number' && key === 'filter' ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; - return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — ` + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + `${why}. Use \`dataSource.${key}\` on the component ` + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` @@ -4088,7 +4088,7 @@ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: code: 'custom', path: [executorKey], message: - `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling. ` + `${subject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling. ` + `Write \`${executorKey}: 'url'\`: on a page action row the alias runs the same navigator on the same ` + '`target` and `openIn`, so nothing else changes, and a `\'url\'` row\'s `target` is judged by the url ' + 'scheme set.', @@ -5464,7 +5464,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * a record-form value it moves is converted at `filter` like any other. */ defaultFilters: retiredKey( - '`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ' + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index 6ff894e35ee..ca058b821ec 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -124,7 +124,7 @@ describe('the element tombstones — refused at the key, with the prescription', expect(issues[0]!.path).toEqual([key]); const message = issues[0]!.message; // House convention 1 + 2: the qualified key opens it, then the release and ADR. - expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — `)).toBe(true); + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); // The live mechanism, named at its own key on the node. expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); @@ -218,7 +218,7 @@ describe('`object-grid.defaultFilters` — refused at the key, with the prescrip expect(at).toHaveLength(1); expect(at[0]!.code).toBe('invalid_type'); expect(at[0]!.path).toEqual(['defaultFilters']); - expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); expect(at[0]!.message).toContain('Use `filter`.'); expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); }); diff --git a/packages/spec/src/ui/view-data-api-provider-retirement.test.ts b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts index 5dd253265be..943bf800d58 100644 --- a/packages/spec/src/ui/view-data-api-provider-retirement.test.ts +++ b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts @@ -56,7 +56,7 @@ import { FormViewSchema, ListViewSchema, ViewDataSchema, ViewSchema } from './vi // The bare major, the house form of every prescription added since the v18 // line opened (Changesets pre mode: the first release carrying this removal // is `18.0.0-next.N`, so a minted minor would name a version that never ships). -const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 17 \(ADR-0049/; +const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/; /** The spelling that used to be legal, in its fullest form. */ const RETIRED_SOURCE = { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index d91bfa369ae..07909fa7455 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -128,6 +128,7 @@ describe('ViewDataSchema', () => { const issue = result.error!.issues.find((i) => i.path.join('.') === 'provider'); expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); expect(issue!.message).toMatch(/`view\.data\.provider: 'api'` was removed/); + expect(issue!.message).toContain('was removed in @objectstack/spec 18 ('); }); it('should accept value provider with static items', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 31f279c275e..1c8a4fbbb9a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -190,7 +190,7 @@ export type { HttpMethodSubset } from '../shared/http.zod'; * `view-data-api-provider-retired` carries the judgement an upgrader owes. */ const VIEW_DATA_API_PROVIDER_RETIRED = - "`view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — " + "`view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — " + 'a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call ' + 'written there was stored with the view, served to every client that read it and sent by the ' + "console as written; a metadata-declared API call now has one door, the page element's binding, "