Skip to content
Merged
31 changes: 31 additions & 0 deletions .changeset/21005-action-row-endpoint-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
---

feat(spec)!: `action:button` / `action:icon` refuse `endpoint` with the rename `ActionSchema` already prescribes — `endpoint` → `target` (#21005)

**BREAKING** — `endpoint` on an `action:button` or `action:icon` component (`ActionButtonProps`, `ActionIconProps`) is no longer a declared key. `ActionSchema` has always refused `endpoint` with "Did you mean `endpoint` → `target`?", while these two rows accepted it. objectui's console registers its own `api` handler, which reads `target` and never `endpoint`, so an `api` button written with `endpoint` passed the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table the action and both rows share. Write the endpoint as `target`.

Clause-②: yes (narrowing)

## FROM → TO

| you wrote (17.5 and earlier) | write instead |
| --- | --- |
| `{ type: 'action:button', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:button', properties: { actionType: 'api', target: '/api/v1/x' } }` |
| `{ type: 'action:icon', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:icon', properties: { actionType: 'api', target: '/api/v1/x' } }` |
| `endpoint` on a block with no `actionType` | add `actionType: 'api'` and rename `endpoint` to `target` |

**The one-line fix:** rename `endpoint` to `target` in the block's `properties`; the value (the URL the `api` action calls) is unchanged.

**What an author who still writes it sees.** A page is never refused for it: a page component's `properties` is an open bag, so `definePage()`, `defineStack({ pages })` and the page write door accept the page as before. `os validate` / `os build` / `os lint` report `component-props-unknown-key` as a warning at `properties.endpoint`, with the rename "Did you mean `endpoint` → `target`?" — the same clause `ActionSchema` prints. The two rows also stop answering `path` with the edit-distance guess `patch` (the declarative write's field values): `url`, `endpoint`, `path` and `href` all rename to `target`, on the action and on both blocks alike. A typed `ActionButtonProps` / `ActionIconProps` input fails `tsc` at `endpoint`.

## The migration kit

- **The D2 conversion `action-block-endpoint-to-target`** (protocol 18, retired from the load path) renames `endpoint` to `target` on an `action:button` / `action:icon` whose `actionType` is `api`, the one meaning the key declared, with one notice per block. It reaches blocks in regions, nested in a container's `children`, and in a slotted page's named slots, so a stored `page` row or a built artifact that carries the key loads with `target` through the rehydration seams, which replay it. An already-present `target` wins: a twin with the same value is dropped. A block with no `actionType`, another `actionType`, a non-string `endpoint`, or a `target` that names a different endpoint is left as stored and reported as a TODO. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.
- **The D3 entry `action-block-endpoint-spelling-retired`** names what the rename cannot decide: the TODO sites above, and code — a custom action handler that read `endpoint` off the action reads nothing once the block carries `target`.
- **No deprecation window**, per the project's startup-stage posture.

Census at landing: no producer in this repository (examples, templates, platform pages, fixtures) or in objectui's examples authors `endpoint` on either block. ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.

<!-- adr-0087: registered action-block-endpoint-to-target, action-block-endpoint-spelling-retired -->
2 changes: 0 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ const result = AIChatWindowProps.parse(data);
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
Expand Down Expand Up @@ -118,7 +117,6 @@ const result = AIChatWindowProps.parse(data);
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
Expand Down
2 changes: 0 additions & 2 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
"ui/ActionButtonProps:confirmText",
"ui/ActionButtonProps:description",
"ui/ActionButtonProps:disabled",
"ui/ActionButtonProps:endpoint",
"ui/ActionButtonProps:errorMessage",
"ui/ActionButtonProps:icon",
"ui/ActionButtonProps:label",
Expand Down Expand Up @@ -103,7 +102,6 @@
"ui/ActionIconProps:confirmText",
"ui/ActionIconProps:description",
"ui/ActionIconProps:disabled",
"ui/ActionIconProps:endpoint",
"ui/ActionIconProps:errorMessage",
"ui/ActionIconProps:icon",
"ui/ActionIconProps:label",
Expand Down
147 changes: 147 additions & 0 deletions packages/spec/src/conversions/action-block-endpoint-to-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { ActionButtonPropsSchema, ActionIconPropsSchema } from '../ui/component.zod.js';
import { applyConversions } from './apply.js';
import { ALL_CONVERSIONS } from './registry.js';
import { applyConversionsToStoredItem } from './stored.js';
import type { ConversionNotice, ConversionTodoNotice } from './types.js';

/**
* [#21005] `action-block-endpoint-to-target` — the D2 half of refusing
* `endpoint` on the `action:button` / `action:icon` rows.
*
* The fixture pair in `conversions.test.ts` already proves before → after over
* the whole table. What this file pins:
*
* - the stored-row seam REWRITES a stored `endpoint` to `target` on both
* blocks (the entry is `retiredFromLoadPath`, so only data-at-rest seams
* and `os migrate meta` apply it), and the result parses against the row
* that now refuses `endpoint`;
* - the authoring funnel does NOT replay it — an author meets the rows'
* rename instead;
* - every site with no lossless rewrite is left byte-identical and reported
* as a TODO, never converted and never silent;
* - idempotence, and copy-on-write identity for a page with nothing to do.
*/
const ID = 'action-block-endpoint-to-target';

/** A stored `page` row carrying one block in a region. */
const pageWith = (component: Record<string, unknown>) => ({
name: 'ops_console',
regions: [{ name: 'main', components: [component] }],
});

/** The one block of a converted {@link pageWith} row. */
const blockOf = (page: unknown) =>
(page as { regions: { components: Record<string, unknown>[] }[] }).regions[0]!.components[0]!;

/** Replay the stored chain over `page`, collecting this entry's notices and TODOs. */
function replayStored(page: Record<string, unknown>) {
const notices: ConversionNotice[] = [];
const todos: ConversionTodoNotice[] = [];
const out = applyConversionsToStoredItem('page', page, {
onNotice: (n) => notices.push(n),
onTodo: (t) => todos.push(t),
});
return {
out,
notices: notices.filter((n) => n.conversionId === ID),
todos: todos.filter((t) => t.conversionId === ID),
};
}

describe('[#21005] action-block-endpoint-to-target (ADR-0087 D2)', () => {
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);
});

it.each([
['action:button', ActionButtonPropsSchema, { label: 'Sync now' }],
['action:icon', ActionIconPropsSchema, { icon: 'refresh-cw' }],
] as const)('the stored-row seam rewrites a stored `endpoint` to `target` on `%s`', (type, schema, rest) => {
const props = { ...rest, actionType: 'api', endpoint: '/api/v1/ops/sync', method: 'POST' };
// Before: the row refuses the stored props — a pre-#21005 row would be
// badged by the props gate without the replay.
expect(schema.safeParse(props).success).toBe(false);
const { out, notices, todos } = replayStored(pageWith({ type, properties: props }));
const block = blockOf(out);
expect(block.properties).toEqual({ ...rest, actionType: 'api', target: '/api/v1/ops/sync', method: 'POST' });
expect(schema.safeParse(block.properties).success).toBe(true);
expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([
['endpoint', 'target', 'pages[0].regions[0].components[0].properties.target'],
]);
expect(todos).toEqual([]);
});

it('a redundant twin is dropped; a disagreeing pair is kept and reported as a TODO', () => {
const twin = replayStored(pageWith({
type: 'action:icon',
properties: { icon: 'x', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/a' },
}));
expect(blockOf(twin.out).properties).toEqual({ icon: 'x', actionType: 'api', target: '/api/v1/a' });
expect(twin.notices).toHaveLength(1);

const pair = pageWith({
type: 'action:button',
properties: { label: 'Both', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/b' },
});
const disagreeing = replayStored(pair);
expect(disagreeing.out, 'left exactly as stored').toEqual(pair);
expect(disagreeing.notices).toEqual([]);
expect(disagreeing.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.endpoint']);
});

it.each([
['no `actionType`', { label: 'Legacy', endpoint: '/api/v1/legacy' }, /actionType: 'api'/],
['another `actionType`', { label: 'Open', actionType: 'url', endpoint: '/x' }, /"url"/],
['a non-string `endpoint`', { label: 'Cfg', actionType: 'api', endpoint: { url: '/x' } }, /not a string/],
])('%s: no lossless rewrite — left as stored and reported as a TODO', (_label, properties, reason) => {
const page = pageWith({ type: 'action:button', properties });
const { out, notices, todos } = replayStored(page);
expect(out).toEqual(page);
expect(notices).toEqual([]);
expect(todos).toHaveLength(1);
expect(todos[0]!.path).toBe('pages[0].regions[0].components[0].properties.endpoint');
expect(todos[0]!.reason).toMatch(reason);
});

it('control: `endpoint` on another block type is neither converted nor reported', () => {
const page = pageWith({ type: 'element:text', properties: { endpoint: '/not/an/action' } });
const { out, notices, todos } = replayStored(page);
expect(out).toEqual(page);
expect(notices).toEqual([]);
expect(todos).toEqual([]);
});

it('control: a canonical page comes back as the SAME reference, with no notice', () => {
const clean = { pages: [pageWith({ type: 'action:button', properties: { label: 'Run', actionType: 'api', target: '/api/v1/run' } })] };
const notices: ConversionNotice[] = [];
const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) });
expect(out, 'nothing to rename ⇒ copy-on-write returns the input').toBe(clean);
expect(notices.filter((n) => n.conversionId === ID)).toEqual([]);
});

it('is idempotent — the converted result replays to itself with no second notice', () => {
const once = applyConversions(
{ pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'api', endpoint: '/api/v1/go' } })] },
{ 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 authoring funnel does not replay it — an author meets the rows\' rename instead', () => {
const authored = { pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'api', endpoint: '/api/v1/go' } })] };
const notices: ConversionNotice[] = [];
const out = applyConversions(authored, { onNotice: (n) => notices.push(n) });
expect(out).toBe(authored);
expect(notices.filter((n) => n.conversionId === ID)).toEqual([]);
});
});
Loading
Loading