@ultimat3/action 12.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -21,6 +21,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
21
21
  | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
22
22
  | `openapi.ts` | deterministic OpenAPI 3.1 document |
23
23
  | `client.ts` | typed RPC client (browser-safe: no server imports) |
24
+ | `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
25
+ | `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate |
24
26
  | — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
25
27
  | `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` |
26
28
  | `mcp-tool.ts` | MCP descriptor, same `invoke` |
@@ -44,6 +46,34 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
44
46
 
45
47
  ## Invariants
46
48
 
49
+ - **`X_INPUT_INVALID` carries the rejections TWICE, and they are one value.** The flattened line
50
+ stays in `cause` — it is what an operator reads in a log and what a non-form caller sees — and
51
+ `meta.issues` carries the same list structured, so a client rebuilding a form knows WHICH field
52
+ each rejection belongs to instead of splitting a string on `'; '` and guessing. `validate.ts` is
53
+ the one caller that passes both, and `validate.test.ts` pins `cause` to
54
+ `formatIssues(issues).join('; ')`; the rendering deliberately does NOT happen inside
55
+ `InputInvalidError`, because that module is reachable from browser-safe `client.ts` and
56
+ `@ultimat3/schema` declares no `sideEffects`, so a value import of `formatIssues` there would drag
57
+ that package's whole barrel into every bundle holding the typed client.
58
+ - **`toValidationIssues`, never a library's raw issues.** A conforming schema library's issue object
59
+ may carry members Ultimate's shape does not — including the rejected VALUE — and this list is
60
+ handed to an HTTP surface that returns it to the caller. Four members travel. The same rule on the
61
+ way back in: `issuesFromWire` REBUILDS each entry member by member rather than copying it.
62
+ - **`X_OUTPUT_INVALID` keeps the line alone.** An output rejection is a server defect whose remedy
63
+ is a code change; no client can act on a per-field list, and shipping the handler's internal
64
+ projection to a caller is new surface for nothing.
65
+ - **An `issues` list off the wire is all-or-nothing.** A partly-parsed list would DROP the entries
66
+ it could not read, and a caller that finds `meta.issues` uses it INSTEAD of `cause` — so a dropped
67
+ entry is a rejection the user never hears about. `MAX_WIRE_ISSUES` bounds it, because whoever
68
+ displays the list renders it into a DOM.
69
+ - **`transition()` is a factory, not a primitive, and it decides nothing about the machine.** It
70
+ returns a `mutator`, so every projection is inherited rather than re-declared, and it holds no
71
+ legality rule: `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
72
+ `@ultimat3/entity`'s and propagate untouched. `from` is REQUIRED — it is the UPDATE's predicate,
73
+ which is what makes the refusal free; defaulting or inferring it is the lost update coming back.
74
+ `conflict: 'server-wins'` is fixed (the server is the half that refused), and `audit` is OFF
75
+ unless declared (`audit: true` with no sink is `X_AUDIT_SINK_MISSING` before the input parse, so
76
+ defaulting it on would hold the factory hostage to an unrelated decision).
47
77
  - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse
48
78
  output. Adding a second execution path is the one unforgivable change here.
49
79
  - **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `invoke` entered
package/README.md CHANGED
@@ -240,6 +240,53 @@ the core, because it never leaves the client; keep it a pure function of `(tx, i
240
240
  SQLite). Type your tables once: `declare module '@ultimat3/action' { interface
241
241
  LocalTables { posts: PostRow } }`.
242
242
 
243
+ ## `transition()` — a mutator factory over a state machine
244
+
245
+ `As of 2026-08-24`. A move through an entity column's state machine is a server-authoritative write
246
+ with an input schema, an output schema and a policy — which is what a `mutator` already is. So
247
+ `transition()` **returns one**, and the move inherits the route, the OpenAPI operation, the typed
248
+ client, the MCP tool, the job handle and its `PRIMITIVE_FACTORIES` row. It is not a ninth primitive
249
+ and it declares no error code of its own.
250
+
251
+ ```ts
252
+ import { t, transition, type TransitionTarget } from '@ultimat3/action';
253
+ import type { Ctx } from '@ultimat3/core';
254
+ import { can } from '@ultimat3/policy';
255
+
256
+ const ORDER_STATES = ['pending', 'paid', 'shipped'] as const;
257
+ type OrderState = (typeof ORDER_STATES)[number];
258
+
259
+ const OrderView = t.object({ id: t.uuid, status: t.enum(ORDER_STATES) });
260
+
261
+ // `@ultimat3/entity`'s `orders(ctx)`: a real `Table` satisfies the seam as written.
262
+ declare function orders(ctx: Ctx): TransitionTarget<{ id: string; status: OrderState }, OrderState>;
263
+ declare const id: string;
264
+ declare const ctx: Ctx;
265
+
266
+ export const moveOrder = transition({
267
+ table: (ctx) => orders(ctx), // the request's table — tenant-scoped like every write
268
+ column: 'status', // the column whose enumerated().transitions() IS the machine
269
+ states: ORDER_STATES, // typed against the row: a state it cannot hold is a compile error
270
+ localTable: 'orders', // what the optimistic twin patches
271
+ output: OrderView,
272
+ policy: can('order:move'),
273
+ });
274
+
275
+ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
276
+ ```
277
+
278
+ | Rule | Why |
279
+ |---|---|
280
+ | **`from` is required, and never defaulted or inferred** | it rides in the UPDATE's own predicate, so the state observed and the state written are one decision under the row's lock. Measured on the mechanism underneath: twenty concurrent moves at one row gave 14 winners with a read-then-check-then-write and **1 winner plus 19 refusals** with `from` in the predicate. Anything that supplies `from` for the caller is the lost update coming back |
281
+ | the states are the **input schema**, not a `t.string` | the union survives into `InferOutput`, so the typed client refuses a typo at **compile** time, the MCP tool's `inputSchema` and the OpenAPI component both publish the legal set, and a bad state is `X_INPUT_INVALID` before a database is touched |
282
+ | `conflict: 'server-wins'`, not overridable | the server is the half that REFUSED the move; a local twin winning the rebase would leave the client showing a state the database rejected |
283
+ | `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
284
+ | `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
285
+
286
+ `table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no
287
+ dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do
288
+ not — and a real `Table` satisfies the seam as written.
289
+
243
290
  ## Determinism + idempotency
244
291
 
245
292
  `serializeOpenApi(buildOpenApi())` sorts keys at every depth, iterates the registry
@@ -548,7 +595,7 @@ never a pass — the assertion says which code got in the way and names `input:`
548
595
  | `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
549
596
  | `X_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely. Owned by `@ultimat3/http`, which owns the conversion | make both positive, or delete the block |
550
597
  | `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
551
- | `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
598
+ | `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions describe <name> --json` |
552
599
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
553
600
  | `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
554
601
  | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
@@ -570,6 +617,12 @@ server's own `docs`/`type` when it sent an `http(s)` one, this build's registere
570
617
  knows the code, otherwise the error index. A per-code URL is never synthesized for a code
571
618
  nothing here declares.
572
619
 
620
+ A document carrying an `issues` member arrives parsed as well: `meta.issues`, read by
621
+ `issuesFromWire` — a wire value, so the list is rebuilt member by member and a list this build
622
+ cannot read is dropped whole rather than half-kept, leaving `cause` (which still holds every
623
+ rejection) as the answer. It is exported for the island that posts with a plain `fetch` and holds
624
+ the body itself.
625
+
573
626
  ## Boundaries
574
627
 
575
628
  Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "12.0.0",
3
+ "version": "14.0.0",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,10 +34,10 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/cache": "12.0.0",
38
- "@ultimat3/core": "12.0.0",
39
- "@ultimat3/http": "12.0.0",
40
- "@ultimat3/policy": "12.0.0",
41
- "@ultimat3/schema": "12.0.0"
37
+ "@ultimat3/cache": "14.0.0",
38
+ "@ultimat3/core": "14.0.0",
39
+ "@ultimat3/http": "14.0.0",
40
+ "@ultimat3/policy": "14.0.0",
41
+ "@ultimat3/schema": "14.0.0"
42
42
  }
43
43
  }
package/src/client.ts CHANGED
@@ -16,6 +16,7 @@ import type { Action } from './action';
16
16
  import { ContractDriftError, RemoteActionError, RpcFailedError } from './errors';
17
17
  import { derivePath } from './naming';
18
18
  import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
19
+ import { issuesFromWire } from './wire-issues';
19
20
 
20
21
  /**
21
22
  * Loose constraint on purpose: a map of concrete `Action<In, Out>` values must be
@@ -212,6 +213,9 @@ function toUltimateError(text: string, status: number, name: string): UltimateEr
212
213
  action: name,
213
214
  status,
214
215
  code,
216
+ // Parsed, never taken: `body` is whatever answered the request. A list this build cannot read
217
+ // is dropped rather than repaired, and `cause` below still carries every rejection in it.
218
+ issues: issuesFromWire(body['issues']),
215
219
  cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
216
220
  fix: stringOr(body['fix'], `x actions describe ${name} --json`),
217
221
  // RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
package/src/errors.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  UltimateError,
14
14
  } from '@ultimat3/core';
15
15
  import type { SurfaceDenial } from '@ultimat3/policy';
16
+ import type { ValidationIssue } from '@ultimat3/schema';
16
17
 
17
18
  // Re-exported, not re-declared: the five idempotency failures moved to their own file when this
18
19
  // one reached the line ceiling, and every importer still reads them from `./errors`.
@@ -177,12 +178,32 @@ export class ActionPolicyMissingError extends UltimateError {
177
178
  }
178
179
 
179
180
  export class InputInvalidError extends UltimateError {
180
- constructor(name: string, detail: string) {
181
+ /**
182
+ * The rejections, addressed by path — `undefined` where the caller had only text.
183
+ *
184
+ * A `cause` is one line for a human and an agent to read; a client that renders a form needs to
185
+ * know WHICH field each rejection belongs to, and splitting the line back apart is guesswork the
186
+ * moment a message contains the separator. Both travel: the line is unchanged, and this is a
187
+ * structured channel beside it.
188
+ */
189
+ readonly issues: readonly ValidationIssue[] | undefined;
190
+
191
+ /**
192
+ * `detail` is the rendered form of `issues` and must stay so — `formatIssues(issues).join('; ')`,
193
+ * which is what `validate.ts` (the one caller that passes both) does, and what `validate.test.ts`
194
+ * pins. The rendering is NOT done here on purpose: this module is reachable from `client.ts`,
195
+ * which is browser-safe, and `@ultimat3/schema` declares no `sideEffects`, so a value import of
196
+ * `formatIssues` here would pull that package's whole barrel into every browser bundle holding
197
+ * the typed client.
198
+ */
199
+ constructor(name: string, detail: string, issues?: readonly ValidationIssue[]) {
181
200
  super({
182
201
  code: 'X_INPUT_INVALID',
183
202
  cause: `input for action "${name}" failed validation: ${detail}`,
184
203
  fix: `x actions describe ${name} --json # prints the expected input schema`,
204
+ ...(issues === undefined ? {} : { meta: { issues } }),
185
205
  });
206
+ this.issues = issues;
186
207
  }
187
208
  }
188
209
 
@@ -233,6 +254,12 @@ export interface RemoteFailure {
233
254
  * a `javascript:` in the preferred slot cannot suppress a usable link behind it.
234
255
  */
235
256
  readonly docs?: readonly (string | undefined)[] | undefined;
257
+ /**
258
+ * The per-field rejections the document carried, already parsed — `issuesFromWire`'s answer,
259
+ * never the raw member. `undefined` where the body had none or where it had one this build
260
+ * refuses to read, and in both cases `cause` still holds every rejection.
261
+ */
262
+ readonly issues?: readonly ValidationIssue[] | undefined;
236
263
  }
237
264
 
238
265
  /** A link, not a string the server happened to put in a field the overlay renders as an href. */
@@ -286,7 +313,14 @@ export class RemoteActionError extends UltimateError {
286
313
  // `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
287
314
  // `terminal` on the one field the framework promises a client never has to infer.
288
315
  retry: retryForStatus(failure.code, failure.status),
289
- meta: { origin: 'remote', action: failure.action, status: failure.status },
316
+ meta: {
317
+ origin: 'remote',
318
+ action: failure.action,
319
+ status: failure.status,
320
+ // Absent rather than `undefined`: `meta` is rendered into `--json` and the error reporter,
321
+ // and a null member reads as "the server sent an empty list" rather than "it sent none".
322
+ ...(failure.issues === undefined ? {} : { issues: failure.issues }),
323
+ },
290
324
  });
291
325
  this.status = failure.status;
292
326
  }
package/src/index.ts CHANGED
@@ -233,3 +233,23 @@ export {
233
233
  registerActions,
234
234
  resetRegistry,
235
235
  } from './registry';
236
+ /**
237
+ * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
238
+ * a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
239
+ * handle and its manifest row. The machine itself is `@ultimat3/entity`'s — this package owns the
240
+ * projection, not the legality rule.
241
+ */
242
+ export type {
243
+ TransitionDef,
244
+ TransitionInput,
245
+ TransitionTarget,
246
+ TransitionValues,
247
+ } from './transition';
248
+ export { transition } from './transition';
249
+ /**
250
+ * The one reader of a problem document's `issues` member. Exported because the typed client is not
251
+ * the only caller that meets one: an island that posts with a plain `fetch` — which is what
252
+ * `x g resource` emits, to keep this package out of its chunk — holds the parsed body itself and
253
+ * would otherwise write a second, unvalidated reader.
254
+ */
255
+ export { issuesFromWire, MAX_WIRE_ISSUES } from './wire-issues';
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `transition()` — a MUTATOR factory over one entity column's state machine. Not a ninth primitive:
3
+ * a move is a server-authoritative write with an input schema, an output schema and a policy, which
4
+ * is what a `mutator` already is, so this RETURNS one and inherits the route, the OpenAPI operation,
5
+ * the typed client, the MCP tool, the job handle and its manifest row.
6
+ *
7
+ * It lives here and not in `@ultimat3/entity` because `mutator()` is tier 3 and entity is tier 2 —
8
+ * the same relationship `search()` has to `@ultimat3/query`. The mechanism underneath is entity's:
9
+ * this file makes no legality decision and answers no refusal of its own.
10
+ */
11
+
12
+ import type { Ctx } from '@ultimat3/core';
13
+ import type {
14
+ InferOutput,
15
+ ObjectSchema,
16
+ Schema,
17
+ StandardSchemaV1,
18
+ StringSchema,
19
+ } from '@ultimat3/schema';
20
+ import { t } from '@ultimat3/schema';
21
+ import { type LocalRow, type Mutator, mutator } from './mutator';
22
+ import type { ActionPolicy } from './policy-gate';
23
+
24
+ /**
25
+ * The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition`
26
+ * satisfies it as written. Structural and not an import because `@ultimat3/action` holds no
27
+ * dependency edge on `@ultimat3/entity` — the tier table permits one (2 is below 3), the manifest
28
+ * and the lockfile do not — the same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down.
29
+ *
30
+ * `id` is a plain `string` rather than entity's `IdOf<Row>`: that alias "collapses to `string` for
31
+ * every unbranded entity" by its own account, and a branded one still satisfies this because a
32
+ * method's parameters compare bivariantly. The input schema mints a `string`, so declaring anything
33
+ * narrower here would buy a cast and nothing else.
34
+ */
35
+ /**
36
+ * The input every transition takes, spelled once: the row, the state the caller believes it is in,
37
+ * and the state it wants. Named because it is what the typed client and the MCP tool are typed by.
38
+ */
39
+ /** The parsed input, spelled concretely — what `TransitionInput<S>` reduces to at every call site. */
40
+ export interface TransitionValues<S extends string> {
41
+ readonly id: string;
42
+ readonly from: S;
43
+ readonly to: S;
44
+ }
45
+
46
+ export type TransitionInput<S extends string> = ObjectSchema<{
47
+ readonly id: StringSchema;
48
+ readonly from: Schema<S, S>;
49
+ readonly to: Schema<S, S>;
50
+ }>;
51
+
52
+ export interface TransitionTarget<Row, S extends string> {
53
+ transition(column: string, id: string, move: { readonly from: S; readonly to: S }): Promise<Row>;
54
+ }
55
+
56
+ export interface TransitionDef<
57
+ TOutput extends StandardSchemaV1,
58
+ Row extends InferOutput<TOutput> & object,
59
+ K extends keyof Row & string,
60
+ S extends Row[K] & string,
61
+ > {
62
+ /** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */
63
+ readonly table: (ctx: Ctx) => TransitionTarget<Row, S>;
64
+ /** The column whose `enumerated().transitions()` declaration IS the machine. */
65
+ readonly column: K;
66
+ /**
67
+ * The states, as the input schema. Typed `Row[K]`, so a state the row cannot hold is a compile
68
+ * error here — and every projection inherits the enum: OpenAPI documents the legal set, the MCP
69
+ * tool's `inputSchema` carries it, the typed client refuses a typo at COMPILE time, and a
70
+ * misspelled state is `X_INPUT_INVALID` before the request reaches a database.
71
+ *
72
+ * It is the one thing restated from the column's own declaration, and the reason is a boundary:
73
+ * reading the machine off the entity needs `@ultimat3/entity` as a real dependency of this
74
+ * package. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is the
75
+ * enum in the refusal.
76
+ */
77
+ readonly states: readonly [S, ...S[]];
78
+ /** The local store's name for this entity — what the optimistic twin patches. */
79
+ readonly localTable: string;
80
+ /** The projection the caller gets back. Unknown keys are dropped by the parse, so a `$view` works. */
81
+ readonly output: TOutput;
82
+ readonly policy: ActionPolicy;
83
+ /**
84
+ * OFF unless the app says otherwise, and deliberately not `?? true`.
85
+ *
86
+ * A transition is exactly the kind of event an audit sink is for — and `audit: true` with no sink
87
+ * installed is `X_AUDIT_SINK_MISSING`, raised before the input parse. Defaulting it on would make
88
+ * every `transition()` refuse in an app that has not made a separate, unrelated decision, which is
89
+ * a framework default holding the feature hostage. What the row is kept for, and for how long, is
90
+ * the same compliance question that kept a purge out of `postgresAuditSink`.
91
+ */
92
+ readonly audit?: boolean;
93
+ }
94
+
95
+ /**
96
+ * `from` is REQUIRED and is never defaulted or inferred. It rides in the UPDATE's own predicate, so
97
+ * the state observed and the state written are one decision under the row's lock — optimistic
98
+ * concurrency in the ETag shape. Measured on the mechanism underneath: twenty concurrent moves at
99
+ * one row produced 14 winners with a read-then-check-then-write, and 1 winner plus 19 refusals with
100
+ * `from` in the predicate. Anything that supplies `from` on the caller's behalf is the lost update
101
+ * coming back.
102
+ */
103
+ export function transition<
104
+ TOutput extends StandardSchemaV1,
105
+ Row extends InferOutput<TOutput> & object,
106
+ K extends keyof Row & string,
107
+ const S extends Row[K] & string,
108
+ >(def: TransitionDef<TOutput, Row, K, S>): Mutator<TransitionInput<S>, TOutput> {
109
+ const state = t.enum(def.states);
110
+ // ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is
111
+ // a mapped type over its shape, and a mapped type does not reduce while a type parameter is still
112
+ // open — so `input.id` is unreachable INSIDE this function even though every call site resolves
113
+ // it exactly. `@ultimat3/entity`'s `transitionRow` spells its own patch this way for the same
114
+ // reason. What arrives here has already been parsed by the schema two lines up, and nothing else
115
+ // can reach these two callbacks.
116
+ const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
117
+ return mutator({
118
+ input: t.object({ id: t.uuid, from: state, to: state }),
119
+ output: def.output,
120
+ policy: def.policy,
121
+ ...(def.audit === undefined ? {} : { audit: def.audit }),
122
+ // Never overridable: the server is the half that REFUSED the move, and a local twin that won
123
+ // the rebase would leave the client showing a state the database rejected.
124
+ conflict: 'server-wins',
125
+ local: (tx, raw) => {
126
+ const input = valuesOf(raw);
127
+ // `as Partial<…>`: a computed key widens to an index signature, which is never assignable to
128
+ // a `Partial` of a type parameter. `def.column` is `keyof Row`, so the shape is a real one.
129
+ tx.table<Row & LocalRow>(def.localTable).update(input.id, {
130
+ [def.column]: input.to,
131
+ } as Partial<Row & LocalRow>);
132
+ },
133
+ // No cast on `from`/`to`: they are the enum's own union, which is `Row[K]`. And no legality
134
+ // check here — `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
135
+ // entity's and propagate as they are. A second error class over one failure is a second path.
136
+ server: (ctx, raw) => {
137
+ const input = valuesOf(raw);
138
+ return def.table(ctx).transition(def.column, input.id, {
139
+ from: input.from,
140
+ to: input.to,
141
+ });
142
+ },
143
+ });
144
+ }
package/src/validate.ts CHANGED
@@ -5,9 +5,19 @@
5
5
  */
6
6
 
7
7
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
8
- import { formatIssues, validateAsync } from '@ultimat3/schema';
8
+ import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
9
9
  import { InputInvalidError, OutputInvalidError } from './errors';
10
10
 
11
+ /**
12
+ * The refusal carries the issue list as well as the line, and the two are ONE value rendered twice:
13
+ * `formatIssues` reads `path` and `message`, which is exactly what `toValidationIssues` copied out
14
+ * of the library's own issues, so the string is byte-identical to the one this threw before.
15
+ *
16
+ * `toValidationIssues`, never the raw `result.issues`: a conforming library's issue object may
17
+ * carry members Ultimate's shape does not — including the rejected VALUE — and this list is
18
+ * handed to an HTTP surface that returns it to the caller. Four members travel, and
19
+ * `describeValue` is what keeps a value out of the fifth.
20
+ */
11
21
  export async function validateInput<S extends StandardSchemaV1>(
12
22
  schema: S,
13
23
  raw: unknown,
@@ -15,7 +25,8 @@ export async function validateInput<S extends StandardSchemaV1>(
15
25
  ): Promise<InferOutput<S>> {
16
26
  const result = await validateAsync(schema, raw);
17
27
  if (result.issues !== undefined) {
18
- throw new InputInvalidError(actionName, formatIssues(result.issues).join('; '));
28
+ const issues = toValidationIssues(result.issues);
29
+ throw new InputInvalidError(actionName, formatIssues(issues).join('; '), issues);
19
30
  }
20
31
  return result.value;
21
32
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The one reader of a problem document's `issues` member: an untrusted array off the wire, back
3
+ * into the `ValidationIssue` shape `@ultimat3/schema` already mints. Its own module because it is
4
+ * the only place in the client where a value nobody in this process built is turned into a
5
+ * structure another layer will render.
6
+ */
7
+
8
+ import { stringField } from '@ultimat3/core';
9
+ import type { ValidationIssue } from '@ultimat3/schema';
10
+
11
+ /**
12
+ * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
13
+ * entries are rendered into a DOM by whoever displays them, so the bound is here rather than there.
14
+ */
15
+ export const MAX_WIRE_ISSUES = 100;
16
+
17
+ /**
18
+ * All-or-nothing on purpose. A partly-parsed list would DROP the entries it could not read, and
19
+ * nothing downstream would know: a caller that finds `meta.issues` uses it INSTEAD of the
20
+ * flattened `cause`, so a dropped entry is a rejection the user never hears about. Refusing the
21
+ * whole list leaves the `cause` — which still holds every issue — as the answer.
22
+ */
23
+ export function issuesFromWire(value: unknown): readonly ValidationIssue[] | undefined {
24
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_WIRE_ISSUES) {
25
+ return undefined;
26
+ }
27
+ const issues: ValidationIssue[] = [];
28
+ for (const entry of value as readonly unknown[]) {
29
+ if (typeof entry !== 'object' || entry === null) return undefined;
30
+ // Strict on the two members that DECIDE where an issue lands, defaulted on the two that only
31
+ // describe it: a `path` that is not a string would bind a rejection somewhere it does not
32
+ // belong, while a missing `expected` cannot mis-route anything.
33
+ const path = stringField(entry, 'path');
34
+ const message = stringField(entry, 'message');
35
+ if (path === undefined || message === undefined || message.length === 0) return undefined;
36
+ // Built member by member, never spread: a foreign issue object may carry the rejected VALUE
37
+ // (some libraries put it in `received`), and a whole-object copy would forward it to whoever
38
+ // renders the list. Four members travel; everything else stops here.
39
+ issues.push({
40
+ path,
41
+ expected: stringField(entry, 'expected') ?? '',
42
+ received: stringField(entry, 'received') ?? '',
43
+ message,
44
+ });
45
+ }
46
+ return issues;
47
+ }