@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.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. 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
+ }