@ultimat3/action 22.15.0 → 24.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/src/mutator.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  } from './action';
19
19
  import { action, isAction } from './action';
20
20
  import type { Deprecation } from './deprecation';
21
+ import { MutatorNotIdempotentError } from './errors';
21
22
  import type { ActionHttp } from './http-path';
22
23
  import { assertConflictClock } from './mutator-clock';
23
24
  import type { ActionPolicy } from './policy-gate';
@@ -97,7 +98,14 @@ export interface MutatorDef<
97
98
  readonly deprecated?: Deprecation;
98
99
  /** Same key, same meaning as an action's: `http: { path }` pins the URL. */
99
100
  readonly http?: ActionHttp;
100
- readonly idempotent?: boolean;
101
+ /**
102
+ * REQUIRED, and only `true`. A mutator's write is replayed under one `Idempotency-Key` —
103
+ * `useMutation`'s retry, the offline queue's drain after a dropped response — and the server
104
+ * reads that key only for an idempotent action. Declared rather than defaulted because the
105
+ * replay is answered from the idempotency store, and which store that is (`configureIdempotency`)
106
+ * is the app's decision to see. Omitted: a compile error, and `X_MUTATOR_NOT_IDEMPOTENT`.
107
+ */
108
+ readonly idempotent: true;
101
109
  /**
102
110
  * The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
103
111
  * the action until 2026-09-23, so such a policy received `row === null` and denied every call.
@@ -158,6 +166,9 @@ export function mutator<
158
166
  TOutput extends StandardSchemaV1,
159
167
  TRow = unknown,
160
168
  >(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
169
+ // First, and for a caller the compiler never saw: a replay that runs the server half twice is
170
+ // silent, so the declaration is refused where it is written rather than on the second POST.
171
+ if (def.idempotent !== true) throw new MutatorNotIdempotentError();
161
172
  const row = def.row;
162
173
  const actionDef: ActionDef<TInput, TOutput, TRow> = {
163
174
  input: def.input,
@@ -169,7 +180,7 @@ export function mutator<
169
180
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
170
181
  ...(def.http === undefined ? {} : { http: def.http }),
171
182
  ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
172
- ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
183
+ idempotent: true,
173
184
  ...(def.audit === undefined ? {} : { audit: def.audit }),
174
185
  handle: ({ input, ctx }) => def.server(ctx, input),
175
186
  };
package/src/openapi.ts CHANGED
@@ -76,7 +76,12 @@ function compareByName(a: AnyAction, b: AnyAction): number {
76
76
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
77
77
  }
78
78
 
79
- /** RFC 9457 + the Ultimate error contract (code / cause / fix / docs). */
79
+ /**
80
+ * RFC 9457 + the Ultimate error contract, member for member what `@ultimat3/http`'s `toProblem`
81
+ * serves (`openapi-problem.test.ts` compares the two). `code` carries no `pattern`: `factsOf`
82
+ * serves whatever string an app's own throwable holds, and a published pattern the server breaks
83
+ * makes a generated client reject the very document it was written to read.
84
+ */
80
85
  const PROBLEM_SCHEMA: JsonSchemaObject = {
81
86
  type: 'object',
82
87
  required: ['type', 'title', 'status', 'code'],
@@ -85,9 +90,27 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
85
90
  title: { type: 'string' },
86
91
  status: { type: 'integer' },
87
92
  detail: { type: 'string' },
88
- code: { type: 'string', pattern: '^X_[A-Z0-9_]+$' },
93
+ instance: { type: 'string' },
94
+ code: { type: 'string' },
89
95
  cause: { type: 'string' },
90
96
  fix: { type: 'string' },
91
97
  docs: { type: 'string', format: 'uri' },
98
+ requestId: { type: 'string' },
99
+ // Absent when the failure produced none — never `[]`, which would claim "validated clean".
100
+ issues: {
101
+ type: 'array',
102
+ items: {
103
+ type: 'object',
104
+ required: ['path', 'expected', 'received', 'message'],
105
+ properties: {
106
+ path: { type: 'string' },
107
+ expected: { type: 'string' },
108
+ received: { type: 'string' },
109
+ message: { type: 'string' },
110
+ },
111
+ },
112
+ },
113
+ // Only the keys the code declared through `registerProblemMeta`; its shape is per code.
114
+ meta: { type: 'object' },
92
115
  },
93
116
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What the server says to a caller that derived an action's URL under the WRONG path style: a
3
+ * script or another service passing `rpc({ pathStyle })` by hand, where matching build ids prove
4
+ * nothing. Handed to `@ultimat3/http` as `hooks.explainMiss`, so it runs only when the router
5
+ * matched nothing — and answers `X_CONTRACT_DRIFT` naming the style served, in place of a
6
+ * `X_ROUTE_NOT_FOUND` that sends the caller looking for a route that exists.
7
+ */
8
+
9
+ import type { ActionPathStyle } from '@ultimat3/core';
10
+ import { ACTION_PATH_PREFIX, ACTION_PATH_STYLES, actionRoute } from '@ultimat3/core';
11
+ import { ContractDriftError } from './errors';
12
+ import { actionPathStyle, servedActionRoute } from './http-path';
13
+ import { defOf } from './invoke';
14
+ import { seatedActions } from './registry-store';
15
+
16
+ /**
17
+ * The action `pathname` names under a style this app does not serve, or `undefined`. A pinned
18
+ * action is skipped: its URL is the pin under every style, so no style explains missing it.
19
+ *
20
+ * A walk of the registry per miss, and deliberately not an index: the registry and the style both
21
+ * move (`x dev` re-evaluates modules), and only a POST under `/api/` that matched no route pays —
22
+ * one string derivation per action, no I/O.
23
+ */
24
+ function driftedAction(
25
+ pathname: string,
26
+ ): { readonly name: string; readonly under: ActionPathStyle } | undefined {
27
+ const served = actionPathStyle();
28
+ for (const under of ACTION_PATH_STYLES) {
29
+ if (under === served) continue;
30
+ // why no index: a memo keyed by path would outlive a re-registration and name a stale action.
31
+ for (const [name, seated] of seatedActions) {
32
+ if (defOf(seated).http?.path !== undefined) continue;
33
+ if (actionRoute(name, under).path === pathname) return { name, under };
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * `hooks.explainMiss` for the action surface. Every value in the message is the framework's own:
41
+ * the name is a registered export name and both paths are derived from it — the caller's bytes
42
+ * reach the message only by being equal to one of them.
43
+ */
44
+ export function explainActionPathMiss(
45
+ method: string,
46
+ pathname: string,
47
+ ): ContractDriftError | undefined {
48
+ if (method !== 'POST' || !pathname.startsWith(`${ACTION_PATH_PREFIX}/`)) return undefined;
49
+ const drifted = driftedAction(pathname);
50
+ if (drifted === undefined) return undefined;
51
+ const served = actionPathStyle();
52
+ return new ContractDriftError(
53
+ `POST ${pathname} is ${drifted.name} under pathStyle '${drifted.under}'; this server serves pathStyle '${served}', where it is POST ${servedActionRoute(drifted.name).path}`,
54
+ `rpc<Api['actions']>({ baseUrl, pathStyle: '${served}' }) # in a browser, pass no pathStyle: the page states it`,
55
+ );
56
+ }
package/src/registry.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ActionPathStyle } from '@ultimat3/core';
8
- import { SchemaUnsupportedError } from '@ultimat3/schema';
8
+ import { SchemaUnsupportedError, toWireSchema } from '@ultimat3/schema';
9
9
  import type { ActionDescriptor, AnyAction } from './action';
10
10
  import { isAction, nameAction } from './action';
11
11
  import { resetApiDeclaration } from './api-declaration';
@@ -19,7 +19,7 @@ import {
19
19
  } from './http-path';
20
20
  import { assertIdempotencyScope } from './idempotency';
21
21
  import { defOf } from './invoke';
22
- import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
22
+ import { jsonSchemaOf } from './json-schema';
23
23
  import type { ActionPath } from './naming';
24
24
  import { seatedActions } from './registry-store';
25
25
 
@@ -111,7 +111,8 @@ function assertProjectable(name: string, target: AnyAction): void {
111
111
  for (const field of ['input', 'output'] as const) {
112
112
  try {
113
113
  jsonSchemaOf(target[field]);
114
- mcpSchemaOf(target[field]);
114
+ // The schema `.tool()` and `tools/list` publish — the one an MCP client reads.
115
+ toWireSchema(target[field]);
115
116
  } catch {
116
117
  // The thrown value is deliberately not rendered into the cause: it is the provider's, of
117
118
  // unknown shape, and this package's own two facts — which action, which field — are the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The app's request deadline — how long an idempotency record bound to a transaction may stay in
3
+ * flight before a retry takes the key. Read from `@ultimat3/http`'s own declaration and resolved
4
+ * by its own `defineHttpConfig`, so the default and its screen are never restated here.
5
+ */
6
+ import { configuredHttp, defineHttpConfig } from '@ultimat3/http';
7
+
8
+ let resolved: { readonly declared: number | undefined; readonly ms: number } | undefined;
9
+
10
+ /**
11
+ * `configureHttp({ requestTimeoutMs })`, else http's default; `0` is "no deadline". Read on every
12
+ * call because the app declares it at module scope AFTER the boot has built its stores — resolved
13
+ * once per declared value, since `defineHttpConfig` screens a whole config to answer one field.
14
+ */
15
+ export function requestDeadlineMs(): number {
16
+ const declared = configuredHttp()?.requestTimeoutMs;
17
+ if (resolved === undefined || resolved.declared !== declared) {
18
+ const config = defineHttpConfig({
19
+ ...(declared === undefined ? {} : { requestTimeoutMs: declared }),
20
+ rateLimit: { enabled: false, scope: 'process' },
21
+ });
22
+ resolved = { declared, ms: config.requestTimeoutMs };
23
+ }
24
+ return resolved.ms;
25
+ }
package/src/transition.ts CHANGED
@@ -17,15 +17,14 @@ import type {
17
17
  StandardSchemaV1,
18
18
  StringSchema,
19
19
  } from '@ultimat3/schema';
20
- import { t } from '@ultimat3/schema';
20
+ import { nodeOf, t } from '@ultimat3/schema';
21
21
  import { type Mutator, mutator } from './mutator';
22
22
  import type { ActionPolicy } from './policy-gate';
23
23
 
24
24
  /**
25
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.
26
+ * satisfies it as written. Structural because it is all this file needs of a table — one method —
27
+ * and a fake that implements it is the whole test seam; the package does import entity elsewhere.
29
28
  *
30
29
  * `id` is a plain `string` rather than entity's `IdOf<Row>`: that alias "collapses to `string` for
31
30
  * every unbranded entity" by its own account, and a branded one still satisfies this because a
@@ -69,16 +68,22 @@ export interface TransitionDef<
69
68
  * tool's `inputSchema` carries it, the typed client refuses a typo at COMPILE time, and a
70
69
  * misspelled state is `X_INPUT_INVALID` before the request reaches a database.
71
70
  *
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.
71
+ * It is the one thing restated from the column's own declaration: `table` is resolved per request
72
+ * (`(ctx) => …`), and the input schema is needed at declaration, before any table exists to read
73
+ * a machine off. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is
74
+ * the enum in the refusal.
76
75
  */
77
76
  readonly states: readonly [S, ...S[]];
78
77
  /** The local store's name for this entity — what the optimistic twin patches. */
79
78
  readonly localTable: string;
80
79
  /** The projection the caller gets back. Unknown keys are dropped by the parse, so a `$view` works. */
81
80
  readonly output: TOutput;
81
+ /**
82
+ * The key's schema, for an `output` that does not carry the key as `id`. Omitted — the usual
83
+ * case — it is read off `output.id` (`keySchemaOf`), which for an entity `$view` IS the key
84
+ * column's own shape.
85
+ */
86
+ readonly id?: StringSchema;
82
87
  readonly policy: ActionPolicy;
83
88
  /**
84
89
  * OFF unless the app says otherwise, and deliberately not `?? true`.
@@ -115,9 +120,12 @@ export function transition<
115
120
  // can reach these two callbacks.
116
121
  const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
117
122
  return mutator({
118
- input: t.object({ id: t.uuid, from: state, to: state }),
123
+ input: t.object({ id: def.id ?? keySchemaOf(def.output), from: state, to: state }),
119
124
  output: def.output,
120
125
  policy: def.policy,
126
+ // A compare-and-set move replayed under its key answers the first outcome, never a second
127
+ // `X_STATE_CONFLICT` for a move that already happened.
128
+ idempotent: true,
121
129
  ...(def.audit === undefined ? {} : { audit: def.audit }),
122
130
  // Never overridable: the server is the half that REFUSED the move, and a local twin that won
123
131
  // the rebase would leave the client showing a state the database rejected.
@@ -142,3 +150,21 @@ export function transition<
142
150
  },
143
151
  });
144
152
  }
153
+
154
+ /**
155
+ * The `id` a move takes, from the key the OUTPUT declares — never a fixed `t.uuid`, which made
156
+ * every call on an entity keyed by `text()` or `bigint()` `X_INPUT_INVALID` while the mechanism
157
+ * underneath addresses any single-column key. An entity `$view` builds its `id` node from the key
158
+ * column, so the bounds copied here are that column's: a uuid stays a uuid (and a malformed one is
159
+ * still refused before a database reads it), a `bigint()` keeps its digits pattern, a `text()` its
160
+ * length. Never empty: an empty string names no row. An output with no string `id` keeps the
161
+ * framework's default key type, `uuid().primaryKey()`.
162
+ */
163
+ function keySchemaOf(output: StandardSchemaV1): StringSchema {
164
+ const key = nodeOf(output)?.properties?.['id'];
165
+ if (key === undefined || key.kind !== 'string' || key.format === 'uuid') return t.uuid;
166
+ let schema = t.string.min(Math.max(1, key.minLength ?? 1));
167
+ if (key.maxLength !== undefined) schema = schema.max(key.maxLength);
168
+ if (key.pattern !== undefined) schema = schema.pattern(new RegExp(key.pattern, key.patternFlags));
169
+ return schema;
170
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The open database transaction, as this package needs it — **the only** file here that imports
3
+ * `@ultimat3/db` (`tx-scope.test.ts` holds that). When is a write durable (`openCommitScope`, the
4
+ * cache bust), and is a transaction really OPEN, on which client (`liveTransaction`, idempotency).
5
+ */
6
+
7
+ // Type-only: `idempotency-postgres.ts` imports this module, and a runtime edge would be a cycle.
8
+ import type { PgExecutor } from '@ultimat3/core';
9
+ import { currentTx, liveTxConnection } from '@ultimat3/db';
10
+
11
+ /** The half of a transaction a deferred effect needs. `@ultimat3/db`'s `DbTx` satisfies it. */
12
+ export interface CommitScope {
13
+ /** Fired once the ROOT transaction has committed; dropped on rollback or a lost COMMIT. */
14
+ onCommit(effect: () => void): void;
15
+ }
16
+
17
+ /**
18
+ * The transaction this call runs inside, or `undefined`. `currentTx()` on purpose, FINISHED scopes
19
+ * included: a promise chain the body forgot to await still finds the handle after the scope
20
+ * closed, and `onCommit` on it runs the effect at once after a COMMIT and drops it after a
21
+ * ROLLBACK — the answer `@ultimat3/entity`'s row observer gets from the same call, so a late bust
22
+ * and a late change report never disagree about one write.
23
+ */
24
+ export function openCommitScope(): CommitScope | undefined {
25
+ return currentTx();
26
+ }
27
+
28
+ /** A transaction that is still OPEN: statements sent now are inside it. */
29
+ export interface LiveTransaction extends CommitScope {
30
+ /** The client it was opened on — which DATABASE this is, compared by identity. */
31
+ readonly origin: object;
32
+ /** Fired when the scope rolls back; never after a commit. */
33
+ onRollback(undo: () => void): void;
34
+ /** `(text, values)` on the transaction's OWN connection. */
35
+ readonly executor: PgExecutor;
36
+ }
37
+
38
+ /**
39
+ * The open transaction, or `undefined` — outside one, and ALSO for a finished scope's handle. A
40
+ * settlement bound to a transaction that already ended is bound to nothing: it would run as its
41
+ * own commit while the record claimed otherwise, and that record is the one a retry may reclaim.
42
+ *
43
+ * The fragment is assembled by hand, exactly as `@ultimat3/cli`'s `pgExecutorFor` does: the caller
44
+ * wrote the `$1..$n` text itself and hands over already-bound values.
45
+ */
46
+ export function liveTransaction(): LiveTransaction | undefined {
47
+ const tx = currentTx();
48
+ if (tx === undefined || liveTxConnection() === undefined) return undefined;
49
+ return {
50
+ origin: tx.origin,
51
+ onCommit: (effect) => tx.onCommit(effect),
52
+ onRollback: (undo) => tx.onRollback(undo),
53
+ executor: {
54
+ query: <R>(text: string, values: readonly unknown[]) => tx.query<R>({ text, values }),
55
+ },
56
+ };
57
+ }