@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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The Node adapter: `toNodeHandler(dispatcher)` puts the dispatcher
|
|
4
|
+
* behind `(req, res)` — `http.createServer`'s listener, and what Express
|
|
5
|
+
* mounts with `app.use`. Dependency-free and STRUCTURALLY typed: nothing
|
|
6
|
+
* here imports `node:http`; the request is anything with `method`, `url`,
|
|
7
|
+
* `headersDistinct` (or `headers`) and a readable-stream event surface,
|
|
8
|
+
* the response anything with `writeHead`/`end`.
|
|
9
|
+
*
|
|
10
|
+
* The body is collected chunk by chunk up to the matched operation's
|
|
11
|
+
* `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
|
|
12
|
+
* answered with `connection: close` and the request is destroyed once
|
|
13
|
+
* the response has flushed. A declared `content-length` above the limit
|
|
14
|
+
* is never read at all; an unmatched request's body is never read (the
|
|
15
|
+
* dispatcher answers 404/405 without it and the platform discards the
|
|
16
|
+
* rest). Bytes are handed to the dispatcher as received — for a JSON
|
|
17
|
+
* operation too, so its strict UTF-8 decode decides `JC2005`. Repeated
|
|
18
|
+
* header lines reach the dispatcher as arrays (`headersDistinct`), which
|
|
19
|
+
* is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
|
|
20
|
+
* aborts when the client goes away before the response finished.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {import('../http/serve.js').HttpDispatcher} HttpDispatcher
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The request surface the adapter reads — `http.IncomingMessage` fits.
|
|
29
|
+
* @typedef {Object} NodeRequestLike
|
|
30
|
+
* @property {string} [method]
|
|
31
|
+
* @property {string} [url]
|
|
32
|
+
* @property {Record<string, string[] | undefined>} [headersDistinct]
|
|
33
|
+
* @property {Record<string, string | string[] | undefined>} headers
|
|
34
|
+
* @property {(event: string, listener: (...args: any[]) => void) => unknown} on
|
|
35
|
+
* @property {() => unknown} [pause]
|
|
36
|
+
* @property {(error?: Error) => unknown} [destroy]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The response surface the adapter writes — `http.ServerResponse` fits.
|
|
41
|
+
* `write` and `flushHeaders` are read only for a streaming (SSE)
|
|
42
|
+
* response.
|
|
43
|
+
* @typedef {Object} NodeResponseLike
|
|
44
|
+
* @property {(status: number, headers?: Record<string, string>) => unknown} writeHead
|
|
45
|
+
* @property {(body?: string | Uint8Array, callback?: () => void) => unknown} end
|
|
46
|
+
* @property {(event: string, listener: (...args: any[]) => void) => unknown} on
|
|
47
|
+
* @property {(chunk: string | Uint8Array) => unknown} [write]
|
|
48
|
+
* @property {() => unknown} [flushHeaders]
|
|
49
|
+
* @property {boolean} [writableFinished]
|
|
50
|
+
* @property {boolean} [headersSent]
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the request could carry a body the operation reads.
|
|
55
|
+
* @param {string} method
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function mayCarryBody(method) {
|
|
59
|
+
return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The lowercase header table: distinct lines when the platform exposes
|
|
64
|
+
* them (a single line stays a string, repeats become an array), the
|
|
65
|
+
* combined table otherwise.
|
|
66
|
+
* @param {NodeRequestLike} req
|
|
67
|
+
* @returns {Record<string, string | string[]>}
|
|
68
|
+
*/
|
|
69
|
+
function headersOf(req) {
|
|
70
|
+
/** @type {Record<string, string | string[]>} */
|
|
71
|
+
const out = {};
|
|
72
|
+
const distinct = req.headersDistinct;
|
|
73
|
+
if (distinct !== undefined && distinct !== null) {
|
|
74
|
+
const names = Object.keys(distinct);
|
|
75
|
+
for (let i = 0; i < names.length; i++) {
|
|
76
|
+
const lines = distinct[names[i]];
|
|
77
|
+
if (lines === undefined || lines.length === 0) continue;
|
|
78
|
+
out[names[i].toLowerCase()] = lines.length === 1 ? lines[0] : lines.slice();
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
const names = Object.keys(req.headers);
|
|
83
|
+
for (let i = 0; i < names.length; i++) {
|
|
84
|
+
const v = req.headers[names[i]];
|
|
85
|
+
if (v === undefined) continue;
|
|
86
|
+
out[names[i].toLowerCase()] = Array.isArray(v) ? v.slice() : v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Concatenate collected chunks into one Uint8Array.
|
|
93
|
+
* @param {Uint8Array[]} chunks
|
|
94
|
+
* @param {number} total
|
|
95
|
+
* @returns {Uint8Array}
|
|
96
|
+
*/
|
|
97
|
+
function concat(chunks, total) {
|
|
98
|
+
if (chunks.length === 1) return chunks[0];
|
|
99
|
+
const out = new Uint8Array(total);
|
|
100
|
+
let offset = 0;
|
|
101
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
102
|
+
out.set(chunks[i], offset);
|
|
103
|
+
offset += chunks[i].byteLength;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Write a dispatcher response to the platform response.
|
|
110
|
+
* @param {NodeResponseLike} res
|
|
111
|
+
* @param {import('../http/wire.js').HttpResponse} response
|
|
112
|
+
* @param {boolean} close - add `connection: close` (an aborted upload)
|
|
113
|
+
* @param {(() => void) | undefined} done
|
|
114
|
+
*/
|
|
115
|
+
function send(res, response, close, done) {
|
|
116
|
+
/** @type {Record<string, string>} */
|
|
117
|
+
const headers = { ...response.headers };
|
|
118
|
+
if (typeof response.stream === 'function') {
|
|
119
|
+
// an SSE response: headers out immediately, then the pump writes
|
|
120
|
+
// events until the stream ends (the pump ends the response itself);
|
|
121
|
+
// the peer-gone path runs through the request's abort signal
|
|
122
|
+
res.writeHead(response.status, headers);
|
|
123
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
124
|
+
response.stream({
|
|
125
|
+
write: (chunk) => {
|
|
126
|
+
if (typeof res.write === 'function') res.write(chunk);
|
|
127
|
+
},
|
|
128
|
+
end: () => {
|
|
129
|
+
try {
|
|
130
|
+
res.end();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// the socket may already be gone
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const body = response.body;
|
|
140
|
+
if (body !== null && headers['content-length'] === undefined) {
|
|
141
|
+
headers['content-length'] = String(typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength);
|
|
142
|
+
}
|
|
143
|
+
if (close) headers.connection = 'close';
|
|
144
|
+
res.writeHead(response.status, headers);
|
|
145
|
+
if (body === null) res.end(undefined, done);
|
|
146
|
+
else res.end(body, done);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Put a dispatcher behind Node's `(req, res)` listener.
|
|
151
|
+
* @param {HttpDispatcher} dispatcher
|
|
152
|
+
* @returns {(req: NodeRequestLike, res: NodeResponseLike) => void}
|
|
153
|
+
* @example
|
|
154
|
+
* http.createServer(toNodeHandler(serveHttp(contract, handlers))).listen(8080);
|
|
155
|
+
*/
|
|
156
|
+
export function toNodeHandler(dispatcher) {
|
|
157
|
+
if (dispatcher === null || typeof dispatcher !== 'object' || typeof dispatcher.dispatch !== 'function') {
|
|
158
|
+
throw new TypeError('toNodeHandler: the argument must be a dispatcher from serveHttp');
|
|
159
|
+
}
|
|
160
|
+
const contract = dispatcher.contract;
|
|
161
|
+
const head = dispatcher.capabilities.head;
|
|
162
|
+
|
|
163
|
+
return function nodeHandler(req, res) {
|
|
164
|
+
const method = req.method === undefined ? 'GET' : req.method;
|
|
165
|
+
const url = req.url === undefined ? '/' : req.url;
|
|
166
|
+
const q = url.indexOf('?');
|
|
167
|
+
const path = q === -1 ? url : url.slice(0, q);
|
|
168
|
+
const headers = headersOf(req);
|
|
169
|
+
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
res.on('close', () => {
|
|
172
|
+
if (res.writableFinished !== true) controller.abort();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** @param {import('../http/wire.js').HttpResponse} response @param {boolean} close */
|
|
176
|
+
const finish = (response, close) => {
|
|
177
|
+
send(res, response, close, close ? () => { if (typeof req.destroy === 'function') req.destroy(); } : undefined);
|
|
178
|
+
};
|
|
179
|
+
/** @param {string | Uint8Array | null} body @param {boolean} close */
|
|
180
|
+
const answer = (body, close) => {
|
|
181
|
+
dispatcher.dispatch({ method, url, headers, body, signal: controller.signal })
|
|
182
|
+
.then((response) => finish(response, close), (err) => {
|
|
183
|
+
// only JC1004 can arrive here, and this adapter builds a
|
|
184
|
+
// well-formed request; still, a rejection must not hang the socket
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
send(res, { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: message }, true, undefined);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let hit = null;
|
|
191
|
+
if (mayCarryBody(method)) {
|
|
192
|
+
hit = contract.match(method, path);
|
|
193
|
+
if (hit === null && method === 'HEAD' && head) hit = contract.match('GET', path);
|
|
194
|
+
}
|
|
195
|
+
if (hit === null) {
|
|
196
|
+
answer(null, false);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const op = hit.op;
|
|
200
|
+
const limit = op.policy.limits.maxBodyBytes;
|
|
201
|
+
const declared = Number(headers['content-length']);
|
|
202
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
203
|
+
// the dispatcher answers the 413 from the header; the body is never read
|
|
204
|
+
answer(null, false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @type {Uint8Array[]} */
|
|
209
|
+
const chunks = [];
|
|
210
|
+
let total = 0;
|
|
211
|
+
let settled = false;
|
|
212
|
+
req.on('data', (chunk) => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
const bytes = /** @type {Uint8Array} */ (chunk);
|
|
215
|
+
total += bytes.byteLength;
|
|
216
|
+
if (total > limit) {
|
|
217
|
+
settled = true;
|
|
218
|
+
if (typeof req.pause === 'function') req.pause();
|
|
219
|
+
// an oversize stream: the bytes read so far already exceed the
|
|
220
|
+
// limit, so the dispatcher answers the 413 from that length
|
|
221
|
+
// without a body; the request is destroyed after the response
|
|
222
|
+
// has flushed
|
|
223
|
+
headers['content-length'] = String(total);
|
|
224
|
+
answer(null, true);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
chunks.push(bytes);
|
|
228
|
+
});
|
|
229
|
+
req.on('end', () => {
|
|
230
|
+
if (settled) return;
|
|
231
|
+
settled = true;
|
|
232
|
+
answer(total === 0 ? null : concat(chunks, total), false);
|
|
233
|
+
});
|
|
234
|
+
req.on('error', () => {
|
|
235
|
+
settled = true;
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `contractAppBinding(contract, options)`: the generated
|
|
4
|
+
* `@jarenjs/app` documents of a contract (docs/CONTRACT-FORMAT.md §11) —
|
|
5
|
+
* pure JSON, the `fsmToApp` shape, with no import in either direction.
|
|
6
|
+
* For every operation the app uses it emits one task slot in a state
|
|
7
|
+
* slice, a `start`, a `done` and a `reset` action in the async-task
|
|
8
|
+
* convention of `@jarenjs/app`'s TASKS.md (a monotonic slot `id`, the
|
|
9
|
+
* start patches the slot AND hands the new id to the effect, the
|
|
10
|
+
* completion guards on that id so an out-of-order response is a
|
|
11
|
+
* provable no-op), and a JSON Schema for the slice to compose into
|
|
12
|
+
* `validateState`. The app document names ONE effect, `run: "contract"`;
|
|
13
|
+
* the per-operation task mode is never NAMED in the document — it comes
|
|
14
|
+
* from `policy.task` through `createContractEffect` — but the generator
|
|
15
|
+
* derives the document's state-side guards from it exactly as it
|
|
16
|
+
* derives the document's shape from `kind`, so that the slot and the
|
|
17
|
+
* effect tell the same story in every mode (D10): an `exhaust`
|
|
18
|
+
* operation's `start` is a no-op in state while its slot is `loading`
|
|
19
|
+
* (the effect would ignore the duplicate start; state decides first, so
|
|
20
|
+
* the one completion that arrives carries the current id and lands).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
24
|
+
|
|
25
|
+
import { ContractHostError } from '../errors.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {import('../compile.js').Contract} Contract
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} ContractAppBindingOptions
|
|
33
|
+
* @property {string} [namespace] - the action-name prefix; default `contract/`
|
|
34
|
+
* @property {string} [statePath] - where the slice lives in app state, as a
|
|
35
|
+
* chain of identifier-safe segments; default `/contract`
|
|
36
|
+
* @property {readonly string[]} [ops] - the operations the app uses; default every operation
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One task slot: the slice member of an operation. `kind` is the failed
|
|
41
|
+
* outcome's kind while `status` is `"error"` (`"failure"` a declared or
|
|
42
|
+
* taxonomy error, `"network"`, `"contract"`; `"cancelled"` never lands —
|
|
43
|
+
* nothing is dispatched for it) and `null` otherwise, so a view tells
|
|
44
|
+
* "you are offline" from "the server refused this" without parsing
|
|
45
|
+
* `error.code`.
|
|
46
|
+
* @typedef {{ id: number, status: 'idle' | 'loading' | 'done' | 'error', kind: 'failure' | 'network' | 'contract' | null, value: unknown, error: unknown, meta: unknown }} TaskSlot
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One subscription slot: the slice member of a subscribe operation
|
|
51
|
+
* (docs/CONTRACT-FORMAT.md §11.4). `input` is what `start` was
|
|
52
|
+
* dispatched with — the subscription entry reads it from state, which
|
|
53
|
+
* is what lets the generated `withQuery` resolve the stream's input
|
|
54
|
+
* without a second channel; `value` is the maintained snapshot, `seq`
|
|
55
|
+
* the last applied emission's seq.
|
|
56
|
+
* @typedef {{ id: number, status: 'idle' | 'live' | 'error', kind: 'failure' | 'network' | 'contract' | null, input: unknown, value: unknown, error: unknown, meta: unknown, seq: number }} StreamSlot
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The generated binding.
|
|
61
|
+
* @typedef {Object} ContractAppBinding
|
|
62
|
+
* @property {Record<string, TaskSlot | StreamSlot>} slice - operation id → its initial slot; mount it at `statePath`
|
|
63
|
+
* @property {Record<string, any>} actions - per read/command operation
|
|
64
|
+
* `<namespace><op>/start`, `/done` and `/reset`; per subscribe operation
|
|
65
|
+
* `/start`, `/stop`, `/snapshot`, `/patch`, `/error` and `/reset`
|
|
66
|
+
* @property {any[]} subs - one subscription entry per subscribe operation
|
|
67
|
+
* (`run: "contract-stream"`); spread into the app document's `subs`
|
|
68
|
+
* @property {any} schema - the slice's JSON Schema, `$defs` of the contract carried, for `validateState`
|
|
69
|
+
* @property {'contract'} effect - the effect name the task actions invoke
|
|
70
|
+
* @property {'contract-stream'} subscription - the handler name the subs entries run
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** `statePath`: identifier-safe segments only, so it maps to a JSONPath without quoting. */
|
|
74
|
+
const STATE_PATH = /^(\/[A-Za-z_][A-Za-z0-9_]*)+$/;
|
|
75
|
+
|
|
76
|
+
/** The D6 error object as a schema: what a failed outcome puts in `error`. */
|
|
77
|
+
const ERROR_SCHEMA = Object.freeze({
|
|
78
|
+
type: 'object',
|
|
79
|
+
required: ['code', 'message', 'status', 'details', 'retryable'],
|
|
80
|
+
properties: {
|
|
81
|
+
code: { type: 'string' },
|
|
82
|
+
message: { type: 'string' },
|
|
83
|
+
status: { type: ['integer', 'null'] },
|
|
84
|
+
details: {},
|
|
85
|
+
retryable: { type: 'boolean' },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** The outcome `meta` as a schema. */
|
|
90
|
+
const META_SCHEMA = Object.freeze({
|
|
91
|
+
type: 'object',
|
|
92
|
+
required: ['op', 'attempt', 'trace', 'revision', 'etag', 'notModified'],
|
|
93
|
+
properties: {
|
|
94
|
+
op: { type: 'string' },
|
|
95
|
+
attempt: {},
|
|
96
|
+
trace: { type: ['string', 'null'] },
|
|
97
|
+
revision: { type: ['string', 'null'] },
|
|
98
|
+
etag: { type: ['string', 'null'] },
|
|
99
|
+
notModified: { type: 'boolean' },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} reason
|
|
105
|
+
* @returns {ContractHostError}
|
|
106
|
+
*/
|
|
107
|
+
function host(reason) {
|
|
108
|
+
return new ContractHostError('JC1007', `contractAppBinding: ${reason}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A JSON Patch `replace` op with a literal or query value.
|
|
113
|
+
* @param {string} path
|
|
114
|
+
* @param {unknown} value
|
|
115
|
+
*/
|
|
116
|
+
function replace(path, value) {
|
|
117
|
+
return { op: 'replace', path, value };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The generated documents of one subscribe operation
|
|
122
|
+
* (docs/CONTRACT-FORMAT.md §11.4): a guarded `start` (no second
|
|
123
|
+
* subscription while the slot is `live` — the same state-first rule as
|
|
124
|
+
* an exhaust command's start), `stop`, the stream actions the
|
|
125
|
+
* subscription handler dispatches (`snapshot`, `patch` — both guarded
|
|
126
|
+
* on the slot id, `patch` additionally on a strictly greater `seq`, so
|
|
127
|
+
* a stale instance's or an out-of-order event's dispatch is a provable
|
|
128
|
+
* no-op — and `error`), `reset`, the `subs` entry whose `withQuery`
|
|
129
|
+
* resolves the slot's `id` and `input` from state (restart keyed by the
|
|
130
|
+
* resolved props by value), and the slot's schema. The `patch` action
|
|
131
|
+
* receives the WHOLE patched document in `$payload.value` — an app
|
|
132
|
+
* action's `patch` member is a literal op list whose members are query
|
|
133
|
+
* expressions, so it cannot splice a runtime array of RFC 6902 ops; the
|
|
134
|
+
* subscription handler applies the emission with `@jarenjs/json/patch`
|
|
135
|
+
* (copy-on-write, structural sharing preserved) and the action replaces
|
|
136
|
+
* the slot value with the result.
|
|
137
|
+
* @param {Contract} contract
|
|
138
|
+
* @param {any} op
|
|
139
|
+
* @param {string} id
|
|
140
|
+
* @param {string} namespace
|
|
141
|
+
* @param {string} slot
|
|
142
|
+
* @param {string} slotQuery
|
|
143
|
+
* @param {string} idQuery
|
|
144
|
+
* @param {any} nextId
|
|
145
|
+
* @param {Record<string, any>} actions
|
|
146
|
+
* @param {any[]} subs
|
|
147
|
+
* @param {Record<string, any>} properties
|
|
148
|
+
*/
|
|
149
|
+
function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties) {
|
|
150
|
+
const idGuard = { $eq: ['$payload.id', idQuery] };
|
|
151
|
+
|
|
152
|
+
setObjectMember(actions, `${namespace}${id}/start`, {
|
|
153
|
+
$if: [
|
|
154
|
+
{ $ne: [`${slotQuery}.status`, 'live'] },
|
|
155
|
+
{
|
|
156
|
+
patch: [
|
|
157
|
+
replace(`${slot}/id`, nextId),
|
|
158
|
+
replace(`${slot}/status`, 'live'),
|
|
159
|
+
replace(`${slot}/kind`, null),
|
|
160
|
+
replace(`${slot}/error`, null),
|
|
161
|
+
replace(`${slot}/input`, '$payload'),
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
setObjectMember(actions, `${namespace}${id}/stop`, {
|
|
168
|
+
patch: [replace(`${slot}/status`, 'idle')],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
setObjectMember(actions, `${namespace}${id}/snapshot`, {
|
|
172
|
+
$if: [
|
|
173
|
+
idGuard,
|
|
174
|
+
{
|
|
175
|
+
patch: [
|
|
176
|
+
replace(`${slot}/value`, '$payload.value'),
|
|
177
|
+
replace(`${slot}/seq`, '$payload.seq'),
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
setObjectMember(actions, `${namespace}${id}/patch`, {
|
|
184
|
+
$if: [
|
|
185
|
+
{ $and: [idGuard, { $gt: ['$payload.seq', `${slotQuery}.seq`] }] },
|
|
186
|
+
{
|
|
187
|
+
patch: [
|
|
188
|
+
replace(`${slot}/value`, '$payload.value'),
|
|
189
|
+
replace(`${slot}/seq`, '$payload.seq'),
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
setObjectMember(actions, `${namespace}${id}/error`, {
|
|
196
|
+
$if: [
|
|
197
|
+
idGuard,
|
|
198
|
+
{
|
|
199
|
+
patch: [
|
|
200
|
+
replace(`${slot}/status`, 'error'),
|
|
201
|
+
replace(`${slot}/kind`, '$payload.outcome.kind'),
|
|
202
|
+
replace(`${slot}/error`, '$payload.outcome.error'),
|
|
203
|
+
replace(`${slot}/meta`, '$payload.outcome.meta'),
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
setObjectMember(actions, `${namespace}${id}/reset`, {
|
|
210
|
+
patch: [
|
|
211
|
+
replace(`${slot}/status`, 'idle'),
|
|
212
|
+
replace(`${slot}/kind`, null),
|
|
213
|
+
replace(`${slot}/error`, null),
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
subs.push({
|
|
218
|
+
run: 'contract-stream',
|
|
219
|
+
when: { $eq: [`${slotQuery}.status`, 'live'] },
|
|
220
|
+
withQuery: {
|
|
221
|
+
op: id,
|
|
222
|
+
id: idQuery,
|
|
223
|
+
input: `${slotQuery}.input`,
|
|
224
|
+
snapshot: `${namespace}${id}/snapshot`,
|
|
225
|
+
patch: `${namespace}${id}/patch`,
|
|
226
|
+
error: `${namespace}${id}/error`,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const output = op.output.schema;
|
|
231
|
+
const valueSchema = output === true ? true
|
|
232
|
+
: output === false ? { type: 'null' }
|
|
233
|
+
: { anyOf: [{ type: 'null' }, output] };
|
|
234
|
+
const inputSchema = op.input === null
|
|
235
|
+
? { type: 'null' }
|
|
236
|
+
: { anyOf: [{ type: 'null' }, op.input.schema] };
|
|
237
|
+
setObjectMember(properties, id, {
|
|
238
|
+
type: 'object',
|
|
239
|
+
required: ['id', 'status', 'kind', 'input', 'value', 'error', 'meta', 'seq'],
|
|
240
|
+
properties: {
|
|
241
|
+
id: { type: 'integer', minimum: 0 },
|
|
242
|
+
status: { enum: ['idle', 'live', 'error'] },
|
|
243
|
+
kind: { enum: [null, 'failure', 'network', 'contract'] },
|
|
244
|
+
input: inputSchema,
|
|
245
|
+
value: valueSchema,
|
|
246
|
+
error: { anyOf: [{ type: 'null' }, ERROR_SCHEMA] },
|
|
247
|
+
meta: { anyOf: [{ type: 'null' }, META_SCHEMA] },
|
|
248
|
+
seq: { type: 'integer', minimum: 0 },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate the app documents of a contract: a state slice with one task
|
|
255
|
+
* slot per operation, `start`/`done`/`reset` actions per operation with
|
|
256
|
+
* the TASKS.md id guard built in, and the slice's JSON Schema.
|
|
257
|
+
*
|
|
258
|
+
* The `start` action takes the operation's input as `$payload`: it
|
|
259
|
+
* increments the slot `id`, sets `status: "loading"`, clears `kind` and
|
|
260
|
+
* `error`, and runs the `contract` effect with `{ op, input: $payload, id, done,
|
|
261
|
+
* slot }` — the `id` written as the same increment expression the patch
|
|
262
|
+
* uses (everything in an action evaluates against the PRE-transition
|
|
263
|
+
* state). For an operation whose `policy.task` is `exhaust` the whole
|
|
264
|
+
* start is wrapped in `$if: [{ $ne: [<slot>.status, "loading"] }, …]`
|
|
265
|
+
* — a `$if` without else is the empty sequence (APP-FORMAT §3.2): no
|
|
266
|
+
* patch, no effect, no render — so a duplicate start leaves the slot id
|
|
267
|
+
* where it is and the single completion lands. The `done` action guards
|
|
268
|
+
* on `$payload.id` against the slot id, reads the outcome from
|
|
269
|
+
* `$payload.result` (or `$payload.error`, where a host failure projected
|
|
270
|
+
* by the effect lands), and stores it: `ok` → `status: "done"`, `kind:
|
|
271
|
+
* null`, `value`, `meta`, `error: null`; otherwise `status: "error"`,
|
|
272
|
+
* `kind` (the outcome's), `error`, `meta`, and `value` UNTOUCHED — a
|
|
273
|
+
* failed reload keeps the last good value. The `reset` action releases
|
|
274
|
+
* the slot: `status: "idle"`, `kind: null`, `error: null`; `id`, `value`
|
|
275
|
+
* and `meta` stay (the id must stay monotonic so a late completion of a
|
|
276
|
+
* cancelled attempt is still rejected; the last good value survives a
|
|
277
|
+
* reset as it survives an error).
|
|
278
|
+
*
|
|
279
|
+
* @param {Contract} contract
|
|
280
|
+
* @param {ContractAppBindingOptions} [options]
|
|
281
|
+
* @returns {ContractAppBinding}
|
|
282
|
+
* @throws {ContractHostError} `JC1007` for an unknown/uncarriable operation in `ops` or a malformed option
|
|
283
|
+
* @example
|
|
284
|
+
* const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
|
|
285
|
+
* createApp({ state: { contract: slice }, view, actions: { ...actions, ...own } }, {
|
|
286
|
+
* effects: { contract: createContractEffect(client, { createTaskEffect }) },
|
|
287
|
+
* validateState: (state) => validate(state), // validate compiled over { properties: { contract: schema } }
|
|
288
|
+
* });
|
|
289
|
+
* app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
|
|
290
|
+
*/
|
|
291
|
+
export function contractAppBinding(contract, options = {}) {
|
|
292
|
+
if (contract === null || typeof contract !== 'object' || !Array.isArray(contract.ids)
|
|
293
|
+
|| contract.operations === null || typeof contract.operations !== 'object') {
|
|
294
|
+
throw host('the first argument must be a compiled contract (compileContract)');
|
|
295
|
+
}
|
|
296
|
+
if (options === null || typeof options !== 'object') throw host('options must be an object');
|
|
297
|
+
const namespace = options.namespace === undefined ? 'contract/' : options.namespace;
|
|
298
|
+
if (typeof namespace !== 'string') throw host('namespace must be a string');
|
|
299
|
+
const statePath = options.statePath === undefined ? '/contract' : options.statePath;
|
|
300
|
+
if (typeof statePath !== 'string' || !STATE_PATH.test(statePath)) {
|
|
301
|
+
throw host('statePath must be a chain of identifier-safe segments, like /contract or /ui/api');
|
|
302
|
+
}
|
|
303
|
+
const ops = options.ops === undefined ? contract.ids : options.ops;
|
|
304
|
+
if (!Array.isArray(ops)) throw host('ops must be an array of operation ids');
|
|
305
|
+
const queryRoot = '$' + statePath.replaceAll('/', '.');
|
|
306
|
+
|
|
307
|
+
/** @type {Record<string, TaskSlot | StreamSlot>} */
|
|
308
|
+
const slice = {};
|
|
309
|
+
/** @type {Record<string, any>} */
|
|
310
|
+
const actions = {};
|
|
311
|
+
/** @type {any[]} */
|
|
312
|
+
const subs = [];
|
|
313
|
+
/** @type {Record<string, any>} */
|
|
314
|
+
const properties = {};
|
|
315
|
+
/** @type {string[]} */
|
|
316
|
+
const required = [];
|
|
317
|
+
|
|
318
|
+
for (let i = 0; i < ops.length; i++) {
|
|
319
|
+
const id = ops[i];
|
|
320
|
+
if (typeof id !== 'string' || !Object.hasOwn(contract.operations, id)) {
|
|
321
|
+
throw host(`ops names '${String(id)}', which is not an operation of the contract`);
|
|
322
|
+
}
|
|
323
|
+
const op = contract.operations[id];
|
|
324
|
+
const slot = `${statePath}/${id}`;
|
|
325
|
+
const slotQuery = `${queryRoot}['${id}']`;
|
|
326
|
+
const idQuery = `${slotQuery}.id`;
|
|
327
|
+
const nextId = { $add: [idQuery, 1] };
|
|
328
|
+
const done = `${namespace}${id}/done`;
|
|
329
|
+
|
|
330
|
+
if (op.kind === 'subscribe') {
|
|
331
|
+
appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties);
|
|
332
|
+
setObjectMember(slice, id, { id: 0, status: 'idle', kind: null, input: null, value: null, error: null, meta: null, seq: 0 });
|
|
333
|
+
required.push(id);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
setObjectMember(slice, id, { id: 0, status: 'idle', kind: null, value: null, error: null, meta: null });
|
|
338
|
+
|
|
339
|
+
const start = {
|
|
340
|
+
patch: [
|
|
341
|
+
replace(`${slot}/id`, nextId),
|
|
342
|
+
replace(`${slot}/status`, 'loading'),
|
|
343
|
+
replace(`${slot}/kind`, null),
|
|
344
|
+
replace(`${slot}/error`, null),
|
|
345
|
+
],
|
|
346
|
+
effects: [
|
|
347
|
+
{ run: 'contract', with: { op: id, input: '$payload', id: nextId, done, slot: id } },
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
// the state-side guard derived from policy.task (the mode itself is
|
|
351
|
+
// never written): an exhaust operation's duplicate start is a no-op
|
|
352
|
+
// in state exactly as it is in the effect, so the one completion
|
|
353
|
+
// that arrives carries the current id
|
|
354
|
+
setObjectMember(actions, `${namespace}${id}/start`, op.policy.task === 'exhaust'
|
|
355
|
+
? { $if: [{ $ne: [`${slotQuery}.status`, 'loading'] }, start] }
|
|
356
|
+
: start);
|
|
357
|
+
|
|
358
|
+
setObjectMember(actions, done, {
|
|
359
|
+
$if: [
|
|
360
|
+
{ $eq: ['$payload.id', idQuery] },
|
|
361
|
+
{
|
|
362
|
+
$let: { outcome: { $coalesce: ['$payload.result', '$payload.error'] } },
|
|
363
|
+
$return: {
|
|
364
|
+
$if: [
|
|
365
|
+
{ $eq: ['$outcome.ok', true] },
|
|
366
|
+
{ patch: [
|
|
367
|
+
replace(`${slot}/status`, 'done'),
|
|
368
|
+
replace(`${slot}/kind`, null),
|
|
369
|
+
replace(`${slot}/value`, '$outcome.value'),
|
|
370
|
+
replace(`${slot}/meta`, '$outcome.meta'),
|
|
371
|
+
replace(`${slot}/error`, null),
|
|
372
|
+
] },
|
|
373
|
+
{ patch: [
|
|
374
|
+
replace(`${slot}/status`, 'error'),
|
|
375
|
+
replace(`${slot}/kind`, '$outcome.kind'),
|
|
376
|
+
replace(`${slot}/error`, '$outcome.error'),
|
|
377
|
+
replace(`${slot}/meta`, '$outcome.meta'),
|
|
378
|
+
] },
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
setObjectMember(actions, `${namespace}${id}/reset`, {
|
|
386
|
+
patch: [
|
|
387
|
+
replace(`${slot}/status`, 'idle'),
|
|
388
|
+
replace(`${slot}/kind`, null),
|
|
389
|
+
replace(`${slot}/error`, null),
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const output = op.output.schema;
|
|
394
|
+
const valueSchema = output === true ? true
|
|
395
|
+
: output === false ? { type: 'null' }
|
|
396
|
+
: { anyOf: [{ type: 'null' }, output] };
|
|
397
|
+
setObjectMember(properties, id, {
|
|
398
|
+
type: 'object',
|
|
399
|
+
required: ['id', 'status', 'kind', 'value', 'error', 'meta'],
|
|
400
|
+
properties: {
|
|
401
|
+
id: { type: 'integer', minimum: 0 },
|
|
402
|
+
status: { enum: ['idle', 'loading', 'done', 'error'] },
|
|
403
|
+
// the enum, not the cross-member invariant (kind is null exactly
|
|
404
|
+
// when status is not "error"): a JSON Schema if/then would cost
|
|
405
|
+
// every transition, and the generated actions are the only writer
|
|
406
|
+
kind: { enum: [null, 'failure', 'network', 'contract'] },
|
|
407
|
+
value: valueSchema,
|
|
408
|
+
error: { anyOf: [{ type: 'null' }, ERROR_SCHEMA] },
|
|
409
|
+
meta: { anyOf: [{ type: 'null' }, META_SCHEMA] },
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
required.push(id);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @type {any} */
|
|
416
|
+
const schema = { type: 'object', required, properties };
|
|
417
|
+
// the operations' output schemas may `$ref` the contract's `$defs`; an
|
|
418
|
+
// embedded resource with its own `$id` keeps `#/$defs/...` pointing
|
|
419
|
+
// here wherever the consumer mounts the slice schema
|
|
420
|
+
if (Object.keys(contract.$defs).length > 0) {
|
|
421
|
+
schema.$id = `urn:jaren:contract-app:${contract.id === null ? 'contract' : contract.id}`;
|
|
422
|
+
schema.$defs = contract.$defs;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { slice, actions, subs, schema, effect: 'contract', subscription: 'contract-stream' };
|
|
426
|
+
}
|