@ultimat3/action 3.0.0 → 4.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 +41 -13
- package/package.json +6 -6
- package/src/contract-test.ts +15 -3
- package/src/define-api.ts +8 -1
- package/src/http.ts +19 -1
- package/src/idempotency.ts +1 -2
- package/src/job-handle.ts +7 -5
- package/src/naming.ts +6 -2
- package/src/sample-input.ts +10 -2
- package/src/stable.ts +20 -58
package/CLAUDE.md
CHANGED
|
@@ -35,7 +35,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
35
35
|
| `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the memory sink, the installed-sink store |
|
|
36
36
|
| `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
|
|
37
37
|
| `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
|
|
38
|
-
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers |
|
|
38
|
+
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer only — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
|
|
39
39
|
|
|
40
40
|
## Invariants
|
|
41
41
|
|
|
@@ -61,16 +61,32 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
61
61
|
author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
|
|
62
62
|
it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
|
|
63
63
|
drives a row-level action over the real pipeline and counts the evaluations: exactly one.
|
|
64
|
-
- **`stable.ts` holds
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`stable.test.ts` pins
|
|
64
|
+
- **`stable.ts` holds the DOCUMENT form and NOTHING else, `As of 2026-08`.** `stableStringify` is
|
|
65
|
+
published as `openapi.json` by `serializeOpenApi` and re-read with `JSON.parse` by
|
|
66
|
+
`json-schema.ts`, so a non-finite number has to be `null` and a `Date` has to be its ISO string —
|
|
67
|
+
the bare token `NaN` would make a published spec unparseable. The HASH form left this file:
|
|
68
|
+
`canonicalJson` + `fingerprint` are **`@ultimat3/core`'s**, because `@ultimat3/query` and
|
|
69
|
+
`@ultimat3/realtime` each held their own copy of the identical function and all three are tier 3,
|
|
70
|
+
so no two of them could import each other and a copy in any was a second answer for the other
|
|
71
|
+
two. They had already diverged — query's had no `Date` branch, so every date window of a read
|
|
72
|
+
shared one cache key. Nothing about this package's keys moved: `fingerprint` is the same code at
|
|
73
|
+
the same width, `stable.test.ts` still pins the document duty (a `JSON.parse` of what it emits)
|
|
74
|
+
and now pins the byte-equality against core's form for an ordinary payload, which is what says no
|
|
75
|
+
idempotency record and no enqueued job moved.
|
|
76
|
+
**Why the hash form must be a separate function at all** — the reason that survives the move.
|
|
77
|
+
`NaN`, `±Infinity` and JSON `null` all encoded as `'null'` and `String(-0)` is `"0"`, so four
|
|
78
|
+
distinct inputs shared one `requestHash` (one caller handed another's stored response on replay)
|
|
79
|
+
and one job dedupe key. And three more values folded onto `{}`, the first of which `t.date`
|
|
80
|
+
produces on every parse: a `Date`, a `Map` and a `Set` have no own enumerable key, so
|
|
81
|
+
`Object.keys` was empty and the object branch rendered all three `{}` —
|
|
82
|
+
`fingerprint({ x: new Map([['a', 1]]) })` equalled `fingerprint({ x: new Set([1, 2]) })` equalled
|
|
83
|
+
`fingerprint({ x: {} })`, and an `idempotent: true` action taking `t.object({ at: t.date })`,
|
|
84
|
+
called twice under one key with two DIFFERENT dates, handed the second caller the first one's
|
|
85
|
+
stored response with no `X_IDEMPOTENCY_CONFLICT` and the handler run once. The two forms disagree
|
|
86
|
+
about all four exactly as they disagree about numbers: the document form is `JSON.stringify`'s own
|
|
87
|
+
rendering, and the hash form TAGS them — `Date(<epoch>)`, `Map(k:v,...)`, `Set(v,...)`. The tag is
|
|
88
|
+
not decoration: an untagged epoch is the same token a `t.number` field holding that epoch emits.
|
|
89
|
+
Map and Set entries are SORTED, as object keys are.
|
|
74
90
|
- **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `packages/action/src/tags.ts`
|
|
75
91
|
and `packages/query/src/tags.ts` were byte-identical, and both packages are tier 3, so neither can
|
|
76
92
|
import the other and a copy in either is a second answer for the other. `tagKey` went with it: it
|
|
@@ -201,8 +217,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
201
217
|
can drive it by hand and still get the action's parse + policy. **The missing halves are
|
|
202
218
|
`tenant` and `retry`**, both required on `JobDefinition` with deliberately no default, so "zero
|
|
203
219
|
rewriting" was never reachable regardless of wiring. The bridge is one `job({ … })` call, and it
|
|
204
|
-
belongs in the app or at tier 4+: `action` and `jobs` are both tier 3.
|
|
205
|
-
|
|
220
|
+
belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. **It exists, `As of
|
|
221
|
+
2026-08`** — `agentJob()` in `@ultimat3/ai` (`agent-job.ts`), tier 4, which takes an `Action` and
|
|
222
|
+
nothing agent-specific and supplies the two missing halves as its own options. So "nothing in the
|
|
223
|
+
framework consumes it", which `job-handle.ts`'s header said until this was checked, is false: it
|
|
224
|
+
has one consumer, and an app writes the same three lines.
|
|
206
225
|
- An action has no `.def`. Inside the package read it with `defOf(target)`; outside,
|
|
207
226
|
read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`.
|
|
208
227
|
- **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased
|
|
@@ -349,6 +368,15 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
349
368
|
the module for its own test and absent from `src/index.ts` exactly as `sortSchema` is; the
|
|
350
369
|
shipped `toJsonSchema` cannot reach it today (it returns an object literal on every path), so
|
|
351
370
|
the test drives the guard directly rather than pretending a converter can be swapped.
|
|
371
|
+
- **A lookup table is read with `Object.hasOwn`, never with the index alone.** `IRREGULAR[word]`
|
|
372
|
+
in `naming.ts` and `BY_FORMAT[node.format]` in `sample-input.ts` both read the prototype chain:
|
|
373
|
+
`splitWords` lowercases, which keeps `toString` and `hasOwnProperty` out of reach, but
|
|
374
|
+
`constructor` is already lowercase and survived — so `pluralize('constructor')` answered the
|
|
375
|
+
`Object` FUNCTION where its return type says `string`, `derivePath('addConstructor')` mounted the
|
|
376
|
+
action at `/api/function Object() { [native code] }/add` and published that as its OpenAPI path
|
|
377
|
+
and `tags`, and a provider emitting `format: 'constructor'` put a function in the payload the
|
|
378
|
+
policy contract test invokes with. Both keys are caller- or provider-supplied, which is the whole
|
|
379
|
+
test for whether this applies. Same discriminator `packages/flags/src/subject.ts:75` uses.
|
|
352
380
|
- App code reaches a projection through the action (`publishPost.tool()`), never through
|
|
353
381
|
`.def` and never by importing the projection function. `facade.ts` is where a new method
|
|
354
382
|
is bound; the projection itself keeps living in its own file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -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": "4.1.0",
|
|
35
|
+
"@ultimat3/core": "4.1.0",
|
|
36
|
+
"@ultimat3/http": "4.1.0",
|
|
37
|
+
"@ultimat3/policy": "4.1.0",
|
|
38
|
+
"@ultimat3/schema": "4.1.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/contract-test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
-
import { createContext, isUltimateError } from '@ultimat3/core';
|
|
8
|
+
import { createContext, isUltimateError, logger } from '@ultimat3/core';
|
|
9
9
|
import type { AnyAction } from './action';
|
|
10
10
|
import { ActionDeniedError, ContractDriftError } from './errors';
|
|
11
11
|
import { actionName, invoke } from './invoke';
|
|
@@ -51,8 +51,9 @@ export function contractTestsFor(
|
|
|
51
51
|
await expectThrow(
|
|
52
52
|
() => invoke(target, garbage, { ctx, surface: 'http' }),
|
|
53
53
|
'X_INPUT_INVALID',
|
|
54
|
-
`${name} accepted
|
|
54
|
+
`${name} accepted the value this assertion sent as garbage`,
|
|
55
55
|
`tighten \`input:\` in the ${name} definition`,
|
|
56
|
+
{ action: name, garbage },
|
|
56
57
|
);
|
|
57
58
|
},
|
|
58
59
|
},
|
|
@@ -75,7 +76,7 @@ export function contractTestsFor(
|
|
|
75
76
|
if (document.paths[path] === undefined) {
|
|
76
77
|
throw new ContractDriftError(
|
|
77
78
|
`OpenAPI document has no entry for ${path}`,
|
|
78
|
-
'x verify --contract',
|
|
79
|
+
'x verify --json # the contract suite is a step of it',
|
|
79
80
|
);
|
|
80
81
|
}
|
|
81
82
|
},
|
|
@@ -163,12 +164,21 @@ async function expectDenied(
|
|
|
163
164
|
);
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* `fields` and not a rendered value: `garbage` is the caller's, typed `unknown`, and it was
|
|
169
|
+
* interpolated into `cause` with `JSON.stringify` — which throws on a circular object and on a
|
|
170
|
+
* `bigint`. That throw happened on the way INTO this helper, so the assertion never ran and the
|
|
171
|
+
* failure reported was the stringifier's, not the schema's. The logger takes the value as a field
|
|
172
|
+
* and shapes it itself, the way `cache-gate.ts` and `idempotency.ts` hand it their `error`.
|
|
173
|
+
*/
|
|
166
174
|
async function expectThrow(
|
|
167
175
|
run: () => Promise<unknown>,
|
|
168
176
|
code: string,
|
|
169
177
|
cause: string,
|
|
170
178
|
fix: string,
|
|
179
|
+
fields: Readonly<Record<string, unknown>>,
|
|
171
180
|
): Promise<void> {
|
|
181
|
+
const report = (): void => logger.error('action.contract.assertion-failed', { ...fields, code });
|
|
172
182
|
try {
|
|
173
183
|
await run();
|
|
174
184
|
} catch (error) {
|
|
@@ -179,7 +189,9 @@ async function expectThrow(
|
|
|
179
189
|
// answers it. It keeps its own code and its own runnable fix — the same rule `expectDenied`
|
|
180
190
|
// follows for every code it cannot attribute to `input:`.
|
|
181
191
|
if (error.code === 'X_AUDIT_SINK_MISSING') throw error;
|
|
192
|
+
report();
|
|
182
193
|
throw new ContractDriftError(`${cause} (got ${error.code}, expected ${code})`, fix);
|
|
183
194
|
}
|
|
195
|
+
report();
|
|
184
196
|
throw new ContractDriftError(cause, fix);
|
|
185
197
|
}
|
package/src/define-api.ts
CHANGED
|
@@ -132,7 +132,14 @@ function moduleList(modules: ApiModules | undefined): readonly ApiModule[] {
|
|
|
132
132
|
* a name no surface serves, and the last module exporting that name would win in silence.
|
|
133
133
|
*/
|
|
134
134
|
function byRegisteredName(primitives: readonly RegisteredPrimitive[]): ApiModule {
|
|
135
|
-
|
|
135
|
+
// A null-prototype map, because `name` is an export name and `map['__proto__'] = value` on a
|
|
136
|
+
// plain object runs the PROTOTYPE SETTER instead of adding a key: the primitive would vanish
|
|
137
|
+
// from `Object.keys`, from `api.actions` and from `rpc()`, while `registerPrimitive` reported
|
|
138
|
+
// it registered. `Object.create(null)` has no such accessor, so the assignment is a key.
|
|
139
|
+
const map: Record<string, RegisteredPrimitive> = Object.create(null) as Record<
|
|
140
|
+
string,
|
|
141
|
+
RegisteredPrimitive
|
|
142
|
+
>;
|
|
136
143
|
for (const primitive of primitives) map[primitive.name] = primitive;
|
|
137
144
|
return Object.freeze(map);
|
|
138
145
|
}
|
package/src/http.ts
CHANGED
|
@@ -95,7 +95,17 @@ export function toRoute(target: AnyAction): Route {
|
|
|
95
95
|
// stage deciding first would decide from `row: null` — a denial for the row's own
|
|
96
96
|
// author, from an authz system that never saw the row.
|
|
97
97
|
enforcedBy: 'handler',
|
|
98
|
-
input
|
|
98
|
+
// `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
|
|
99
|
+
// Setting it hands the action's schema to the pipeline's `body` stage, which throws
|
|
100
|
+
// `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
|
|
101
|
+
// the operation two hundred lines down published `400 X_INPUT_INVALID`, and the SAME action
|
|
102
|
+
// called over MCP, over the typed client or directly answered `X_INPUT_INVALID`. One action,
|
|
103
|
+
// one input schema, two codes, decided by the surface the call arrived through.
|
|
104
|
+
//
|
|
105
|
+
// `invoke`'s own `validateInput` is the one parser now, and nothing is lost by dropping this:
|
|
106
|
+
// `bodyRaw()` still parses by content-type, caches, and enforces the size cap — the body stage
|
|
107
|
+
// only ever added SCHEMA validation on top. `X_BODY_INVALID` keeps its job, which is a body
|
|
108
|
+
// failing a plain `route.ts`'s own schema, where no primitive owns the input.
|
|
99
109
|
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
|
|
100
110
|
tags: [resource],
|
|
101
111
|
// Name AND numbers. The name alone selected a bucket the limiter's table never held, so
|
|
@@ -145,7 +155,15 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
145
155
|
description: 'ok',
|
|
146
156
|
content: { 'application/json': { schema: { $ref: schemaRef(outputSchemaName(name)) } } },
|
|
147
157
|
},
|
|
158
|
+
// BOTH, because they are two different failures and this operation published only one of
|
|
159
|
+
// them while the route answered only the other. `X_INPUT_INVALID` is the body that parsed
|
|
160
|
+
// and failed THIS action's declared schema — the primitive's own code, identical over MCP,
|
|
161
|
+
// the typed client and a direct call. `X_BODY_INVALID` is the bytes never becoming a body
|
|
162
|
+
// at all: malformed JSON, over `bodyLimitBytes`, an unreadable content type. That one is
|
|
163
|
+
// HTTP-only, because only HTTP has bytes, and it is raised in `bodyRaw()` before any schema
|
|
164
|
+
// exists to fail.
|
|
148
165
|
'400': problemResponse('X_INPUT_INVALID'),
|
|
166
|
+
'422': problemResponse('X_BODY_INVALID'),
|
|
149
167
|
'403': problemResponse('policy denied'),
|
|
150
168
|
...(idempotent ? { '409': problemResponse('X_IDEMPOTENCY_CONFLICT') } : {}),
|
|
151
169
|
},
|
package/src/idempotency.ts
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
* a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
|
|
5
5
|
* double charge is worse than a 409.
|
|
6
6
|
*/
|
|
7
|
-
import { isUltimateError, logger } from '@ultimat3/core';
|
|
7
|
+
import { fingerprint, isUltimateError, logger } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
IdempotencyConflictError,
|
|
10
10
|
IdempotencyNotSharedError,
|
|
11
11
|
IdempotencyReplayedFailureError,
|
|
12
12
|
} from './errors';
|
|
13
13
|
import { MemoryIdempotencyStore } from './idempotency-memory';
|
|
14
|
-
import { fingerprint } from './stable';
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* Where a store's records live. Declared by the driver, never inferred — the same rule
|
package/src/job-handle.ts
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Projection 5: an action as durable work — its input schema, a payload-derived
|
|
3
3
|
* idempotency key, and an `invoke` that runs the action's one execution path under
|
|
4
4
|
* `surface: 'job'`, so a queued run gets the same validation and policy evaluation
|
|
5
|
-
* as the HTTP call.
|
|
5
|
+
* as the HTTP call. `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any
|
|
6
|
+
* `Action` and composes `job()` around this shape — and an app may write the same three lines.
|
|
6
7
|
*/
|
|
7
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
|
+
import { fingerprint } from '@ultimat3/core';
|
|
8
10
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
11
|
import type { Action } from './action';
|
|
10
12
|
import { actionName, invoke } from './invoke';
|
|
11
|
-
import { fingerprint } from './stable';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* **`@ultimat3/jobs` does not consume this, and cannot as written** (`As of 2026-08`; the header
|
|
@@ -24,9 +25,10 @@ import { fingerprint } from './stable';
|
|
|
24
25
|
* `tenant` and `retry` are what the bridge cannot fill: both are REQUIRED on `JobDefinition` with
|
|
25
26
|
* no default, on purpose — jobs states that every candidate default for `tenant` is a
|
|
26
27
|
* cross-tenant read waiting to happen. So "enqueueing an action costs zero rewriting" was never
|
|
27
|
-
* reachable; two facts an action does not declare have to come from somewhere.
|
|
28
|
-
*
|
|
29
|
-
* neither may import the
|
|
28
|
+
* reachable; two facts an action does not declare have to come from somewhere. The adapter that
|
|
29
|
+
* supplies them is `agentJob()` in `@ultimat3/ai` (`agent-job.ts`) — tier 4, which is where this
|
|
30
|
+
* paragraph said it belongs, since `action` and `jobs` are both tier 3 and neither may import the
|
|
31
|
+
* other. It takes an `Action` and nothing agent-specific, so it is the bridge for any action.
|
|
30
32
|
*/
|
|
31
33
|
export interface ActionJobHandle<
|
|
32
34
|
TInput extends StandardSchemaV1 = StandardSchemaV1,
|
package/src/naming.ts
CHANGED
|
@@ -40,8 +40,12 @@ export function splitWords(name: string): string[] {
|
|
|
40
40
|
* alone, so `publishPosts` and `publishPost` agree on the `posts` resource.
|
|
41
41
|
*/
|
|
42
42
|
export function pluralize(word: string): string {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// `Object.hasOwn`, never a truthiness check on the read: `IRREGULAR['constructor']` is the
|
|
44
|
+
// `Object` FUNCTION off the prototype chain, not `undefined`, and `splitWords` lowercases —
|
|
45
|
+
// which keeps `toString` out of reach and lets `constructor` straight through. `pluralize` is
|
|
46
|
+
// public API returning `string`, and `derivePath` publishes what it answers as the action's
|
|
47
|
+
// HTTP path, its OpenAPI `paths` key and its `tags` entry.
|
|
48
|
+
if (Object.hasOwn(IRREGULAR, word)) return IRREGULAR[word] ?? word;
|
|
45
49
|
if (word.endsWith('s')) return word;
|
|
46
50
|
if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
|
|
47
51
|
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
package/src/sample-input.ts
CHANGED
|
@@ -33,7 +33,11 @@ function sampleString(node: SchemaNode): string {
|
|
|
33
33
|
if (node.format !== undefined) {
|
|
34
34
|
// A format value is already the exact shape its validator wants; padding or truncating it
|
|
35
35
|
// to a length bound would break the only thing that makes it valid.
|
|
36
|
-
|
|
36
|
+
// `Object.hasOwn`, never the read alone: `node.format` comes from a provider's IR, and
|
|
37
|
+
// `BY_FORMAT['constructor']` is the `Object` function off the prototype chain rather than
|
|
38
|
+
// `undefined` — a function in the payload the policy assertion invokes with, where the type
|
|
39
|
+
// says `string`. Same discriminator `naming.ts` uses on its irregular-plural table.
|
|
40
|
+
const known = Object.hasOwn(BY_FORMAT, node.format) ? BY_FORMAT[node.format] : undefined;
|
|
37
41
|
return known ?? SAMPLE_STRING;
|
|
38
42
|
}
|
|
39
43
|
const min = node.minLength ?? 0;
|
|
@@ -172,6 +176,10 @@ function patternAt(node: SchemaNode | undefined, path: string): string | undefin
|
|
|
172
176
|
if (node === undefined) return undefined;
|
|
173
177
|
if (path === '' || path === ROOT_PATH) return node.pattern;
|
|
174
178
|
const [head, ...rest] = path.split('.');
|
|
175
|
-
|
|
179
|
+
// `path` is the caller's — `describeSampleGap` is exported — so the same own-property read the
|
|
180
|
+
// format table takes: `properties['constructor']` is otherwise the `Object` function.
|
|
181
|
+
const properties = node.properties ?? {};
|
|
182
|
+
const child =
|
|
183
|
+
head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
|
|
176
184
|
return child === undefined ? undefined : patternAt(child, rest.join('.'));
|
|
177
185
|
}
|
package/src/stable.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic JSON
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Deterministic JSON, DOCUMENT form. `serializeOpenApi` publishes this string as `openapi.json`
|
|
3
|
+
* and `json-schema.ts` re-reads it with `JSON.parse`, so what it emits must be valid JSON: a
|
|
4
|
+
* non-finite number is `null` and a `Date` is what `JSON.stringify` makes of one.
|
|
5
|
+
*
|
|
6
|
+
* The HASH form is `@ultimat3/core`'s `canonicalJson`, and the two are deliberately NOT one
|
|
7
|
+
* function. That one must be INJECTIVE, so it emits bare `NaN` / `Infinity` / `-0` tokens and tags
|
|
8
|
+
* a `Date`, a `Map` and a `Set` — none of which parses, which is exactly why it may not be
|
|
9
|
+
* published. Ordinary payloads are byte-identical between the two; `stable.test.ts` pins that.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
export type JsonObject = Record<string, unknown>;
|
|
@@ -11,47 +15,19 @@ export function isJsonObject(value: unknown): value is JsonObject {
|
|
|
11
15
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
/** How the two forms disagree, and the only thing they disagree about. */
|
|
15
|
-
type NumberForm = (value: number) => string;
|
|
16
|
-
|
|
17
|
-
/** JSON's own rule: a non-finite number has no token in the grammar, so it is `null`. */
|
|
18
|
-
const jsonNumber: NumberForm = (value) => (Number.isFinite(value) ? String(value) : 'null');
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
|
|
22
|
-
* word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
|
|
23
|
-
* JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
|
|
24
|
-
* one `requestHash` and therefore one idempotency record. `-0` is spelled out for the same reason:
|
|
25
|
-
* `String(-0)` is `"0"`, so `-0` and `0` were one record too. The twin of `@ultimat3/query`'s rule
|
|
26
|
-
* in its own `stable.ts`; both are tier 3, so neither can import the other.
|
|
27
|
-
*/
|
|
28
|
-
const hashNumber: NumberForm = (value) => {
|
|
29
|
-
if (Number.isNaN(value)) return 'NaN';
|
|
30
|
-
if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
|
|
31
|
-
return Object.is(value, -0) ? '-0' : String(value);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
18
|
/** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
|
|
35
19
|
export function stableStringify(value: unknown, indent = 0): string {
|
|
36
|
-
return write(value, indent, 0
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The canonical form a `fingerprint` is taken over. Byte-identical to `stableStringify(value)` for
|
|
41
|
-
* any value carrying no `NaN`, no `±Infinity` and no `-0`, which is why no idempotency record and
|
|
42
|
-
* no job dedupe key issued before this moved.
|
|
43
|
-
*/
|
|
44
|
-
export function canonicalJson(value: unknown): string {
|
|
45
|
-
return write(value, 0, 0, hashNumber);
|
|
20
|
+
return write(value, indent, 0);
|
|
46
21
|
}
|
|
47
22
|
|
|
48
|
-
function write(value: unknown, indent: number, depth: number
|
|
23
|
+
function write(value: unknown, indent: number, depth: number): string {
|
|
49
24
|
if (value === null) return 'null';
|
|
50
25
|
switch (typeof value) {
|
|
51
26
|
case 'string':
|
|
52
27
|
return JSON.stringify(value);
|
|
28
|
+
// JSON's own rule: a non-finite number has no token in the grammar, so it is `null`.
|
|
53
29
|
case 'number':
|
|
54
|
-
return
|
|
30
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
55
31
|
case 'boolean':
|
|
56
32
|
return String(value);
|
|
57
33
|
case 'bigint':
|
|
@@ -63,11 +39,17 @@ function write(value: unknown, indent: number, depth: number, number: NumberForm
|
|
|
63
39
|
default:
|
|
64
40
|
break;
|
|
65
41
|
}
|
|
42
|
+
// Ahead of the object branch, because none of the three has an own enumerable key: `Object.keys`
|
|
43
|
+
// is empty for all of them, so the branch below would answer `{}` for a date as well. What each
|
|
44
|
+
// becomes is `JSON.stringify`'s own answer — the ISO string (`null` for an Invalid Date), and
|
|
45
|
+
// `{}` for a Map and a Set — because this string is published and re-parsed.
|
|
46
|
+
if (value instanceof Date) return JSON.stringify(value);
|
|
47
|
+
if (value instanceof Map || value instanceof Set) return '{}';
|
|
66
48
|
const pad = indent > 0 ? '\n'.padEnd(1 + indent * (depth + 1), ' ') : '';
|
|
67
49
|
const close = indent > 0 ? '\n'.padEnd(1 + indent * depth, ' ') : '';
|
|
68
50
|
if (Array.isArray(value)) {
|
|
69
51
|
if (value.length === 0) return '[]';
|
|
70
|
-
const items = value.map((item) => write(item, indent, depth + 1
|
|
52
|
+
const items = value.map((item) => write(item, indent, depth + 1));
|
|
71
53
|
return `[${pad}${items.join(`,${pad || ''}`)}${close}]`;
|
|
72
54
|
}
|
|
73
55
|
const record = value as JsonObject;
|
|
@@ -77,27 +59,7 @@ function write(value: unknown, indent: number, depth: number, number: NumberForm
|
|
|
77
59
|
if (keys.length === 0) return '{}';
|
|
78
60
|
const gap = indent > 0 ? ' ' : '';
|
|
79
61
|
const entries = keys.map(
|
|
80
|
-
(key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1
|
|
62
|
+
(key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1)}`,
|
|
81
63
|
);
|
|
82
64
|
return `{${pad}${entries.join(`,${pad || ''}`)}${close}}`;
|
|
83
65
|
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/query`'s `fingerprint`
|
|
87
|
-
* and `@ultimat3/realtime`'s `stableDigest` already chose, and for the same reason.
|
|
88
|
-
*
|
|
89
|
-
* A fingerprint here is a SHARING key over input a client chooses, not a checksum. It is the
|
|
90
|
-
* `requestHash` that decides "same request, replay the stored response" and the job dedupe key
|
|
91
|
-
* `job-handle.ts` files an enqueue under, so a collision hands one caller's stored response to a
|
|
92
|
-
* different request, or drops an enqueue as a duplicate of a job it shares nothing with. FNV-1a/32
|
|
93
|
-
* — what this was — is 4x10^9 values, brute-forceable offline in seconds, so a payload landing on
|
|
94
|
-
* another request's hash was something an attacker could mint rather than something they had to
|
|
95
|
-
* wait for.
|
|
96
|
-
*
|
|
97
|
-
* It is taken over `canonicalJson` and not `stableStringify` for the second half of the same
|
|
98
|
-
* argument: the document form is not injective, so four distinct inputs shared one hash without
|
|
99
|
-
* anyone having to mint anything.
|
|
100
|
-
*/
|
|
101
|
-
export function fingerprint(value: unknown): string {
|
|
102
|
-
return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
103
|
-
}
|