New:Socket for Asana Is Now Available.Learn more
Get Started

@ultimat3/action

Package Overview
Dependencies
Maintainers
1
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/action - npm Package Compare versions

Comparing version
12.0.0
to
13.0.0
+144
src/transition.ts
/**
* `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"
}
}

@@ -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 @@

@@ -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`),

@@ -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;

@@ -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';

@@ -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;