Skip to content

Commit 36281b5

Browse files
committed
Merge origin/main into claude/issue-20752-stage4-verify-plugin-strings
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
2 parents 29a50b2 + d6d6e87 commit 36281b5

29 files changed

Lines changed: 1039 additions & 26 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/client': minor
4+
'@objectstack/cli': patch
5+
---
6+
7+
feat(spec,client)!: `ActionSchema` gains `outcomeMessages` — success copy per closed handler `outcome`, interpolating `${result.*}` — and `client.environments.delete` no longer guarantees `message` on its archive answer (#21095)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient) the one narrowing in this changeset is the declared RETURN type of `ObjectStackClient.environments.delete`, a published SDK method of an exported class: its archive answer's `message` becomes optional, beside a new optional `outcome`. That class is not a Zod schema, not a spec contract, not an object definition and not referenced by one, so `objectstack migrate meta` has nothing to rewrite, and the compiler at the consumer's own read is the channel that reaches every affected consumer. The spec half is a widening only (a new optional key, refused solely where it would be inert), so no stored metadata changes meaning and no ADR-0087 entry is owed for it. The other categories are closed on facts: all three packages publish (not `unpublished`); no ADR-0087 id covers a client return type and this diff adds none (not `registered` / `already-registered`); and the diff touches `packages/spec`, which `type-surface-only` refuses. -->
12+
13+
**BREAKING for TypeScript readers of `client.environments.delete`'s archive answer**: `message` is now `message?: string`. Code that assigns it to a `string` stops compiling at that read. Nothing else in the SDK changes, and the request is unchanged.
14+
15+
**`ActionSchema.outcomeMessages`** (`@objectstack/spec/ui`). A server-executing action can succeed in more than one way: an environment delete archives, finds the environment already archived, defers a purge, or destroys it. One static `successMessage` cannot say which of these happened. The handler now reports a closed `outcome` fact in its success payload, and the action declares the copy for each outcome:
16+
17+
```ts
18+
outcomeMessages: {
19+
archived: 'Environment ${result.environmentId} archived.',
20+
already_archived: 'Environment ${result.environmentId} was already archived.',
21+
}
22+
```
23+
24+
- Keys are snake_case outcome names; values are `I18nLabel`s. A key that is not snake_case is refused at its own path (`invalid_key`).
25+
- The key is valid on `type: 'api'` and `type: 'script'` actions only, the two types with a success payload that can carry an `outcome`. It is refused with a prescription on `url` / `modal` / `flow` / `form`, beside `resultDialog` (which suppresses the success toast), and beside `operation: 'update'` (no handler, so no outcome).
26+
- `successMessage` and each outcome message may interpolate `${result.*}`, the server-response scope `onSuccess.navigate` already declares. This is not a new dialect.
27+
- The console picks `outcomeMessages[result.outcome]`, falls back to `successMessage`, and then to its default text. The console does not read it yet. Until it does, the key is accepted, validated, translated and extracted, and the liveness ledger grades it `planned`, so `os lint` tells an author who writes it that it is not shown yet.
28+
29+
**Translation.** `TranslationData` carries the copy beside `successMessage`: `objects.OBJECT._actions.ACTION.outcomeMessages.OUTCOME` and `globalActions.ACTION.outcomeMessages.OUTCOME`. Outcome keys there are snake_case too. `translateAction` overlays them per outcome (object-scoped first, then global) and only for outcomes the action declares. `os i18n extract` emits one key per declared outcome.
30+
31+
**`client.environments.delete`** (`@objectstack/client`). The control plane is replacing its English `message` with the closed `outcome` fact: the server returns facts, and the console composes the message in the user's locale. The archive answer declares `outcome?: 'archived' | 'already_archived' | 'purge_deferred'`, and the teardown answer declares `outcome?: 'destroyed'`. Both `outcome` and `message` are optional, because a 200 may carry either one or both depending on which control-plane release answers. To learn what happened, read `deleted` and `purgeDeferred`, which every answer still carries, or `outcome` when it is present.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an object declares which of its fields is the record's picture — `imageField`, beside `nameField`
6+
7+
Clause-②: yes (widening)
8+
9+
`ObjectSchema` accepts one more optional key, `imageField`. It names the field
10+
whose value is the record's picture, the way `nameField` names the field that is
11+
the record's name: one object-level declaration, read by the record page header
12+
(the record chrome every record detail page shares) — not a per-page
13+
`page:header` prop.
14+
15+
```ts
16+
defineStack({
17+
objects: [{
18+
name: 'crm_account',
19+
nameField: 'name',
20+
imageField: 'logo',
21+
fields: {
22+
name: Field.text({ label: 'Name' }),
23+
logo: Field.image({ label: 'Logo' }),
24+
},
25+
}],
26+
});
27+
```
28+
29+
- **What it may name.** A field of the same object whose type is `image` or
30+
`avatar`. A name the object does not declare, or a field of any other type
31+
(a `text` URL column, a `file`), is refused at parse with an issue at
32+
`imageField` that names the two accepted types — so `defineStack`,
33+
`ObjectSchema.create()`, `os validate` and the metadata save door
34+
(`422 INVALID_METADATA`) all refuse it.
35+
- **An empty field.** The contract the reader is held to: a record whose
36+
picture field is empty shows no picture — no initials or placeholder in its
37+
place.
38+
- **Who draws it.** No renderer reads the key yet. The record chrome in
39+
`@object-ui/components` is the reader to come, and until it lands an authored
40+
`imageField` is accepted, stored and served but nothing draws it. It takes
41+
effect when that renderer ships, with no re-authoring. The liveness ledger
42+
records the key as `planned`.
43+
44+
Nothing that parsed before is refused: the key is new, and an object that does
45+
not set it is unchanged.

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const Account = ObjectSchema.create({
6868
| :--- | :--- | :--- | :--- |
6969
| `nameField` | `string` | optional | The stored field used as the record display name, e.g. `'name'` or `'title'` (ADR-0079). The deprecated alias `displayNameField` is still accepted. |
7070
| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence |
71+
| `imageField` | `string` | optional | The record's picture: names a field of this object whose type is `image` or `avatar` — any other name is refused when the object is validated or published. It is the one object-level declaration the record page header is to draw beside the title once the renderer reads it; no renderer draws it yet. |
7172
| `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout` — the old spelling was retired and is now rejected) |
7273
| `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) |
7374

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ Metadata query with filtering, sorting, and pagination
960960
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
961961
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
962962
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
963+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
963964
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
964965
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
965966
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |

‎content/docs/references/data/object.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ const result = ApiMethod.parse(data);
164164
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
165165
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
166166
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
167+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
167168
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
168169
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
169170
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
@@ -459,7 +460,8 @@ const result = ApiMethod.parse(data);
459460
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
460461
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
461462
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
462-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
463+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
464+
| **outcomeMessages** | `Record<string, string \| Record<string, string>>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). |
463465
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
464466
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
465467
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ const result = MetadataBulkResultSchema.parse(data);
330330
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
331331
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
332332
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
333-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
333+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
334+
| **outcomeMessages** | `Record<string, string \| Record<string, string>>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). |
334335
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
335336
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
336337
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |

0 commit comments

Comments
 (0)