@ultimat3/action
Advanced tools
| /** | ||
| * `transition()` — a MUTATOR factory over one entity column's state machine. Not a ninth primitive: | ||
| * a move is a server-authoritative write with an input schema, an output schema and a policy, which | ||
| * is what a `mutator` already is, so this RETURNS one and inherits the route, the OpenAPI operation, | ||
| * the typed client, the MCP tool, the job handle and its manifest row. | ||
| * | ||
| * It lives here and not in `@ultimat3/entity` because `mutator()` is tier 3 and entity is tier 2 — | ||
| * the same relationship `search()` has to `@ultimat3/query`. The mechanism underneath is entity's: | ||
| * this file makes no legality decision and answers no refusal of its own. | ||
| */ | ||
| import type { Ctx } from '@ultimat3/core'; | ||
| import type { | ||
| InferOutput, | ||
| ObjectSchema, | ||
| Schema, | ||
| StandardSchemaV1, | ||
| StringSchema, | ||
| } from '@ultimat3/schema'; | ||
| import { t } from '@ultimat3/schema'; | ||
| import { type LocalRow, type Mutator, mutator } from './mutator'; | ||
| import type { ActionPolicy } from './policy-gate'; | ||
| /** | ||
| * The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition` | ||
| * satisfies it as written. Structural and not an import because `@ultimat3/action` holds no | ||
| * dependency edge on `@ultimat3/entity` — the tier table permits one (2 is below 3), the manifest | ||
| * and the lockfile do not — the same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down. | ||
| * | ||
| * `id` is a plain `string` rather than entity's `IdOf<Row>`: that alias "collapses to `string` for | ||
| * every unbranded entity" by its own account, and a branded one still satisfies this because a | ||
| * method's parameters compare bivariantly. The input schema mints a `string`, so declaring anything | ||
| * narrower here would buy a cast and nothing else. | ||
| */ | ||
| /** | ||
| * The input every transition takes, spelled once: the row, the state the caller believes it is in, | ||
| * and the state it wants. Named because it is what the typed client and the MCP tool are typed by. | ||
| */ | ||
| /** The parsed input, spelled concretely — what `TransitionInput<S>` reduces to at every call site. */ | ||
| export interface TransitionValues<S extends string> { | ||
| readonly id: string; | ||
| readonly from: S; | ||
| readonly to: S; | ||
| } | ||
| export type TransitionInput<S extends string> = ObjectSchema<{ | ||
| readonly id: StringSchema; | ||
| readonly from: Schema<S, S>; | ||
| readonly to: Schema<S, S>; | ||
| }>; | ||
| export interface TransitionTarget<Row, S extends string> { | ||
| transition(column: string, id: string, move: { readonly from: S; readonly to: S }): Promise<Row>; | ||
| } | ||
| export interface TransitionDef< | ||
| TOutput extends StandardSchemaV1, | ||
| Row extends InferOutput<TOutput> & object, | ||
| K extends keyof Row & string, | ||
| S extends Row[K] & string, | ||
| > { | ||
| /** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */ | ||
| readonly table: (ctx: Ctx) => TransitionTarget<Row, S>; | ||
| /** The column whose `enumerated().transitions()` declaration IS the machine. */ | ||
| readonly column: K; | ||
| /** | ||
| * The states, as the input schema. Typed `Row[K]`, so a state the row cannot hold is a compile | ||
| * error here — and every projection inherits the enum: OpenAPI documents the legal set, the MCP | ||
| * tool's `inputSchema` carries it, the typed client refuses a typo at COMPILE time, and a | ||
| * misspelled state is `X_INPUT_INVALID` before the request reaches a database. | ||
| * | ||
| * It is the one thing restated from the column's own declaration, and the reason is a boundary: | ||
| * reading the machine off the entity needs `@ultimat3/entity` as a real dependency of this | ||
| * package. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is the | ||
| * enum in the refusal. | ||
| */ | ||
| readonly states: readonly [S, ...S[]]; | ||
| /** The local store's name for this entity — what the optimistic twin patches. */ | ||
| readonly localTable: string; | ||
| /** The projection the caller gets back. Unknown keys are dropped by the parse, so a `$view` works. */ | ||
| readonly output: TOutput; | ||
| readonly policy: ActionPolicy; | ||
| /** | ||
| * OFF unless the app says otherwise, and deliberately not `?? true`. | ||
| * | ||
| * A transition is exactly the kind of event an audit sink is for — and `audit: true` with no sink | ||
| * installed is `X_AUDIT_SINK_MISSING`, raised before the input parse. Defaulting it on would make | ||
| * every `transition()` refuse in an app that has not made a separate, unrelated decision, which is | ||
| * a framework default holding the feature hostage. What the row is kept for, and for how long, is | ||
| * the same compliance question that kept a purge out of `postgresAuditSink`. | ||
| */ | ||
| readonly audit?: boolean; | ||
| } | ||
| /** | ||
| * `from` is REQUIRED and is 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 — optimistic | ||
| * concurrency in the ETag shape. Measured on the mechanism underneath: twenty concurrent moves at | ||
| * one row produced 14 winners with a read-then-check-then-write, and 1 winner plus 19 refusals with | ||
| * `from` in the predicate. Anything that supplies `from` on the caller's behalf is the lost update | ||
| * coming back. | ||
| */ | ||
| export function transition< | ||
| TOutput extends StandardSchemaV1, | ||
| Row extends InferOutput<TOutput> & object, | ||
| K extends keyof Row & string, | ||
| const S extends Row[K] & string, | ||
| >(def: TransitionDef<TOutput, Row, K, S>): Mutator<TransitionInput<S>, TOutput> { | ||
| const state = t.enum(def.states); | ||
| // ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is | ||
| // a mapped type over its shape, and a mapped type does not reduce while a type parameter is still | ||
| // open — so `input.id` is unreachable INSIDE this function even though every call site resolves | ||
| // it exactly. `@ultimat3/entity`'s `transitionRow` spells its own patch this way for the same | ||
| // reason. What arrives here has already been parsed by the schema two lines up, and nothing else | ||
| // can reach these two callbacks. | ||
| const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>; | ||
| return mutator({ | ||
| input: t.object({ id: t.uuid, from: state, to: state }), | ||
| output: def.output, | ||
| policy: def.policy, | ||
| ...(def.audit === undefined ? {} : { audit: def.audit }), | ||
| // Never overridable: the server is the half that REFUSED the move, and a local twin that won | ||
| // the rebase would leave the client showing a state the database rejected. | ||
| conflict: 'server-wins', | ||
| local: (tx, raw) => { | ||
| const input = valuesOf(raw); | ||
| // `as Partial<…>`: a computed key widens to an index signature, which is never assignable to | ||
| // a `Partial` of a type parameter. `def.column` is `keyof Row`, so the shape is a real one. | ||
| tx.table<Row & LocalRow>(def.localTable).update(input.id, { | ||
| [def.column]: input.to, | ||
| } as Partial<Row & LocalRow>); | ||
| }, | ||
| // No cast on `from`/`to`: they are the enum's own union, which is `Row[K]`. And no legality | ||
| // check here — `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are | ||
| // entity's and propagate as they are. A second error class over one failure is a second path. | ||
| server: (ctx, raw) => { | ||
| const input = valuesOf(raw); | ||
| return def.table(ctx).transition(def.column, input.id, { | ||
| from: input.from, | ||
| to: input.to, | ||
| }); | ||
| }, | ||
| }); | ||
| } |
| /** | ||
| * The one reader of a problem document's `issues` member: an untrusted array off the wire, back | ||
| * into the `ValidationIssue` shape `@ultimat3/schema` already mints. Its own module because it is | ||
| * the only place in the client where a value nobody in this process built is turned into a | ||
| * structure another layer will render. | ||
| */ | ||
| import { stringField } from '@ultimat3/core'; | ||
| import type { ValidationIssue } from '@ultimat3/schema'; | ||
| /** | ||
| * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The | ||
| * entries are rendered into a DOM by whoever displays them, so the bound is here rather than there. | ||
| */ | ||
| export const MAX_WIRE_ISSUES = 100; | ||
| /** | ||
| * All-or-nothing on purpose. A partly-parsed list would DROP the entries it could not read, and | ||
| * nothing downstream would know: a caller that finds `meta.issues` uses it INSTEAD of the | ||
| * flattened `cause`, so a dropped entry is a rejection the user never hears about. Refusing the | ||
| * whole list leaves the `cause` — which still holds every issue — as the answer. | ||
| */ | ||
| export function issuesFromWire(value: unknown): readonly ValidationIssue[] | undefined { | ||
| if (!Array.isArray(value) || value.length === 0 || value.length > MAX_WIRE_ISSUES) { | ||
| return undefined; | ||
| } | ||
| const issues: ValidationIssue[] = []; | ||
| for (const entry of value as readonly unknown[]) { | ||
| if (typeof entry !== 'object' || entry === null) return undefined; | ||
| // Strict on the two members that DECIDE where an issue lands, defaulted on the two that only | ||
| // describe it: a `path` that is not a string would bind a rejection somewhere it does not | ||
| // belong, while a missing `expected` cannot mis-route anything. | ||
| const path = stringField(entry, 'path'); | ||
| const message = stringField(entry, 'message'); | ||
| if (path === undefined || message === undefined || message.length === 0) return undefined; | ||
| // Built member by member, never spread: a foreign issue object may carry the rejected VALUE | ||
| // (some libraries put it in `received`), and a whole-object copy would forward it to whoever | ||
| // renders the list. Four members travel; everything else stops here. | ||
| issues.push({ | ||
| path, | ||
| expected: stringField(entry, 'expected') ?? '', | ||
| received: stringField(entry, 'received') ?? '', | ||
| message, | ||
| }); | ||
| } | ||
| return issues; | ||
| } |
+30
-0
@@ -24,2 +24,4 @@ # @ultimat3/action | ||
| | `client.ts` | typed RPC client (browser-safe: no server imports) | | ||
| | `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape | | ||
| | `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate | | ||
| | — | 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 | | ||
@@ -48,2 +50,30 @@ | `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` | | ||
| - **`X_INPUT_INVALID` carries the rejections TWICE, and they are one value.** The flattened line | ||
| stays in `cause` — it is what an operator reads in a log and what a non-form caller sees — and | ||
| `meta.issues` carries the same list structured, so a client rebuilding a form knows WHICH field | ||
| each rejection belongs to instead of splitting a string on `'; '` and guessing. `validate.ts` is | ||
| the one caller that passes both, and `validate.test.ts` pins `cause` to | ||
| `formatIssues(issues).join('; ')`; the rendering deliberately does NOT happen inside | ||
| `InputInvalidError`, because that module is reachable from browser-safe `client.ts` and | ||
| `@ultimat3/schema` declares no `sideEffects`, so a value import of `formatIssues` there would drag | ||
| that package's whole barrel into every bundle holding the typed client. | ||
| - **`toValidationIssues`, never a library's raw issues.** A conforming schema library's issue object | ||
| may carry members Ultimate's shape does not — including the rejected VALUE — and this list is | ||
| handed to an HTTP surface that returns it to the caller. Four members travel. The same rule on the | ||
| way back in: `issuesFromWire` REBUILDS each entry member by member rather than copying it. | ||
| - **`X_OUTPUT_INVALID` keeps the line alone.** An output rejection is a server defect whose remedy | ||
| is a code change; no client can act on a per-field list, and shipping the handler's internal | ||
| projection to a caller is new surface for nothing. | ||
| - **An `issues` list off the wire is all-or-nothing.** A partly-parsed list would DROP the entries | ||
| it could not read, and a caller that finds `meta.issues` uses it INSTEAD of `cause` — so a dropped | ||
| entry is a rejection the user never hears about. `MAX_WIRE_ISSUES` bounds it, because whoever | ||
| displays the list renders it into a DOM. | ||
| - **`transition()` is a factory, not a primitive, and it decides nothing about the machine.** It | ||
| returns a `mutator`, so every projection is inherited rather than re-declared, and it holds no | ||
| legality rule: `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are | ||
| `@ultimat3/entity`'s and propagate untouched. `from` is REQUIRED — it is the UPDATE's predicate, | ||
| which is what makes the refusal free; defaulting or inferring it is the lost update coming back. | ||
| `conflict: 'server-wins'` is fixed (the server is the half that refused), and `audit` is OFF | ||
| unless declared (`audit: true` with no sink is `X_AUDIT_SINK_MISSING` before the input parse, so | ||
| defaulting it on would hold the factory hostage to an unrelated decision). | ||
| - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse | ||
@@ -50,0 +80,0 @@ output. Adding a second execution path is the one unforgivable change here. |
+6
-6
| { | ||
| "name": "@ultimat3/action", | ||
| "version": "12.0.0", | ||
| "version": "13.0.0", | ||
| "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests", | ||
@@ -37,8 +37,8 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/cache": "12.0.0", | ||
| "@ultimat3/core": "12.0.0", | ||
| "@ultimat3/http": "12.0.0", | ||
| "@ultimat3/policy": "12.0.0", | ||
| "@ultimat3/schema": "12.0.0" | ||
| "@ultimat3/cache": "13.0.0", | ||
| "@ultimat3/core": "13.0.0", | ||
| "@ultimat3/http": "13.0.0", | ||
| "@ultimat3/policy": "13.0.0", | ||
| "@ultimat3/schema": "13.0.0" | ||
| } | ||
| } |
+54
-1
@@ -243,2 +243,49 @@ # @ultimat3/action ⚡ | ||
| ## `transition()` — a mutator factory over a state machine | ||
| `As of 2026-08-24`. A move through an entity column's state machine is a server-authoritative write | ||
| with an input schema, an output schema and a policy — which is what a `mutator` already is. So | ||
| `transition()` **returns one**, and the move inherits the route, the OpenAPI operation, the typed | ||
| client, the MCP tool, the job handle and its `PRIMITIVE_FACTORIES` row. It is not a ninth primitive | ||
| and it declares no error code of its own. | ||
| ```ts | ||
| import { t, transition, type TransitionTarget } from '@ultimat3/action'; | ||
| import type { Ctx } from '@ultimat3/core'; | ||
| import { can } from '@ultimat3/policy'; | ||
| const ORDER_STATES = ['pending', 'paid', 'shipped'] as const; | ||
| type OrderState = (typeof ORDER_STATES)[number]; | ||
| const OrderView = t.object({ id: t.uuid, status: t.enum(ORDER_STATES) }); | ||
| // `@ultimat3/entity`'s `orders(ctx)`: a real `Table` satisfies the seam as written. | ||
| declare function orders(ctx: Ctx): TransitionTarget<{ id: string; status: OrderState }, OrderState>; | ||
| declare const id: string; | ||
| declare const ctx: Ctx; | ||
| export const moveOrder = transition({ | ||
| table: (ctx) => orders(ctx), // the request's table — tenant-scoped like every write | ||
| column: 'status', // the column whose enumerated().transitions() IS the machine | ||
| states: ORDER_STATES, // typed against the row: a state it cannot hold is a compile error | ||
| localTable: 'orders', // what the optimistic twin patches | ||
| output: OrderView, | ||
| policy: can('order:move'), | ||
| }); | ||
| await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx }); | ||
| ``` | ||
| | Rule | Why | | ||
| |---|---| | ||
| | **`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 | | ||
| | 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 | | ||
| | `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 | | ||
| | `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` | | ||
| | `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 | | ||
| `table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no | ||
| dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do | ||
| not — and a real `Table` satisfies the seam as written. | ||
| ## Determinism + idempotency | ||
@@ -552,3 +599,3 @@ | ||
| | `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant | | ||
| | `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` | | ||
| | `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` | | ||
| | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later | | ||
@@ -575,2 +622,8 @@ | `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 | | ||
| A document carrying an `issues` member arrives parsed as well: `meta.issues`, read by | ||
| `issuesFromWire` — a wire value, so the list is rebuilt member by member and a list this build | ||
| cannot read is dropped whole rather than half-kept, leaving `cause` (which still holds every | ||
| rejection) as the answer. It is exported for the island that posts with a plain `fetch` and holds | ||
| the body itself. | ||
| ## Boundaries | ||
@@ -577,0 +630,0 @@ |
+4
-0
@@ -19,2 +19,3 @@ /** | ||
| import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers'; | ||
| import { issuesFromWire } from './wire-issues'; | ||
@@ -216,2 +217,5 @@ /** | ||
| code, | ||
| // Parsed, never taken: `body` is whatever answered the request. A list this build cannot read | ||
| // is dropped rather than repaired, and `cause` below still carries every rejection in it. | ||
| issues: issuesFromWire(body['issues']), | ||
| cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`), | ||
@@ -218,0 +222,0 @@ fix: stringOr(body['fix'], `x actions describe ${name} --json`), |
+36
-2
@@ -16,2 +16,3 @@ /** | ||
| import type { SurfaceDenial } from '@ultimat3/policy'; | ||
| import type { ValidationIssue } from '@ultimat3/schema'; | ||
@@ -181,3 +182,21 @@ // Re-exported, not re-declared: the five idempotency failures moved to their own file when this | ||
| export class InputInvalidError extends UltimateError { | ||
| constructor(name: string, detail: string) { | ||
| /** | ||
| * The rejections, addressed by path — `undefined` where the caller had only text. | ||
| * | ||
| * A `cause` is one line for a human and an agent to read; a client that renders a form needs to | ||
| * know WHICH field each rejection belongs to, and splitting the line back apart is guesswork the | ||
| * moment a message contains the separator. Both travel: the line is unchanged, and this is a | ||
| * structured channel beside it. | ||
| */ | ||
| readonly issues: readonly ValidationIssue[] | undefined; | ||
| /** | ||
| * `detail` is the rendered form of `issues` and must stay so — `formatIssues(issues).join('; ')`, | ||
| * which is what `validate.ts` (the one caller that passes both) does, and what `validate.test.ts` | ||
| * pins. The rendering is NOT done here on purpose: this module is reachable from `client.ts`, | ||
| * which is browser-safe, and `@ultimat3/schema` declares no `sideEffects`, so a value import of | ||
| * `formatIssues` here would pull that package's whole barrel into every browser bundle holding | ||
| * the typed client. | ||
| */ | ||
| constructor(name: string, detail: string, issues?: readonly ValidationIssue[]) { | ||
| super({ | ||
@@ -187,3 +206,5 @@ code: 'X_INPUT_INVALID', | ||
| fix: `x actions describe ${name} --json # prints the expected input schema`, | ||
| ...(issues === undefined ? {} : { meta: { issues } }), | ||
| }); | ||
| this.issues = issues; | ||
| } | ||
@@ -239,2 +260,8 @@ } | ||
| readonly docs?: readonly (string | undefined)[] | undefined; | ||
| /** | ||
| * The per-field rejections the document carried, already parsed — `issuesFromWire`'s answer, | ||
| * never the raw member. `undefined` where the body had none or where it had one this build | ||
| * refuses to read, and in both cases `cause` still holds every rejection. | ||
| */ | ||
| readonly issues?: readonly ValidationIssue[] | undefined; | ||
| } | ||
@@ -293,3 +320,10 @@ | ||
| retry: retryForStatus(failure.code, failure.status), | ||
| meta: { origin: 'remote', action: failure.action, status: failure.status }, | ||
| meta: { | ||
| origin: 'remote', | ||
| action: failure.action, | ||
| status: failure.status, | ||
| // Absent rather than `undefined`: `meta` is rendered into `--json` and the error reporter, | ||
| // and a null member reads as "the server sent an empty list" rather than "it sent none". | ||
| ...(failure.issues === undefined ? {} : { issues: failure.issues }), | ||
| }, | ||
| }); | ||
@@ -296,0 +330,0 @@ this.status = failure.status; |
+20
-0
@@ -236,1 +236,21 @@ /** | ||
| } from './registry'; | ||
| /** | ||
| * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through | ||
| * a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job | ||
| * handle and its manifest row. The machine itself is `@ultimat3/entity`'s — this package owns the | ||
| * projection, not the legality rule. | ||
| */ | ||
| export type { | ||
| TransitionDef, | ||
| TransitionInput, | ||
| TransitionTarget, | ||
| TransitionValues, | ||
| } from './transition'; | ||
| export { transition } from './transition'; | ||
| /** | ||
| * The one reader of a problem document's `issues` member. Exported because the typed client is not | ||
| * the only caller that meets one: an island that posts with a plain `fetch` — which is what | ||
| * `x g resource` emits, to keep this package out of its chunk — holds the parsed body itself and | ||
| * would otherwise write a second, unvalidated reader. | ||
| */ | ||
| export { issuesFromWire, MAX_WIRE_ISSUES } from './wire-issues'; |
+13
-2
@@ -8,5 +8,15 @@ /** | ||
| import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema'; | ||
| import { formatIssues, validateAsync } from '@ultimat3/schema'; | ||
| import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema'; | ||
| import { InputInvalidError, OutputInvalidError } from './errors'; | ||
| /** | ||
| * The refusal carries the issue list as well as the line, and the two are ONE value rendered twice: | ||
| * `formatIssues` reads `path` and `message`, which is exactly what `toValidationIssues` copied out | ||
| * of the library's own issues, so the string is byte-identical to the one this threw before. | ||
| * | ||
| * `toValidationIssues`, never the raw `result.issues`: a conforming library's issue object may | ||
| * carry members Ultimate's shape does not — including the rejected VALUE — and this list is | ||
| * handed to an HTTP surface that returns it to the caller. Four members travel, and | ||
| * `describeValue` is what keeps a value out of the fifth. | ||
| */ | ||
| export async function validateInput<S extends StandardSchemaV1>( | ||
@@ -19,3 +29,4 @@ schema: S, | ||
| if (result.issues !== undefined) { | ||
| throw new InputInvalidError(actionName, formatIssues(result.issues).join('; ')); | ||
| const issues = toValidationIssues(result.issues); | ||
| throw new InputInvalidError(actionName, formatIssues(issues).join('; '), issues); | ||
| } | ||
@@ -22,0 +33,0 @@ return result.value; |
321891
6.71%40
5.26%4774
5.53%630
9.19%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated
Updated