@ultimat3/action 9.0.0 → 11.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 +5 -2
- package/package.json +6 -6
- package/src/errors-idempotency.ts +1 -8
- package/src/errors.ts +13 -26
package/CLAUDE.md
CHANGED
|
@@ -262,9 +262,12 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
262
262
|
`ActionDeniedError` re-uses the policy decision's — and then says so, because the browser
|
|
263
263
|
bundle never registered it: `name` marks it in a stack, `meta.origin: 'remote'` marks it in
|
|
264
264
|
`--json`, the overlay and the error reporter. **It never synthesizes a docs URL.**
|
|
265
|
-
|
|
265
|
+
a `…/errors/X_SIGNUP_CLOSED` invented for an app-declared code is a 404 dressed as
|
|
266
266
|
documentation; the link is the server's own `docs`/`type` when it sent an `http(s)` one, this
|
|
267
|
-
build's registered link when `hasErrorCode` knows the code, otherwise `
|
|
267
|
+
build's registered link when `hasErrorCode` knows the code, otherwise `ERROR_DOCS_URL`. Those
|
|
268
|
+
last two agree by construction now that core resolves every code to one URL — `hasErrorCode`
|
|
269
|
+
still separates them only for a package that declared its own `docs:`, which is why the branch
|
|
270
|
+
stays. The
|
|
268
271
|
code must be `X_SCREAMING_SNAKE` to be taken at all — `typeof code === 'string'` accepted `""`
|
|
269
272
|
from a gateway — and anything else is `RpcFailedError`, which is what that code means.
|
|
270
273
|
`docs` and `type` travel to `remoteDocs` as an ordered pair, not `docs ?? type`: preference is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.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",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/http": "
|
|
37
|
-
"@ultimat3/policy": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/cache": "11.0.0",
|
|
35
|
+
"@ultimat3/core": "11.0.0",
|
|
36
|
+
"@ultimat3/http": "11.0.0",
|
|
37
|
+
"@ultimat3/policy": "11.0.0",
|
|
38
|
+
"@ultimat3/schema": "11.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -4,14 +4,11 @@
|
|
|
4
4
|
* `registerErrorCodes` call, because a second registration is how two modules end up deciding a
|
|
5
5
|
* title by load order.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
8
8
|
// Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
|
|
9
9
|
// cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
|
|
10
10
|
import type { IdempotencyFailure } from './idempotency';
|
|
11
11
|
|
|
12
|
-
// Core's spelling, aliased — never a second one, and the same alias `errors.ts` takes.
|
|
13
|
-
const docs = errorDocsUrl;
|
|
14
|
-
|
|
15
12
|
export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
|
|
16
13
|
|
|
17
14
|
export class IdempotencyConflictError extends UltimateError {
|
|
@@ -28,7 +25,6 @@ export class IdempotencyConflictError extends UltimateError {
|
|
|
28
25
|
reason === 'payload-mismatch'
|
|
29
26
|
? 'set the Idempotency-Key header to a fresh crypto.randomUUID() — one key per payload, since this one already names a different request'
|
|
30
27
|
: 'resend this request with the same Idempotency-Key once the first one settles — a fresh crypto.randomUUID() here would run the mutation twice',
|
|
31
|
-
docs: docs('X_IDEMPOTENCY_CONFLICT'),
|
|
32
28
|
});
|
|
33
29
|
}
|
|
34
30
|
}
|
|
@@ -56,7 +52,6 @@ export class IdempotencyKeyInvalidError extends UltimateError {
|
|
|
56
52
|
? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
|
|
57
53
|
: `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
|
|
58
54
|
fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
|
|
59
|
-
docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
|
|
60
55
|
meta: { action, problem, length },
|
|
61
56
|
});
|
|
62
57
|
}
|
|
@@ -83,7 +78,6 @@ export class IdempotencyNotSharedError extends UltimateError {
|
|
|
83
78
|
// reservation. The framework's own boot already installs this store; a host booting the
|
|
84
79
|
// framework itself wraps the client it opened.
|
|
85
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' })",
|
|
86
|
-
docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
|
|
87
81
|
meta: { storeScope: storeScope ?? null },
|
|
88
82
|
});
|
|
89
83
|
}
|
|
@@ -140,7 +134,6 @@ export class IdempotencyStatusUnknownError extends UltimateError {
|
|
|
140
134
|
`x_idempotency.status holds ${renderCauseValue(input.value)} for key "${input.key}", ` +
|
|
141
135
|
`which this build does not know — it reads ${input.known.join(', ')}`,
|
|
142
136
|
fix: `psql "$DATABASE_URL" -c "select key, status from x_idempotency where status not in ('in-flight', 'settled', 'failed')" # then drain the older processes: a status this build cannot read was written by a newer deploy`,
|
|
143
|
-
docs: docs('X_IDEMPOTENCY_STATUS_UNKNOWN'),
|
|
144
137
|
meta: { key: input.key, value: renderCauseValue(input.value), known: [...input.known] },
|
|
145
138
|
});
|
|
146
139
|
}
|
package/src/errors.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
8
|
assertNever,
|
|
9
|
-
|
|
10
|
-
errorDocsUrl,
|
|
9
|
+
ERROR_DOCS_URL,
|
|
11
10
|
hasErrorCode,
|
|
12
11
|
registerErrorCodes,
|
|
13
12
|
UltimateError,
|
|
@@ -25,10 +24,6 @@ export {
|
|
|
25
24
|
IdempotencyStatusUnknownError,
|
|
26
25
|
} from './errors-idempotency';
|
|
27
26
|
|
|
28
|
-
// Core's spelling, aliased — never a second one. A local template drifts from what
|
|
29
|
-
// `x errors explain` prints the moment `ERROR_DOCS_BASE` moves.
|
|
30
|
-
const docs = errorDocsUrl;
|
|
31
|
-
|
|
32
27
|
/**
|
|
33
28
|
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
34
29
|
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
@@ -70,7 +65,6 @@ export class ActionUnregisteredError extends UltimateError {
|
|
|
70
65
|
code: 'X_ACTION_UNREGISTERED',
|
|
71
66
|
cause: 'an action was projected before it was registered, so it has no name',
|
|
72
67
|
fix: "call registerActions(await import('./actions')) at boot, before mounting routes",
|
|
73
|
-
docs: docs('X_ACTION_UNREGISTERED'),
|
|
74
68
|
});
|
|
75
69
|
}
|
|
76
70
|
}
|
|
@@ -87,7 +81,6 @@ export class ActionForeignError extends UltimateError {
|
|
|
87
81
|
code: 'X_ACTION_FOREIGN',
|
|
88
82
|
cause: `"${name === '' ? 'anonymous' : name}" is not an action built by action()`,
|
|
89
83
|
fix: "declare it as `export const name = action({ input, output, policy, handle })` from '@ultimat3/action'",
|
|
90
|
-
docs: docs('X_ACTION_FOREIGN'),
|
|
91
84
|
});
|
|
92
85
|
}
|
|
93
86
|
}
|
|
@@ -134,7 +127,6 @@ export class ActionDeniedError extends UltimateError {
|
|
|
134
127
|
code,
|
|
135
128
|
cause: `${action} denied: ${denialReason(denial)}`,
|
|
136
129
|
fix: `x policy explain ${action} --json # shows which clause decided and why`,
|
|
137
|
-
docs: docs(code),
|
|
138
130
|
});
|
|
139
131
|
this.denial = denial;
|
|
140
132
|
}
|
|
@@ -146,7 +138,6 @@ export class ActionDuplicateError extends UltimateError {
|
|
|
146
138
|
code: 'X_ACTION_DUPLICATE',
|
|
147
139
|
cause: `two actions are registered under the name "${name}"`,
|
|
148
140
|
fix: `rename one export — action names are globally unique: x actions list --json`,
|
|
149
|
-
docs: docs('X_ACTION_DUPLICATE'),
|
|
150
141
|
});
|
|
151
142
|
}
|
|
152
143
|
}
|
|
@@ -163,7 +154,6 @@ export class ActionPathDuplicateError extends UltimateError {
|
|
|
163
154
|
code: 'X_ACTION_PATH_DUPLICATE',
|
|
164
155
|
cause: `actions "${input.name}" and "${input.existing}" both derive ${input.path}`,
|
|
165
156
|
fix: `rename one export so the two derive different paths — x actions list --json prints every derived route`,
|
|
166
|
-
docs: docs('X_ACTION_PATH_DUPLICATE'),
|
|
167
157
|
});
|
|
168
158
|
}
|
|
169
159
|
}
|
|
@@ -181,7 +171,6 @@ export class ActionPolicyMissingError extends UltimateError {
|
|
|
181
171
|
code: 'X_ACTION_POLICY_MISSING',
|
|
182
172
|
cause: `action "${name}" was registered without a policy`,
|
|
183
173
|
fix: `add \`policy: can('<resource>:<verb>')\` to the action() that exports "${name}" — a permission your definePermissions() call declares, never the action's own name — or \`allow('<resource>:<verb>')\` if the operation is genuinely public`,
|
|
184
|
-
docs: docs('X_ACTION_POLICY_MISSING'),
|
|
185
174
|
});
|
|
186
175
|
}
|
|
187
176
|
}
|
|
@@ -192,7 +181,6 @@ export class InputInvalidError extends UltimateError {
|
|
|
192
181
|
code: 'X_INPUT_INVALID',
|
|
193
182
|
cause: `input for action "${name}" failed validation: ${detail}`,
|
|
194
183
|
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
195
|
-
docs: docs('X_INPUT_INVALID'),
|
|
196
184
|
});
|
|
197
185
|
}
|
|
198
186
|
}
|
|
@@ -208,7 +196,6 @@ export class OutputInvalidError extends UltimateError {
|
|
|
208
196
|
code: 'X_OUTPUT_INVALID',
|
|
209
197
|
cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
|
|
210
198
|
fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
|
|
211
|
-
docs: docs('X_OUTPUT_INVALID'),
|
|
212
199
|
});
|
|
213
200
|
}
|
|
214
201
|
}
|
|
@@ -225,7 +212,6 @@ export class ActionDeprecationInvalidError extends UltimateError {
|
|
|
225
212
|
code: 'X_ACTION_DEPRECATION_INVALID',
|
|
226
213
|
cause: `action "${action}" declares deprecated.${field} as "${value}", which is not a date`,
|
|
227
214
|
fix: `edit \`deprecated: { ${field}: … }\` on ${action} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
|
|
228
|
-
docs: docs('X_ACTION_DEPRECATION_INVALID'),
|
|
229
215
|
meta: { action, field, value },
|
|
230
216
|
});
|
|
231
217
|
}
|
|
@@ -252,23 +238,27 @@ export interface RemoteFailure {
|
|
|
252
238
|
const ABSOLUTE_HTTP_URL = /^https?:\/\//;
|
|
253
239
|
|
|
254
240
|
/**
|
|
255
|
-
* The docs link, or the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* page. So: the server's own link when it sent a resolvable one, this build's registered link
|
|
260
|
-
* when it knows the code, and otherwise the index — a page that exists.
|
|
241
|
+
* The docs link the SERVER offered, or the framework's own. It never synthesizes a per-code URL:
|
|
242
|
+
* a code the server owns — `X_SIGNUP_CLOSED`, declared by the app through `registerErrorStatus` —
|
|
243
|
+
* is one this bundle never registered, and a `https://…/errors/X_SIGNUP_CLOSED` invented for it
|
|
244
|
+
* would be a 404 printed under `docs:` as if the framework wrote the page.
|
|
261
245
|
*
|
|
262
246
|
* `sent` is ordered, not singular: a server that fills `docs` with a `javascript:` URI still
|
|
263
247
|
* sent RFC-9457's `type`, and taking the first *resolvable* candidate means the unusable one
|
|
264
248
|
* costs nothing. Testing only the preferred slot would have dropped a valid link on the floor.
|
|
249
|
+
*
|
|
250
|
+
* The two fallbacks agree by construction since `ERROR_DOCS_URL` replaced the per-code base:
|
|
251
|
+
* `describeErrorCode` answers an unregistered code with that same constant, so `hasErrorCode`
|
|
252
|
+
* only still distinguishes a package that declared its OWN `docs:` on a registered code. Kept
|
|
253
|
+
* explicit rather than collapsed to `undefined`, because that declaration is the seam that would
|
|
254
|
+
* make them differ again.
|
|
265
255
|
*/
|
|
266
256
|
function remoteDocs(code: string, sent: readonly (string | undefined)[] = []): string | undefined {
|
|
267
257
|
const link = sent.find((value) => value !== undefined && ABSOLUTE_HTTP_URL.test(value));
|
|
268
258
|
if (link !== undefined) return link;
|
|
269
259
|
// `undefined` lets the constructor resolve the REGISTERED descriptor, whose docs a package
|
|
270
260
|
// may have declared as something other than the default URL.
|
|
271
|
-
return hasErrorCode(code) ? undefined :
|
|
261
|
+
return hasErrorCode(code) ? undefined : ERROR_DOCS_URL;
|
|
272
262
|
}
|
|
273
263
|
|
|
274
264
|
/**
|
|
@@ -303,7 +293,6 @@ export class RpcFailedError extends UltimateError {
|
|
|
303
293
|
code: 'X_RPC_FAILED',
|
|
304
294
|
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
305
295
|
fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
|
|
306
|
-
docs: docs('X_RPC_FAILED'),
|
|
307
296
|
});
|
|
308
297
|
}
|
|
309
298
|
}
|
|
@@ -321,7 +310,6 @@ export class AuditSinkMissingError extends UltimateError {
|
|
|
321
310
|
code: 'X_AUDIT_SINK_MISSING',
|
|
322
311
|
cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
|
|
323
312
|
fix: "call setAuditSink(yourSink) from '@ultimat3/action' at boot, before registerActions()",
|
|
324
|
-
docs: docs('X_AUDIT_SINK_MISSING'),
|
|
325
313
|
});
|
|
326
314
|
}
|
|
327
315
|
}
|
|
@@ -354,7 +342,6 @@ export class AuditSinkFailedError extends UltimateError {
|
|
|
354
342
|
fix: replayable
|
|
355
343
|
? `fix the sink installed by setAuditSink, then retry with the same Idempotency-Key — the replay re-records without re-running the handler`
|
|
356
344
|
: `fix the sink installed by setAuditSink, then reconcile this one change by hand — do NOT retry: ${action} ran with no Idempotency-Key, so a second call runs the committed handler again. Add \`idempotent: true\` and send the header to make retries safe`,
|
|
357
|
-
docs: docs('X_AUDIT_SINK_FAILED'),
|
|
358
345
|
// Read by `--json` and the error reporter: whether a retry is safe is the one decision an
|
|
359
346
|
// operator makes here, so it is a field and not only a sentence.
|
|
360
347
|
meta: { action, replayable },
|
|
@@ -365,6 +352,6 @@ export class AuditSinkFailedError extends UltimateError {
|
|
|
365
352
|
|
|
366
353
|
export class ContractDriftError extends UltimateError {
|
|
367
354
|
constructor(cause: string, fix: string) {
|
|
368
|
-
super({ code: 'X_CONTRACT_DRIFT', cause, fix
|
|
355
|
+
super({ code: 'X_CONTRACT_DRIFT', cause, fix });
|
|
369
356
|
}
|
|
370
357
|
}
|