@ultimat3/action 30.9.0 → 31.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 +17 -32
- package/README.md +57 -49
- package/package.json +11 -9
- package/src/action-stream.ts +17 -1
- package/src/adapter.ts +22 -0
- package/src/audit-gate.ts +10 -8
- package/src/audit-memory.ts +2 -2
- package/src/audit-postgres.ts +7 -10
- package/src/audit.ts +2 -2
- package/src/client.ts +7 -6
- package/src/error-titles.ts +1 -1
- package/src/errors-http.ts +1 -1
- package/src/errors-idempotency.ts +2 -2
- package/src/errors-meter.ts +1 -1
- package/src/errors-stream.ts +1 -1
- package/src/errors.ts +29 -43
- package/src/exercise.ts +2 -1
- package/src/handler-override.ts +18 -0
- package/src/idempotency-memory.ts +14 -2
- package/src/idempotency-postgres.ts +7 -7
- package/src/idempotency.ts +21 -11
- package/src/index.ts +19 -57
- package/src/invoke.ts +29 -1
- package/src/loader.ts +5 -0
- package/src/meter-store.ts +13 -5
- package/src/openapi.ts +2 -0
- package/src/policy-gate.ts +21 -56
- package/src/rate-limit-gate.ts +18 -29
- package/src/validate.ts +9 -1
- package/src/wire-issues.ts +7 -9
package/CLAUDE.md
CHANGED
|
@@ -47,17 +47,20 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
47
47
|
| `declared-errors-contract.ts` | `.contract()`'s source scan |
|
|
48
48
|
| `openapi-errors.ts` | core's builder bound to http's status and `problemTypeFor` |
|
|
49
49
|
| `idempotency.ts` | the store SEAM: types, the installed-store slot, the scope declaration + `assertIdempotencyScope`, and `withIdempotency` — the replay-or-run gate |
|
|
50
|
+
| `loader.ts` | `@ultimat3/action/loader`: `registerAction`/`registerActions` for `x`'s loader and framework fixtures. Never the barrel — `defineApi` is an app's one path (`loader.test.ts`) |
|
|
51
|
+
| `adapter.ts` | `@ultimat3/action/adapter`: both stores' installers (each returns a release removing that install alone) + resets, port types, store errors, and `overrideHandler`. Never the barrel |
|
|
52
|
+
| `handler-override.ts` | the slot `overrideHandler` writes: a test double where `handle` is, keyed by the stored declaration and read per call by `invoke` (`run`) and, for a stream, by the drain (`action-stream.ts`) — so policy, parse, meter, idempotency and output parse still run on every surface. `@ultimat3/testing`'s `stub()` is its one caller (`handler-override.test.ts`) |
|
|
50
53
|
| `idempotency-key.ts` | the namespaced key — action + actor + the caller's key, as one JSON tuple — and the refusal of one that names no request |
|
|
51
54
|
| `idempotency-memory.ts` | the process default: bounded, swept, `scope: 'process'` |
|
|
52
55
|
| `idempotency-redact.ts` | `restingAnswer` — the copy of an answer a store may keep: core's `isRedactedKey` + `isSecret`, the SAME reference when nothing is redacted |
|
|
53
56
|
| `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
|
|
54
|
-
| `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as
|
|
57
|
+
| `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `PrimitiveDenial` and `SurfaceDenial` as types, which erases) |
|
|
55
58
|
| `cache-gate.ts` | the post-COMMIT bust — **the only** file that calls `invalidateTags` |
|
|
56
59
|
| `tx-scope.ts` | the open transaction — **the only** file that imports `@ultimat3/db` (`tx-scope.test.ts`): `openCommitScope` (the bust), `liveTransaction` (the settle), `completeTransaction`, and `inRolledBackScope`, the exercise's always-rolled-back scope and the memory transaction it opens always |
|
|
57
60
|
| `rate-limit-gate.ts` | **the one place** a declared `rateLimit:` is spent (a `limit` resolved per call from `ctx`, through `toBucket`) |
|
|
58
61
|
| `meter*.ts`, `errors-meter.ts` | `meter:` — declaration (`meter.ts`), UTC window, store seam + memory + Postgres (`x_meter`), and `meter-gate.ts`, **the one place** it is drawn |
|
|
59
62
|
| `request-deadline.ts` | `requestDeadlineMs` — the app's `requestTimeoutMs`, resolved by http's own `defineHttpConfig` |
|
|
60
|
-
| `audit.ts` | the audit seam's TYPES, re-exported from `@ultimat3/core` (shared with `query`); the slot (`
|
|
63
|
+
| `audit.ts` | the audit seam's TYPES, re-exported from `@ultimat3/core` (shared with `query`); the slot (`installAuditSink`, `@ultimat3/core/adapter`) is core's alone |
|
|
61
64
|
| `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
|
|
62
65
|
| `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
|
|
63
66
|
| `audit-input.ts` | what may be written DOWN: an `input` redacted through core's table and made JSON-representable on every path |
|
|
@@ -157,8 +160,8 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
157
160
|
- **Both clients inject `traceparent`** before the caller's headers; an incomplete span sends nothing.
|
|
158
161
|
- `serializeOpenApi` output is byte-stable: sorted keys, sorted registry, no clock. `client.ts` stays
|
|
159
162
|
free of server imports.
|
|
160
|
-
- **`stable.ts` holds the DOCUMENT form only** (`stableStringify
|
|
161
|
-
`json-schema.ts`). The HASH form is core's `canonicalJson`/`fingerprint` (injective: tags
|
|
163
|
+
- **`stable.ts` holds the DOCUMENT form only** (`stableStringify`, exported: `openapi.json`,
|
|
164
|
+
`json-schema.ts`, mcp's `tools.lock.json`, testing's cassettes — no other serializer). The HASH form is core's `canonicalJson`/`fingerprint` (injective: tags
|
|
162
165
|
`NaN`/`±Infinity`/`-0`, `Date`, `Map`, `Set`); `stable.test.ts` pins byte-equality for ordinary
|
|
163
166
|
payloads.
|
|
164
167
|
- **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (imported, never
|
|
@@ -184,21 +187,17 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
184
187
|
- **Flight control is `@ultimat3/core`'s, and NOT re-exported** — its values are imported from
|
|
185
188
|
core (`X_HELPER_COPY` refuses a re-export); only the types `ClientFlight`/`ClientRetry`. Fix the
|
|
186
189
|
pipeline in `packages/core/src/client-flight.ts`. No new code, curve, fence or retry loop here (`bun run flight-copies`).
|
|
187
|
-
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** (a caller's `AbortError`
|
|
188
|
-
is terminal). Must survive.
|
|
189
190
|
- **`ClientFlight` is a TYPE inside `client.ts`, never a value.** Measured,
|
|
190
191
|
`bun build --target=browser --minify`, one entry importing from `@ultimat3/action` — **the ONE
|
|
191
192
|
table for these figures** (`packages/core/CLAUDE.md` points here):
|
|
192
193
|
|
|
193
|
-
| Entry | `As of 2026-11-
|
|
194
|
+
| Entry | `As of 2026-11-09`, Bun 1.4.4, one entry over the working tree |
|
|
194
195
|
|---|---|
|
|
195
|
-
| `rpc` |
|
|
196
|
-
| `rpc` +
|
|
196
|
+
| `rpc` | 24,473 B, pinned ≤ 25,000 (`client-bundle.test.ts`); 29,972 B while it read core's barrel |
|
|
197
|
+
| `rpc` + `clientFlight` from `@ultimat3/core/page` | 32,929 B (38,416 B from the barrel) |
|
|
197
198
|
|
|
198
|
-
-
|
|
199
|
-
|
|
200
|
-
not. An http refusal needs no http module: core's decoders take its title off the body and its
|
|
201
|
-
`retry-after` off the header (`rpc-refusal.test.ts`).
|
|
199
|
+
- `isTransientFailure`'s inversion, the client graph's import rule, `sideEffects`: still rules, in
|
|
200
|
+
`docs/history/action-client-graph.md` (moved 2026-11-09).
|
|
202
201
|
|
|
203
202
|
## Invariants — idempotency
|
|
204
203
|
|
|
@@ -206,29 +205,15 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
206
205
|
is SETTLED as a failure and a retry replays it; `release()` is only for a pre-handler failure. A
|
|
207
206
|
`settle` that refuses leaves the record in flight. `idempotency-failure.test.ts`.
|
|
208
207
|
- **A keyed run is owed its end**: `completeTransaction()`, then `runToCompletion(run)` under `idempotencyCeilingMs()` (`X_IDEMPOTENCY_RUN_CEILING`); `invoke` reads the handler's `ctx` inside it. `idempotency-orphan.test.ts`.
|
|
209
|
-
- **A record belongs to ONE CALLER**: the key is
|
|
210
|
-
`JSON.stringify([action, actor.kind, actor.id, actor.orgId ?? null, key])` — a JSON tuple, never a
|
|
211
|
-
joined string. A blank header is `X_IDEMPOTENCY_KEY_INVALID` (4xx, before the handler), never "no
|
|
212
|
-
key". Anonymous callers still share one key space.
|
|
213
|
-
- **The `idempotency-key` header also NAMES the write, on every action**: `http.ts` runs `invoke`
|
|
214
|
-
inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
|
|
215
|
-
never a gate.
|
|
216
208
|
- **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
|
|
217
209
|
`settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)` /
|
|
218
|
-
`release(key, reservationId)`. A fenced no-op is never thrown.
|
|
210
|
+
`release(key, reservationId)`. A fenced no-op is never thrown; an autocommit `settle`/`fail` logs `action.idempotency.settlement-fenced` on BOTH stores (`idempotency-memory-fenced.test.ts`).
|
|
219
211
|
- **An answer rests REDACTED** (#591): `withIdempotency` passes the store `restingAnswer(value)`
|
|
220
212
|
(core's `isRedactedKey` + `isSecret`) and `redacted` as `settle`'s REQUIRED 4th argument. A
|
|
221
213
|
flagged replay is `X_IDEMPOTENT_REPLAY_REDACTED`, by the FLAG, never by scanning. Full rule: `docs/history/action.md`.
|
|
222
214
|
- **A finished record skips the row half; a stale peek judges it in `beforeRun`** (`idempotency-row.test.ts`). **A `bigint` answer settles failed** (`X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE`).
|
|
223
|
-
- **
|
|
224
|
-
|
|
225
|
-
- **Where records live is DECLARED and refused at registration**: `IdempotencyStore.scope` vs
|
|
226
|
-
`configureIdempotency({ scope })`, compared by `assertIdempotencyScope` in `registerAction`
|
|
227
|
-
(`X_IDEMPOTENCY_NOT_SHARED`). Default `'process'`.
|
|
228
|
-
- **The memory store is bounded; `in-flight` records are the last evicted.** Never an LRU.
|
|
229
|
-
- **`postgresIdempotencyStore` is the shared store** over `@ultimat3/core`'s structural `PgExecutor`; the
|
|
230
|
-
reservation is ONE `insert … on conflict` statement. The CLI boot installs it.
|
|
231
|
-
- **Inside a transaction the SETTLE commits with the write, on BOTH stores** (`liveTransaction()`). `idempotency-parity.test.ts`. Full rule: `docs/history/action.md`.
|
|
215
|
+
- **Caller keying, the write-origin label, status narrowing, declared scope, eviction order, the shared store**: `docs/history/action.md#idempotency-invariants-moved-from-claudemd`.
|
|
216
|
+
- **Inside a transaction the SETTLE commits with the write, on BOTH stores** (`liveTransaction()`). `idempotencyStoreConformance` (`@ultimat3/testing/conformance`). Full rule: `docs/history/action.md`.
|
|
232
217
|
- **The deadline is `reclaimAfterMs: () => number`, REQUIRED on the Postgres store** and read per
|
|
233
218
|
reservation (`requestDeadlineMs`, never a number here); `origin: () => object` is required too. Full rule: `docs/history/action.md`.
|
|
234
219
|
|
|
@@ -254,7 +239,7 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
254
239
|
`locale`, `tz`, `buildId`, `role`, actor `id`/`kind`/`orgId`/`onBehalfOf`) and keeps
|
|
255
240
|
`failure.code`, never the error.
|
|
256
241
|
- **`x_audit` ships no purge**, deliberately. **`SQL_AUDIT_INSERT` is positional**, pinned by
|
|
257
|
-
`audit-
|
|
242
|
+
`audit-postgres-columns.test.ts` (a distinct value per field) and `auditSinkConformance` live. Only `*_TABLE` DDL is exported
|
|
258
243
|
(`X_SQL_EXPORT_UNREAD`).
|
|
259
244
|
- **The two failure policies are opposites**: `X_AUDIT_SINK_MISSING` before the input parse; a sink refusing an ALLOWED record is `X_AUDIT_SINK_FAILED`; refusing a denied or failed one is logged. Full rule: `docs/history/action.md`.
|
|
260
245
|
- **`auditSettled` sits outside the `catch`**, and **`auditOutcomeFor` is TOTAL** (fails closed to
|
|
@@ -263,7 +248,7 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
263
248
|
## Commands
|
|
264
249
|
|
|
265
250
|
```
|
|
266
|
-
bun test packages/action
|
|
251
|
+
bun test ./packages/action/
|
|
267
252
|
bun run typecheck
|
|
268
253
|
```
|
|
269
254
|
|
package/README.md
CHANGED
|
@@ -105,7 +105,8 @@ names deriving one route collide with `X_ACTION_PATH_DUPLICATE` — `pluralize`
|
|
|
105
105
|
|
|
106
106
|
`registerActions` is what `defineApi` composes for the three action-shaped keys; the other three
|
|
107
107
|
go through core's `primitiveRegistrar(kind)`, because this package may not import `@ultimat3/query`
|
|
108
|
-
or `@ultimat3/jobs` sideways.
|
|
108
|
+
or `@ultimat3/jobs` sideways. It is not on the `.` barrel: `defineApi` is the one path an app has,
|
|
109
|
+
and `x`'s module loader imports `registerAction`/`registerActions` from `@ultimat3/action/loader`.
|
|
109
110
|
|
|
110
111
|
## Call it — `rpc`
|
|
111
112
|
|
|
@@ -470,7 +471,7 @@ call (`surface: 'server'` — a service, a seed, a test) is app code, not a call
|
|
|
470
471
|
| Fact | Answer |
|
|
471
472
|
|---|---|
|
|
472
473
|
| whose bucket | the caller's: actor → org → connection address (`@ultimat3/http`'s `rateLimitSpends`). An anonymous caller with no address (MCP, a job) shares one bucket |
|
|
473
|
-
| where it is counted | `@ultimat3/http`'s installed store
|
|
474
|
+
| where it is counted | `@ultimat3/http`'s installed store — the slot `@ultimat3/query` spends from too; the boot fills it from `rateLimitStore` in `apps/<app>/runtime.ts` (else Postgres over its pool), on every role |
|
|
474
475
|
| what HTTP answers | `RateLimit-*` from the bucket closest to refusing, and on a refusal `429` + `Retry-After` — the pipeline's own shape |
|
|
475
476
|
| a retry | an admitted attempt spends one token; a refused one spends nothing. A job refused `X_RATE_LIMITED` is rescheduled for its `Retry-After` WITHOUT counting an attempt, so a backlog is delayed, never dead-lettered |
|
|
476
477
|
| an idempotent replay | spends nothing: the stored answer is served, never a 429 |
|
|
@@ -480,15 +481,13 @@ call (`surface: 'server'` — a service, a seed, a test) is app code, not a call
|
|
|
480
481
|
|
|
481
482
|
```ts
|
|
482
483
|
import type { PgExecutor } from '@ultimat3/core';
|
|
483
|
-
import {
|
|
484
|
+
import { postgresRateLimitStore } from '@ultimat3/http';
|
|
484
485
|
|
|
485
|
-
declare const executor: PgExecutor; // the pool this
|
|
486
|
+
declare const executor: PgExecutor; // the pool this process already opened
|
|
486
487
|
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
491
|
-
installRateLimitStore(rateLimitStore);
|
|
488
|
+
// apps/<app>/runtime.ts — the one store every surface and every replica counts in. `x dev` and a
|
|
489
|
+
// container boot install Postgres over their own pool without it; this line replaces that store.
|
|
490
|
+
export const runtime = { rateLimitStore: postgresRateLimitStore({ executor }) };
|
|
492
491
|
```
|
|
493
492
|
|
|
494
493
|
`toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
|
|
@@ -604,13 +603,8 @@ must say so and bring a store that can keep it, or the retry that lands on anoth
|
|
|
604
603
|
no record and runs the handler again:
|
|
605
604
|
|
|
606
605
|
```ts
|
|
607
|
-
// boot
|
|
608
|
-
import {
|
|
609
|
-
configureIdempotency,
|
|
610
|
-
postgresIdempotencyStore,
|
|
611
|
-
requestDeadlineMs,
|
|
612
|
-
setIdempotencyStore,
|
|
613
|
-
} from '@ultimat3/action';
|
|
606
|
+
// apps/<app>/runtime.ts — the boot installs the store before defineApi() registers
|
|
607
|
+
import { configureIdempotency, postgresIdempotencyStore, requestDeadlineMs } from '@ultimat3/action';
|
|
614
608
|
import { db } from '@ultimat3/db';
|
|
615
609
|
|
|
616
610
|
const client = db();
|
|
@@ -618,17 +612,17 @@ const client = db();
|
|
|
618
612
|
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
|
|
619
613
|
// template whose positional form is `unsafe`), so that line compiles and throws on the first
|
|
620
614
|
// reservation.
|
|
621
|
-
// The framework boot installs this store for you;
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
postgresIdempotencyStore({
|
|
615
|
+
// The framework boot installs this store for you; hand it one only to replace it, and wrap the
|
|
616
|
+
// client this process already opened.
|
|
617
|
+
export const runtime = {
|
|
618
|
+
idempotencyStore: postgresIdempotencyStore({
|
|
625
619
|
executor: { query: (text, values) => client.query({ text, values }) },
|
|
626
620
|
// The client transactions on THIS database open on: a settle rides one of those and no other.
|
|
627
621
|
origin: () => client,
|
|
628
622
|
// The app's `requestTimeoutMs` (`configureHttp`), read per reservation — never a number here.
|
|
629
623
|
reclaimAfterMs: requestDeadlineMs,
|
|
630
624
|
}),
|
|
631
|
-
|
|
625
|
+
};
|
|
632
626
|
configureIdempotency({ scope: 'shared' });
|
|
633
627
|
```
|
|
634
628
|
|
|
@@ -675,7 +669,7 @@ status is `in-flight`** — one that ignores the second argument reopens the dou
|
|
|
675
669
|
type-checking cannot catch it: a one-parameter function still satisfies the signature.
|
|
676
670
|
|
|
677
671
|
**Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
|
|
678
|
-
`
|
|
672
|
+
`idempotencyStoreConformance` on `@ultimat3/testing/conformance` runs one set of cases over memory and Postgres). An action invoked
|
|
679
673
|
inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
|
|
680
674
|
its `onCommit` (memory).
|
|
681
675
|
|
|
@@ -779,11 +773,15 @@ price change are not the same event, and the framework is not the thing that kno
|
|
|
779
773
|
them your business has to keep.
|
|
780
774
|
|
|
781
775
|
```ts
|
|
782
|
-
|
|
776
|
+
// apps/web/runtime.ts — the boot installs it before the app's modules import
|
|
777
|
+
import type { RuntimeOverrides } from '@ultimat3/cli/serve';
|
|
778
|
+
import type { AuditRecord } from '@ultimat3/core';
|
|
783
779
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
780
|
+
declare function keep(record: AuditRecord): Promise<void>; // your table, log or queue
|
|
781
|
+
|
|
782
|
+
export const runtime = {
|
|
783
|
+
auditSink: { write: (record) => keep(record) },
|
|
784
|
+
} satisfies RuntimeOverrides;
|
|
787
785
|
```
|
|
788
786
|
|
|
789
787
|
What the framework supplies is what it genuinely knows:
|
|
@@ -800,8 +798,9 @@ What the framework supplies is what it genuinely knows:
|
|
|
800
798
|
| `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
|
|
801
799
|
|
|
802
800
|
**One contract, one sink, shared with reads.** `AuditRecord`, `AuditSink` and the installed slot
|
|
803
|
-
are `@ultimat3/core`'s, and so is the one way to install it: `
|
|
804
|
-
|
|
801
|
+
are `@ultimat3/core`'s, and so is the one way to install it: the `auditSink` field of
|
|
802
|
+
`apps/web/runtime.ts`, which the boot installs through core's `installAuditSink`
|
|
803
|
+
(`@ultimat3/core/adapter`, 31.0; this package re-exports the two types, never the slot) — the sink `query({ audit: true })` writes to as well (`@ultimat3/query`'s README). A sink
|
|
805
804
|
tells the two apart by `record.primitive`.
|
|
806
805
|
|
|
807
806
|
What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
|
|
@@ -818,18 +817,17 @@ one would make three of them wrong.
|
|
|
818
817
|
The memory sink is bounded because a record pins a whole `Ctx`: at 50 audited writes a second an
|
|
819
818
|
unbounded array is 4.3M immortal records a day and the pod dies holding the trail it was
|
|
820
819
|
retaining. The trap it names out loud is that the shortest edit clearing `X_AUDIT_SINK_MISSING`
|
|
821
|
-
is `
|
|
820
|
+
is `auditSink: memoryAuditSink()`, and nothing at that line says the result is amnesiac.
|
|
822
821
|
|
|
823
822
|
```ts
|
|
824
|
-
// apps/web/
|
|
823
|
+
// apps/web/runtime.ts — the boot installs the sink, over the pool it opened
|
|
825
824
|
import { postgresAuditSink } from '@ultimat3/action';
|
|
826
|
-
import {
|
|
827
|
-
import {
|
|
825
|
+
import type { RuntimeOverrides } from '@ultimat3/cli/serve';
|
|
826
|
+
import { dbExecutor } from '@ultimat3/db';
|
|
828
827
|
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
);
|
|
828
|
+
export const runtime = {
|
|
829
|
+
auditSink: postgresAuditSink({ executor: dbExecutor() }),
|
|
830
|
+
} satisfies RuntimeOverrides;
|
|
833
831
|
```
|
|
834
832
|
|
|
835
833
|
**The table is applied by the boot; the sink is not.** `startQueue` runs `SQL_AUDIT_TABLE` on
|
|
@@ -898,7 +896,8 @@ The row it produces is the app's, and so is every question the framework refused
|
|
|
898
896
|
which fields, whose tenant, chained or not, kept how long:
|
|
899
897
|
|
|
900
898
|
```ts
|
|
901
|
-
|
|
899
|
+
// apps/web/runtime.ts — every name below but `runtime` is the app's own
|
|
900
|
+
export const runtime = { auditSink: {
|
|
902
901
|
async write(record) {
|
|
903
902
|
const { ctx } = record; // the services a sink needs to write a row
|
|
904
903
|
const prev = await chainHead(ctx); // hash-chained: the app's choice
|
|
@@ -910,7 +909,7 @@ setAuditSink({
|
|
|
910
909
|
prevHash: prev, hash: await sha256(prev, record),
|
|
911
910
|
});
|
|
912
911
|
},
|
|
913
|
-
}
|
|
912
|
+
} } satisfies RuntimeOverrides; // apps/web/runtime.ts
|
|
914
913
|
```
|
|
915
914
|
|
|
916
915
|
## The audit trail, for a subject export or erase
|
|
@@ -956,6 +955,24 @@ Pass `input:` when the schema carries a constraint the IR cannot invert (a bare
|
|
|
956
955
|
when `row:` needs an id that resolves. Anything thrown *before* the policy decides is drift,
|
|
957
956
|
never a pass — the assertion says which code got in the way and names `input:` as the fix.
|
|
958
957
|
|
|
958
|
+
## `@ultimat3/action/loader` — where an export is named
|
|
959
|
+
|
|
960
|
+
`registerAction(name, action)` / `registerActions(module)`: what `defineApi` and `x`'s module scan
|
|
961
|
+
call to stamp an export's name onto it, and what a framework fixture calls to name one action.
|
|
962
|
+
Never on the `.` barrel — an app hands its modules to `defineApi` (`loader.test.ts` pins both).
|
|
963
|
+
|
|
964
|
+
## `@ultimat3/action/adapter` — the stores' installers and the handler override
|
|
965
|
+
|
|
966
|
+
The boot's installers live here, never on the `.` barrel: `installIdempotencyStore(store)` and
|
|
967
|
+
`installMeterStore(store)` each return a release that removes that install alone, in any order, beside
|
|
968
|
+
`resetIdempotency()` / `resetMeterStore()`, the port types and the errors a store throws. An app
|
|
969
|
+
swaps a store with `idempotencyStore:` / `meterStore:` in `apps/<app>/runtime.ts`.
|
|
970
|
+
|
|
971
|
+
`overrideHandler(action, handle | undefined)` puts a test double where `handle` is, on every
|
|
972
|
+
surface — a direct call, `.as()`, HTTP, MCP, `.job()`, `.stream()` (the drain reads it per call).
|
|
973
|
+
The policy, the rate limit, the input parse, the meter, idempotency and the output parse still run
|
|
974
|
+
around it; `undefined` restores the declared handler. `@ultimat3/testing`'s `stub()` drives it.
|
|
975
|
+
|
|
959
976
|
## Errors
|
|
960
977
|
|
|
961
978
|
| Code | When | Fix |
|
|
@@ -980,8 +997,8 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
980
997
|
| `X_CONTRACT_EXERCISE_UNREACHED` | no generated run reached the handler | `.contract({ exercise: { actor } })` with a permitted actor, or bind a row with `exercise.input` |
|
|
981
998
|
| `X_CONTRACT_EXERCISE_UNDECLARED` | `.contract()` on a guarded action with no `exercise`, or a blank `off` | `.contract({ exercise: { actor } })` or `{ exercise: { off: '<reason>' } }` |
|
|
982
999
|
| `X_RPC_FAILED` | registered, thrown by nothing since 21.0.0 — a non-`problem+json` failure is core's `X_CLIENT_TRANSPORT_FAILED` now, as for a query | match `X_CLIENT_TRANSPORT_FAILED` instead |
|
|
983
|
-
| `X_ACTION_UNREGISTERED` | projected before `
|
|
984
|
-
| `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `
|
|
1000
|
+
| `X_ACTION_UNREGISTERED` | projected before `defineApi()` named it | hand its module to `defineApi` at boot |
|
|
1001
|
+
| `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `auditSink: postgresAuditSink({ executor })` in `apps/web/runtime.ts` |
|
|
985
1002
|
| `X_AUDIT_SINK_FAILED` | the sink refused the record for an attempt that **succeeded** | fix the sink — then retry the same `Idempotency-Key` if this call carried one, else reconcile by hand |
|
|
986
1003
|
|
|
987
1004
|
Denials re-throw the policy layer's own codes (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) —
|
|
@@ -1009,21 +1026,12 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
1009
1026
|
| Class | Code | Declared in |
|
|
1010
1027
|
|---|---|---|
|
|
1011
1028
|
| `ActionDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
|
|
1012
|
-
| `ActionDeprecationInvalidError` | `X_ACTION_DEPRECATION_INVALID` | `src/errors.ts` |
|
|
1013
|
-
| `ActionDuplicateError` | `X_ACTION_DUPLICATE` | `src/errors.ts` |
|
|
1014
|
-
| `ActionForeignError` | `X_ACTION_FOREIGN` | `src/errors.ts` |
|
|
1015
|
-
| `ActionPathDuplicateError` | `X_ACTION_PATH_DUPLICATE` | `src/errors.ts` |
|
|
1016
|
-
| `ActionPolicyMissingError` | `X_ACTION_POLICY_MISSING` | `src/errors.ts` |
|
|
1017
|
-
| `ActionUnregisteredError` | `X_ACTION_UNREGISTERED` | `src/errors.ts` |
|
|
1018
1029
|
| `AuditSinkFailedError` | `X_AUDIT_SINK_FAILED` | `src/errors.ts` |
|
|
1019
|
-
| `AuditSinkMissingError` | `X_AUDIT_SINK_MISSING` | `src/errors.ts` |
|
|
1020
1030
|
| `ContractDriftError` | `X_CONTRACT_DRIFT` | `src/errors.ts` |
|
|
1021
1031
|
| `IdempotencyConflictError` | `X_IDEMPOTENCY_CONFLICT` | `src/errors-idempotency.ts` |
|
|
1022
1032
|
| `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
|
|
1023
|
-
| `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
|
|
1024
1033
|
| `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
|
|
1025
1034
|
| `IdempotencyReservationLostError` | `X_IDEMPOTENCY_RESERVATION_LOST` | `src/errors-idempotency.ts` |
|
|
1026
|
-
| `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
|
|
1027
1035
|
| `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
|
|
1028
1036
|
| `MutatorNotIdempotentError` | `X_MUTATOR_NOT_IDEMPOTENT` | `src/errors-idempotency.ts` |
|
|
1029
1037
|
| `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.1.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"provenance": true
|
|
29
29
|
},
|
|
30
30
|
"exports": {
|
|
31
|
-
".": "./src/index.ts"
|
|
31
|
+
".": "./src/index.ts",
|
|
32
|
+
"./adapter": "./src/adapter.ts",
|
|
33
|
+
"./loader": "./src/loader.ts"
|
|
32
34
|
},
|
|
33
35
|
"files": [
|
|
34
36
|
"src",
|
|
@@ -46,12 +48,12 @@
|
|
|
46
48
|
"test": "bun test"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
|
-
"@ultimat3/cache": "
|
|
50
|
-
"@ultimat3/core": "
|
|
51
|
-
"@ultimat3/db": "
|
|
52
|
-
"@ultimat3/entity": "
|
|
53
|
-
"@ultimat3/http": "
|
|
54
|
-
"@ultimat3/policy": "
|
|
55
|
-
"@ultimat3/schema": "
|
|
51
|
+
"@ultimat3/cache": "31.1.0",
|
|
52
|
+
"@ultimat3/core": "31.1.0",
|
|
53
|
+
"@ultimat3/db": "31.1.0",
|
|
54
|
+
"@ultimat3/entity": "31.1.0",
|
|
55
|
+
"@ultimat3/http": "31.1.0",
|
|
56
|
+
"@ultimat3/policy": "31.1.0",
|
|
57
|
+
"@ultimat3/schema": "31.1.0"
|
|
56
58
|
}
|
|
57
59
|
}
|
package/src/action-stream.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { asyncContext } from '@ultimat3/core';
|
|
|
9
9
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
10
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
11
11
|
import { ActionStreamInvalidError } from './errors-stream';
|
|
12
|
+
import { handlerOverrideOf } from './handler-override';
|
|
12
13
|
|
|
13
14
|
/** Where a draining handler hands its chunks: `http-stream.ts` opens one per streamed request. */
|
|
14
15
|
export interface ChunkSink {
|
|
@@ -39,6 +40,18 @@ export function withChunkSink<T>(sink: ChunkSink, fn: () => T): T {
|
|
|
39
40
|
return sinks.run({ sink, claimed: false }, fn);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Each draining copy → the declaration it wraps. Built once per `.named()` twin, so an override is
|
|
45
|
+
* keyed by the declaration (`declarationOf`), never the copy, and reaches every twin at once.
|
|
46
|
+
*/
|
|
47
|
+
const DRAINED = new WeakMap<object, object>();
|
|
48
|
+
|
|
49
|
+
/** Whether `def`'s `handle` is the drain: the drain, not `invoke`, resolves its override. */
|
|
50
|
+
export const drainsItsHandler = (def: object): boolean => DRAINED.has(def);
|
|
51
|
+
|
|
52
|
+
/** The declaration an override is keyed by: the wrapped one for a draining copy, else `def`. */
|
|
53
|
+
export const declarationOf = (def: object): object => DRAINED.get(def) ?? def;
|
|
54
|
+
|
|
42
55
|
interface StreamingDef {
|
|
43
56
|
readonly stream?: StandardSchemaV1;
|
|
44
57
|
handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
@@ -53,9 +66,12 @@ export function streamingDef<D extends StreamingDef>(def: D, name: () => string)
|
|
|
53
66
|
if (stream === undefined) return def;
|
|
54
67
|
const wrapped: D = {
|
|
55
68
|
...def,
|
|
69
|
+
// The override is resolved per call, never captured here: a test double installed after the
|
|
70
|
+
// declaration (`overrideHandler`) is drained exactly as the declared generator would be.
|
|
56
71
|
handle: (args: { readonly input: unknown; readonly ctx: Ctx }) =>
|
|
57
|
-
drain(wrapped, stream, name(), def.handle(args)),
|
|
72
|
+
drain(wrapped, stream, name(), (handlerOverrideOf(def) ?? def.handle.bind(def))(args)),
|
|
58
73
|
};
|
|
74
|
+
DRAINED.set(wrapped, def);
|
|
59
75
|
return wrapped;
|
|
60
76
|
}
|
|
61
77
|
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `@ultimat3/action/adapter` — what an idempotency or meter store is built and installed from: the
|
|
2
|
+
// port interfaces, the errors a store throws, and the boot's installers with their resets. Never
|
|
3
|
+
// the `.` barrel: an app swaps a store in `apps/<app>/runtime.ts` (`idempotencyStore:`,
|
|
4
|
+
// `meterStore:`), so the only callers here are the cli boot, `@ultimat3/testing` and a store author.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
IdempotencyReservationLostError,
|
|
8
|
+
IdempotencyStatusUnknownError,
|
|
9
|
+
} from './errors';
|
|
10
|
+
export type { HandlerOverride } from './handler-override';
|
|
11
|
+
export type {
|
|
12
|
+
IdempotencyFailure,
|
|
13
|
+
IdempotencyRecord,
|
|
14
|
+
IdempotencyReservation,
|
|
15
|
+
IdempotencyScope,
|
|
16
|
+
IdempotencyStatus,
|
|
17
|
+
IdempotencyStore,
|
|
18
|
+
} from './idempotency';
|
|
19
|
+
export { installIdempotencyStore, resetIdempotency } from './idempotency';
|
|
20
|
+
export { overrideHandler } from './invoke';
|
|
21
|
+
export type { MeterDraw, MeterDrawResult, MeterKey, MeterStore } from './meter-store';
|
|
22
|
+
export { installMeterStore, resetMeterStore } from './meter-store';
|
package/src/audit-gate.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* an attempt that already failed do not want the same answer.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
AUDIT_SINK_FAILED_EVENT,
|
|
9
|
+
auditFailureOf,
|
|
10
|
+
auditOutcomeOf,
|
|
11
|
+
getAuditSink,
|
|
12
|
+
logger,
|
|
13
|
+
} from '@ultimat3/core';
|
|
8
14
|
import type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from './audit';
|
|
9
15
|
import { ActionDeniedError, AuditSinkFailedError, AuditSinkMissingError } from './errors';
|
|
10
16
|
|
|
@@ -31,15 +37,11 @@ export function auditSinkFor(action: string): AuditSink {
|
|
|
31
37
|
* examined is not evidence of a policy denial, so it fails closed to `failed`.
|
|
32
38
|
*/
|
|
33
39
|
export function auditOutcomeFor(error: unknown): AuditOutcome {
|
|
34
|
-
|
|
35
|
-
return error instanceof ActionDeniedError ? 'denied' : 'failed';
|
|
36
|
-
} catch {
|
|
37
|
-
return 'failed';
|
|
38
|
-
}
|
|
40
|
+
return auditOutcomeOf(error, (thrown) => thrown instanceof ActionDeniedError);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function auditFailureFor(error: unknown): AuditFailure {
|
|
42
|
-
return
|
|
44
|
+
return auditFailureOf(error);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -82,7 +84,7 @@ export async function auditThrew(sink: AuditSink, record: AuditRecord): Promise<
|
|
|
82
84
|
// Core's logger, not `ctx.logger`: an HTTP `Ctx` is a cast request context that carries none,
|
|
83
85
|
// the same reason `cache-gate.ts` gives. Never the record — rendering an input the sink just
|
|
84
86
|
// choked on is the second throw this branch exists to prevent.
|
|
85
|
-
logger.error(
|
|
87
|
+
logger.error(AUDIT_SINK_FAILED_EVENT, {
|
|
86
88
|
action: record.name,
|
|
87
89
|
outcome: record.outcome,
|
|
88
90
|
error,
|
package/src/audit-memory.ts
CHANGED
|
@@ -26,8 +26,8 @@ export interface MemoryAuditSinkOptions {
|
|
|
26
26
|
* merely because it is not durable: it DISCARDS.** Past `maxRecords` the oldest record is dropped
|
|
27
27
|
* on every write, so an audited action can run, succeed, be recorded, and leave nothing behind —
|
|
28
28
|
* which is exactly what an audit trail must never do. The trap this shape exists to make visible
|
|
29
|
-
* is that the shortest edit clearing `X_AUDIT_SINK_MISSING` is `
|
|
30
|
-
* and nothing about
|
|
29
|
+
* is that the shortest edit clearing `X_AUDIT_SINK_MISSING` is `auditSink: memoryAuditSink()`,
|
|
30
|
+
* and nothing about that line says the result is amnesiac. A deployment that must keep its
|
|
31
31
|
* trail installs `postgresAuditSink({ executor })`, which drops nothing.
|
|
32
32
|
*
|
|
33
33
|
* `dropped` is what makes that statement checkable in a running process rather than a sentence
|
package/src/audit-postgres.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The durable audit sink: one append-only Postgres table, one insert per record. Without it the
|
|
3
|
-
* shortest edit that clears `X_AUDIT_SINK_MISSING` is `
|
|
4
|
-
* reads durable at the
|
|
3
|
+
* shortest edit that clears `X_AUDIT_SINK_MISSING` is `auditSink: memoryAuditSink()`, which
|
|
4
|
+
* reads durable at the line and is a ring that DROPS — compliant in dev, amnesiac in
|
|
5
5
|
* production. Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -102,16 +102,13 @@ export interface PostgresAuditSink extends AuditSink {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* **
|
|
106
|
-
*
|
|
105
|
+
* **Name it in `apps/web/runtime.ts`, beside the rest of this app's durability**, over the pool the
|
|
106
|
+
* boot opened — the boot installs it (`installAuditSink`) before the app's modules import:
|
|
107
107
|
*
|
|
108
108
|
* ```ts
|
|
109
|
-
* const
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
113
|
-
* }),
|
|
114
|
-
* );
|
|
109
|
+
* export const runtime = {
|
|
110
|
+
* auditSink: postgresAuditSink({ executor: dbExecutor() }),
|
|
111
|
+
* } satisfies RuntimeOverrides;
|
|
115
112
|
* ```
|
|
116
113
|
*
|
|
117
114
|
* `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
|
package/src/audit.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* The contract — the record, the sink, the one installed-sink slot — moved to `@ultimat3/core`'s
|
|
4
4
|
* `audit.ts` so `@ultimat3/query` (a sibling this package cannot import) writes the same record
|
|
5
5
|
* into the same sink. The TYPES are re-exported for this package's own modules; the slot's VALUES
|
|
6
|
-
* (`getAuditSink
|
|
7
|
-
* re-exported — a second import path for one value is `X_HELPER_COPY` (`bun run flight-copies`).
|
|
6
|
+
* (`getAuditSink`; the boot's `installAuditSink` is `@ultimat3/core/adapter`'s) are imported from
|
|
7
|
+
* core and never re-exported — a second import path for one value is `X_HELPER_COPY` (`bun run flight-copies`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from '@ultimat3/core';
|
package/src/client.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
ClientRetry,
|
|
22
22
|
FetchLike,
|
|
23
23
|
UltimateError,
|
|
24
|
-
} from '@ultimat3/core';
|
|
24
|
+
} from '@ultimat3/core/page';
|
|
25
25
|
import {
|
|
26
26
|
actionPath,
|
|
27
27
|
BUILD_ID_HEADER,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
problemOf,
|
|
34
34
|
remoteTitleOf,
|
|
35
35
|
renderedActionPin,
|
|
36
|
-
} from '@ultimat3/core';
|
|
36
|
+
} from '@ultimat3/core/page';
|
|
37
37
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
38
38
|
import type { Action, StreamAction } from './action';
|
|
39
39
|
import { ContractDriftError, RemoteActionError } from './errors';
|
|
@@ -101,7 +101,7 @@ export type StreamClientMethod<
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
/** Core's one declaration, re-exported by name so `import type { FetchLike }` keeps resolving. */
|
|
104
|
-
export type { FetchLike } from '@ultimat3/core';
|
|
104
|
+
export type { FetchLike } from '@ultimat3/core/page';
|
|
105
105
|
|
|
106
106
|
export interface ClientOptions {
|
|
107
107
|
readonly baseUrl: string;
|
|
@@ -311,6 +311,7 @@ function toUltimateError(
|
|
|
311
311
|
// Parsed, never taken: `body` is whatever answered the request. A list this build cannot read
|
|
312
312
|
// is dropped rather than repaired, and `cause` below still carries every rejection in it.
|
|
313
313
|
issues: issuesFromWire(body['issues']),
|
|
314
|
+
issuesTruncated: body['issuesTruncated'] === true ? true : undefined,
|
|
314
315
|
meta: metaFromWire(body['meta']),
|
|
315
316
|
requestId: body['requestId'],
|
|
316
317
|
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
|
|
@@ -325,8 +326,8 @@ function toUltimateError(
|
|
|
325
326
|
|
|
326
327
|
/**
|
|
327
328
|
* The document's `meta` member, or nothing. Parsed JSON is JSON-safe by construction, so the only
|
|
328
|
-
* work is shape: an object, copied own key by own key onto a fresh record. `issues`
|
|
329
|
-
*
|
|
329
|
+
* work is shape: an object, copied own key by own key onto a fresh record. `issues` and its flag
|
|
330
|
+
* `issuesTruncated` are skipped because they have their own reader up top, and `__proto__` because
|
|
330
331
|
* `JSON.parse` mints it as a real own key — copied by assignment it would replace the prototype
|
|
331
332
|
* of the error's `meta`, which the reporter and the overlay walk. EMPTY is `undefined`: an
|
|
332
333
|
* `{}` under `meta` would say the server declared keys and sent none.
|
|
@@ -336,7 +337,7 @@ function metaFromWire(value: unknown): Readonly<Record<string, unknown>> | undef
|
|
|
336
337
|
const out: Record<string, unknown> = {};
|
|
337
338
|
let carried = 0;
|
|
338
339
|
for (const [key, member] of Object.entries(value)) {
|
|
339
|
-
if (key === 'issues' || key === '__proto__') continue;
|
|
340
|
+
if (key === 'issues' || key === 'issuesTruncated' || key === '__proto__') continue;
|
|
340
341
|
Object.defineProperty(out, key, {
|
|
341
342
|
value: member,
|
|
342
343
|
writable: true,
|
package/src/error-titles.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
|
|
5
5
|
// its title from the registry; `errors.ts` and its two siblings import it bare as well.
|
|
6
6
|
|
|
7
|
-
import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core';
|
|
7
|
+
import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core/page';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Titles for the framework-wide code table — every one of them owned by this package.
|
package/src/errors-http.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* these registers its title first.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core/page';
|
|
9
9
|
import './error-titles';
|
|
10
10
|
|
|
11
11
|
export class ActionHttpPathInvalidError extends UltimateError {
|