@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
package/src/compile.js ADDED
@@ -0,0 +1,1162 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `compileContract`: a `$contract` document (docs/CONTRACT-FORMAT.md
4
+ * §2) becomes a frozen `Contract` — per-operation validators, transport
5
+ * normalizers, the materialized HTTP binding with every default resolved,
6
+ * and one path matcher over the operation table.
7
+ *
8
+ * Two stages, like every compiler in the suite: everything is decided
9
+ * here, once; nothing that runs per request allocates or re-reads the
10
+ * document. The document is TRUSTED input but is still read totally: it
11
+ * is first snapshotted through guarded access into plain JSON (a
12
+ * throwing accessor, a non-JSON member or a cycle is `JC0001` at the
13
+ * member), then validated rule by rule against a CLOSED vocabulary
14
+ * (`JC0013` — a silently ignored `policy` member is a behavior bug), and
15
+ * only then compiled. Every refusal is a `ContractCompileError` with the
16
+ * `docPath` of the member at fault.
17
+ *
18
+ * `$ref` resolution rides the validator: the document is registered under
19
+ * a synthetic id and each operation schema is compiled as a `$ref` into
20
+ * it, so `#/$defs/Product` means the contract's own `$defs` and an
21
+ * absolute `$id` means one of `options.schemas`. Unresolved is `JC0007`
22
+ * at compile — never at request time.
23
+ */
24
+
25
+ import { isJsonObject, setObjectMember, deepFreeze } from '@jarenjs/core/object';
26
+ import { JarenValidator } from '@jarenjs/validate';
27
+ import {
28
+ compileNormalizer, collectSameDocumentAnchors, resolveSameDocumentRef,
29
+ } from '@jarenjs/validate/normalize';
30
+ import { encodeJSONPointerSegment, parseJSONPointer } from '@jarenjs/json/pointer';
31
+
32
+ import { ContractCompileError } from './errors.js';
33
+ import { parsePathTemplate, pathShape, compileRoutes } from './path.js';
34
+ import { describeContract } from './describe.js';
35
+ import { contractRevision } from './revision.js';
36
+
37
+ //#region vocabulary
38
+
39
+ const CONTRACT_VERSION = '0.1';
40
+
41
+ const ROOT_MEMBERS = new Set(['$contract', 'id', 'version', 'compat', '$defs', 'operations']);
42
+ const OP_MEMBERS = new Set(['kind', 'input', 'output', 'errors', 'policy', 'http', 'doc']);
43
+ const POLICY_MEMBERS = new Set(['task', 'idempotency', 'revision', 'cache', 'limits', 'errors', 'retry', 'stream', 'audience']);
44
+ const LIMITS_MEMBERS = new Set(['maxBodyBytes']);
45
+ const STREAM_MEMBERS = new Set(['resume', 'heartbeatMs', 'maxPatchBytes']);
46
+ const POLICY_ERRORS_MEMBERS = new Set(['details']);
47
+ const RETRY_MEMBERS = new Set(['max', 'on']);
48
+ const HTTP_MEMBERS = new Set(['method', 'path', 'in', 'body', 'status', 'media']);
49
+ const ERROR_DECL_MEMBERS = new Set(['status', 'schema']);
50
+
51
+ const KINDS = Object.freeze(['read', 'command', 'subscribe']);
52
+ const TASKS = Object.freeze(['switch', 'exhaust', 'concat', 'parallel']);
53
+ const IDEMPOTENCY = Object.freeze(['none', 'optional', 'required']);
54
+ const CACHE = Object.freeze(['none', 'revision']);
55
+ const RESUME = Object.freeze(['snapshot', 'replay']);
56
+ const DETAILS = Object.freeze(['none', 'paths', 'full']);
57
+ const AUDIENCES = Object.freeze(['public', 'server']);
58
+ const METHODS = Object.freeze(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
59
+ const LOCATIONS = Object.freeze(['path', 'query', 'header', 'body']);
60
+
61
+ const DEFAULT_MAX_BODY_BYTES = 1048576;
62
+ const DEFAULT_MEDIA = 'application/json';
63
+ const STREAM_MEDIA = 'text/event-stream';
64
+ const DEFAULT_STATUS = 200;
65
+ const DEFAULT_ERROR_STATUS = 400;
66
+ const DEFAULT_HEARTBEAT_MS = 15000;
67
+
68
+ /** The contract `id`: an identifier that may carry hyphens. */
69
+ const CONTRACT_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
70
+ /** An operation id: dotted lowercase words. */
71
+ const OP_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/;
72
+ /** A declared error code: a lowercase hyphenated word. */
73
+ const ERROR_CODE = /^[a-z][a-z0-9-]*$/;
74
+ /** A media type: `type/subtype` with optional parameters. */
75
+ const MEDIA_TYPE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:\s*;.*)?$/;
76
+
77
+ /** JSON-value keywords whose content is data, not schema — not walked for `$ref`. */
78
+ const DATA_KEYWORDS = new Set(['const', 'enum', 'default', 'examples']);
79
+
80
+ /** One synthetic `$id` per compile, so a shared validator never sees two documents under one name. */
81
+ let compileSequence = 0;
82
+
83
+ //#endregion
84
+
85
+ //#region helpers
86
+
87
+ /**
88
+ * @param {string} code
89
+ * @param {string} reason
90
+ * @param {string} docPath
91
+ * @param {Error} [cause]
92
+ * @returns {ContractCompileError}
93
+ */
94
+ function refuse(code, reason, docPath, cause) {
95
+ return new ContractCompileError(code, reason, docPath, cause);
96
+ }
97
+
98
+ /**
99
+ * @param {unknown} v
100
+ * @returns {Error | undefined}
101
+ */
102
+ function asCause(v) {
103
+ return v instanceof Error ? v : undefined;
104
+ }
105
+
106
+ /**
107
+ * Append one reference token to a JSON Pointer.
108
+ * @param {string} base
109
+ * @param {string | number} key
110
+ * @returns {string}
111
+ */
112
+ function at(base, key) {
113
+ return `${base}/${encodeJSONPointerSegment(key)}`;
114
+ }
115
+
116
+ /**
117
+ * A URI fragment addressing `pointer` inside the registered document.
118
+ * Reference tokens are RFC 6901-escaped and then percent-encoded, which
119
+ * the validator decodes; `~` is unreserved so `~0`/`~1` survive.
120
+ * @param {(string | number)[]} tokens
121
+ * @returns {string}
122
+ */
123
+ function fragment(tokens) {
124
+ let out = '#';
125
+ for (let i = 0; i < tokens.length; i++) {
126
+ out += '/' + encodeURIComponent(encodeJSONPointerSegment(tokens[i]));
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * True for a JSON Schema value: an object or a boolean.
133
+ * @param {unknown} v
134
+ * @returns {boolean}
135
+ */
136
+ function isSchema(v) {
137
+ return typeof v === 'boolean' || isJsonObject(v);
138
+ }
139
+
140
+ /**
141
+ * Whether a media type carries JSON: `application/json` or any `+json`
142
+ * structured syntax suffix; parameters are ignored.
143
+ * @param {string} media
144
+ * @returns {boolean}
145
+ */
146
+ function isJsonMedia(media) {
147
+ const semi = media.indexOf(';');
148
+ const bare = (semi === -1 ? media : media.slice(0, semi)).trim().toLowerCase();
149
+ return bare === 'application/json' || bare.endsWith('+json');
150
+ }
151
+
152
+ /**
153
+ * Freeze a compiled structure at every level, functions included, so a
154
+ * host cannot reshape what a binding will read per request.
155
+ * @template T
156
+ * @param {T} value
157
+ * @returns {T}
158
+ */
159
+ function freezeAll(value) {
160
+ if (typeof value === 'function') return Object.freeze(value);
161
+ if (typeof value !== 'object' || value === null) return value;
162
+ if (Object.isFrozen(value)) return value;
163
+ const keys = Object.keys(value);
164
+ for (let i = 0; i < keys.length; i++) freezeAll(/** @type {any} */ (value)[keys[i]]);
165
+ return Object.freeze(value);
166
+ }
167
+
168
+ /**
169
+ * Snapshot a document member into plain JSON through guarded access.
170
+ * TOTAL: an accessor that throws, a member that is not a JSON value
171
+ * (function, symbol, bigint, non-finite number, class instance), or a
172
+ * cycle is `JC0001` at the member's `docPath`. `undefined` members are
173
+ * absent, as JSON would have them.
174
+ * @param {unknown} value
175
+ * @param {string} docPath
176
+ * @param {Set<object>} path - containers on the current descent
177
+ * @returns {any}
178
+ */
179
+ function snapshot(value, docPath, path) {
180
+ switch (typeof value) {
181
+ case 'string':
182
+ case 'boolean':
183
+ return value;
184
+ case 'number':
185
+ if (!Number.isFinite(value)) throw refuse('JC0001', 'a non-finite number is not a JSON value', docPath);
186
+ return value;
187
+ case 'object':
188
+ break;
189
+ default:
190
+ throw refuse('JC0001', `a ${typeof value} is not a JSON value`, docPath);
191
+ }
192
+ if (value === null) return null;
193
+ const obj = /** @type {any} */ (value);
194
+ let isArray;
195
+ let proto;
196
+ let keys;
197
+ try {
198
+ isArray = Array.isArray(obj);
199
+ proto = isArray ? null : Object.getPrototypeOf(obj);
200
+ keys = isArray ? null : Object.keys(obj);
201
+ }
202
+ catch (err) {
203
+ throw refuse('JC0001', 'the member threw when read', docPath, asCause(err));
204
+ }
205
+ if (!isArray && proto !== Object.prototype && proto !== null) {
206
+ throw refuse('JC0001', 'a class instance is not a JSON value (expected a plain object)', docPath);
207
+ }
208
+ if (path.has(obj)) throw refuse('JC0001', 'the document is cyclic', docPath);
209
+ path.add(obj);
210
+ let out;
211
+ if (isArray) {
212
+ let length;
213
+ try {
214
+ length = obj.length;
215
+ }
216
+ catch (err) {
217
+ throw refuse('JC0001', 'the member threw when read', docPath, asCause(err));
218
+ }
219
+ out = new Array(length);
220
+ for (let i = 0; i < length; i++) {
221
+ let item;
222
+ try {
223
+ item = obj[i];
224
+ }
225
+ catch (err) {
226
+ throw refuse('JC0001', 'the member threw when read', at(docPath, i), asCause(err));
227
+ }
228
+ out[i] = item === undefined ? null : snapshot(item, at(docPath, i), path);
229
+ }
230
+ }
231
+ else {
232
+ out = {};
233
+ const names = /** @type {string[]} */ (keys);
234
+ for (let i = 0; i < names.length; i++) {
235
+ const key = names[i];
236
+ let member;
237
+ try {
238
+ member = obj[key];
239
+ }
240
+ catch (err) {
241
+ throw refuse('JC0001', 'the member threw when read', at(docPath, key), asCause(err));
242
+ }
243
+ if (member === undefined) continue;
244
+ setObjectMember(out, key, snapshot(member, at(docPath, key), path));
245
+ }
246
+ }
247
+ path.delete(obj);
248
+ return out;
249
+ }
250
+
251
+ //#endregion
252
+
253
+ //#region references
254
+
255
+ /**
256
+ * The reference resolver of one compile: same-document refs through the
257
+ * document's anchors, absolute refs through the validator's registry.
258
+ * @typedef {Object} RefScope
259
+ * @property {any} src - the snapshotted document
260
+ * @property {Map<string, object>} anchors
261
+ * @property {JarenValidator<any>} validator
262
+ */
263
+
264
+ /**
265
+ * A URI fragment as a same-document reference: percent-decoded when it
266
+ * decodes, kept verbatim otherwise (the resolver then fails to find it).
267
+ * @param {string} frag
268
+ * @returns {string}
269
+ */
270
+ function decodeFragment(frag) {
271
+ try {
272
+ return decodeURIComponent(frag);
273
+ }
274
+ catch {
275
+ return frag;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Follow a `$ref` chain to the schema it names, or `undefined` when a
281
+ * link does not resolve. Bounded so a reference cycle terminates.
282
+ * @param {any} node
283
+ * @param {RefScope} scope
284
+ * @returns {any}
285
+ */
286
+ function effectiveSchema(node, scope) {
287
+ let cur = node;
288
+ for (let hops = 0; hops < 32 && isJsonObject(cur) && typeof cur.$ref === 'string'; hops++) {
289
+ const ref = cur.$ref;
290
+ let target;
291
+ if (ref.startsWith('#')) {
292
+ target = resolveSameDocumentRef(ref, scope.src, scope.anchors);
293
+ }
294
+ else {
295
+ const hash = ref.indexOf('#');
296
+ const base = hash === -1 ? ref : ref.slice(0, hash);
297
+ const frag = hash === -1 ? '#' : ref.slice(hash);
298
+ const external = scope.validator.getSchema(base);
299
+ target = external === null || external === undefined
300
+ ? undefined
301
+ : resolveSameDocumentRef(decodeFragment(frag), external, collectSameDocumentAnchors(external));
302
+ }
303
+ if (target === undefined) return undefined;
304
+ cur = target;
305
+ }
306
+ return cur;
307
+ }
308
+
309
+ /**
310
+ * Walk a schema subtree and refuse (`JC0007`) the first `$ref` that
311
+ * resolves neither inside the document nor to a registered `$id`, at
312
+ * the `$ref` member's own `docPath`. A subtree declaring its own `$id`
313
+ * is an embedded resource with another base and is left to the
314
+ * validator's whole-document probe.
315
+ * @param {any} node
316
+ * @param {string} docPath
317
+ * @param {RefScope} scope
318
+ * @param {boolean} isRoot
319
+ */
320
+ function checkRefs(node, docPath, scope, isRoot) {
321
+ if (Array.isArray(node)) {
322
+ for (let i = 0; i < node.length; i++) checkRefs(node[i], at(docPath, i), scope, false);
323
+ return;
324
+ }
325
+ if (!isJsonObject(node)) return;
326
+ if (!isRoot && typeof node.$id === 'string') return;
327
+ if (typeof node.$ref === 'string') {
328
+ const ref = node.$ref;
329
+ if (ref.startsWith('#')) {
330
+ if (resolveSameDocumentRef(ref, scope.src, scope.anchors) === undefined) {
331
+ throw refuse('JC0007', `the $ref '${ref}' does not resolve inside the document`, at(docPath, '$ref'));
332
+ }
333
+ }
334
+ else {
335
+ const hash = ref.indexOf('#');
336
+ const base = hash === -1 ? ref : ref.slice(0, hash);
337
+ const external = scope.validator.getSchema(base);
338
+ if (external === null || external === undefined) {
339
+ throw refuse('JC0007',
340
+ `the $ref '${ref}' names no registered schema (register it through options.schemas)`,
341
+ at(docPath, '$ref'));
342
+ }
343
+ // the validator resolves a pointer fragment into a registered schema
344
+ // lazily (a missing pointer surfaces at validation time), so the
345
+ // fragment is checked here — unresolved must be a compile refusal
346
+ if (hash !== -1 && resolveSameDocumentRef(decodeFragment(ref.slice(hash)), external, collectSameDocumentAnchors(external)) === undefined) {
347
+ throw refuse('JC0007',
348
+ `the $ref '${ref}' names a registered schema but its fragment does not resolve inside it`,
349
+ at(docPath, '$ref'));
350
+ }
351
+ }
352
+ }
353
+ const keys = Object.keys(node);
354
+ for (let i = 0; i < keys.length; i++) {
355
+ const key = keys[i];
356
+ if (DATA_KEYWORDS.has(key)) continue;
357
+ checkRefs(node[key], at(docPath, key), scope, false);
358
+ }
359
+ }
360
+
361
+ //#endregion
362
+
363
+ //#region the compiled shapes
364
+
365
+ /**
366
+ * A compiled validator: whatever the injected validator's `compile`
367
+ * returns — `{ valid, errors }` under the default collect-errors
368
+ * validator, a boolean under a boolean one.
369
+ * @typedef {(value: unknown) => any} CompiledValidate
370
+ */
371
+
372
+ /**
373
+ * The transport half of an operation's input: the members that travel
374
+ * as strings (path, query, header) and the normalizer that decodes them
375
+ * with `coerceTypes` scoped to exactly those members. `repeated` lists
376
+ * the query and header members whose effective schema type is `array` —
377
+ * a decoder collects repeats of those into an array (a repeated query
378
+ * key; a repeated header line or a comma-separated header list) before
379
+ * normalizing; every other query member is last-wins and every other
380
+ * header member is a single line. Body members are never here.
381
+ * `schemas` holds each transport member's declared schema (what the
382
+ * normalizer was compiled over) and `required` the transport members the
383
+ * input schema requires — what a URL builder validates without the body.
384
+ * @typedef {Object} InputTransport
385
+ * @property {(value: any) => any} normalize
386
+ * @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
387
+ * @property {Readonly<Record<string, any>>} schemas
388
+ * @property {readonly string[]} required
389
+ */
390
+
391
+ /**
392
+ * @typedef {Object} CompiledInput
393
+ * @property {any} schema - the declared input schema (frozen source)
394
+ * @property {any} effective - the object schema `schema` resolves to: itself,
395
+ * or the end of its `$ref` chain (inside the document or a registered
396
+ * schema) — whose `properties` are the operation's members; frozen
397
+ * @property {CompiledValidate} validate
398
+ * @property {InputTransport | null} transport - `null` when no member travels as a string
399
+ */
400
+
401
+ /**
402
+ * @typedef {Object} CompiledOutput
403
+ * @property {any} schema
404
+ * @property {CompiledValidate} validate
405
+ */
406
+
407
+ /**
408
+ * @typedef {Object} CompiledErrorDecl
409
+ * @property {number} status
410
+ * @property {any} schema - `null` when undeclared
411
+ * @property {CompiledValidate | null} validate
412
+ */
413
+
414
+ /**
415
+ * The resolved policy, every default materialized.
416
+ * @typedef {Object} CompiledPolicy
417
+ * @property {'switch' | 'exhaust' | 'concat' | 'parallel'} task
418
+ * @property {'none' | 'optional' | 'required'} idempotency
419
+ * @property {string | null} revision - `input:<json-pointer>` or null
420
+ * @property {'none' | 'revision'} cache
421
+ * @property {{ maxBodyBytes: number }} limits
422
+ * @property {{ details: 'none' | 'paths' | 'full' }} errors
423
+ * @property {{ max: number, on: readonly string[] } | null} retry
424
+ * @property {{ resume: 'snapshot' | 'replay', heartbeatMs: number, maxPatchBytes: number | null } | null} stream
425
+ * - the stream policy of a subscribe operation, defaults materialized; `null` on every other kind
426
+ * @property {'public' | 'server'} audience - who may see the operation: `server`
427
+ * keeps it out of the public projection and every projection built on it
428
+ */
429
+
430
+ /**
431
+ * The materialized HTTP binding. `template` is the parsed canonical
432
+ * template (segments in order — the input of a URL builder); `in` maps
433
+ * every declared input member to its location; `body` names the member
434
+ * whose value IS the request body, or `null` when the body is the object
435
+ * of body-located members; `opaque` is true for non-JSON `media`.
436
+ * @typedef {Object} CompiledHttp
437
+ * @property {string} method
438
+ * @property {string} path
439
+ * @property {import('./path.js').ParsedPathTemplate} template
440
+ * @property {readonly string[]} variables
441
+ * @property {Readonly<Record<string, 'path' | 'query' | 'header' | 'body'>>} in
442
+ * @property {string | null} body
443
+ * @property {number} status
444
+ * @property {string} media
445
+ * @property {boolean} opaque
446
+ */
447
+
448
+ /**
449
+ * @typedef {Object} CompiledOperation
450
+ * @property {string} id
451
+ * @property {'read' | 'command' | 'subscribe'} kind
452
+ * @property {string | null} doc
453
+ * @property {CompiledInput | null} input
454
+ * @property {CompiledOutput} output
455
+ * @property {Readonly<Record<string, CompiledErrorDecl>>} errors
456
+ * @property {CompiledPolicy} policy
457
+ * @property {CompiledHttp} http
458
+ */
459
+
460
+ /**
461
+ * @typedef {Object} Contract
462
+ * @property {any} doc - the source document, deep-frozen
463
+ * @property {string | null} id
464
+ * @property {string | null} version
465
+ * @property {readonly string[]} compat
466
+ * @property {Readonly<Record<string, CompiledOperation>>} operations
467
+ * @property {readonly string[]} ids - operation ids in document order
468
+ * @property {(method: string, path: string) => { op: CompiledOperation, params: Readonly<Record<string, string>> } | null} match
469
+ * @property {(path: string) => string[]} allowed - the methods under which
470
+ * this path shape reaches an operation, sorted (`[]` for none) — what a
471
+ * 405 answers in `Allow`; the path only, query split off, like `match`
472
+ * @property {() => any} describe - a pure-JSON summary (docs/CONTRACT-FORMAT.md §3)
473
+ * @property {() => Promise<string>} revision - the SHA-256 (lowercase hex)
474
+ * over the RFC 8785 canonical bytes of the public projection, memoized —
475
+ * computed at most once per compiled contract (docs/CONTRACT-FORMAT.md §14)
476
+ * @property {any} $defs - frozen view of the document's `$defs` (`{}` when absent)
477
+ */
478
+
479
+ /**
480
+ * @typedef {Object} CompileContractOptions
481
+ * @property {JarenValidator<any>} [validator] - the validator every schema
482
+ * compiles through; default `new JarenValidator({ collectErrors: true, skipErrors: false })`
483
+ * @property {Record<string, any>[]} [schemas] - schemas registered by `$id` before compile, so
484
+ * an absolute `$ref` resolves
485
+ */
486
+
487
+ //#endregion
488
+
489
+ //#region per-member validation
490
+
491
+ /**
492
+ * @param {any} src
493
+ * @param {RefScope} scope
494
+ */
495
+ function checkRoot(src, scope) {
496
+ const keys = Object.keys(src);
497
+ for (let i = 0; i < keys.length; i++) {
498
+ if (!ROOT_MEMBERS.has(keys[i])) {
499
+ throw refuse('JC0013',
500
+ `unknown document member '${keys[i]}' — the root vocabulary is closed ($contract, id, version, compat, $defs, operations)`,
501
+ at('', keys[i]));
502
+ }
503
+ }
504
+ if (src.$contract !== CONTRACT_VERSION) {
505
+ throw refuse('JC0001',
506
+ src.$contract === undefined
507
+ ? 'the document does not declare "$contract": "0.1"'
508
+ : `unknown contract format version ${JSON.stringify(src.$contract)} (this compiler speaks '0.1')`,
509
+ '/$contract');
510
+ }
511
+ if (src.id !== undefined && (typeof src.id !== 'string' || !CONTRACT_ID.test(src.id))) {
512
+ throw refuse('JC0015', 'id must be an identifier string ([A-Za-z_][A-Za-z0-9_-]*)', '/id');
513
+ }
514
+ if (src.version !== undefined && (typeof src.version !== 'string' || src.version === '')) {
515
+ throw refuse('JC0015', 'version must be a non-empty string', '/version');
516
+ }
517
+ if (src.compat !== undefined) {
518
+ if (!Array.isArray(src.compat)) throw refuse('JC0015', 'compat must be an array of version strings', '/compat');
519
+ for (let i = 0; i < src.compat.length; i++) {
520
+ if (typeof src.compat[i] !== 'string' || src.compat[i] === '') {
521
+ throw refuse('JC0015', 'compat entries must be non-empty version strings', at('/compat', i));
522
+ }
523
+ }
524
+ }
525
+ if (src.$defs !== undefined) {
526
+ if (!isJsonObject(src.$defs)) throw refuse('JC0001', '$defs must be an object of named schemas', '/$defs');
527
+ const names = Object.keys(src.$defs);
528
+ for (let i = 0; i < names.length; i++) {
529
+ if (!isSchema(src.$defs[names[i]])) {
530
+ throw refuse('JC0001', `$defs entry '${names[i]}' is not a schema (an object or a boolean)`, at('/$defs', names[i]));
531
+ }
532
+ }
533
+ checkRefs(src.$defs, '/$defs', scope, false);
534
+ }
535
+ if (!isJsonObject(src.operations) || Object.keys(src.operations).length === 0) {
536
+ throw refuse('JC0002', 'operations must be an object with at least one operation', '/operations');
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Validate `errors` and return the resolved declarations (statuses
542
+ * defaulted, schemas checked); validators are compiled later.
543
+ * @param {any} errors
544
+ * @param {string} base - `/operations/<id>/errors`
545
+ * @param {RefScope} scope
546
+ * @returns {{ code: string, status: number, schema: any }[]}
547
+ */
548
+ function checkErrors(errors, base, scope) {
549
+ if (errors === undefined) return [];
550
+ if (!isJsonObject(errors)) throw refuse('JC0011', 'errors must be an object of code → { status?, schema? }', base);
551
+ const out = [];
552
+ const codes = Object.keys(errors);
553
+ for (let i = 0; i < codes.length; i++) {
554
+ const code = codes[i];
555
+ const path = at(base, code);
556
+ if (!ERROR_CODE.test(code)) {
557
+ throw refuse('JC0011', `error code '${code}' must match ^[a-z][a-z0-9-]*$`, path);
558
+ }
559
+ const decl = errors[code];
560
+ if (!isJsonObject(decl)) throw refuse('JC0011', `error '${code}' must be an object { status?, schema? }`, path);
561
+ const members = Object.keys(decl);
562
+ for (let j = 0; j < members.length; j++) {
563
+ if (!ERROR_DECL_MEMBERS.has(members[j])) {
564
+ throw refuse('JC0013', `unknown error member '${members[j]}' — an error declaration is { status?, schema? }`, at(path, members[j]));
565
+ }
566
+ }
567
+ let status = DEFAULT_ERROR_STATUS;
568
+ if (decl.status !== undefined) {
569
+ if (!Number.isInteger(decl.status) || decl.status < 100 || decl.status > 599) {
570
+ throw refuse('JC0011', `error '${code}' status must be an integer in 100–599`, at(path, 'status'));
571
+ }
572
+ status = decl.status;
573
+ }
574
+ let schema = null;
575
+ if (decl.schema !== undefined) {
576
+ if (!isSchema(decl.schema)) throw refuse('JC0011', `error '${code}' schema must be a schema (an object or a boolean)`, at(path, 'schema'));
577
+ checkRefs(decl.schema, at(path, 'schema'), scope, false);
578
+ schema = decl.schema;
579
+ }
580
+ out.push({ code, status, schema });
581
+ }
582
+ return out;
583
+ }
584
+
585
+ /**
586
+ * Validate `policy` and return it with every default materialized.
587
+ * @param {any} policy
588
+ * @param {'read' | 'command' | 'subscribe'} kind
589
+ * @param {readonly string[] | null} inputMembers - the input's declared property names, `null` when the operation declares no input
590
+ * @param {string} base - `/operations/<id>/policy`
591
+ * @returns {CompiledPolicy}
592
+ */
593
+ function checkPolicy(policy, kind, inputMembers, base) {
594
+ const p = policy === undefined ? {} : policy;
595
+ if (!isJsonObject(p)) throw refuse('JC0014', 'policy must be an object', base);
596
+ const members = Object.keys(p);
597
+ for (let i = 0; i < members.length; i++) {
598
+ if (!POLICY_MEMBERS.has(members[i])) {
599
+ throw refuse('JC0013',
600
+ `unknown policy member '${members[i]}' — the policy vocabulary is closed (task, idempotency, revision, cache, limits, errors, retry, stream, audience)`,
601
+ at(base, members[i]));
602
+ }
603
+ }
604
+ const task = p.task === undefined ? (kind === 'command' ? 'exhaust' : 'switch') : p.task;
605
+ if (!TASKS.includes(task)) {
606
+ throw refuse('JC0014', `policy.task must be one of switch, exhaust, concat, parallel`, at(base, 'task'));
607
+ }
608
+ if (kind === 'subscribe' && task !== 'switch') {
609
+ throw refuse('JC0018',
610
+ "a subscribe operation's policy.task must be switch (or absent) — a subscription slot is replaced, never queued",
611
+ at(base, 'task'));
612
+ }
613
+ const idempotency = p.idempotency === undefined ? 'none' : p.idempotency;
614
+ if (!IDEMPOTENCY.includes(idempotency)) {
615
+ throw refuse('JC0014', 'policy.idempotency must be one of none, optional, required', at(base, 'idempotency'));
616
+ }
617
+ if (kind === 'read' && idempotency !== 'none') {
618
+ throw refuse('JC0014', 'a read operation is idempotent by nature; policy.idempotency must be none (or absent)', at(base, 'idempotency'));
619
+ }
620
+ if (kind === 'subscribe' && idempotency !== 'none') {
621
+ throw refuse('JC0020',
622
+ "a subscribe operation's policy.idempotency must be none (or absent) — a subscription registers, it does not commit",
623
+ at(base, 'idempotency'));
624
+ }
625
+ let revision = null;
626
+ if (p.revision !== undefined) {
627
+ if (typeof p.revision !== 'string' || !p.revision.startsWith('input:')) {
628
+ throw refuse('JC0014', 'policy.revision must be a string "input:<json-pointer>"', at(base, 'revision'));
629
+ }
630
+ let tokens;
631
+ try {
632
+ tokens = parseJSONPointer(p.revision.slice('input:'.length));
633
+ }
634
+ catch (err) {
635
+ throw refuse('JC0014', 'policy.revision must carry a valid RFC 6901 pointer after "input:"', at(base, 'revision'), asCause(err));
636
+ }
637
+ // the pointer must address a declared input member: the first
638
+ // reference token names one of input.properties (deeper tokens are
639
+ // not checked — a member's schema may be a $ref or open)
640
+ if (inputMembers === null) {
641
+ throw refuse('JC0014', 'policy.revision names an input member but the operation has no input', at(base, 'revision'));
642
+ }
643
+ if (tokens.length > 0 && !inputMembers.includes(String(tokens[0]))) {
644
+ throw refuse('JC0014', `policy.revision points at '/${String(tokens[0])}' but input declares no member '${String(tokens[0])}'`, at(base, 'revision'));
645
+ }
646
+ revision = p.revision;
647
+ }
648
+ const cache = p.cache === undefined ? 'none' : p.cache;
649
+ if (!CACHE.includes(cache)) {
650
+ throw refuse('JC0014', 'policy.cache must be one of none, revision', at(base, 'cache'));
651
+ }
652
+ let maxBodyBytes = DEFAULT_MAX_BODY_BYTES;
653
+ if (p.limits !== undefined) {
654
+ if (!isJsonObject(p.limits)) throw refuse('JC0014', 'policy.limits must be an object', at(base, 'limits'));
655
+ const lm = Object.keys(p.limits);
656
+ for (let i = 0; i < lm.length; i++) {
657
+ if (!LIMITS_MEMBERS.has(lm[i])) {
658
+ throw refuse('JC0013', `unknown limits member '${lm[i]}' — limits is { maxBodyBytes? }`, at(at(base, 'limits'), lm[i]));
659
+ }
660
+ }
661
+ if (p.limits.maxBodyBytes !== undefined) {
662
+ if (!Number.isInteger(p.limits.maxBodyBytes) || p.limits.maxBodyBytes <= 0) {
663
+ throw refuse('JC0014', 'policy.limits.maxBodyBytes must be a positive integer', at(at(base, 'limits'), 'maxBodyBytes'));
664
+ }
665
+ maxBodyBytes = p.limits.maxBodyBytes;
666
+ }
667
+ }
668
+ /** @type {'none' | 'paths' | 'full'} */
669
+ let details = 'paths';
670
+ if (p.errors !== undefined) {
671
+ if (!isJsonObject(p.errors)) throw refuse('JC0014', 'policy.errors must be an object', at(base, 'errors'));
672
+ const em = Object.keys(p.errors);
673
+ for (let i = 0; i < em.length; i++) {
674
+ if (!POLICY_ERRORS_MEMBERS.has(em[i])) {
675
+ throw refuse('JC0013', `unknown policy.errors member '${em[i]}' — policy.errors is { details? }`, at(at(base, 'errors'), em[i]));
676
+ }
677
+ }
678
+ if (p.errors.details !== undefined) {
679
+ if (!DETAILS.includes(p.errors.details)) {
680
+ throw refuse('JC0014', 'policy.errors.details must be one of none, paths, full', at(at(base, 'errors'), 'details'));
681
+ }
682
+ details = p.errors.details;
683
+ }
684
+ }
685
+ let retry = null;
686
+ if (p.retry !== undefined) {
687
+ const rp = at(base, 'retry');
688
+ if (!isJsonObject(p.retry)) throw refuse('JC0014', 'policy.retry must be an object { max, on }', rp);
689
+ const rm = Object.keys(p.retry);
690
+ for (let i = 0; i < rm.length; i++) {
691
+ if (!RETRY_MEMBERS.has(rm[i])) {
692
+ throw refuse('JC0013', `unknown retry member '${rm[i]}' — retry is { max, on }`, at(rp, rm[i]));
693
+ }
694
+ }
695
+ if (!Number.isInteger(p.retry.max) || p.retry.max < 0) {
696
+ throw refuse('JC0014', 'policy.retry.max must be an integer ≥ 0', at(rp, 'max'));
697
+ }
698
+ if (!Array.isArray(p.retry.on)) throw refuse('JC0014', 'policy.retry.on must be an array of error codes', at(rp, 'on'));
699
+ for (let i = 0; i < p.retry.on.length; i++) {
700
+ if (typeof p.retry.on[i] !== 'string' || p.retry.on[i] === '') {
701
+ throw refuse('JC0014', 'policy.retry.on entries must be error code strings', at(at(rp, 'on'), i));
702
+ }
703
+ }
704
+ // a command may only be retried under a key the server can deduplicate
705
+ // on: a retried command without one runs twice
706
+ if (kind === 'command' && idempotency !== 'required') {
707
+ throw refuse('JC0014',
708
+ "policy.retry on a command requires policy.idempotency 'required' — a retried command without an idempotency key runs twice",
709
+ rp);
710
+ }
711
+ retry = { max: p.retry.max, on: p.retry.on.slice() };
712
+ }
713
+ /** @type {CompiledPolicy['stream']} */
714
+ let stream = null;
715
+ if (p.stream !== undefined && kind !== 'subscribe') {
716
+ throw refuse('JC0014', 'policy.stream applies to subscribe operations only', at(base, 'stream'));
717
+ }
718
+ if (kind === 'subscribe') {
719
+ const sp = at(base, 'stream');
720
+ const s = p.stream === undefined ? {} : p.stream;
721
+ if (!isJsonObject(s)) throw refuse('JC0014', 'policy.stream must be an object { resume?, heartbeatMs?, maxPatchBytes? }', sp);
722
+ const sm = Object.keys(s);
723
+ for (let i = 0; i < sm.length; i++) {
724
+ if (!STREAM_MEMBERS.has(sm[i])) {
725
+ throw refuse('JC0013', `unknown stream member '${sm[i]}' — policy.stream is { resume?, heartbeatMs?, maxPatchBytes? }`, at(sp, sm[i]));
726
+ }
727
+ }
728
+ const resume = s.resume === undefined ? 'snapshot' : s.resume;
729
+ if (!RESUME.includes(resume)) {
730
+ throw refuse('JC0014', 'policy.stream.resume must be one of snapshot, replay', at(sp, 'resume'));
731
+ }
732
+ let heartbeatMs = DEFAULT_HEARTBEAT_MS;
733
+ if (s.heartbeatMs !== undefined) {
734
+ if (!Number.isInteger(s.heartbeatMs) || s.heartbeatMs < 1000) {
735
+ throw refuse('JC0014', 'policy.stream.heartbeatMs must be an integer ≥ 1000', at(sp, 'heartbeatMs'));
736
+ }
737
+ heartbeatMs = s.heartbeatMs;
738
+ }
739
+ let maxPatchBytes = null;
740
+ if (s.maxPatchBytes !== undefined) {
741
+ if (!Number.isInteger(s.maxPatchBytes) || s.maxPatchBytes <= 0) {
742
+ throw refuse('JC0014', 'policy.stream.maxPatchBytes must be a positive integer', at(sp, 'maxPatchBytes'));
743
+ }
744
+ maxPatchBytes = s.maxPatchBytes;
745
+ }
746
+ stream = { resume, heartbeatMs, maxPatchBytes };
747
+ }
748
+ const audience = p.audience === undefined ? 'public' : p.audience;
749
+ if (!AUDIENCES.includes(audience)) {
750
+ throw refuse('JC0014', 'policy.audience must be one of public, server', at(base, 'audience'));
751
+ }
752
+ return { task, idempotency, revision, cache, limits: { maxBodyBytes }, errors: { details }, retry, stream, audience };
753
+ }
754
+
755
+ /**
756
+ * Validate `http` and materialize the binding: the canonical
757
+ * `POST /<op-id>` with every member in the body when absent, otherwise
758
+ * the declared binding with template canonicalized, locations defaulted
759
+ * (path variables → `path`; `read` → `query`; `command` → `body`) and
760
+ * every cross-rule (`JC0009`, `JC0016`, `JC0017`) applied. Returns the
761
+ * compiled http and the location table by member.
762
+ * @param {any} http
763
+ * @param {string} id
764
+ * @param {'read' | 'command' | 'subscribe'} kind
765
+ * @param {readonly string[]} members - the input's declared property names
766
+ * @param {string} base - `/operations/<id>/http`
767
+ * @returns {CompiledHttp}
768
+ */
769
+ function checkHttp(http, id, kind, members, base) {
770
+ /** @type {Record<string, 'path' | 'query' | 'header' | 'body'>} */
771
+ const locations = {};
772
+ if (http === undefined) {
773
+ const template = parsePathTemplate(`/${id}`);
774
+ if (kind === 'subscribe') {
775
+ // the canonical subscribe binding: GET /<op-id>, every member in
776
+ // the query (input travels as for a read), the stream media
777
+ for (let i = 0; i < members.length; i++) setObjectMember(locations, members[i], 'query');
778
+ return {
779
+ method: 'GET', path: template.path, template, variables: template.variables,
780
+ in: locations, body: null, status: DEFAULT_STATUS, media: STREAM_MEDIA, opaque: false,
781
+ };
782
+ }
783
+ for (let i = 0; i < members.length; i++) setObjectMember(locations, members[i], 'body');
784
+ return {
785
+ method: 'POST', path: template.path, template, variables: template.variables,
786
+ in: locations, body: null, status: DEFAULT_STATUS, media: DEFAULT_MEDIA, opaque: false,
787
+ };
788
+ }
789
+ if (!isJsonObject(http)) throw refuse('JC0012', 'http must be an object { method, path, in?, body?, status?, media? }', base);
790
+ const hm = Object.keys(http);
791
+ for (let i = 0; i < hm.length; i++) {
792
+ if (!HTTP_MEMBERS.has(hm[i])) {
793
+ throw refuse('JC0013',
794
+ `unknown http member '${hm[i]}' — the http vocabulary is closed (method, path, in, body, status, media)`,
795
+ at(base, hm[i]));
796
+ }
797
+ }
798
+ if (typeof http.method !== 'string' || !METHODS.includes(http.method)) {
799
+ throw refuse('JC0012',
800
+ `http.method must be an uppercase token of ${METHODS.join(', ')}` + (typeof http.method === 'string' && METHODS.includes(http.method.toUpperCase()) ? ` (write '${http.method.toUpperCase()}')` : ''),
801
+ at(base, 'method'));
802
+ }
803
+ const method = http.method;
804
+ if (kind === 'subscribe' && method !== 'GET') {
805
+ throw refuse('JC0019',
806
+ `a subscribe operation must be bound to GET (a stream is fetched, not sent), got ${method}`,
807
+ at(base, 'method'));
808
+ }
809
+ let status = DEFAULT_STATUS;
810
+ if (http.status !== undefined) {
811
+ if (!Number.isInteger(http.status) || http.status < 200 || http.status > 299) {
812
+ throw refuse('JC0012', 'http.status must be an integer in 200–299', at(base, 'status'));
813
+ }
814
+ status = http.status;
815
+ }
816
+ let media = kind === 'subscribe' ? STREAM_MEDIA : DEFAULT_MEDIA;
817
+ if (http.media !== undefined) {
818
+ if (typeof http.media !== 'string' || !MEDIA_TYPE.test(http.media)) {
819
+ throw refuse('JC0012', 'http.media must be a media type string (type/subtype)', at(base, 'media'));
820
+ }
821
+ if (kind === 'subscribe' && http.media !== STREAM_MEDIA) {
822
+ // forced, never silently overridden: a declared conflicting media
823
+ // would be a behavior the binding cannot honor
824
+ throw refuse('JC0012', `a subscribe operation's http.media is ${STREAM_MEDIA} (leave it absent — the binding forces it)`, at(base, 'media'));
825
+ }
826
+ media = http.media;
827
+ }
828
+ if (http.path === undefined) throw refuse('JC0008', 'http.path is required when http is declared', at(base, 'path'));
829
+ let template;
830
+ try {
831
+ template = parsePathTemplate(http.path);
832
+ }
833
+ catch (err) {
834
+ throw refuse('JC0008', `http.path is not a valid template: ${asCause(err)?.message ?? 'malformed'}`, at(base, 'path'), asCause(err));
835
+ }
836
+ const variables = template.variables;
837
+ for (let i = 0; i < variables.length; i++) {
838
+ if (!members.includes(variables[i])) {
839
+ throw refuse('JC0009', `path variable '${variables[i]}' is not a member of input.properties`, at(base, 'path'));
840
+ }
841
+ }
842
+ let bodyMember = null;
843
+ if (http.body !== undefined) {
844
+ if (typeof http.body !== 'string' || !members.includes(http.body)) {
845
+ throw refuse('JC0009', 'http.body must name a member of input.properties whose value is the request body', at(base, 'body'));
846
+ }
847
+ if (variables.includes(http.body)) {
848
+ throw refuse('JC0009', `http.body '${http.body}' is a path variable and cannot also be the body`, at(base, 'body'));
849
+ }
850
+ bodyMember = http.body;
851
+ }
852
+ const inMap = http.in === undefined ? {} : http.in;
853
+ if (!isJsonObject(inMap)) throw refuse('JC0009', 'http.in must be an object of member → path | query | header | body', at(base, 'in'));
854
+ const inKeys = Object.keys(inMap);
855
+ for (let i = 0; i < inKeys.length; i++) {
856
+ const m = inKeys[i];
857
+ const loc = inMap[m];
858
+ const path = at(at(base, 'in'), m);
859
+ if (!members.includes(m)) throw refuse('JC0009', `http.in names '${m}', which is not a member of input.properties`, path);
860
+ if (typeof loc !== 'string' || !LOCATIONS.includes(loc)) {
861
+ throw refuse('JC0009', `http.in.${m} must be one of path, query, header, body`, path);
862
+ }
863
+ if (variables.includes(m) && loc !== 'path') {
864
+ throw refuse('JC0009', `'${m}' is a path variable and cannot travel as ${loc}`, path);
865
+ }
866
+ if (!variables.includes(m) && loc === 'path') {
867
+ throw refuse('JC0009', `'${m}' is mapped to path but the template declares no {${m}}`, path);
868
+ }
869
+ if (bodyMember !== null && m === bodyMember && loc !== 'body') {
870
+ throw refuse('JC0009', `'${m}' is the http.body member and cannot travel as ${loc}`, path);
871
+ }
872
+ }
873
+ const defaultLocation = kind === 'command' ? 'body' : 'query';
874
+ for (let i = 0; i < members.length; i++) {
875
+ const m = members[i];
876
+ /** @type {'path' | 'query' | 'header' | 'body'} */
877
+ let loc;
878
+ if (variables.includes(m)) loc = 'path';
879
+ else if (m === bodyMember) loc = 'body';
880
+ else if (inMap[m] !== undefined) loc = inMap[m];
881
+ else loc = defaultLocation;
882
+ if (bodyMember !== null && m !== bodyMember && loc === 'body') {
883
+ throw refuse('JC0009',
884
+ `'${m}' would travel in the body, but http.body names '${bodyMember}' as the whole body — map '${m}' to query or header`,
885
+ inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'body'));
886
+ }
887
+ setObjectMember(locations, m, loc);
888
+ }
889
+ if (method === 'GET' || method === 'HEAD') {
890
+ for (let i = 0; i < members.length; i++) {
891
+ const m = members[i];
892
+ if (locations[m] === 'body') {
893
+ throw refuse('JC0016',
894
+ `a ${kind} operation bound to ${method} cannot carry '${m}' in the body (a GET body)`,
895
+ m === bodyMember ? at(base, 'body') : (inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'method')));
896
+ }
897
+ }
898
+ }
899
+ // a subscribe operation's media is the stream envelope, not an opaque
900
+ // body: its events are JSON the contract decodes and validates
901
+ const opaque = kind !== 'subscribe' && !isJsonMedia(media);
902
+ if (opaque) {
903
+ // an opaque body is bytes the contract never decodes (§4.5): a
904
+ // body-located member could never be validated, so the transport
905
+ // members of an opaque operation are ALWAYS its whole input
906
+ for (let i = 0; i < members.length; i++) {
907
+ const m = members[i];
908
+ if (locations[m] === 'body') {
909
+ const declaredBy = m === bodyMember ? 'http.body' : (inMap[m] !== undefined ? `http.in.${m}` : `the default location of a ${kind} member`);
910
+ throw refuse('JC0017',
911
+ `an opaque operation (media ${media}) cannot carry '${m}' in the body (placed there by ${declaredBy}) — its body is bytes the contract never decodes; map '${m}' to query or header, or make the operation JSON`,
912
+ m === bodyMember ? at(base, 'body') : (inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'media')));
913
+ }
914
+ }
915
+ }
916
+ return {
917
+ method, path: template.path, template, variables, in: locations, body: bodyMember,
918
+ status, media, opaque,
919
+ };
920
+ }
921
+
922
+ //#endregion
923
+
924
+ /**
925
+ * Compile a `$contract` document (docs/CONTRACT-FORMAT.md) into a frozen
926
+ * `Contract`: every operation's `input`/`output`/error validators, its
927
+ * transport normalizer, its resolved policy and materialized HTTP
928
+ * binding, and one `match(method, path)` over the whole table.
929
+ * Synchronous; total for a hostile document; no I/O.
930
+ *
931
+ * @param {unknown} doc - the contract document
932
+ * @param {CompileContractOptions} [options]
933
+ * @returns {Contract}
934
+ * @throws {ContractCompileError} when the document violates the format (`JC0001–JC0017`)
935
+ * @example
936
+ * const contract = compileContract({
937
+ * $contract: '0.1',
938
+ * operations: {
939
+ * 'catalog.load': { kind: 'read', output: true, http: { method: 'GET', path: '/api/catalog' } },
940
+ * },
941
+ * });
942
+ * contract.match('GET', '/api/catalog').op.id; // 'catalog.load'
943
+ */
944
+ export function compileContract(doc, options = {}) {
945
+ const validator = options.validator ?? new JarenValidator({ collectErrors: true, skipErrors: false });
946
+ if (options.schemas !== undefined) {
947
+ if (!Array.isArray(options.schemas)) throw new TypeError('compileContract: options.schemas must be an array of schemas with $id');
948
+ for (let i = 0; i < options.schemas.length; i++) {
949
+ const s = options.schemas[i];
950
+ if (!isJsonObject(s) || typeof s.$id !== 'string') {
951
+ throw new TypeError(`compileContract: options.schemas[${i}] must be a schema object with a string $id`);
952
+ }
953
+ validator.addSchema(s);
954
+ }
955
+ }
956
+
957
+ if (!isJsonObject(doc)) throw refuse('JC0001', 'the contract document must be an object', '');
958
+ const src = deepFreeze(snapshot(doc, '', new Set()));
959
+ /** @type {RefScope} */
960
+ const scope = { src, anchors: collectSameDocumentAnchors(src), validator };
961
+
962
+ checkRoot(src, scope);
963
+
964
+ // ——— per-operation structure ———
965
+ /** @type {Set<string>} method + shape */
966
+ const shapes = new Set();
967
+ /** @type {string[]} */
968
+ const ids = [];
969
+ /** @type {{ id: string, kind: 'read' | 'command' | 'subscribe', doc: string | null, input: any, inputEffective: any, members: string[], output: any, errors: { code: string, status: number, schema: any }[], policy: CompiledPolicy, http: CompiledHttp }[]} */
970
+ const parsed = [];
971
+ const opIds = Object.keys(src.operations);
972
+ for (let n = 0; n < opIds.length; n++) {
973
+ const id = opIds[n];
974
+ const base = at('/operations', id);
975
+ if (!OP_ID.test(id)) {
976
+ throw refuse('JC0003', `operation id '${id}' must match ^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$`, base);
977
+ }
978
+ const op = src.operations[id];
979
+ if (!isJsonObject(op)) throw refuse('JC0002', `operation '${id}' must be an object`, base);
980
+ const om = Object.keys(op);
981
+ for (let i = 0; i < om.length; i++) {
982
+ if (!OP_MEMBERS.has(om[i])) {
983
+ throw refuse('JC0013',
984
+ `unknown operation member '${om[i]}' — the operation vocabulary is closed (kind, input, output, errors, policy, http, doc)`,
985
+ at(base, om[i]));
986
+ }
987
+ }
988
+ if (!KINDS.includes(op.kind)) {
989
+ throw refuse('JC0004', "kind must be 'read', 'command' or 'subscribe'", at(base, 'kind'));
990
+ }
991
+ /** @type {'read' | 'command' | 'subscribe'} */
992
+ const kind = op.kind;
993
+ if (op.doc !== undefined && typeof op.doc !== 'string') {
994
+ throw refuse('JC0015', 'doc must be a string', at(base, 'doc'));
995
+ }
996
+ if (op.output === undefined) throw refuse('JC0006', 'output is required (a schema; `true` accepts anything)', at(base, 'output'));
997
+ if (!isSchema(op.output)) throw refuse('JC0006', 'output must be a schema (an object or a boolean)', at(base, 'output'));
998
+ checkRefs(op.output, at(base, 'output'), scope, false);
999
+
1000
+ let inputEffective = null;
1001
+ /** @type {string[]} */
1002
+ let members = [];
1003
+ let declaredMembers = null;
1004
+ if (op.input !== undefined) {
1005
+ if (!isJsonObject(op.input)) throw refuse('JC0005', 'input must be an object schema (or a $ref to one)', at(base, 'input'));
1006
+ checkRefs(op.input, at(base, 'input'), scope, false);
1007
+ inputEffective = effectiveSchema(op.input, scope);
1008
+ if (!isJsonObject(inputEffective) || inputEffective.type !== 'object') {
1009
+ throw refuse('JC0005', 'input must be a schema whose effective type is object (declare "type": "object")', at(base, 'input'));
1010
+ }
1011
+ members = isJsonObject(inputEffective.properties) ? Object.keys(inputEffective.properties) : [];
1012
+ declaredMembers = members;
1013
+ }
1014
+
1015
+ const errors = checkErrors(op.errors, at(base, 'errors'), scope);
1016
+ const policy = checkPolicy(op.policy, kind, declaredMembers, at(base, 'policy'));
1017
+ const http = checkHttp(op.http, id, kind, members, at(base, 'http'));
1018
+
1019
+ const shape = `${http.method} ${pathShape(http.template)}`;
1020
+ if (shapes.has(shape)) {
1021
+ throw refuse('JC0010', `operation '${id}' shares the route shape ${shape} with an earlier operation`,
1022
+ op.http === undefined ? base : at(at(base, 'http'), 'path'));
1023
+ }
1024
+ shapes.add(shape);
1025
+
1026
+ ids.push(id);
1027
+ parsed.push({
1028
+ id, kind, doc: op.doc === undefined ? null : op.doc,
1029
+ input: op.input === undefined ? null : op.input, inputEffective, members,
1030
+ output: op.output, errors, policy, http,
1031
+ });
1032
+ }
1033
+
1034
+ // ——— references: the validator's whole-document probe ———
1035
+ const synthetic = `urn:jaren:contract:${++compileSequence}`;
1036
+ validator.addSchema({ ...src, $id: synthetic });
1037
+ try {
1038
+ validator.compile({ $ref: `${synthetic}#` });
1039
+ }
1040
+ catch (err) {
1041
+ throw refuse('JC0007', `a $ref in the document does not resolve: ${asCause(err)?.message ?? 'unresolved'}`, '', asCause(err));
1042
+ }
1043
+
1044
+ /**
1045
+ * @param {(string | number)[]} tokens
1046
+ * @param {string} docPath
1047
+ * @returns {CompiledValidate}
1048
+ */
1049
+ function compileAt(tokens, docPath) {
1050
+ try {
1051
+ return validator.compile({ $ref: synthetic + fragment(tokens) });
1052
+ }
1053
+ catch (err) {
1054
+ throw refuse('JC0007', `the schema failed to compile: ${asCause(err)?.message ?? 'unresolved'}`, docPath, asCause(err));
1055
+ }
1056
+ }
1057
+
1058
+ // ——— compilation ———
1059
+ /** @type {Record<string, CompiledOperation>} */
1060
+ const operations = {};
1061
+ /** @type {{ method: string, path: string, key: string }[]} */
1062
+ const routes = [];
1063
+ for (let n = 0; n < parsed.length; n++) {
1064
+ const p = parsed[n];
1065
+ const base = at('/operations', p.id);
1066
+
1067
+ /** @type {CompiledInput | null} */
1068
+ let input = null;
1069
+ if (p.input !== null) {
1070
+ const validate = compileAt(['operations', p.id, 'input'], at(base, 'input'));
1071
+ /** @type {InputTransport | null} */
1072
+ let transport = null;
1073
+ const pathMembers = [];
1074
+ const queryMembers = [];
1075
+ const headerMembers = [];
1076
+ const repeated = [];
1077
+ /** @type {Record<string, any>} */
1078
+ const pick = {};
1079
+ for (let i = 0; i < p.members.length; i++) {
1080
+ const m = p.members[i];
1081
+ const loc = p.http.in[m];
1082
+ if (loc === 'body') continue;
1083
+ const schema = p.inputEffective.properties[m];
1084
+ setObjectMember(pick, m, schema);
1085
+ if (loc === 'path') pathMembers.push(m);
1086
+ else {
1087
+ if (loc === 'query') queryMembers.push(m);
1088
+ else headerMembers.push(m);
1089
+ const eff = effectiveSchema(schema, scope);
1090
+ const type = isJsonObject(eff) ? eff.type : undefined;
1091
+ if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
1092
+ }
1093
+ }
1094
+ if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
1095
+ // the sub-schema is rooted on the document itself, so every
1096
+ // same-document `$ref` a member schema carries resolves exactly as
1097
+ // it does for the validator
1098
+ const sub = { ...src, type: 'object', properties: pick };
1099
+ const normalize = compileNormalizer(sub, { coerceTypes: true });
1100
+ const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
1101
+ transport = {
1102
+ normalize,
1103
+ members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
1104
+ schemas: pick,
1105
+ required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
1106
+ };
1107
+ }
1108
+ input = { schema: p.input, effective: p.inputEffective, validate, transport };
1109
+ }
1110
+
1111
+ /** @type {CompiledOutput} */
1112
+ const output = { schema: p.output, validate: compileAt(['operations', p.id, 'output'], at(base, 'output')) };
1113
+
1114
+ /** @type {Record<string, CompiledErrorDecl>} */
1115
+ const errors = {};
1116
+ for (let i = 0; i < p.errors.length; i++) {
1117
+ const e = p.errors[i];
1118
+ setObjectMember(errors, e.code, {
1119
+ status: e.status,
1120
+ schema: e.schema,
1121
+ validate: e.schema === null
1122
+ ? null
1123
+ : compileAt(['operations', p.id, 'errors', e.code, 'schema'], at(at(at(base, 'errors'), e.code), 'schema')),
1124
+ });
1125
+ }
1126
+
1127
+ /** @type {CompiledOperation} */
1128
+ const compiled = {
1129
+ id: p.id, kind: p.kind, doc: p.doc,
1130
+ input, output, errors, policy: p.policy, http: p.http,
1131
+ };
1132
+ setObjectMember(operations, p.id, freezeAll(compiled));
1133
+ routes.push({ method: p.http.method, path: p.http.path, key: p.id });
1134
+ }
1135
+
1136
+ const router = compileRoutes(routes);
1137
+
1138
+ /**
1139
+ * @param {string} method
1140
+ * @param {string} path
1141
+ */
1142
+ function match(method, path) {
1143
+ const found = router.match(method, path);
1144
+ return found === null ? null : { op: operations[found.key], params: found.params };
1145
+ }
1146
+
1147
+ /** @type {Contract} */
1148
+ const contract = {
1149
+ doc: src,
1150
+ id: src.id === undefined ? null : src.id,
1151
+ version: src.version === undefined ? null : src.version,
1152
+ compat: Object.freeze(src.compat === undefined ? [] : src.compat.slice()),
1153
+ operations: Object.freeze(operations),
1154
+ ids: Object.freeze(ids),
1155
+ match,
1156
+ allowed: router.allowed,
1157
+ describe: () => describeContract(contract),
1158
+ revision: () => contractRevision(contract),
1159
+ $defs: src.$defs === undefined ? Object.freeze({}) : src.$defs,
1160
+ };
1161
+ return freezeAll(contract);
1162
+ }