@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/CLAUDE.md +49 -9
- package/README.md +67 -13
- package/package.json +9 -7
- package/src/audit-memory.ts +2 -0
- package/src/audit-postgres.ts +1 -2
- package/src/cache-gate.ts +25 -6
- package/src/client-scale-pins.ts +126 -0
- package/src/client.ts +10 -5
- package/src/contract-test.ts +35 -11
- package/src/define-api.ts +18 -6
- package/src/errors-idempotency.ts +36 -2
- package/src/errors.ts +7 -2
- package/src/idempotency-memory.ts +69 -3
- package/src/idempotency-postgres.ts +72 -31
- package/src/idempotency.ts +4 -0
- package/src/index.ts +15 -7
- package/src/invoke.ts +3 -2
- package/src/json-schema.ts +4 -9
- package/src/mcp-tool.ts +12 -4
- package/src/mutator.ts +13 -2
- package/src/openapi.ts +25 -2
- package/src/path-style-miss.ts +56 -0
- package/src/registry.ts +4 -3
- package/src/request-deadline.ts +25 -0
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
package/src/define-api.ts
CHANGED
|
@@ -50,16 +50,28 @@ type Registered<TModule, TKind extends string> = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Intersect a
|
|
53
|
+
* Intersect a list of module namespaces. `{ createPost } & { inviteMember }` is the map the
|
|
54
54
|
* typed client indexes, so `rpc<Api['actions']>()` knows every action without a codegen step.
|
|
55
|
+
*
|
|
56
|
+
* Never by recursion over the tuple. `Registered<Head> & Merge<Rest>` is not a tail call, so the
|
|
57
|
+
* compiler spent one level of its instantiation depth per MODULE and answered TS2589 at the 48th
|
|
58
|
+
* entry of one list — whatever the number of actions inside them (measured: 30 modules of 10 pass,
|
|
59
|
+
* 48 modules of 1 fail). The list is read as a union and intersected in one step, so the depth is
|
|
60
|
+
* constant and `client-scale-pins.ts` holds it there.
|
|
55
61
|
*/
|
|
56
62
|
type Merge<TModules, TKind extends string> = [TModules] extends [undefined]
|
|
57
63
|
? EmptyModule
|
|
58
|
-
: TModules extends readonly [
|
|
59
|
-
?
|
|
60
|
-
: TModules
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
: TModules extends readonly unknown[]
|
|
65
|
+
? MergeAll<TModules[number], TKind>
|
|
66
|
+
: Registered<TModules, TKind>;
|
|
67
|
+
|
|
68
|
+
/** An empty list has no members to intersect — and `unknown` is not a map `rpc` accepts. */
|
|
69
|
+
type MergeAll<TModule, TKind extends string> = [TModule] extends [never]
|
|
70
|
+
? EmptyModule
|
|
71
|
+
: Intersect<TModule extends unknown ? (map: Registered<TModule, TKind>) => void : never>;
|
|
72
|
+
|
|
73
|
+
/** A union of one-parameter functions infers that parameter as the intersection of them all. */
|
|
74
|
+
type Intersect<TFns> = [TFns] extends [(map: infer TMap) => void] ? TMap : never;
|
|
63
75
|
|
|
64
76
|
type EmptyModule = Readonly<Record<never, never>>;
|
|
65
77
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
|
|
3
3
|
* stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
|
|
4
4
|
* `registerErrorCodes` call, because a second registration is how two modules end up deciding a
|
|
5
5
|
* title by load order.
|
|
@@ -77,7 +77,7 @@ export class IdempotencyNotSharedError extends UltimateError {
|
|
|
77
77
|
// positional form is `unsafe`), so that line compiled and would have thrown on the first
|
|
78
78
|
// reservation. The framework's own boot already installs this store; a host booting the
|
|
79
79
|
// framework itself wraps the client it opened.
|
|
80
|
-
fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
80
|
+
fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) }, origin: () => client, reclaimAfterMs: requestDeadlineMs })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
81
81
|
meta: { storeScope: storeScope ?? null },
|
|
82
82
|
});
|
|
83
83
|
}
|
|
@@ -138,3 +138,37 @@ export class IdempotencyStatusUnknownError extends UltimateError {
|
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A mutator declared without `idempotent: true`. Refused at declaration: the client replays a
|
|
144
|
+
* mutator's write under one `Idempotency-Key` (a retry, an offline queue draining after a lost
|
|
145
|
+
* response), and `toRoute` reads that key only for an idempotent action — so the alternative is a
|
|
146
|
+
* toggle that flips back, with nothing in any log to say the handler ran twice.
|
|
147
|
+
*/
|
|
148
|
+
export class MutatorNotIdempotentError extends UltimateError {
|
|
149
|
+
constructor() {
|
|
150
|
+
super({
|
|
151
|
+
code: 'X_MUTATOR_NOT_IDEMPOTENT',
|
|
152
|
+
cause:
|
|
153
|
+
'a mutator is declared without idempotent: true, so a replay of its write under the same Idempotency-Key would run the server half a second time',
|
|
154
|
+
fix: 'add `idempotent: true` to the mutator definition',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A settlement inside the handler's transaction matched no record: the attempt ran past the
|
|
161
|
+
* request deadline, a retry reclaimed the key, and this one no longer owns it. THROWN, where the
|
|
162
|
+
* autocommit path only logs — nothing here has committed, so the throw rolls this attempt's
|
|
163
|
+
* writes back and the retry's are the only ones stored.
|
|
164
|
+
*/
|
|
165
|
+
export class IdempotencyReservationLostError extends UltimateError {
|
|
166
|
+
constructor(key: string) {
|
|
167
|
+
super({
|
|
168
|
+
code: 'X_IDEMPOTENCY_RESERVATION_LOST',
|
|
169
|
+
cause: `idempotency key "${key}" was reclaimed by a retry while this attempt was still in flight past the request deadline, so its transaction is rolled back rather than committed beside the retry's`,
|
|
170
|
+
fix: 'resend this request with the same Idempotency-Key — the attempt that took the key over answers it, and this one was rolled back',
|
|
171
|
+
meta: { key },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every failure @ultimat3/action can produce, one subclass per stable code so
|
|
3
3
|
* callers `instanceof` a specific failure instead of string-matching a message.
|
|
4
|
-
* The idempotency
|
|
4
|
+
* The idempotency failures live in `errors-idempotency.ts` — this file reached the line ceiling —
|
|
5
5
|
* and are re-exported here, so `./errors` stays the one import path for all of them.
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
18
18
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
19
19
|
|
|
20
|
-
// Re-exported, not re-declared: the
|
|
20
|
+
// Re-exported, not re-declared: the idempotency failures moved to their own file when this
|
|
21
21
|
// one reached the line ceiling, and every importer still reads them from `./errors`.
|
|
22
22
|
export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
|
|
23
23
|
export {
|
|
@@ -25,7 +25,9 @@ export {
|
|
|
25
25
|
IdempotencyKeyInvalidError,
|
|
26
26
|
IdempotencyNotSharedError,
|
|
27
27
|
IdempotencyReplayedFailureError,
|
|
28
|
+
IdempotencyReservationLostError,
|
|
28
29
|
IdempotencyStatusUnknownError,
|
|
30
|
+
MutatorNotIdempotentError,
|
|
29
31
|
} from './errors-idempotency';
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -60,6 +62,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
60
62
|
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
61
63
|
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
62
64
|
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
65
|
+
X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
|
|
66
|
+
X_IDEMPOTENCY_RESERVATION_LOST:
|
|
67
|
+
"an idempotent action's reservation was taken over before its transaction could settle it",
|
|
63
68
|
};
|
|
64
69
|
|
|
65
70
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Shaped after `@ultimat3/http`'s `memoryRateLimitStore`, including the deliberate eviction order.
|
|
5
5
|
*/
|
|
6
6
|
import { finiteCount, uuid } from '@ultimat3/core';
|
|
7
|
+
import { IdempotencyReservationLostError } from './errors-idempotency';
|
|
7
8
|
import type {
|
|
8
9
|
IdempotencyFailure,
|
|
9
10
|
IdempotencyRecord,
|
|
@@ -11,6 +12,8 @@ import type {
|
|
|
11
12
|
IdempotencyScope,
|
|
12
13
|
IdempotencyStore,
|
|
13
14
|
} from './idempotency';
|
|
15
|
+
import { requestDeadlineMs } from './request-deadline';
|
|
16
|
+
import { liveTransaction } from './tx-scope';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* How long a key is remembered. A day is the window every payment API this shape exists to serve
|
|
@@ -34,6 +37,24 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
34
37
|
readonly maxKeys?: number | undefined;
|
|
35
38
|
/** Injectable so a test can age a record without sleeping. */
|
|
36
39
|
readonly now?: (() => number) | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The request deadline, read at every reservation — how long an in-flight record whose
|
|
42
|
+
* settlement rides a transaction is kept before a retry may take the key. Default: the app's
|
|
43
|
+
* own `requestTimeoutMs` (`requestDeadlineMs`). `0` is "no deadline".
|
|
44
|
+
*/
|
|
45
|
+
readonly reclaimAfterMs?: (() => number) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What an in-flight record's transaction has to do with it — the memory twin of the Postgres
|
|
50
|
+
* store's `tx_bound` column and of the row lock its settle holds until COMMIT. Keyed by the record
|
|
51
|
+
* object: a settled or failed record is a new object, so these never outlive the flight.
|
|
52
|
+
*/
|
|
53
|
+
interface Flight {
|
|
54
|
+
/** Reserved inside a transaction: its settle commits with the write or not at all. */
|
|
55
|
+
readonly bound: boolean;
|
|
56
|
+
/** The settle ran and its transaction has not ended yet — nobody may reclaim it now. */
|
|
57
|
+
settling: boolean;
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
@@ -43,6 +64,8 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
43
64
|
readonly #maxKeys: number;
|
|
44
65
|
readonly #evictTo: number;
|
|
45
66
|
readonly #now: () => number;
|
|
67
|
+
readonly #reclaimAfterMs: () => number;
|
|
68
|
+
readonly #flights = new WeakMap<IdempotencyRecord, Flight>();
|
|
46
69
|
readonly #records = new Map<string, IdempotencyRecord>();
|
|
47
70
|
#lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
48
71
|
|
|
@@ -65,6 +88,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
65
88
|
// Batched down to 90% so the eviction sort is paid once per 10% of the cap, not per write.
|
|
66
89
|
this.#evictTo = Math.max(1, Math.floor(this.#maxKeys * 0.9));
|
|
67
90
|
this.#now = options.now ?? ((): number => Date.now());
|
|
91
|
+
this.#reclaimAfterMs = options.reclaimAfterMs ?? requestDeadlineMs;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
/** Records tracked right now — the bound, observable. */
|
|
@@ -77,7 +101,11 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
77
101
|
const existing = this.#records.get(key);
|
|
78
102
|
// Expired is missing. A record past the window answers exactly as a first-ever key does, so
|
|
79
103
|
// reclaiming it here is what makes the window mean something rather than being a comment.
|
|
80
|
-
if (
|
|
104
|
+
if (
|
|
105
|
+
existing !== undefined &&
|
|
106
|
+
!this.#expired(existing, nowMs) &&
|
|
107
|
+
!this.#abandoned(existing, nowMs)
|
|
108
|
+
) {
|
|
81
109
|
return Promise.resolve({ record: existing, created: false });
|
|
82
110
|
}
|
|
83
111
|
const record: IdempotencyRecord = {
|
|
@@ -89,10 +117,28 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
89
117
|
createdAt: nowMs,
|
|
90
118
|
};
|
|
91
119
|
this.#records.set(key, record);
|
|
120
|
+
this.#flights.set(record, { bound: liveTransaction() !== undefined, settling: false });
|
|
92
121
|
this.#maintain(nowMs);
|
|
93
122
|
return Promise.resolve({ record, created: true });
|
|
94
123
|
}
|
|
95
124
|
|
|
125
|
+
/**
|
|
126
|
+
* An in-flight record past the request deadline whose settle was BOUND to a transaction: that
|
|
127
|
+
* settle lands with the commit or not at all, so the record being in flight proves nothing
|
|
128
|
+
* committed. An autocommit handler's record is never this — it may have written before it died.
|
|
129
|
+
*/
|
|
130
|
+
#abandoned(record: IdempotencyRecord, nowMs: number): boolean {
|
|
131
|
+
const flight = this.#flights.get(record);
|
|
132
|
+
if (record.status !== 'in-flight' || flight === undefined) return false;
|
|
133
|
+
if (!flight.bound || flight.settling) return false;
|
|
134
|
+
const reclaimAfterMs = finiteCount(
|
|
135
|
+
'MemoryIdempotencyStore',
|
|
136
|
+
'reclaimAfterMs',
|
|
137
|
+
this.#reclaimAfterMs(),
|
|
138
|
+
);
|
|
139
|
+
return reclaimAfterMs > 0 && nowMs - record.createdAt >= reclaimAfterMs;
|
|
140
|
+
}
|
|
141
|
+
|
|
96
142
|
/**
|
|
97
143
|
* Both settlements are FENCED on the reservation's own `id` AND on `in-flight`, as
|
|
98
144
|
* `SQL_IDEMPOTENCY_SETTLE` is and as `@ultimat3/jobs`' `SQL_ACK` is. A record past the window is
|
|
@@ -104,9 +150,29 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
104
150
|
*/
|
|
105
151
|
settle(key: string, value: unknown, reservationId: string): Promise<void> {
|
|
106
152
|
const existing = this.#owned(key, reservationId);
|
|
107
|
-
|
|
108
|
-
|
|
153
|
+
const tx = liveTransaction();
|
|
154
|
+
if (tx === undefined) {
|
|
155
|
+
if (existing !== undefined) this.#records.set(key, { ...existing, status: 'settled', value });
|
|
156
|
+
return Promise.resolve();
|
|
157
|
+
}
|
|
158
|
+
// Inside a transaction the record is settled BY the commit, as the Postgres store's is: a
|
|
159
|
+
// rollback leaves it in flight rather than replaying a success for rows nobody stored. And a
|
|
160
|
+
// settle that lost its reservation is thrown, because nothing has committed yet and the throw
|
|
161
|
+
// is what rolls this attempt back beside the retry that replaced it.
|
|
162
|
+
if (existing === undefined) {
|
|
163
|
+
return Promise.reject(new IdempotencyReservationLostError(key));
|
|
109
164
|
}
|
|
165
|
+
const flight = this.#flights.get(existing);
|
|
166
|
+
if (flight !== undefined) flight.settling = true;
|
|
167
|
+
tx.onRollback(() => {
|
|
168
|
+
if (flight !== undefined) flight.settling = false;
|
|
169
|
+
});
|
|
170
|
+
tx.onCommit(() => {
|
|
171
|
+
// Still this reservation's: `settling` kept a reclaim away, and `release` may have dropped it.
|
|
172
|
+
if (this.#records.get(key) === existing) {
|
|
173
|
+
this.#records.set(key, { ...existing, status: 'settled', value });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
110
176
|
return Promise.resolve();
|
|
111
177
|
}
|
|
112
178
|
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* without it, `replicas: 3` means a retry that lands elsewhere re-runs a committed handler.
|
|
5
5
|
* Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
7
8
|
import { finiteCount, logger, uuid } from '@ultimat3/core';
|
|
8
|
-
import { IdempotencyStatusUnknownError } from './errors';
|
|
9
|
+
import { IdempotencyReservationLostError, IdempotencyStatusUnknownError } from './errors';
|
|
9
10
|
import type {
|
|
10
11
|
IdempotencyFailure,
|
|
11
12
|
IdempotencyRecord,
|
|
@@ -15,22 +16,7 @@ import type {
|
|
|
15
16
|
} from './idempotency';
|
|
16
17
|
import { IDEMPOTENCY_STATUSES, isIdempotencyStatus } from './idempotency';
|
|
17
18
|
import { DEFAULT_IDEMPOTENCY_WINDOW_MS } from './idempotency-memory';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The one thing this store needs from the DB layer, declared structurally rather than imported.
|
|
21
|
-
* `@ultimat3/jobs` declares the same shape for the same reason: neither package owns the other's
|
|
22
|
-
* connection, and neither depends on a database package.
|
|
23
|
-
*
|
|
24
|
-
* **`Bun.sql` does not satisfy it** — verified against Bun 1.3.14: `Bun.sql.query` is `undefined`.
|
|
25
|
-
* `Bun.sql` is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
|
|
26
|
-
* would `TypeError` on the first reservation, which is the one call path that must not fail open.
|
|
27
|
-
* What satisfies it is a client that already speaks `(text, values)`, wrapped in one line —
|
|
28
|
-
* `@ultimat3/cli`'s `pgExecutorFor(client)` over `@ultimat3/db`'s `DbClient.query({ text, values })`
|
|
29
|
-
* is the framework's own — or a transaction handle, which is a client on its own connection.
|
|
30
|
-
*/
|
|
31
|
-
export interface PgExecutor {
|
|
32
|
-
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
33
|
-
}
|
|
19
|
+
import { liveTransaction } from './tx-scope';
|
|
34
20
|
|
|
35
21
|
/**
|
|
36
22
|
* The store's ONE install point, applied the way `SQL_JOBS_TABLE` is — by the boot, not by an app
|
|
@@ -51,25 +37,37 @@ create table if not exists x_idempotency (
|
|
|
51
37
|
);
|
|
52
38
|
|
|
53
39
|
create index if not exists x_idempotency_created_at_idx on x_idempotency (created_at);
|
|
40
|
+
|
|
41
|
+
alter table x_idempotency add column if not exists tx_bound boolean not null default false;
|
|
54
42
|
`;
|
|
55
43
|
|
|
56
44
|
/**
|
|
57
|
-
* The reservation, atomic in one statement. The `do update` fires ONLY for a row
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
45
|
+
* The reservation, atomic in one statement. The `do update` fires ONLY for a row that answers as a
|
|
46
|
+
* missing one, so a returned row always means this caller owns the reservation and must run the
|
|
47
|
+
* handler. No row back means a live record exists and belongs to someone else.
|
|
48
|
+
*
|
|
49
|
+
* Two rows answer as missing. One outside the window. And one still `in-flight` past the request
|
|
50
|
+
* deadline (`$6`) whose settlement was BOUND TO A TRANSACTION (`tx_bound`): that settle commits
|
|
51
|
+
* with the handler's writes or not at all, so the record being in flight proves nothing committed
|
|
52
|
+
* — the process died, or the unit of work rolled back. An autocommit handler's record is never
|
|
53
|
+
* reclaimed this way: "died before the write" and "wrote, then died before the settle" are one
|
|
54
|
+
* row, and re-running the second is the double charge.
|
|
61
55
|
*/
|
|
62
56
|
export const SQL_IDEMPOTENCY_RESERVE = `
|
|
63
|
-
insert into x_idempotency (key, id, request_hash, status)
|
|
64
|
-
values ($1, $2, $3, 'in-flight')
|
|
57
|
+
insert into x_idempotency (key, id, request_hash, status, tx_bound)
|
|
58
|
+
values ($1, $2, $3, 'in-flight', $5)
|
|
65
59
|
on conflict (key) do update
|
|
66
60
|
set id = excluded.id,
|
|
67
61
|
request_hash = excluded.request_hash,
|
|
68
62
|
status = 'in-flight',
|
|
69
63
|
value = null,
|
|
70
64
|
failure = null,
|
|
65
|
+
tx_bound = excluded.tx_bound,
|
|
71
66
|
created_at = now()
|
|
72
67
|
where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
|
|
68
|
+
or (x_idempotency.status = 'in-flight'
|
|
69
|
+
and x_idempotency.tx_bound
|
|
70
|
+
and x_idempotency.created_at < now() - make_interval(secs => $6::double precision))
|
|
73
71
|
returning key, id, request_hash, status, value, failure,
|
|
74
72
|
(extract(epoch from created_at) * 1000)::bigint as created_at
|
|
75
73
|
`;
|
|
@@ -125,7 +123,24 @@ interface IdempotencyRow {
|
|
|
125
123
|
|
|
126
124
|
export interface PostgresIdempotencyStoreOptions {
|
|
127
125
|
readonly executor: PgExecutor;
|
|
126
|
+
/**
|
|
127
|
+
* The client transactions on THIS database are opened on — `baseClient` from `@ultimat3/db` for
|
|
128
|
+
* the store the boot installs. Compared by identity with an open transaction's `origin`: the
|
|
129
|
+
* settle rides a transaction on this database and no other. One opened elsewhere
|
|
130
|
+
* (`withTransaction(fn, { client: shard })`) has no `x_idempotency` to settle in, so the record
|
|
131
|
+
* is settled on the pool, exactly as an autocommit handler's is. REQUIRED: a store that could
|
|
132
|
+
* not tell would send the settle to whichever database the handler happened to be writing.
|
|
133
|
+
*/
|
|
134
|
+
readonly origin: () => object;
|
|
128
135
|
readonly windowMs?: number | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* The request deadline in milliseconds, read at every reservation: how long an in-flight record
|
|
138
|
+
* whose settlement rides a transaction is kept before a retry may take the key. REQUIRED and a
|
|
139
|
+
* function — the deadline is the app's (`requestTimeoutMs`), declared after this store is built,
|
|
140
|
+
* and a number restated here would silently disagree with it. `0` is "no deadline": nothing is
|
|
141
|
+
* reclaimed before the window. Never applies to an autocommit handler's record.
|
|
142
|
+
*/
|
|
143
|
+
readonly reclaimAfterMs: () => number;
|
|
129
144
|
/**
|
|
130
145
|
* Injectable, exactly as `MemoryIdempotencyStoreOptions.now` is. The two stores are one seam and
|
|
131
146
|
* a caller must be able to drive either from the same clock; a hardcoded `Date.now()` here made
|
|
@@ -148,7 +163,7 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
|
|
|
148
163
|
/**
|
|
149
164
|
* **The boot installs this for you — an app declares the scope and nothing else.**
|
|
150
165
|
* `@ultimat3/cli`'s `startServices` builds a `PgExecutor` from the client it already resolved and
|
|
151
|
-
* calls `setIdempotencyStore(postgresIdempotencyStore({ executor }))` before `loadApp`, so the
|
|
166
|
+
* calls `setIdempotencyStore(postgresIdempotencyStore({ executor, origin, reclaimAfterMs }))` before `loadApp`, so the
|
|
152
167
|
* store is in place by the time `registerAction` evaluates a declaration against it. All an app
|
|
153
168
|
* owes is the one line `x new` scaffolds into `apps/web/server.ts`:
|
|
154
169
|
*
|
|
@@ -165,6 +180,8 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
|
|
|
165
180
|
* setIdempotencyStore(
|
|
166
181
|
* postgresIdempotencyStore({
|
|
167
182
|
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
183
|
+
* origin: () => client,
|
|
184
|
+
* reclaimAfterMs: requestDeadlineMs, // the app's `requestTimeoutMs`, from '@ultimat3/action'
|
|
168
185
|
* }),
|
|
169
186
|
* );
|
|
170
187
|
* ```
|
|
@@ -181,7 +198,18 @@ export function postgresIdempotencyStore(
|
|
|
181
198
|
1,
|
|
182
199
|
);
|
|
183
200
|
const windowSecs = windowMs / 1000;
|
|
201
|
+
// Screened on every read, like the window above: the value is the app's and arrives late. Never
|
|
202
|
+
// past the window, which already frees the record — and `0` means exactly that.
|
|
203
|
+
const reclaimSecs = (): number => {
|
|
204
|
+
const ms = finiteCount('postgresIdempotencyStore', 'reclaimAfterMs', options.reclaimAfterMs());
|
|
205
|
+
return (ms === 0 ? windowMs : Math.min(ms, windowMs)) / 1000;
|
|
206
|
+
};
|
|
184
207
|
const exec = options.executor;
|
|
208
|
+
/** The executor of a transaction open on THIS database, or `undefined` — the pool settles. */
|
|
209
|
+
const boundTx = (): PgExecutor | undefined => {
|
|
210
|
+
const tx = liveTransaction();
|
|
211
|
+
return tx !== undefined && tx.origin === options.origin() ? tx.executor : undefined;
|
|
212
|
+
};
|
|
185
213
|
const now = options.now ?? ((): number => Date.now());
|
|
186
214
|
|
|
187
215
|
const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
|
|
@@ -199,11 +227,16 @@ export function postgresIdempotencyStore(
|
|
|
199
227
|
// back empty is a concurrent `release`/`purgeExpired` deleting the row between them, and a
|
|
200
228
|
// caller losing that race twice is a store nobody should keep retrying against.
|
|
201
229
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
230
|
+
// On the POOL, never the open transaction: a duplicate has to see the reservation now,
|
|
231
|
+
// and one that rolled back with the handler would let both attempts run. What rides the
|
|
232
|
+
// transaction is the SETTLE — and the row says so, which is what makes it reclaimable.
|
|
202
233
|
const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
|
|
203
234
|
key,
|
|
204
235
|
uuid(),
|
|
205
236
|
requestHash,
|
|
206
237
|
windowSecs,
|
|
238
|
+
boundTx() !== undefined,
|
|
239
|
+
reclaimSecs(),
|
|
207
240
|
]);
|
|
208
241
|
const row = claimed[0];
|
|
209
242
|
if (row !== undefined) return { record: toRecord(row), created: true };
|
|
@@ -226,14 +259,22 @@ export function postgresIdempotencyStore(
|
|
|
226
259
|
},
|
|
227
260
|
|
|
228
261
|
async settle(key, value, reservationId): Promise<void> {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
reservationId,
|
|
233
|
-
|
|
234
|
-
|
|
262
|
+
const params = [key, JSON.stringify(value ?? null), reservationId];
|
|
263
|
+
const tx = boundTx();
|
|
264
|
+
if (tx === undefined) {
|
|
265
|
+
fenced(await exec.query(SQL_IDEMPOTENCY_SETTLE, params), key, reservationId, 'settle');
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// On the handler's OWN connection: the record is settled by the same COMMIT that makes the
|
|
269
|
+
// write durable, so a rollback leaves it in flight instead of replaying a success for rows
|
|
270
|
+
// that were never stored. And a settle that matches nothing here is THROWN, not logged —
|
|
271
|
+
// the key was taken over, nothing has committed yet, and the throw is what rolls it back.
|
|
272
|
+
const rows = await tx.query(SQL_IDEMPOTENCY_SETTLE, params);
|
|
273
|
+
if (rows.length === 0) throw new IdempotencyReservationLostError(key);
|
|
235
274
|
},
|
|
236
275
|
|
|
276
|
+
// On the pool, whatever is open: the transaction this failure came out of is about to roll
|
|
277
|
+
// back, and the record of it has to outlive that.
|
|
237
278
|
async fail(key, failure: IdempotencyFailure, reservationId): Promise<void> {
|
|
238
279
|
const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
|
|
239
280
|
key,
|
|
@@ -260,7 +301,7 @@ export function postgresIdempotencyStore(
|
|
|
260
301
|
}
|
|
261
302
|
|
|
262
303
|
/**
|
|
263
|
-
* Logged, never thrown. A settlement lands after the handler has committed, so raising here would
|
|
304
|
+
* Logged, never thrown — the AUTOCOMMIT path. A settlement lands after the handler has committed, so raising here would
|
|
264
305
|
* turn a durable write into the caller's error — the rule `withIdempotency` already follows for a
|
|
265
306
|
* store that refuses. An operator still has to see it: a fenced settle means this attempt's record
|
|
266
307
|
* belongs to another reservation, and the value this attempt produced is stored nowhere.
|
package/src/idempotency.ts
CHANGED
|
@@ -226,6 +226,10 @@ export async function withIdempotency<T>(
|
|
|
226
226
|
//
|
|
227
227
|
// `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
|
|
228
228
|
// lapsed cannot land on the replacement that reclaimed the key.
|
|
229
|
+
//
|
|
230
|
+
// Inside a transaction the shared store settles on that transaction's connection, so "settled"
|
|
231
|
+
// and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
|
|
232
|
+
// (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
|
|
229
233
|
await store.settle(key, value, record.id);
|
|
230
234
|
return { value, replayed: false };
|
|
231
235
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,12 +19,18 @@
|
|
|
19
19
|
* before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
|
|
20
20
|
* flight, and it should not cost a second import.
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* The SHARED store, and the only one an app running more than one replica may install. The
|
|
24
|
+
* statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
|
|
25
|
+
* by `x db up` in development and by the release-phase `ROLE=migrate` in production.
|
|
26
|
+
*/
|
|
22
27
|
export type {
|
|
23
28
|
ClientFlight,
|
|
24
29
|
ClientFlightOptions,
|
|
25
30
|
ClientRetry,
|
|
26
31
|
FlightKeyOptions,
|
|
27
32
|
FlightPlan,
|
|
33
|
+
PgExecutor,
|
|
28
34
|
WireAnswer,
|
|
29
35
|
} from '@ultimat3/core';
|
|
30
36
|
export {
|
|
@@ -130,8 +136,10 @@ export {
|
|
|
130
136
|
IdempotencyKeyInvalidError,
|
|
131
137
|
IdempotencyNotSharedError,
|
|
132
138
|
IdempotencyReplayedFailureError,
|
|
139
|
+
IdempotencyReservationLostError,
|
|
133
140
|
IdempotencyStatusUnknownError,
|
|
134
141
|
InputInvalidError,
|
|
142
|
+
MutatorNotIdempotentError,
|
|
135
143
|
OutputInvalidError,
|
|
136
144
|
RemoteActionError,
|
|
137
145
|
RpcFailedError,
|
|
@@ -189,13 +197,7 @@ export {
|
|
|
189
197
|
DEFAULT_MAX_IDEMPOTENCY_KEYS,
|
|
190
198
|
MemoryIdempotencyStore,
|
|
191
199
|
} from './idempotency-memory';
|
|
192
|
-
/**
|
|
193
|
-
* The SHARED store, and the only one an app running more than one replica may install. The
|
|
194
|
-
* statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
|
|
195
|
-
* by `x db up` in development and by the release-phase `ROLE=migrate` in production.
|
|
196
|
-
*/
|
|
197
200
|
export type {
|
|
198
|
-
PgExecutor,
|
|
199
201
|
PostgresIdempotencyStore,
|
|
200
202
|
PostgresIdempotencyStoreOptions,
|
|
201
203
|
} from './idempotency-postgres';
|
|
@@ -214,7 +216,7 @@ export { actionName, invoke } from './invoke';
|
|
|
214
216
|
export type { ActionJobHandle } from './job-handle';
|
|
215
217
|
export { toJobHandle } from './job-handle';
|
|
216
218
|
export type { JsonSchemaObject } from './json-schema';
|
|
217
|
-
export { jsonSchemaOf
|
|
219
|
+
export { jsonSchemaOf } from './json-schema';
|
|
218
220
|
export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
|
|
219
221
|
export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
|
|
220
222
|
export type {
|
|
@@ -237,6 +239,11 @@ export {
|
|
|
237
239
|
completeOpenApi,
|
|
238
240
|
mountOpenApi,
|
|
239
241
|
} from './openapi-complete';
|
|
242
|
+
/**
|
|
243
|
+
* `@ultimat3/http`'s `hooks.explainMiss` for the action surface: a POST to an action's path under
|
|
244
|
+
* a `pathStyle` this app does not serve answers `X_CONTRACT_DRIFT` naming the style it does.
|
|
245
|
+
*/
|
|
246
|
+
export { explainActionPathMiss } from './path-style-miss';
|
|
240
247
|
export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
|
|
241
248
|
/**
|
|
242
249
|
* `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
|
|
@@ -262,6 +269,7 @@ export {
|
|
|
262
269
|
registerActions,
|
|
263
270
|
resetRegistry,
|
|
264
271
|
} from './registry';
|
|
272
|
+
export { requestDeadlineMs } from './request-deadline';
|
|
265
273
|
/**
|
|
266
274
|
* A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
|
|
267
275
|
* a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
|
package/src/invoke.ts
CHANGED
|
@@ -268,8 +268,9 @@ async function perform(
|
|
|
268
268
|
}
|
|
269
269
|
// Only for a run that actually happened, and only through the gate. A replay ran no handler
|
|
270
270
|
// and changed nothing the first call had not already busted — re-busting per retry re-purges
|
|
271
|
-
// the CDN and re-queues ISR for a write nobody made. And the bust is post-
|
|
272
|
-
//
|
|
271
|
+
// the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
|
|
272
|
+
// invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
|
|
273
|
+
// `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
|
|
273
274
|
// durable write into a failed action.
|
|
274
275
|
if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
|
|
275
276
|
return value;
|
package/src/json-schema.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Standard Schema -> JSON Schema, normalized to a plain record
|
|
3
|
-
*
|
|
2
|
+
* Standard Schema -> JSON Schema, normalized to a plain record, for OpenAPI and the manifest. An
|
|
3
|
+
* MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
-
import { SchemaUnsupportedError, toJsonSchema
|
|
7
|
+
import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
|
|
8
8
|
import { isJsonObject, stableStringify } from './stable';
|
|
9
9
|
|
|
10
10
|
export type JsonSchemaObject = Record<string, unknown>;
|
|
@@ -23,13 +23,8 @@ export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
|
|
|
23
23
|
return normalizeJsonSchema(() => toJsonSchema(schema));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
|
|
27
|
-
export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
|
|
28
|
-
return normalizeJsonSchema(() => toMcpInputSchema(schema));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
/**
|
|
32
|
-
* The narrowing
|
|
27
|
+
* The narrowing `jsonSchemaOf` applies, and the refusal it earns: a converter that answered with
|
|
33
28
|
* something that is not a JSON object is the same failure by a quieter route, so it gets the same
|
|
34
29
|
* shipped code rather than a permissive node. Exported for its own test and nothing else — it is
|
|
35
30
|
* absent from `src/index.ts`, exactly as `sortSchema` is.
|
package/src/mcp-tool.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Ctx } from '@ultimat3/core';
|
|
7
7
|
import { isMcpExposed } from '@ultimat3/core';
|
|
8
|
+
import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
|
|
8
9
|
import type { AnyAction } from './action';
|
|
9
10
|
import { actionName, defOf, invoke } from './invoke';
|
|
10
|
-
import { type JsonSchemaObject,
|
|
11
|
+
import { type JsonSchemaObject, sortSchema } from './json-schema';
|
|
11
12
|
import type { ActionPolicy } from './policy-gate';
|
|
12
13
|
import { listActions } from './registry';
|
|
13
14
|
|
|
@@ -29,8 +30,14 @@ export interface McpToolDescriptor {
|
|
|
29
30
|
* is what makes "an MCP call cannot reach a different authz path" checkable.
|
|
30
31
|
*/
|
|
31
32
|
readonly policy: ActionPolicy;
|
|
33
|
+
/**
|
|
34
|
+
* What `@ultimat3/mcp`'s `tools/list` serves for this action — `@ultimat3/schema`'s wire subset,
|
|
35
|
+
* key-sorted — minus the reserved `idempotencyKey` argument an idempotent action's tool adds,
|
|
36
|
+
* which `invoke` here takes as an option instead.
|
|
37
|
+
*/
|
|
32
38
|
readonly inputSchema: JsonSchemaObject;
|
|
33
|
-
|
|
39
|
+
/** The served `outputSchema`: structure only, and absent when `output` has no object root. */
|
|
40
|
+
readonly outputSchema?: JsonSchemaObject;
|
|
34
41
|
invoke(input: unknown, options?: McpInvokeOptions): Promise<unknown>;
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -47,13 +54,14 @@ export interface McpInvokeOptions {
|
|
|
47
54
|
export function toMcpTool(target: AnyAction): McpToolDescriptor {
|
|
48
55
|
const name = actionName(target);
|
|
49
56
|
const def = defOf(target);
|
|
57
|
+
const output = toWireOutputSchema(def.output);
|
|
50
58
|
return {
|
|
51
59
|
name,
|
|
52
60
|
description: def.mcp?.description ?? name,
|
|
53
61
|
action: name,
|
|
54
62
|
policy: def.policy,
|
|
55
|
-
inputSchema: sortSchema(
|
|
56
|
-
outputSchema: sortSchema(
|
|
63
|
+
inputSchema: sortSchema({ ...toWireSchema(def.input) }),
|
|
64
|
+
...(output === undefined ? {} : { outputSchema: sortSchema({ ...output }) }),
|
|
57
65
|
invoke: (input, options = {}) =>
|
|
58
66
|
invoke(target, input, {
|
|
59
67
|
surface: 'mcp',
|