@ultimat3/action 25.0.0 → 25.1.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
@@ -120,7 +120,7 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
120
120
  - **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
121
121
  codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
122
122
  `audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
123
- `t.uuid`.
123
+ `t.uuid`; `row` goes to `mutator()` as declared, never loaded here (#687).
124
124
  - **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
125
125
  `sample-input.ts`) — caller- or provider-supplied keys.
126
126
 
@@ -222,8 +222,8 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
222
222
  inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
223
223
  never a gate.
224
224
  - **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
225
- `settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)`. A fenced no-op is logged,
226
- never thrown.
225
+ `settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)` /
226
+ `release(key, reservationId)`. A fenced no-op is never thrown.
227
227
  - **An answer rests REDACTED** (#591): `withIdempotency` passes the store `restingAnswer(value)`
228
228
  (core's `isRedactedKey` + `isSecret` — never a second rule) and `redacted` as `settle`'s
229
229
  REQUIRED 4th argument; both stores keep it (`x_idempotency.redacted`, memory's record field).
package/README.md CHANGED
@@ -410,6 +410,7 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
410
410
  | `id` is the entity's **key**, read off `output.id` | a uuid key stays `t.uuid` (a malformed one is `X_INPUT_INVALID` before a database reads it); a `text()` key keeps its length and a `bigint()` key its digits pattern — never a fixed `t.uuid`, which refused every move on an entity keyed by anything else. An `output` that does not carry the key as `id` passes the key's schema as `id:`; with neither, the id is a uuid |
411
411
  | `idempotent: true`, always | a move replayed under its `Idempotency-Key` answers the first outcome, not an `X_STATE_CONFLICT` for a move that already happened |
412
412
  | `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` |
413
+ | a row-level `policy` gets its row from **`row`** | the action's own loader, handed through: `row: ({ input, ctx }) => posts(ctx).find(input.id)`, then `can<TransitionValues<S>, Post>('post:move', ({ actor, row }) => row !== null && row.authorId === actor?.id)`. Loaded once, after the input parse, before the guard and the statement; the factory reads no row itself. Omitted, the rule sees `row: null` and must fail closed. **Check-then-act**, as every row-policy action's: the read is unlocked and outside any transaction, and the move's predicate is `id` + `from` only, so a concurrent change to a column the rule read (an `authorId` reassigned in between) is not seen. Where that matters, write an `action` whose handler opens `withTransaction`, reads the row `for update`, decides and calls `Table.transition` inside it |
413
414
  | `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 |
414
415
 
415
416
  `table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
@@ -519,7 +520,7 @@ export function counted(inner: IdempotencyStore, onSettle: () => void): Idempote
519
520
  onSettle();
520
521
  return inner.settle(key, value, reservationId, redacted);
521
522
  },
522
- release: (key) => inner.release(key),
523
+ release: (key, reservationId) => inner.release(key, reservationId),
523
524
  get: (key) => inner.get(key),
524
525
  };
525
526
  }
@@ -601,6 +602,14 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
601
602
  so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
602
603
  reservation.
603
604
 
605
+ `release` takes it too (`As of 2026-10-07`): it is the pre-handler cleanup when `beforeRun` refuses,
606
+ and keyed alone it DELETED whatever record the key held — a replacement's reservation, or its
607
+ settled answer, so the next retry re-ran a handler that had already committed. A release that
608
+ matches nothing does nothing (`idempotency-release.test.ts`, both stores). **A custom store's
609
+ `release(key, reservationId)` must delete only the record whose id is `reservationId` and whose
610
+ status is `in-flight`** — one that ignores the second argument reopens the double-run window, and
611
+ type-checking cannot catch it: a one-parameter function still satisfies the signature.
612
+
604
613
  **Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
605
614
  `idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
606
615
  inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "25.0.0",
3
+ "version": "25.1.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",
@@ -35,12 +35,12 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/cache": "25.0.0",
39
- "@ultimat3/core": "25.0.0",
40
- "@ultimat3/db": "25.0.0",
41
- "@ultimat3/entity": "25.0.0",
42
- "@ultimat3/http": "25.0.0",
43
- "@ultimat3/policy": "25.0.0",
44
- "@ultimat3/schema": "25.0.0"
38
+ "@ultimat3/cache": "25.1.0",
39
+ "@ultimat3/core": "25.1.0",
40
+ "@ultimat3/db": "25.1.0",
41
+ "@ultimat3/entity": "25.1.0",
42
+ "@ultimat3/http": "25.1.0",
43
+ "@ultimat3/policy": "25.1.0",
44
+ "@ultimat3/schema": "25.1.0"
45
45
  }
46
46
  }
@@ -200,8 +200,9 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
200
200
  return existing;
201
201
  }
202
202
 
203
- release(key: string): Promise<void> {
204
- this.#records.delete(key);
203
+ /** Fenced by `#owned`, as both settlements are: a record that is not this reservation stays. */
204
+ release(key: string, reservationId: string): Promise<void> {
205
+ if (this.#owned(key, reservationId) !== undefined) this.#records.delete(key);
205
206
  return Promise.resolve();
206
207
  }
207
208
 
@@ -109,7 +109,9 @@ update x_idempotency set status = 'failed', value = null, failure = $2::jsonb,
109
109
  returning key
110
110
  `;
111
111
 
112
- export const SQL_IDEMPOTENCY_RELEASE = `delete from x_idempotency where key = $1`;
112
+ export const SQL_IDEMPOTENCY_RELEASE = `
113
+ delete from x_idempotency where key = $1 and id = $2::uuid and status = 'in-flight'
114
+ `;
113
115
 
114
116
  export const SQL_IDEMPOTENCY_PURGE = `
115
117
  delete from x_idempotency where created_at < now() - make_interval(secs => $1::double precision)
@@ -294,8 +296,10 @@ export function postgresIdempotencyStore(
294
296
  fenced(rows, key, reservationId, 'fail');
295
297
  },
296
298
 
297
- async release(key): Promise<void> {
298
- await exec.query(SQL_IDEMPOTENCY_RELEASE, [key]);
299
+ // Fenced, and silent when it matches nothing: a release is a pre-handler cleanup, so a record
300
+ // that is no longer this reservation's is somebody else's to settle.
301
+ async release(key, reservationId): Promise<void> {
302
+ await exec.query(SQL_IDEMPOTENCY_RELEASE, [key, reservationId]);
299
303
  },
300
304
 
301
305
  get: fetch,
@@ -137,8 +137,13 @@ export interface IdempotencyStore {
137
137
  * and re-running it is the double charge.
138
138
  */
139
139
  fail?(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void>;
140
- /** Drop a reservation, so a retry can run. Only ever correct BEFORE the handler starts. */
141
- release(key: string): Promise<void>;
140
+ /**
141
+ * Drop the reservation `reservationId` owns, so a retry can run. Only ever correct BEFORE the
142
+ * handler starts. Fenced like `settle` and `fail`: a straggler whose reservation was reclaimed
143
+ * past the deadline deleted the REPLACEMENT's record by key — settled, the next retry re-ran a
144
+ * handler that had committed. A release that matches nothing is a no-op.
145
+ */
146
+ release(key: string, reservationId: string): Promise<void>;
142
147
  get(key: string): Promise<IdempotencyRecord | undefined>;
143
148
  }
144
149
 
@@ -252,7 +257,15 @@ export async function withIdempotency<T>(
252
257
  try {
253
258
  await options.beforeRun();
254
259
  } catch (error) {
255
- await store.release(key);
260
+ // THIS reservation's id: one reclaimed while `beforeRun` waited is no longer ours to drop.
261
+ // A release the store refuses never replaces the refusal: the caller is owed the 429, not a
262
+ // driver error. The reservation then stays in flight until the window lapses (an autocommit
263
+ // one is never reclaimed early), so a retry inside it is a 409 — logged, so it is findable.
264
+ try {
265
+ await store.release(key, record.id);
266
+ } catch (storeError) {
267
+ logger.error('action.idempotency.release-refused', { key, error: storeError });
268
+ }
256
269
  throw error;
257
270
  }
258
271
  }
@@ -99,6 +99,25 @@ function sampleObject(node: SchemaNode): Record<string, unknown> {
99
99
  return sample;
100
100
  }
101
101
 
102
+ /**
103
+ * The most items a sample will hold. A contract sample is a handful of values; `minItems` past
104
+ * this is an input the framework does not invent — `2 ** 32` made `Array.from` throw, and anything
105
+ * below it allocated that many samples first. Such an array is a GAP (`sampleGaps`), reported as
106
+ * `X_CONTRACT_DRIFT` with the instruction to pass the input, never silently under-sampled.
107
+ */
108
+ export const SAMPLE_ITEMS_MAX = 1000;
109
+
110
+ /**
111
+ * The `minItems` an array sample owes, or `undefined` when none can be built: past the ceiling,
112
+ * or not a count at all. The builtin `t.array` refuses a bad bound at declaration
113
+ * (`X_SCHEMA_BOUNDS_INVALID`), but this IR may come from a swapped provider.
114
+ */
115
+ function owedItems(node: SchemaNode): number | undefined {
116
+ const min = node.minItems;
117
+ if (min === undefined) return 0;
118
+ return Number.isSafeInteger(min) && min >= 0 && min <= SAMPLE_ITEMS_MAX ? min : undefined;
119
+ }
120
+
102
121
  function sampleFor(node: SchemaNode): unknown {
103
122
  // A nullable field accepts `null`, and `null` is the smallest thing it accepts.
104
123
  if (node.nullable === true) return null;
@@ -115,8 +134,13 @@ function sampleFor(node: SchemaNode): unknown {
115
134
  return node.values?.[0] ?? SAMPLE_STRING;
116
135
  case 'literal':
117
136
  return node.literal ?? null;
137
+ // The fewest items the array accepts: `[]` under a `minItems` is a sample its own schema
138
+ // refuses. Items repeat the one item sample; a uniqueness rule would be a refinement.
118
139
  case 'array':
119
- return [];
140
+ // An unbuildable count samples `[]`, which the schema refuses — `sampleGaps` names why.
141
+ return Array.from({ length: owedItems(node) ?? 0 }, () =>
142
+ node.items === undefined ? null : sampleFor(node.items),
143
+ );
120
144
  case 'union': {
121
145
  const first = node.anyOf?.[0];
122
146
  return first === undefined ? null : sampleFor(first);
@@ -174,24 +198,29 @@ function gapsIn(node: SchemaNode, path: string): string[] {
174
198
  }
175
199
  return out;
176
200
  }
177
- if (satisfiesPattern(node, sampleFor(node))) return [];
201
+ const unbuildable = node.kind === 'array' && owedItems(node) === undefined;
202
+ if (!unbuildable && satisfiesPattern(node, sampleFor(node))) return [];
178
203
  return [path === '' ? ROOT_PATH : path];
179
204
  }
180
205
 
181
206
  /** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
182
207
  export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
183
- const pattern = patternAt(tryIntrospect(schema), path);
208
+ const node = nodeAt(tryIntrospect(schema), path);
209
+ if (node?.kind === 'array' && node.minItems !== undefined && owedItems(node) === undefined) {
210
+ return `${path} (needs at least ${node.minItems} items)`;
211
+ }
212
+ const pattern = node?.pattern;
184
213
  return pattern === undefined ? path : `${path} (must match ${pattern})`;
185
214
  }
186
215
 
187
- function patternAt(node: SchemaNode | undefined, path: string): string | undefined {
216
+ function nodeAt(node: SchemaNode | undefined, path: string): SchemaNode | undefined {
188
217
  if (node === undefined) return undefined;
189
- if (path === '' || path === ROOT_PATH) return node.pattern;
218
+ if (path === '' || path === ROOT_PATH) return node;
190
219
  const [head, ...rest] = path.split('.');
191
220
  // `path` is the caller's — `describeSampleGap` is exported — so the same own-property read the
192
221
  // format table takes: `properties['constructor']` is otherwise the `Object` function.
193
222
  const properties = node.properties ?? {};
194
223
  const child =
195
224
  head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
196
- return child === undefined ? undefined : patternAt(child, rest.join('.'));
225
+ return child === undefined ? undefined : nodeAt(child, rest.join('.'));
197
226
  }
package/src/transition.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  StringSchema,
19
19
  } from '@ultimat3/schema';
20
20
  import { nodeOf, t } from '@ultimat3/schema';
21
+ import type { ActionRowArgs } from './action';
21
22
  import { type Mutator, mutator } from './mutator';
22
23
  import type { ActionPolicy } from './policy-gate';
23
24
 
@@ -57,6 +58,7 @@ export interface TransitionDef<
57
58
  Row extends InferOutput<TOutput> & object,
58
59
  K extends keyof Row & string,
59
60
  S extends Row[K] & string,
61
+ TRow = unknown,
60
62
  > {
61
63
  /** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */
62
64
  readonly table: (ctx: Ctx) => TransitionTarget<Row, S>;
@@ -84,7 +86,21 @@ export interface TransitionDef<
84
86
  * column's own shape.
85
87
  */
86
88
  readonly id?: StringSchema;
87
- readonly policy: ActionPolicy;
89
+ readonly policy: ActionPolicy<TRow>;
90
+ /**
91
+ * The row a row-level `policy` decides about — an action's own `row` loader, handed through
92
+ * unchanged: loaded once, after the input parse, before the guard and before the statement. The
93
+ * factory reads no row itself; which columns a rule needs (an `authorId` the `output` view may not
94
+ * carry) is the app's. Omitted, the rule sees `row: null` — and must fail closed on it.
95
+ *
96
+ * CHECK-THEN-ACT, as every row-policy action's: the read is unlocked and outside any transaction,
97
+ * and the compare-and-set predicate is `id` + `from` only. A concurrent write to a column the
98
+ * rule read (an `authorId` reassigned between the read and the move) is not seen, so the move
99
+ * lands on the decision about the older row. Where that matters, do not use `transition()`: an
100
+ * `action` whose handler runs `withTransaction`, reads the row `for update`, decides, and calls
101
+ * `Table.transition` in that transaction closes the window.
102
+ */
103
+ row?(args: ActionRowArgs<TransitionInput<S>>): TRow | null | Promise<TRow | null>;
88
104
  /**
89
105
  * OFF unless the app says otherwise, and deliberately not `?? true`.
90
106
  *
@@ -110,7 +126,8 @@ export function transition<
110
126
  Row extends InferOutput<TOutput> & object,
111
127
  K extends keyof Row & string,
112
128
  const S extends Row[K] & string,
113
- >(def: TransitionDef<TOutput, Row, K, S>): Mutator<TransitionInput<S>, TOutput> {
129
+ TRow = unknown,
130
+ >(def: TransitionDef<TOutput, Row, K, S, TRow>): Mutator<TransitionInput<S>, TOutput> {
114
131
  const state = t.enum(def.states);
115
132
  // ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is
116
133
  // a mapped type over its shape, and a mapped type does not reduce while a type parameter is still
@@ -119,13 +136,16 @@ export function transition<
119
136
  // reason. What arrives here has already been parsed by the schema two lines up, and nothing else
120
137
  // can reach these two callbacks.
121
138
  const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
122
- return mutator({
139
+ const row = def.row;
140
+ return mutator<TransitionInput<S>, TOutput, TRow>({
123
141
  input: t.object({ id: def.id ?? keySchemaOf(def.output), from: state, to: state }),
124
142
  output: def.output,
125
143
  policy: def.policy,
126
144
  // A compare-and-set move replayed under its key answers the first outcome, never a second
127
145
  // `X_STATE_CONFLICT` for a move that already happened.
128
146
  idempotent: true,
147
+ // `.call(def, …)`, as `mutator()` hands its own on: a loader written as a method keeps `this`.
148
+ ...(row === undefined ? {} : { row: (args) => row.call(def, args) }),
129
149
  ...(def.audit === undefined ? {} : { audit: def.audit }),
130
150
  // Never overridable: the server is the half that REFUSED the move, and a local twin that won
131
151
  // the rebase would leave the client showing a state the database rejected.
package/src/tx-scope.ts CHANGED
@@ -40,7 +40,7 @@ export interface LiveTransaction extends CommitScope {
40
40
  * settlement bound to a transaction that already ended is bound to nothing: it would run as its
41
41
  * own commit while the record claimed otherwise, and that record is the one a retry may reclaim.
42
42
  *
43
- * The fragment is assembled by hand, exactly as `@ultimat3/cli`'s `pgExecutorFor` does: the caller
43
+ * The fragment is assembled by hand, exactly as `@ultimat3/db`'s `dbExecutor` does: the caller
44
44
  * wrote the `$1..$n` text itself and hands over already-bound values.
45
45
  */
46
46
  export function liveTransaction(): LiveTransaction | undefined {
package/src/type-pins.ts CHANGED
@@ -92,6 +92,11 @@ export type _APreRedactionStoreIsNotAStore = Assert<
92
92
  Equals<PreRedactionStore extends IdempotencyStore ? true : false, false>
93
93
  >;
94
94
 
95
+ /** A release names the reservation it drops — the fence `settle` and `fail` already carry. */
96
+ export type _ReleaseNamesTheReservation = Assert<
97
+ Equals<Parameters<IdempotencyStore['release']>['length'], 2>
98
+ >;
99
+
95
100
  /** …and `settle`'s flag is required of every CALLER, so a wrapping store cannot drop it either. */
96
101
  export type _SettleRequiresTheRedactedFlag = Assert<
97
102
  Equals<Parameters<IdempotencyStore['settle']>['length'], 4>