@ultimat3/action 1.1.0 → 2.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 +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +17 -10
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
package/src/registry.ts
CHANGED
|
@@ -4,18 +4,35 @@
|
|
|
4
4
|
* actions. Registration is also where a missing policy becomes a build error.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { SchemaUnsupportedError } from '@ultimat3/schema';
|
|
7
8
|
import type { ActionDescriptor, AnyAction } from './action';
|
|
8
9
|
import { isAction, nameAction } from './action';
|
|
9
|
-
import { ActionDuplicateError, ActionPolicyMissingError } from './errors';
|
|
10
|
+
import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
|
|
11
|
+
import { assertIdempotencyScope } from './idempotency';
|
|
12
|
+
import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
|
|
13
|
+
import { derivePath } from './naming';
|
|
10
14
|
|
|
11
15
|
const registry = new Map<string, AnyAction>();
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Derived route -> the action name that owns it. A second index because the name is not the
|
|
19
|
+
* path: `pluralize` leaves a trailing `s` alone by design, so `archiveOrder` and `archiveOrders`
|
|
20
|
+
* are two names and one route. Nothing downstream can refuse that — the router seats whichever
|
|
21
|
+
* came last and the shadowed action stays in the OpenAPI document and the MCP tool list.
|
|
22
|
+
*/
|
|
23
|
+
const paths = new Map<string, string>();
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Register one action under an explicit name. The name lands on the action you
|
|
15
27
|
* passed, so the module's own export is projectable after boot and there is no
|
|
16
28
|
* "use the return value instead" rule to forget.
|
|
17
29
|
*/
|
|
18
30
|
export function registerAction<A extends AnyAction>(name: string, target: A): A {
|
|
31
|
+
// Boot, never the first request, and here rather than in `registerActions` because this is the
|
|
32
|
+
// funnel every registration path goes through — and it necessarily runs before a route is
|
|
33
|
+
// mounted. A no-op unless the app declared `scope: 'shared'`, which is the only case where the
|
|
34
|
+
// framework has been told something it can check. See `assertRateLimitScope`, its twin.
|
|
35
|
+
assertIdempotencyScope();
|
|
19
36
|
const seated = registry.get(name);
|
|
20
37
|
if (seated !== undefined) {
|
|
21
38
|
// Re-registering the SAME object under the SAME name is one registration seen twice, not a
|
|
@@ -28,11 +45,44 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
|
|
|
28
45
|
if (target.policy === undefined || target.policy === null) {
|
|
29
46
|
throw new ActionPolicyMissingError(name);
|
|
30
47
|
}
|
|
48
|
+
assertProjectable(name, target);
|
|
49
|
+
const { path } = derivePath(name);
|
|
50
|
+
const owner = paths.get(path);
|
|
51
|
+
if (owner !== undefined && owner !== name) {
|
|
52
|
+
throw new ActionPathDuplicateError({ name, existing: owner, path });
|
|
53
|
+
}
|
|
31
54
|
const named = nameAction(target, name);
|
|
32
55
|
registry.set(name, named);
|
|
56
|
+
paths.set(path, name);
|
|
33
57
|
return named;
|
|
34
58
|
}
|
|
35
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Both schemas must reach JSON Schema, and this is where that is decided — boot, beside the policy
|
|
62
|
+
* check, never the first `tools/list`. `jsonSchemaOf` used to swallow the refusal into
|
|
63
|
+
* `additionalProperties: true`, so an action whose `input:` the provider cannot describe registered
|
|
64
|
+
* cleanly and then published "any object accepted" on three surfaces while `validateInput` rejected
|
|
65
|
+
* every payload. The shipped `X_SCHEMA_UNSUPPORTED` is re-raised rather than re-coded — the failure
|
|
66
|
+
* is the provider's, and only the cause needs to say which action and which field.
|
|
67
|
+
*/
|
|
68
|
+
function assertProjectable(name: string, target: AnyAction): void {
|
|
69
|
+
for (const field of ['input', 'output'] as const) {
|
|
70
|
+
try {
|
|
71
|
+
jsonSchemaOf(target[field]);
|
|
72
|
+
mcpSchemaOf(target[field]);
|
|
73
|
+
} catch {
|
|
74
|
+
// The thrown value is deliberately not rendered into the cause: it is the provider's, of
|
|
75
|
+
// unknown shape, and this package's own two facts — which action, which field — are the
|
|
76
|
+
// ones a reader acts on.
|
|
77
|
+
throw new SchemaUnsupportedError({
|
|
78
|
+
cause: `${name}: \`${field}:\` cannot be projected to JSON Schema`,
|
|
79
|
+
fix: `declare ${name}'s \`${field}:\` with t.object({ ... }) from @ultimat3/action, or call configureSchemaProvider() with a provider that can introspect it`,
|
|
80
|
+
meta: { action: name, field },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
/**
|
|
37
87
|
* Register every action exported by a module namespace, keyed by export name.
|
|
38
88
|
* `registerActions(await import('./actions'))` at boot; the CLI does the same
|
|
@@ -63,6 +113,7 @@ export function describeActions(): readonly ActionDescriptor[] {
|
|
|
63
113
|
/** Test-only. Production registers once at boot and never unregisters. */
|
|
64
114
|
export function resetRegistry(): void {
|
|
65
115
|
registry.clear();
|
|
116
|
+
paths.clear();
|
|
66
117
|
}
|
|
67
118
|
|
|
68
119
|
function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value an input schema accepts, derived from that schema's own IR. The policy contract
|
|
3
|
+
* test needs its invocation to REACH the policy, and `{}` fails `input:` first for every
|
|
4
|
+
* action with a required field — which is how "policy denies an anonymous actor" passed on
|
|
5
|
+
* `X_INPUT_INVALID` and proved nothing about authz.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SchemaFormat, SchemaNode, StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
|
+
import { requiredKeys, tryIntrospect } from '@ultimat3/schema';
|
|
10
|
+
|
|
11
|
+
/** Version 4, variant 1 — the shape `t.uuid` insists on, and no byte of it means anything. */
|
|
12
|
+
const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
|
|
13
|
+
|
|
14
|
+
/** Short, and valid under `t.slug`'s and `t.cursor`'s patterns as well as bare `t.string`. */
|
|
15
|
+
const SAMPLE_STRING = 'sample';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One entry per `SchemaFormat`, exhaustively — a new format is a type error here rather than a
|
|
19
|
+
* silent `'sample'` that fails its own validator and reads as an authz test that cannot pass.
|
|
20
|
+
*/
|
|
21
|
+
const BY_FORMAT: Readonly<Record<SchemaFormat, string>> = {
|
|
22
|
+
uuid: SAMPLE_UUID,
|
|
23
|
+
email: 'sample@example.test',
|
|
24
|
+
uri: 'https://example.test/sample',
|
|
25
|
+
'date-time': '2020-01-01T00:00:00.000Z',
|
|
26
|
+
slug: SAMPLE_STRING,
|
|
27
|
+
timezone: 'UTC',
|
|
28
|
+
locale: 'en',
|
|
29
|
+
cursor: SAMPLE_STRING,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function sampleString(node: SchemaNode): string {
|
|
33
|
+
if (node.format !== undefined) {
|
|
34
|
+
// A format value is already the exact shape its validator wants; padding or truncating it
|
|
35
|
+
// to a length bound would break the only thing that makes it valid.
|
|
36
|
+
const known: string | undefined = BY_FORMAT[node.format];
|
|
37
|
+
return known ?? SAMPLE_STRING;
|
|
38
|
+
}
|
|
39
|
+
const min = node.minLength ?? 0;
|
|
40
|
+
const padded = SAMPLE_STRING.length >= min ? SAMPLE_STRING : SAMPLE_STRING.padEnd(min, 'x');
|
|
41
|
+
return node.maxLength === undefined ? padded : padded.slice(0, node.maxLength);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the value this module built for `node` satisfies the node's own `pattern`.
|
|
46
|
+
*
|
|
47
|
+
* A regex cannot be inverted, so `sampleString` cannot construct a value for an arbitrary one —
|
|
48
|
+
* but `pattern` IS in the IR, so whether the value it DID construct is acceptable is knowable
|
|
49
|
+
* here, before the sample is ever handed to `invoke`. That is the whole difference between
|
|
50
|
+
* "the framework could not build your payload, pass one" and an `X_INPUT_INVALID` surfacing out
|
|
51
|
+
* of the action's own parse, which reads as the action being wrong when the action is fine.
|
|
52
|
+
*
|
|
53
|
+
* An uncompilable pattern is a gap, not a throw: only a foreign provider's IR can produce one
|
|
54
|
+
* (`t.string.pattern()` takes a `RegExp`, whose `.source` always recompiles), and a generated
|
|
55
|
+
* contract test must not die on the way to reporting what it needs.
|
|
56
|
+
*/
|
|
57
|
+
function satisfiesPattern(node: SchemaNode, value: unknown): boolean {
|
|
58
|
+
if (node.pattern === undefined) return true;
|
|
59
|
+
if (typeof value !== 'string') return false;
|
|
60
|
+
try {
|
|
61
|
+
return new RegExp(node.pattern, node.patternFlags ?? '').test(value);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sampleNumber(node: SchemaNode): number {
|
|
68
|
+
const low = node.minimum ?? 0;
|
|
69
|
+
const value = node.integer === true ? Math.ceil(low) : low;
|
|
70
|
+
if (node.maximum === undefined || value <= node.maximum) return value;
|
|
71
|
+
return node.integer === true ? Math.floor(node.maximum) : node.maximum;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sampleObject(node: SchemaNode): Record<string, unknown> {
|
|
75
|
+
const properties = node.properties ?? {};
|
|
76
|
+
const sample: Record<string, unknown> = {};
|
|
77
|
+
// Required-only is what "minimal" means: an optional key and a defaulted one are both
|
|
78
|
+
// absences the schema already accepts, so adding them would only widen what can go wrong.
|
|
79
|
+
for (const key of requiredKeys(node)) {
|
|
80
|
+
const child = properties[key];
|
|
81
|
+
if (child !== undefined) sample[key] = sampleFor(child);
|
|
82
|
+
}
|
|
83
|
+
return sample;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sampleFor(node: SchemaNode): unknown {
|
|
87
|
+
// A nullable field accepts `null`, and `null` is the smallest thing it accepts.
|
|
88
|
+
if (node.nullable === true) return null;
|
|
89
|
+
switch (node.kind) {
|
|
90
|
+
case 'string':
|
|
91
|
+
return sampleString(node);
|
|
92
|
+
case 'number':
|
|
93
|
+
return sampleNumber(node);
|
|
94
|
+
case 'boolean':
|
|
95
|
+
return false;
|
|
96
|
+
case 'date':
|
|
97
|
+
return BY_FORMAT['date-time'];
|
|
98
|
+
case 'enum':
|
|
99
|
+
return node.values?.[0] ?? SAMPLE_STRING;
|
|
100
|
+
case 'literal':
|
|
101
|
+
return node.literal ?? null;
|
|
102
|
+
case 'array':
|
|
103
|
+
return [];
|
|
104
|
+
case 'union': {
|
|
105
|
+
const first = node.anyOf?.[0];
|
|
106
|
+
return first === undefined ? null : sampleFor(first);
|
|
107
|
+
}
|
|
108
|
+
case 'record':
|
|
109
|
+
return {};
|
|
110
|
+
case 'money':
|
|
111
|
+
return { minor: 0, currency: 'USD' };
|
|
112
|
+
case 'object':
|
|
113
|
+
return sampleObject(node);
|
|
114
|
+
default:
|
|
115
|
+
// `unknown`, and any kind a third-party provider emits that this build has never heard
|
|
116
|
+
// of. Deliberately not `assertNever`: a swapped schema provider must not turn a
|
|
117
|
+
// generated contract test into a crash, and the caller already reports a rejected
|
|
118
|
+
// sample as drift with the instruction to pass the input itself.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Best effort, and honest about it: a schema carrying a constraint the IR does not — a provider's
|
|
125
|
+
* own refinement — yields a value the schema rejects. The caller turns that into
|
|
126
|
+
* `X_CONTRACT_DRIFT`, because a silently skipped assertion is the vacuous test this function
|
|
127
|
+
* exists to end. The one constraint the IR DOES carry is `pattern`, and `sampleGaps` reads it.
|
|
128
|
+
*/
|
|
129
|
+
export function sampleInput(schema: StandardSchemaV1): unknown {
|
|
130
|
+
const node = tryIntrospect(schema);
|
|
131
|
+
return node === undefined ? {} : sampleFor(node);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The root's name in a gap path — a bare `t.string` input has no field to point at. */
|
|
135
|
+
const ROOT_PATH = '(the input)';
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dotted paths of every sampled field whose own `pattern` the synthesized value cannot satisfy —
|
|
139
|
+
* "what this schema needs that the framework cannot invent", in the order a reader fills them in.
|
|
140
|
+
*
|
|
141
|
+
* Empty is the common case, including for `t.slug` and `t.cursor`, whose patterns `'sample'`
|
|
142
|
+
* already matches. It walks the SAMPLED shape, so an optional key — which `sampleObject`
|
|
143
|
+
* deliberately omits — owes nothing.
|
|
144
|
+
*/
|
|
145
|
+
export function sampleGaps(schema: StandardSchemaV1): readonly string[] {
|
|
146
|
+
const node = tryIntrospect(schema);
|
|
147
|
+
return node === undefined ? [] : gapsIn(node, '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function gapsIn(node: SchemaNode, path: string): string[] {
|
|
151
|
+
if (node.nullable === true) return [];
|
|
152
|
+
if (node.kind === 'object') {
|
|
153
|
+
const properties = node.properties ?? {};
|
|
154
|
+
const out: string[] = [];
|
|
155
|
+
for (const key of requiredKeys(node)) {
|
|
156
|
+
const child = properties[key];
|
|
157
|
+
if (child !== undefined) out.push(...gapsIn(child, path === '' ? key : `${path}.${key}`));
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
if (satisfiesPattern(node, sampleFor(node))) return [];
|
|
162
|
+
return [path === '' ? ROOT_PATH : path];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
|
|
166
|
+
export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
|
|
167
|
+
const pattern = patternAt(tryIntrospect(schema), path);
|
|
168
|
+
return pattern === undefined ? path : `${path} (must match ${pattern})`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function patternAt(node: SchemaNode | undefined, path: string): string | undefined {
|
|
172
|
+
if (node === undefined) return undefined;
|
|
173
|
+
if (path === '' || path === ROOT_PATH) return node.pattern;
|
|
174
|
+
const [head, ...rest] = path.split('.');
|
|
175
|
+
const child = head === undefined ? undefined : node.properties?.[head];
|
|
176
|
+
return child === undefined ? undefined : patternAt(child, rest.join('.'));
|
|
177
|
+
}
|
package/src/stable.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic JSON
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Deterministic JSON in two forms, deliberately NOT one function. `stableStringify` is the
|
|
3
|
+
* DOCUMENT form — `openapi.json` is published from it and `json-schema.ts` re-reads it with
|
|
4
|
+
* `JSON.parse`, so a non-finite number must be `null`. `canonicalJson` is the HASH form — it is
|
|
5
|
+
* only ever hashed, so it must be INJECTIVE. One walk, two number rules.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
export type JsonObject = Record<string, unknown>;
|
|
@@ -10,18 +11,47 @@ export function isJsonObject(value: unknown): value is JsonObject {
|
|
|
10
11
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
12
|
}
|
|
12
13
|
|
|
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
|
+
|
|
13
34
|
/** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
|
|
14
35
|
export function stableStringify(value: unknown, indent = 0): string {
|
|
15
|
-
return write(value, indent, 0);
|
|
36
|
+
return write(value, indent, 0, jsonNumber);
|
|
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);
|
|
16
46
|
}
|
|
17
47
|
|
|
18
|
-
function write(value: unknown, indent: number, depth: number): string {
|
|
48
|
+
function write(value: unknown, indent: number, depth: number, number: NumberForm): string {
|
|
19
49
|
if (value === null) return 'null';
|
|
20
50
|
switch (typeof value) {
|
|
21
51
|
case 'string':
|
|
22
52
|
return JSON.stringify(value);
|
|
23
53
|
case 'number':
|
|
24
|
-
return
|
|
54
|
+
return number(value);
|
|
25
55
|
case 'boolean':
|
|
26
56
|
return String(value);
|
|
27
57
|
case 'bigint':
|
|
@@ -37,7 +67,7 @@ function write(value: unknown, indent: number, depth: number): string {
|
|
|
37
67
|
const close = indent > 0 ? '\n'.padEnd(1 + indent * depth, ' ') : '';
|
|
38
68
|
if (Array.isArray(value)) {
|
|
39
69
|
if (value.length === 0) return '[]';
|
|
40
|
-
const items = value.map((item) => write(item, indent, depth + 1));
|
|
70
|
+
const items = value.map((item) => write(item, indent, depth + 1, number));
|
|
41
71
|
return `[${pad}${items.join(`,${pad || ''}`)}${close}]`;
|
|
42
72
|
}
|
|
43
73
|
const record = value as JsonObject;
|
|
@@ -47,22 +77,27 @@ function write(value: unknown, indent: number, depth: number): string {
|
|
|
47
77
|
if (keys.length === 0) return '{}';
|
|
48
78
|
const gap = indent > 0 ? ' ' : '';
|
|
49
79
|
const entries = keys.map(
|
|
50
|
-
(key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1)}`,
|
|
80
|
+
(key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1, number)}`,
|
|
51
81
|
);
|
|
52
82
|
return `{${pad}${entries.join(`,${pad || ''}`)}${close}}`;
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
*/
|
|
66
101
|
export function fingerprint(value: unknown): string {
|
|
67
|
-
return
|
|
102
|
+
return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
68
103
|
}
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Compile-time pins for the erased action view. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
3
|
+
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
|
+
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
|
+
|
|
6
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import type { Action, AnyAction } from './action';
|
|
8
|
+
import type { ClientMethod } from './client';
|
|
9
|
+
import type { ActionJobHandle } from './job-handle';
|
|
10
|
+
|
|
11
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
|
+
type Assert<T extends true> = T;
|
|
13
|
+
|
|
14
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
15
|
+
|
|
16
|
+
type PublishInput = StandardSchemaV1<{ readonly postId: string }>;
|
|
17
|
+
type PublishOutput = StandardSchemaV1<{ readonly published: boolean }>;
|
|
18
|
+
type PublishPost = Action<PublishInput, PublishOutput>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The registry hands back `AnyAction` and nothing else, so the erased view has to project every
|
|
22
|
+
* surface — the queue included. Written as the return type rather than `'job' in keyof` because
|
|
23
|
+
* an `AnyAction['job']` that answered the wrong shape would satisfy a key check.
|
|
24
|
+
*/
|
|
25
|
+
export type _ErasedViewProjectsAJobHandle = Assert<
|
|
26
|
+
Equals<ReturnType<AnyAction['job']>, ActionJobHandle>
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
/** …and the typed action still narrows it to its own schemas, which is what `.job()` is for. */
|
|
30
|
+
export type _TypedActionNarrowsTheJobHandle = Assert<
|
|
31
|
+
Equals<ReturnType<PublishPost['job']>, ActionJobHandle<PublishInput, PublishOutput>>
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why `client()` is NOT on the erased view, pinned rather than asserted in a comment: a
|
|
36
|
+
* `ClientMethod` is a function type, so its input is checked contravariantly and the erased
|
|
37
|
+
* `(input: unknown) => …` is a supertype of no concrete action's method. Spelling `ClientMethod`
|
|
38
|
+
* with method syntax — or with an `any` — would flip this to `true` and make the asymmetry
|
|
39
|
+
* between `job()` and `client()` look arbitrary.
|
|
40
|
+
*/
|
|
41
|
+
export type _ErasedClientIsNotASupertype = Assert<
|
|
42
|
+
ClientMethod<PublishInput, PublishOutput> extends ClientMethod<StandardSchemaV1, StandardSchemaV1>
|
|
43
|
+
? false
|
|
44
|
+
: true
|
|
45
|
+
>;
|
package/src/tags.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cache tags stay opaque to actions — we hand them back to @ultimat3/cache
|
|
3
|
-
* untouched. The only thing this package needs is the wire string per tag, for
|
|
4
|
-
* manifests, OpenAPI metadata and the invalidation graph in `/_x`.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
-
import { serializeTag } from '@ultimat3/cache';
|
|
9
|
-
|
|
10
|
-
export function tagKey(value: CacheTag): string {
|
|
11
|
-
return serializeTag(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Sorted + de-duplicated: descriptor output must not depend on declaration order. */
|
|
15
|
-
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
16
|
-
return [...new Set(tags.map(tagKey))].sort();
|
|
17
|
-
}
|