@jarenjs/contract 0.43.1
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/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
package/src/public.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `publicProjection`: the browser-safe subset of a compiled contract
|
|
4
|
+
* as a JSON document that is ITSELF a valid `$contract` 0.1 — what a
|
|
5
|
+
* client needs and no more, what the revision hashes, and what every other
|
|
6
|
+
* projection (OpenAPI, TypeScript, Markdown, tools) is built on.
|
|
7
|
+
*
|
|
8
|
+
* Retained: the operations whose `policy.audience` is not `server` (and,
|
|
9
|
+
* when the caller narrows with `ops`, only those), each with its resolved
|
|
10
|
+
* binding and policy minus the server-side knobs (`limits`,
|
|
11
|
+
* `errors.details`), plus the `$defs` the retained schemas reach. Member
|
|
12
|
+
* order is FIXED (docs/CONTRACT-FORMAT.md §12.1 — normative, because the
|
|
13
|
+
* revision is the SHA-256 of the canonical bytes of this document): root
|
|
14
|
+
* `$contract, id, version, compat, $defs, operations`; operation `kind,
|
|
15
|
+
* input, output, errors, policy, http, doc`; policy `task, idempotency,
|
|
16
|
+
* revision, cache, retry, stream, audience`; http `method, path, in, body, status,
|
|
17
|
+
* media`; error `status, schema`; operations in document order; `$defs` in
|
|
18
|
+
* first-reference order. Defaults are materialized (the projection says
|
|
19
|
+
* what the binding DOES, not what the author typed), so two documents
|
|
20
|
+
* that behave alike project alike, and the projection of a projection is
|
|
21
|
+
* the projection.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
25
|
+
|
|
26
|
+
import { ContractHostError } from './errors.js';
|
|
27
|
+
import { reachableDefs } from './bundle.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {import('./compile.js').Contract} Contract
|
|
31
|
+
* @typedef {import('./compile.js').CompiledOperation} CompiledOperation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} PublicProjectionOptions
|
|
36
|
+
* @property {readonly string[]} [ops] - the operations to keep, a subset
|
|
37
|
+
* of the contract's ids (default: all); `server` operations are never
|
|
38
|
+
* kept, listed or not
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether `value` is a compiled contract — reads the members every
|
|
43
|
+
* projection needs.
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @returns {value is Contract}
|
|
46
|
+
*/
|
|
47
|
+
export function isCompiledContract(value) {
|
|
48
|
+
if (value === null || typeof value !== 'object') return false;
|
|
49
|
+
const c = /** @type {any} */ (value);
|
|
50
|
+
return isJsonObject(c.doc) && Array.isArray(c.ids) && c.operations !== null && typeof c.operations === 'object'
|
|
51
|
+
&& typeof c.match === 'function';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The operations a projection retains, in document order: the public
|
|
56
|
+
* ones, narrowed by `ops` when given. Throws `JC1008` for a malformed
|
|
57
|
+
* `contract` or an `ops` entry that names no operation.
|
|
58
|
+
* @param {Contract} contract
|
|
59
|
+
* @param {readonly string[] | undefined} ops
|
|
60
|
+
* @param {string} who - the projection's name, for the message
|
|
61
|
+
* @returns {CompiledOperation[]}
|
|
62
|
+
*/
|
|
63
|
+
export function retainedOperations(contract, ops, who) {
|
|
64
|
+
if (!isCompiledContract(contract)) {
|
|
65
|
+
throw new ContractHostError('JC1008', `${who}: contract must be a compiled contract (compileContract(doc))`);
|
|
66
|
+
}
|
|
67
|
+
let wanted = null;
|
|
68
|
+
if (ops !== undefined) {
|
|
69
|
+
if (!Array.isArray(ops)) throw new ContractHostError('JC1008', `${who}: ops must be an array of operation ids`);
|
|
70
|
+
wanted = new Set();
|
|
71
|
+
for (let i = 0; i < ops.length; i++) {
|
|
72
|
+
const id = ops[i];
|
|
73
|
+
if (typeof id !== 'string' || !Object.hasOwn(contract.operations, id)) {
|
|
74
|
+
throw new ContractHostError('JC1008', `${who}: ops[${i}] names no operation of the contract (${typeof id === 'string' ? id : typeof id})`);
|
|
75
|
+
}
|
|
76
|
+
wanted.add(id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let i = 0; i < contract.ids.length; i++) {
|
|
81
|
+
const op = contract.operations[contract.ids[i]];
|
|
82
|
+
if (op.policy.audience === 'server') continue;
|
|
83
|
+
if (wanted !== null && !wanted.has(op.id)) continue;
|
|
84
|
+
out.push(op);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The public policy of an operation: the client-facing members in the
|
|
91
|
+
* fixed order, defaults materialized, the server-side knobs (`limits`,
|
|
92
|
+
* `errors.details`) left out.
|
|
93
|
+
* @param {CompiledOperation} op
|
|
94
|
+
* @returns {Record<string, unknown>}
|
|
95
|
+
*/
|
|
96
|
+
function publicPolicy(op) {
|
|
97
|
+
const p = op.policy;
|
|
98
|
+
/** @type {Record<string, unknown>} */
|
|
99
|
+
const policy = { task: p.task, idempotency: p.idempotency };
|
|
100
|
+
if (p.revision !== null) policy.revision = p.revision;
|
|
101
|
+
policy.cache = p.cache;
|
|
102
|
+
if (p.retry !== null) policy.retry = { max: p.retry.max, on: p.retry.on.slice() };
|
|
103
|
+
if (p.stream !== null) {
|
|
104
|
+
policy.stream = p.stream.maxPatchBytes === null
|
|
105
|
+
? { resume: p.stream.resume, heartbeatMs: p.stream.heartbeatMs }
|
|
106
|
+
: { resume: p.stream.resume, heartbeatMs: p.stream.heartbeatMs, maxPatchBytes: p.stream.maxPatchBytes };
|
|
107
|
+
}
|
|
108
|
+
policy.audience = p.audience;
|
|
109
|
+
return policy;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The public binding of an operation: method, canonical path, every
|
|
114
|
+
* member's location, the whole-body member when declared, status, media.
|
|
115
|
+
* @param {CompiledOperation} op
|
|
116
|
+
* @returns {Record<string, unknown>}
|
|
117
|
+
*/
|
|
118
|
+
function publicHttp(op) {
|
|
119
|
+
const h = op.http;
|
|
120
|
+
/** @type {Record<string, unknown>} */
|
|
121
|
+
const http = { method: h.method, path: h.path };
|
|
122
|
+
/** @type {Record<string, string>} */
|
|
123
|
+
const locations = {};
|
|
124
|
+
const members = Object.keys(h.in);
|
|
125
|
+
for (let i = 0; i < members.length; i++) setObjectMember(locations, members[i], h.in[members[i]]);
|
|
126
|
+
http.in = locations;
|
|
127
|
+
if (h.body !== null) http.body = h.body;
|
|
128
|
+
http.status = h.status;
|
|
129
|
+
http.media = h.media;
|
|
130
|
+
return http;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Project one operation.
|
|
135
|
+
* @param {CompiledOperation} op
|
|
136
|
+
* @returns {Record<string, unknown>}
|
|
137
|
+
*/
|
|
138
|
+
function publicOperation(op) {
|
|
139
|
+
/** @type {Record<string, unknown>} */
|
|
140
|
+
const out = { kind: op.kind };
|
|
141
|
+
if (op.input !== null) out.input = op.input.schema;
|
|
142
|
+
out.output = op.output.schema;
|
|
143
|
+
const codes = Object.keys(op.errors);
|
|
144
|
+
if (codes.length > 0) {
|
|
145
|
+
/** @type {Record<string, unknown>} */
|
|
146
|
+
const errors = {};
|
|
147
|
+
for (let i = 0; i < codes.length; i++) {
|
|
148
|
+
const decl = op.errors[codes[i]];
|
|
149
|
+
/** @type {Record<string, unknown>} */
|
|
150
|
+
const e = { status: decl.status };
|
|
151
|
+
if (decl.schema !== null) e.schema = decl.schema;
|
|
152
|
+
setObjectMember(errors, codes[i], e);
|
|
153
|
+
}
|
|
154
|
+
out.errors = errors;
|
|
155
|
+
}
|
|
156
|
+
out.policy = publicPolicy(op);
|
|
157
|
+
out.http = publicHttp(op);
|
|
158
|
+
if (op.doc !== null) out.doc = op.doc;
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The public projection of a compiled contract: a `$contract` 0.1
|
|
164
|
+
* document (it compiles) holding the public operations with their
|
|
165
|
+
* resolved bindings and policies and the `$defs` they reach, in the fixed
|
|
166
|
+
* member order the revision hashes. Schema subtrees are the contract's own
|
|
167
|
+
* (frozen); the composition is fresh.
|
|
168
|
+
* @param {Contract} contract
|
|
169
|
+
* @param {PublicProjectionOptions} [options]
|
|
170
|
+
* @returns {Record<string, unknown>}
|
|
171
|
+
* @throws {ContractHostError} `JC1008` — not a compiled contract, or `ops` names no operation
|
|
172
|
+
* @example
|
|
173
|
+
* const pub = publicProjection(contract);
|
|
174
|
+
* compileContract(pub).ids; // the public operations, in document order
|
|
175
|
+
*/
|
|
176
|
+
export function publicProjection(contract, options = {}) {
|
|
177
|
+
const ops = retainedOperations(contract, options.ops, 'publicProjection');
|
|
178
|
+
/** @type {Record<string, unknown>} */
|
|
179
|
+
const out = { $contract: '0.1' };
|
|
180
|
+
if (contract.id !== null) out.id = contract.id;
|
|
181
|
+
if (contract.version !== null) out.version = contract.version;
|
|
182
|
+
if (contract.compat.length > 0) out.compat = contract.compat.slice();
|
|
183
|
+
/** @type {unknown[]} */
|
|
184
|
+
const roots = [];
|
|
185
|
+
for (let i = 0; i < ops.length; i++) {
|
|
186
|
+
const op = ops[i];
|
|
187
|
+
if (op.input !== null) roots.push(op.input.schema);
|
|
188
|
+
roots.push(op.output.schema);
|
|
189
|
+
const codes = Object.keys(op.errors);
|
|
190
|
+
for (let j = 0; j < codes.length; j++) {
|
|
191
|
+
if (op.errors[codes[j]].schema !== null) roots.push(op.errors[codes[j]].schema);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const names = reachableDefs(roots, contract.doc);
|
|
195
|
+
if (names.length > 0) {
|
|
196
|
+
/** @type {Record<string, unknown>} */
|
|
197
|
+
const defs = {};
|
|
198
|
+
for (let i = 0; i < names.length; i++) setObjectMember(defs, names[i], contract.doc.$defs[names[i]]);
|
|
199
|
+
out.$defs = defs;
|
|
200
|
+
}
|
|
201
|
+
/** @type {Record<string, unknown>} */
|
|
202
|
+
const operations = {};
|
|
203
|
+
for (let i = 0; i < ops.length; i++) setObjectMember(operations, ops[i].id, publicOperation(ops[i]));
|
|
204
|
+
out.operations = operations;
|
|
205
|
+
return out;
|
|
206
|
+
}
|
package/src/revision.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The contract revision: the lowercase hex SHA-256 over the RFC
|
|
4
|
+
* 8785 canonical bytes of the public projection (docs/CONTRACT-FORMAT.md
|
|
5
|
+
* §14) — the compatibility identity two independent processes agree on.
|
|
6
|
+
* It hashes the PUBLIC projection, never the source document, so a
|
|
7
|
+
* server-audience operation, a `policy.limits` value or an error-detail
|
|
8
|
+
* level can change without moving the revision, while any member a
|
|
9
|
+
* client can observe moves it. Memoized per compiled contract in a
|
|
10
|
+
* private `WeakMap`, so `compileContract` stays synchronous and the
|
|
11
|
+
* digest is computed at most once per process; `peekRevision` is the
|
|
12
|
+
* synchronous read `describe()` uses (`null` until the promise settled).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { canonicalSha256 } from '@jarenjs/json/canonical';
|
|
16
|
+
|
|
17
|
+
import { ContractCompileError } from './errors.js';
|
|
18
|
+
import { publicProjection } from './public.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {import('./compile.js').Contract} Contract
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The memo: contract → `{ promise, value }`. `value` stays `null` until
|
|
26
|
+
* the digest resolved; a rejected computation is NOT memoized, so a
|
|
27
|
+
* transient host failure (`crypto.subtle` unavailable) can be retried,
|
|
28
|
+
* while the deterministic refusal (`JC0061`) simply recurs.
|
|
29
|
+
* @type {WeakMap<object, { promise: Promise<string>, value: string | null }>}
|
|
30
|
+
*/
|
|
31
|
+
const revisions = new WeakMap();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute (once) the revision of a compiled contract: SHA-256 over the
|
|
35
|
+
* canonical bytes of `publicProjection(contract)`, as 64 lowercase hex
|
|
36
|
+
* characters. A projection that is not canonicalizable — a string member
|
|
37
|
+
* carrying an unpaired surrogate, say — rejects with `JC0061`
|
|
38
|
+
* (`ContractCompileError`, its `docPath` the offending value's pointer
|
|
39
|
+
* INTO THE PROJECTION).
|
|
40
|
+
* @param {Contract} contract
|
|
41
|
+
* @returns {Promise<string>}
|
|
42
|
+
* @example
|
|
43
|
+
* const contract = compileContract(doc);
|
|
44
|
+
* await contract.revision(); // 'e3b0c442…' — stable across compiles of equal documents
|
|
45
|
+
*/
|
|
46
|
+
export function contractRevision(contract) {
|
|
47
|
+
const memo = revisions.get(contract);
|
|
48
|
+
if (memo !== undefined) return memo.promise;
|
|
49
|
+
/** @type {{ promise: Promise<string>, value: string | null }} */
|
|
50
|
+
const record = { promise: /** @type {any} */ (null), value: null };
|
|
51
|
+
record.promise = digest(contract, record);
|
|
52
|
+
revisions.set(contract, record);
|
|
53
|
+
return record.promise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Contract} contract
|
|
58
|
+
* @param {{ promise: Promise<string>, value: string | null }} record
|
|
59
|
+
* @returns {Promise<string>}
|
|
60
|
+
*/
|
|
61
|
+
async function digest(contract, record) {
|
|
62
|
+
let hex;
|
|
63
|
+
try {
|
|
64
|
+
hex = await canonicalSha256(publicProjection(contract));
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
revisions.delete(contract);
|
|
68
|
+
if (err !== null && typeof err === 'object' && /** @type {any} */ (err).name === 'JsonCanonicalizeError') {
|
|
69
|
+
throw new ContractCompileError('JC0061',
|
|
70
|
+
`the public projection is not canonicalizable: ${/** @type {Error} */ (err).message}`,
|
|
71
|
+
/** @type {any} */ (err).dataPath, /** @type {Error} */ (err));
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
record.value = hex;
|
|
76
|
+
return hex;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The revision of a compiled contract if it has been computed, else
|
|
81
|
+
* `null` — the synchronous read `describe()` renders, so a description
|
|
82
|
+
* taken before anyone awaited `revision()` honestly says "not computed"
|
|
83
|
+
* rather than blocking.
|
|
84
|
+
* @param {Contract} contract
|
|
85
|
+
* @returns {string | null}
|
|
86
|
+
*/
|
|
87
|
+
export function peekRevision(contract) {
|
|
88
|
+
const memo = revisions.get(contract);
|
|
89
|
+
return memo === undefined ? null : memo.value;
|
|
90
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The client half of the stream binding, carrier-neutral
|
|
4
|
+
* (docs/CONTRACT-FORMAT.md §19): one consumer state machine that both
|
|
5
|
+
* `client.subscribe` implementations feed — the HTTP client with
|
|
6
|
+
* decoded SSE events, the port client with push frames. It validates
|
|
7
|
+
* every snapshot against the operation's output schema, enforces the
|
|
8
|
+
* strictly-increasing seq (`JC2092`), classifies a server `error` event
|
|
9
|
+
* (a declared code is a `failure` outcome under its own code; anything
|
|
10
|
+
* else is `contract` `JC2093` with the server's record in `details`),
|
|
11
|
+
* and delivers each `on*` callback totally — a callback that throws
|
|
12
|
+
* never breaks the machine. After the first terminal event (`error`,
|
|
13
|
+
* `end`, a local failure) the machine is finished: the carrier's
|
|
14
|
+
* `finish` hook has run and every later event is dropped.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { renderMessage, verdict, projectValidationDetails } from '../http/wire.js';
|
|
18
|
+
import { failedOutcome, outcomeError, clientError } from '../client/outcome.js';
|
|
19
|
+
import { STREAM_ERRORS } from './sse.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {import('../client/outcome.js').OutcomeRoute} OutcomeRoute
|
|
23
|
+
* @typedef {import('../client/outcome.js').OutcomeMeta} OutcomeMeta
|
|
24
|
+
* @typedef {import('../client/outcome.js').Outcome} Outcome
|
|
25
|
+
* @typedef {import('../http/wire.js').Catalog} Catalog
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The callbacks of one `client.subscribe` call; every one optional.
|
|
30
|
+
* @typedef {Object} StreamCallbacks
|
|
31
|
+
* @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
|
|
32
|
+
* @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
|
|
33
|
+
* @property {(outcome: Outcome) => void} [onError]
|
|
34
|
+
* @property {(info: { reason: string }) => void} [onEnd]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} StreamConsumerOptions
|
|
39
|
+
* @property {OutcomeRoute & { details: 'none' | 'paths' | 'full' }} route
|
|
40
|
+
* @property {Catalog | null} catalog
|
|
41
|
+
* @property {OutcomeMeta} meta - mutated: `trace` is refreshed from error records
|
|
42
|
+
* @property {StreamCallbacks} callbacks
|
|
43
|
+
* @property {() => void} finish - the carrier's cleanup (remove the entry,
|
|
44
|
+
* cancel readers and timers); called exactly once, before the terminal callback
|
|
45
|
+
* @property {number | null} lastSeq - the resume seq the caller passed (the
|
|
46
|
+
* regression baseline until a snapshot or patch moves it)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The consumer the carriers feed. `snapshot`/`patch` take the seq the
|
|
51
|
+
* wire carried (the SSE id, the frame's `seq`) — `null` falls back to
|
|
52
|
+
* the data's own `seq`; `error`/`end` take the event data; `fail` takes
|
|
53
|
+
* a ready outcome (a transport failure the carrier classified). All are
|
|
54
|
+
* no-ops once finished.
|
|
55
|
+
* @param {StreamConsumerOptions} options
|
|
56
|
+
* @returns {{ snapshot: (seq: number | null, data: unknown) => void,
|
|
57
|
+
* patch: (seq: number | null, data: unknown) => void,
|
|
58
|
+
* error: (data: unknown) => void,
|
|
59
|
+
* end: (data: unknown) => void,
|
|
60
|
+
* fail: (outcome: Outcome) => void,
|
|
61
|
+
* cancel: () => void,
|
|
62
|
+
* finished: () => boolean }}
|
|
63
|
+
*/
|
|
64
|
+
export function createStreamConsumer(options) {
|
|
65
|
+
const { route, catalog, meta, callbacks, finish } = options;
|
|
66
|
+
let lastSeq = options.lastSeq;
|
|
67
|
+
let done = false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run one callback totally.
|
|
71
|
+
* @param {((arg: any, extra?: any) => void) | undefined} cb
|
|
72
|
+
* @param {any} arg
|
|
73
|
+
* @param {any} [extra]
|
|
74
|
+
*/
|
|
75
|
+
function call(cb, arg, extra) {
|
|
76
|
+
if (cb === undefined) return;
|
|
77
|
+
try {
|
|
78
|
+
cb(arg, extra);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// a consumer callback that throws never breaks the stream machine
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Terminate: cleanup first, then the terminal callback. */
|
|
86
|
+
function terminate() {
|
|
87
|
+
if (done) return false;
|
|
88
|
+
done = true;
|
|
89
|
+
try {
|
|
90
|
+
finish();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// the carrier's cleanup must not eat the terminal callback
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A stream-code outcome (`JC2090`/`JC2092`/`JC2094`), rendered here.
|
|
100
|
+
* @param {'JC2090' | 'JC2092' | 'JC2094'} code
|
|
101
|
+
* @param {Record<string, unknown>} params
|
|
102
|
+
* @returns {Outcome}
|
|
103
|
+
*/
|
|
104
|
+
function streamOutcome(code, params) {
|
|
105
|
+
const row = STREAM_ERRORS[code];
|
|
106
|
+
return failedOutcome(row.kind === 'network' ? 'network' : 'contract',
|
|
107
|
+
outcomeError(code, renderMessage(catalog, row.msgid, { op: route.id, ...params }), null, null, row.retryable), meta);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The seq of one event: the wire's, or the data's own.
|
|
112
|
+
* @param {number | null} seq
|
|
113
|
+
* @param {any} data
|
|
114
|
+
* @returns {number | null}
|
|
115
|
+
*/
|
|
116
|
+
function seqOf(seq, data) {
|
|
117
|
+
if (typeof seq === 'number' && Number.isFinite(seq)) return seq;
|
|
118
|
+
const own = data !== null && typeof data === 'object' ? data.seq : undefined;
|
|
119
|
+
return typeof own === 'number' && Number.isFinite(own) ? own : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
snapshot(seq, data) {
|
|
124
|
+
if (done) return;
|
|
125
|
+
const envelope = /** @type {any} */ (data);
|
|
126
|
+
if (envelope === null || typeof envelope !== 'object' || !Object.hasOwn(envelope, 'value')) {
|
|
127
|
+
if (terminate()) call(callbacks.onError, failedOutcome('contract',
|
|
128
|
+
clientError(catalog, 'JC2053', { op: route.id }, null, [{ path: '', keyword: 'snapshot' }]), meta));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const at = seqOf(seq, envelope);
|
|
132
|
+
const v = verdict(route.validateOutput, envelope.value);
|
|
133
|
+
if (!v.valid) {
|
|
134
|
+
if (terminate()) call(callbacks.onError, failedOutcome('contract',
|
|
135
|
+
clientError(catalog, 'JC2053', { op: route.id }, null, projectValidationDetails(route.details, v.errors)), meta));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// a mid-stream snapshot (a maxPatchBytes replacement) must still advance
|
|
139
|
+
if (at !== null && lastSeq !== null && at !== 0 && at <= lastSeq) {
|
|
140
|
+
if (terminate()) call(callbacks.onError, streamOutcome('JC2092', {}));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (at !== null) lastSeq = at;
|
|
144
|
+
call(callbacks.onSnapshot, envelope.value, { seq: at === null ? 0 : at, resumed: envelope.resumed === true });
|
|
145
|
+
},
|
|
146
|
+
patch(seq, data) {
|
|
147
|
+
if (done) return;
|
|
148
|
+
const emission = /** @type {any} */ (data);
|
|
149
|
+
const patch = emission !== null && typeof emission === 'object' ? emission.patch : undefined;
|
|
150
|
+
const at = seqOf(seq, emission);
|
|
151
|
+
if (!Array.isArray(patch) || at === null) {
|
|
152
|
+
if (terminate()) call(callbacks.onError, failedOutcome('contract',
|
|
153
|
+
clientError(catalog, 'JC2053', { op: route.id }, null, [{ path: '', keyword: 'patch' }]), meta));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (lastSeq !== null && at <= lastSeq) {
|
|
157
|
+
if (terminate()) call(callbacks.onError, streamOutcome('JC2092', {}));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
lastSeq = at;
|
|
161
|
+
call(callbacks.onPatch, { patch, seq: at });
|
|
162
|
+
},
|
|
163
|
+
error(data) {
|
|
164
|
+
if (!terminate()) return;
|
|
165
|
+
const record = /** @type {any} */ (data);
|
|
166
|
+
const code = record !== null && typeof record === 'object' && typeof record.code === 'string' ? record.code : null;
|
|
167
|
+
if (record !== null && typeof record === 'object' && typeof record.requestId === 'string' && record.requestId.length > 0) {
|
|
168
|
+
meta.trace = record.requestId;
|
|
169
|
+
}
|
|
170
|
+
if (code !== null && Object.hasOwn(route.errors, code)) {
|
|
171
|
+
const message = typeof record.message === 'string'
|
|
172
|
+
? record.message
|
|
173
|
+
: renderMessage(catalog, 'contract/handler-error', { op: route.id, code });
|
|
174
|
+
const retryable = typeof record.retryable === 'boolean' ? record.retryable : route.retryOn.has(code);
|
|
175
|
+
call(callbacks.onError, failedOutcome('failure', outcomeError(code, message, null, record.details, retryable), meta));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// an undeclared server error ends the stream as a contract violation;
|
|
179
|
+
// the server's record rides in details so a JC2091 stays visible
|
|
180
|
+
const details = code === null ? null : {
|
|
181
|
+
code,
|
|
182
|
+
message: record !== null && typeof record === 'object' && typeof record.message === 'string' ? record.message : null,
|
|
183
|
+
};
|
|
184
|
+
call(callbacks.onError, failedOutcome('contract',
|
|
185
|
+
outcomeError('JC2093', renderMessage(catalog, STREAM_ERRORS.JC2093.msgid, { op: route.id, code: code === null ? 'none' : code }),
|
|
186
|
+
null, details, false), meta));
|
|
187
|
+
},
|
|
188
|
+
end(data) {
|
|
189
|
+
if (!terminate()) return;
|
|
190
|
+
const record = /** @type {any} */ (data);
|
|
191
|
+
const reason = record !== null && typeof record === 'object' && typeof record.reason === 'string' ? record.reason : 'closed';
|
|
192
|
+
call(callbacks.onEnd, { reason });
|
|
193
|
+
},
|
|
194
|
+
fail(outcome) {
|
|
195
|
+
if (!terminate()) return;
|
|
196
|
+
call(callbacks.onError, outcome);
|
|
197
|
+
},
|
|
198
|
+
cancel() {
|
|
199
|
+
if (done) return;
|
|
200
|
+
done = true;
|
|
201
|
+
try {
|
|
202
|
+
finish();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// a cancel is silent either way
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
finished: () => done,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { STREAM_ERRORS };
|