@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,564 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `toOpenApi`: the public projection of a compiled contract as a
4
+ * valid, deterministic OpenAPI 3.1 document (docs/CONTRACT-FORMAT.md
5
+ * §12.2), in two stages that each own one concern:
6
+ *
7
+ * 1. **The keyword policy** (this file, JavaScript) walks every schema
8
+ * the projection carries and maps it into the OpenAPI 3.1 dialect —
9
+ * `#/$defs/X` → `#/components/schemas/X`, `nullable: true` → `null`
10
+ * in `type` — or refuses what it cannot map honestly (`JC0060`: a
11
+ * boolean `required`, a `components` member inside a schema, a
12
+ * same-document `$ref` that lands outside `$defs`) or drops and
13
+ * REPORTS what is Jaren-side (`$query`, `$data`, `errorMessage`,
14
+ * `x-form` and other `x-*`) in the `dropped` list with the `docPath`
15
+ * of the keyword in the contract document. Nothing unsupported passes
16
+ * through silently. It also derives, per operation, the facts a
17
+ * document cannot carry for a `$ref` input — the member table with
18
+ * each member's location, requiredness and effective schema — from
19
+ * the compiled operation.
20
+ * 2. **The stylesheet** (`openapi.jslt.json`, a JSLT document compiled
21
+ * once at module scope) shapes the document: `paths` grouped by
22
+ * canonical path and sorted by path then method, the operation
23
+ * object in a fixed member order, parameters, the request body, the
24
+ * success response, the declared errors grouped by status into
25
+ * wire-error schemas (`code` enum-pinned, `details` the declared
26
+ * schema), the binding's own statuses as shared
27
+ * `components.responses`, `components.schemas` from `$defs`, and the
28
+ * `x-jaren-policy` extension.
29
+ *
30
+ * Rendered twice, the document is byte-identical; a test validates it
31
+ * against the vendored OpenAPI 3.1 meta-schema with `JarenValidator`.
32
+ */
33
+
34
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
35
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
36
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
37
+ import { createTypeTestCompiler } from '@jarenjs/validate/query';
38
+
39
+ import { ContractCompileError, ContractHostError, CONTRACT_CODES } from '../errors.js';
40
+ import { HTTP_ERRORS } from '../http/wire.js';
41
+ import { publicProjection, retainedOperations } from '../public.js';
42
+ import OPENAPI_STYLESHEET from './openapi.jslt.json' with { type: 'json' };
43
+
44
+ /**
45
+ * @typedef {import('../compile.js').Contract} Contract
46
+ * @typedef {import('../compile.js').CompiledOperation} CompiledOperation
47
+ */
48
+
49
+ /**
50
+ * One keyword the projection dropped, with where it was.
51
+ * @typedef {Object} DroppedKeyword
52
+ * @property {string} docPath - JSON Pointer of the keyword in the contract document
53
+ * @property {string} keyword
54
+ * @property {string} reason
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} OpenApiOptions
59
+ * @property {{ title?: string, version?: string, [k: string]: unknown }} [info] -
60
+ * the `info` object; `title` defaults to the contract id, `version` to
61
+ * the contract version
62
+ * @property {{ url: string, description?: string }[]} [servers] - the `servers` array, verbatim
63
+ * @property {boolean} [lenient=false] - drop and report a boolean `required`
64
+ * and an unmappable same-document `$ref` instead of refusing (`JC0060`)
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} OpenApiResult
69
+ * @property {Record<string, unknown>} document - the OpenAPI 3.1 document
70
+ * @property {DroppedKeyword[]} dropped - every keyword the keyword policy removed
71
+ */
72
+
73
+ /** The stylesheet, compiled once. */
74
+ const render = compileJsltStylesheet(OPENAPI_STYLESHEET, { compileTypeTest: createTypeTestCompiler() });
75
+
76
+ /** Jaren-side keywords the OpenAPI dialect has no reading for — dropped and reported. */
77
+ const JAREN_KEYWORDS = new Set(['$query', '$data', 'errorMessage']);
78
+ /** Keywords whose value is one schema. */
79
+ const SCHEMA_KEYWORDS = new Set([
80
+ 'items', 'additionalItems', 'additionalProperties', 'contains', 'not', 'if', 'then', 'else',
81
+ 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties', 'contentSchema',
82
+ ]);
83
+ /** Keywords whose value is an array of schemas. */
84
+ const SCHEMA_ARRAY_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
85
+ /** Keywords whose value is a map of schemas (the keys are names, never keywords). */
86
+ const SCHEMA_MAP_KEYWORDS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']);
87
+ /** Keywords whose value is data, copied verbatim. */
88
+ const DATA_KEYWORDS = new Set(['const', 'enum', 'default', 'examples', 'example']);
89
+
90
+ const COMPONENT_REF = '#/components/schemas/';
91
+
92
+ /**
93
+ * The binding's own responses, shared by every operation they apply to
94
+ * (`components.responses`): name → status, the taxonomy codes the binding
95
+ * answers with that status, and when the response applies. Built from
96
+ * `HTTP_ERRORS`, the single source of the statuses.
97
+ * @type {readonly { name: string, status: number, codes: readonly string[], when: 'always' | 'body' | 'idempotent' }[]}
98
+ */
99
+ const SHARED_RESPONSES = Object.freeze([
100
+ { name: 'BadRequest', status: 400, codes: codesWithStatus(400), when: 'always' },
101
+ { name: 'NotFound', status: 404, codes: codesWithStatus(404), when: 'always' },
102
+ { name: 'IdempotencyConflict', status: 409, codes: codesWithStatus(409), when: 'idempotent' },
103
+ { name: 'PayloadTooLarge', status: 413, codes: codesWithStatus(413), when: 'always' },
104
+ { name: 'UnsupportedMediaType', status: 415, codes: codesWithStatus(415), when: 'body' },
105
+ { name: 'InternalError', status: 500, codes: codesWithStatus(500), when: 'always' },
106
+ ]);
107
+
108
+ /**
109
+ * The taxonomy codes answered with a status, in code order.
110
+ * @param {number} status
111
+ * @returns {string[]}
112
+ */
113
+ function codesWithStatus(status) {
114
+ const out = [];
115
+ const codes = Object.keys(HTTP_ERRORS);
116
+ for (let i = 0; i < codes.length; i++) {
117
+ if (HTTP_ERRORS[/** @type {keyof typeof HTTP_ERRORS} */ (codes[i])].status === status) out.push(codes[i]);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * @param {string} base
124
+ * @param {string | number} key
125
+ * @returns {string}
126
+ */
127
+ function at(base, key) {
128
+ return `${base}/${encodeJSONPointerSegment(key)}`;
129
+ }
130
+
131
+ //#region the keyword policy
132
+
133
+ /**
134
+ * @typedef {Object} MapContext
135
+ * @property {boolean} lenient
136
+ * @property {DroppedKeyword[]} dropped
137
+ * @property {Set<string>} defs - the `$defs` names the projection carries
138
+ * @property {Map<object, any>} mapped - source schema node → its mapped
139
+ * copy; every node is mapped once (the projection's roots cover every
140
+ * member), so a member's mapped schema is a lookup, and a keyword is
141
+ * reported once
142
+ */
143
+
144
+ /**
145
+ * @param {MapContext} ctx
146
+ * @param {string} docPath
147
+ * @param {string} keyword
148
+ * @param {string} reason
149
+ */
150
+ function drop(ctx, docPath, keyword, reason) {
151
+ ctx.dropped.push({ docPath: at(docPath, keyword), keyword, reason });
152
+ }
153
+
154
+ /**
155
+ * Refuse a keyword (`JC0060`), or under `lenient` drop and report it.
156
+ * @param {MapContext} ctx
157
+ * @param {string} docPath
158
+ * @param {string} keyword
159
+ * @param {string} reason
160
+ * @param {boolean} lenientDrops - whether `lenient` may drop it (a `components` member never)
161
+ */
162
+ function refuseOrDrop(ctx, docPath, keyword, reason, lenientDrops) {
163
+ if (ctx.lenient && lenientDrops) {
164
+ drop(ctx, docPath, keyword, reason);
165
+ return;
166
+ }
167
+ throw new ContractCompileError('JC0060', `the OpenAPI projection cannot carry '${keyword}' here: ${reason}`
168
+ + (lenientDrops ? ' (toOpenApi({ lenient: true }) drops it and reports it in `dropped`)' : ''), at(docPath, keyword));
169
+ }
170
+
171
+ /**
172
+ * Map a same-document reference to its place in the OpenAPI document.
173
+ * `#/$defs/X…` becomes `#/components/schemas/X…`; anything else inside the
174
+ * document (`#`, an anchor, a pointer into an operation) has no place
175
+ * there — refused, or dropped under `lenient`. An absolute reference is
176
+ * kept as written. Returns `undefined` for a dropped reference.
177
+ * @param {string} ref
178
+ * @param {string} docPath - of the schema node
179
+ * @param {MapContext} ctx
180
+ * @returns {string | undefined}
181
+ */
182
+ function mapRef(ref, docPath, ctx) {
183
+ if (!ref.startsWith('#')) return ref;
184
+ if (ref.startsWith('#/$defs/')) {
185
+ const rest = ref.slice('#/$defs/'.length);
186
+ const slash = rest.indexOf('/');
187
+ const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash)).replaceAll('~1', '/').replaceAll('~0', '~');
188
+ if (ctx.defs.has(name)) return COMPONENT_REF + rest;
189
+ }
190
+ refuseOrDrop(ctx, docPath, '$ref',
191
+ `the same-document reference '${ref}' lands outside the projection's $defs and would not resolve in the OpenAPI document — keep shared schemas in $defs`,
192
+ true);
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Add `'null'` to a `type` for `nullable: true`; `undefined` when `type`
198
+ * is absent (then `nullable` says nothing the dialect can carry).
199
+ * @param {unknown} type
200
+ * @returns {unknown}
201
+ */
202
+ function nullableType(type) {
203
+ if (typeof type === 'string') return type === 'null' ? type : [type, 'null'];
204
+ if (Array.isArray(type)) return type.includes('null') ? type : [...type, 'null'];
205
+ return undefined;
206
+ }
207
+
208
+ /**
209
+ * Map one schema (object, boolean, or whatever the document carries) into
210
+ * the OpenAPI 3.1 dialect. Fresh objects throughout; data keywords are
211
+ * shared as they are.
212
+ * @param {any} node
213
+ * @param {string} docPath
214
+ * @param {MapContext} ctx
215
+ * @returns {any}
216
+ */
217
+ function mapSchema(node, docPath, ctx) {
218
+ if (!isJsonObject(node)) return node;
219
+ const seen = ctx.mapped.get(node);
220
+ if (seen !== undefined) return seen;
221
+ /** @type {Record<string, any>} */
222
+ const out = {};
223
+ ctx.mapped.set(node, out);
224
+ const keys = Object.keys(node);
225
+ for (let i = 0; i < keys.length; i++) {
226
+ const key = keys[i];
227
+ const value = node[key];
228
+ if (key === '$ref' && typeof value === 'string') {
229
+ const mapped = mapRef(value, docPath, ctx);
230
+ if (mapped !== undefined) out.$ref = mapped;
231
+ continue;
232
+ }
233
+ if (key === 'nullable') {
234
+ if (value === true) {
235
+ const type = nullableType(node.type);
236
+ if (type === undefined) drop(ctx, docPath, key, 'nullable: true without a type — OpenAPI 3.1 carries nullability in type, and there is none to widen');
237
+ else out.type = type;
238
+ }
239
+ else drop(ctx, docPath, key, 'asserts nothing in OpenAPI 3.1 (only nullable: true maps, to "null" in type)');
240
+ continue;
241
+ }
242
+ if (key === 'type' && node.nullable === true && nullableType(value) !== undefined) {
243
+ out.type = nullableType(value);
244
+ continue;
245
+ }
246
+ if (key === 'required' && typeof value === 'boolean') {
247
+ refuseOrDrop(ctx, docPath, key, 'a boolean required (draft-04 style) has no meaning in OpenAPI 3.1 — declare the member in the parent\'s required array', true);
248
+ continue;
249
+ }
250
+ if (key === 'components') {
251
+ refuseOrDrop(ctx, docPath, key, 'a components member inside a schema is an OpenAPI document member, not a schema keyword', false);
252
+ continue;
253
+ }
254
+ if (JAREN_KEYWORDS.has(key) || key.startsWith('x-')) {
255
+ drop(ctx, docPath, key, 'a Jaren-side keyword the OpenAPI dialect has no reading for');
256
+ continue;
257
+ }
258
+ if (DATA_KEYWORDS.has(key)) {
259
+ out[key] = value;
260
+ continue;
261
+ }
262
+ if (SCHEMA_KEYWORDS.has(key)) {
263
+ out[key] = mapSchema(value, at(docPath, key), ctx);
264
+ continue;
265
+ }
266
+ if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) {
267
+ const arr = new Array(value.length);
268
+ for (let j = 0; j < value.length; j++) arr[j] = mapSchema(value[j], at(at(docPath, key), j), ctx);
269
+ out[key] = arr;
270
+ continue;
271
+ }
272
+ if (SCHEMA_MAP_KEYWORDS.has(key) && isJsonObject(value)) {
273
+ /** @type {Record<string, any>} */
274
+ const map = {};
275
+ const names = Object.keys(value);
276
+ for (let j = 0; j < names.length; j++) {
277
+ setObjectMember(map, names[j], mapSchema(value[names[j]], at(at(docPath, key), names[j]), ctx));
278
+ }
279
+ out[key] = map;
280
+ continue;
281
+ }
282
+ if (key === 'dependencies' && isJsonObject(value)) {
283
+ /** @type {Record<string, any>} */
284
+ const map = {};
285
+ const names = Object.keys(value);
286
+ for (let j = 0; j < names.length; j++) {
287
+ const dep = value[names[j]];
288
+ setObjectMember(map, names[j], Array.isArray(dep) ? dep : mapSchema(dep, at(at(docPath, key), names[j]), ctx));
289
+ }
290
+ out[key] = map;
291
+ continue;
292
+ }
293
+ out[key] = value;
294
+ }
295
+ return out;
296
+ }
297
+
298
+ //#endregion
299
+
300
+ //#region the operation view
301
+
302
+ /**
303
+ * The first line of a doc string, and whether there is more.
304
+ * @param {string} doc
305
+ * @returns {{ summary: string, long: boolean }}
306
+ */
307
+ function docLines(doc) {
308
+ const nl = doc.indexOf('\n');
309
+ if (nl === -1) return { summary: doc.trim(), long: false };
310
+ return { summary: doc.slice(0, nl).trim(), long: doc.slice(nl + 1).trim().length > 0 };
311
+ }
312
+
313
+ /**
314
+ * The stylesheet's view of one operation: the projection's own members
315
+ * plus what only the compiled operation knows — the member table (name,
316
+ * location, requiredness, effective schema), the body, the success
317
+ * response and the declared errors with string statuses, and the shared
318
+ * binding responses that apply.
319
+ * @param {CompiledOperation} op
320
+ * @param {Record<string, any>} projected - the operation's public projection, schemas mapped
321
+ * @param {MapContext} ctx
322
+ * @returns {Record<string, unknown>}
323
+ */
324
+ function operationView(op, projected, ctx) {
325
+ const base = at('/operations', op.id);
326
+ const http = op.http;
327
+ /** @type {{ name: string, in: string, required: boolean, schema: any }[]} */
328
+ const parameters = [];
329
+ /** @type {Record<string, any>} */
330
+ const bodyMembers = {};
331
+ /** @type {string[]} */
332
+ const bodyRequired = [];
333
+ let bodyCount = 0;
334
+ let bodyMemberRequired = false;
335
+ const eff = effectiveInput(op);
336
+ const declaredRequired = eff !== null && Array.isArray(eff.required) ? /** @type {unknown[]} */ (eff.required) : [];
337
+ const members = Object.keys(http.in);
338
+ for (let i = 0; i < members.length; i++) {
339
+ const name = members[i];
340
+ const loc = http.in[name];
341
+ // the member's schema as the projection mapped it: the effective
342
+ // input's property (the projection's `input` may be a $ref — the
343
+ // compiled operation resolved it), already walked as part of the
344
+ // input or of the $defs entry it lives in; a member of a registered
345
+ // external schema is mapped here, at the input's docPath
346
+ const mapped = mappedMember(effectiveMemberSchema(op, name), at(base, 'input'), ctx);
347
+ const required = loc === 'path' || declaredRequired.includes(name);
348
+ if (loc === 'body') {
349
+ setObjectMember(bodyMembers, name, mapped);
350
+ if (required) bodyRequired.push(name);
351
+ bodyCount++;
352
+ if (name === http.body) bodyMemberRequired = required;
353
+ }
354
+ else parameters.push({ name, in: loc, required, schema: mapped });
355
+ }
356
+ if (op.policy.idempotency !== 'none') {
357
+ parameters.push({
358
+ name: 'Idempotency-Key', in: 'header', required: op.policy.idempotency === 'required',
359
+ schema: { type: 'string', minLength: 1 },
360
+ description: 'The caller-generated idempotency key the server deduplicates this command on (policy.idempotency '
361
+ + op.policy.idempotency + ').',
362
+ });
363
+ }
364
+ /** @type {Record<string, unknown> | undefined} */
365
+ let body;
366
+ if (http.body !== null) {
367
+ body = { media: http.media, required: bodyMemberRequired, schema: bodyMembers[http.body] };
368
+ }
369
+ else if (bodyCount > 0) {
370
+ const whole = bodyCount === members.length;
371
+ /** @type {Record<string, unknown>} */
372
+ let schema;
373
+ if (whole) schema = projected.input;
374
+ else {
375
+ schema = { type: 'object', properties: bodyMembers };
376
+ if (bodyRequired.length > 0) schema.required = bodyRequired;
377
+ if (eff !== null && eff.additionalProperties !== undefined) {
378
+ schema.additionalProperties = mappedMember(eff.additionalProperties, at(base, 'input'), ctx);
379
+ }
380
+ }
381
+ body = { media: http.media, required: bodyRequired.length > 0, schema };
382
+ }
383
+ /** @type {Record<string, unknown>} */
384
+ const success = { status: String(http.status), media: http.media };
385
+ if (http.status !== 204 && http.method !== 'HEAD') {
386
+ success.schema = http.opaque ? { type: 'string', format: 'binary' } : projected.output;
387
+ }
388
+ /** @type {Record<string, unknown>[]} */
389
+ const errors = [];
390
+ const codes = Object.keys(op.errors);
391
+ for (let i = 0; i < codes.length; i++) {
392
+ const decl = op.errors[codes[i]];
393
+ /** @type {Record<string, unknown>} */
394
+ const e = { code: codes[i], status: String(decl.status), declared: true };
395
+ if (decl.schema !== null) e.schema = projected.errors[codes[i]].schema;
396
+ errors.push(e);
397
+ }
398
+ for (let i = 0; i < SHARED_RESPONSES.length; i++) {
399
+ const shared = SHARED_RESPONSES[i];
400
+ if (shared.when === 'body' && body === undefined) continue;
401
+ if (shared.when === 'idempotent' && op.policy.idempotency === 'none') continue;
402
+ for (let j = 0; j < shared.codes.length; j++) {
403
+ errors.push({ code: shared.codes[j], status: String(shared.status), declared: false, shared: shared.name });
404
+ }
405
+ }
406
+ /** @type {Record<string, unknown>} */
407
+ const view = { id: op.id, tag: op.id.split('.')[0], method: http.method.toLowerCase(), path: http.path };
408
+ if (op.doc !== null) {
409
+ const lines = docLines(op.doc);
410
+ if (lines.summary !== '') view.summary = lines.summary;
411
+ if (lines.long) view.description = op.doc;
412
+ }
413
+ view.parameters = parameters;
414
+ if (body !== undefined) view.body = body;
415
+ view.success = success;
416
+ view.errors = errors;
417
+ view.policy = policyExtension(op);
418
+ return view;
419
+ }
420
+
421
+ /**
422
+ * The effective input schema of an operation — the declared `input` with
423
+ * its `$ref` chain followed — or `null` for an input-less operation.
424
+ * @param {CompiledOperation} op
425
+ * @returns {any}
426
+ */
427
+ function effectiveInput(op) {
428
+ return op.input === null ? null : op.input.effective;
429
+ }
430
+
431
+ /**
432
+ * The mapped copy of a schema node the projection has walked (a member of
433
+ * an input, or of the `$defs` entry a `$ref` input resolves to); a node
434
+ * it has not — a member of a registered external schema — is mapped now,
435
+ * reported at `docPath`.
436
+ * @param {any} schema
437
+ * @param {string} docPath
438
+ * @param {MapContext} ctx
439
+ * @returns {any}
440
+ */
441
+ function mappedMember(schema, docPath, ctx) {
442
+ if (!isJsonObject(schema)) return schema;
443
+ const seen = ctx.mapped.get(schema);
444
+ return seen !== undefined ? seen : mapSchema(schema, docPath, ctx);
445
+ }
446
+
447
+ /**
448
+ * The declared schema of a body-located member, read from the effective
449
+ * input (the projection's `input` may be a `$ref`; the compiled operation
450
+ * resolved it).
451
+ * @param {CompiledOperation} op
452
+ * @param {string} name
453
+ * @returns {any}
454
+ */
455
+ function effectiveMemberSchema(op, name) {
456
+ const eff = effectiveInput(op);
457
+ return eff !== null && isJsonObject(eff.properties) ? eff.properties[name] : undefined;
458
+ }
459
+
460
+ /**
461
+ * The `x-jaren-policy` extension: the client-facing policy verbatim.
462
+ * @param {CompiledOperation} op
463
+ * @returns {Record<string, unknown>}
464
+ */
465
+ function policyExtension(op) {
466
+ const p = op.policy;
467
+ /** @type {Record<string, unknown>} */
468
+ const out = { task: p.task, idempotency: p.idempotency, cache: p.cache };
469
+ if (p.revision !== null) out.revision = p.revision;
470
+ if (p.retry !== null) out.retry = { max: p.retry.max, on: p.retry.on.slice() };
471
+ return out;
472
+ }
473
+
474
+ //#endregion
475
+
476
+ /**
477
+ * Project a compiled contract to an OpenAPI 3.1 document.
478
+ * @param {Contract} contract
479
+ * @param {OpenApiOptions} [options]
480
+ * @returns {OpenApiResult}
481
+ * @throws {ContractHostError} `JC1008` — not a compiled contract, or a malformed option
482
+ * @throws {ContractCompileError} `JC0060` — a schema keyword the projection refuses (see `lenient`)
483
+ * @example
484
+ * const { document, dropped } = toOpenApi(contract, { info: { title: 'Shop', version: '5' } });
485
+ * document.paths['/api/catalog'].get.operationId; // 'catalog.load'
486
+ */
487
+ export function toOpenApi(contract, options = {}) {
488
+ if (!isJsonObject(options)) throw new ContractHostError('JC1008', 'toOpenApi: options must be an object');
489
+ const ops = retainedOperations(contract, undefined, 'toOpenApi');
490
+ const lenient = options.lenient === undefined ? false : options.lenient;
491
+ if (typeof lenient !== 'boolean') throw new ContractHostError('JC1008', 'toOpenApi: options.lenient must be a boolean');
492
+ if (options.info !== undefined && !isJsonObject(options.info)) {
493
+ throw new ContractHostError('JC1008', 'toOpenApi: options.info must be an object { title, version, ... }');
494
+ }
495
+ if (options.servers !== undefined) {
496
+ if (!Array.isArray(options.servers)) throw new ContractHostError('JC1008', 'toOpenApi: options.servers must be an array of { url, description? }');
497
+ for (let i = 0; i < options.servers.length; i++) {
498
+ const s = options.servers[i];
499
+ if (!isJsonObject(s) || typeof s.url !== 'string') {
500
+ throw new ContractHostError('JC1008', `toOpenApi: options.servers[${i}] must be { url: string, description? }`);
501
+ }
502
+ }
503
+ }
504
+ /** @type {Record<string, unknown>} */
505
+ const info = { ...options.info };
506
+ if (typeof info.title !== 'string') info.title = contract.id === null ? 'jaren-contract' : contract.id;
507
+ if (typeof info.version !== 'string') info.version = contract.version === null ? '0' : contract.version;
508
+
509
+ const pub = /** @type {any} */ (publicProjection(contract));
510
+ /** @type {MapContext} */
511
+ const ctx = { lenient, dropped: [], defs: new Set(Object.keys(pub.$defs ?? {})), mapped: new Map() };
512
+
513
+ /** @type {Record<string, any>} */
514
+ const schemas = {};
515
+ const defNames = Object.keys(pub.$defs ?? {});
516
+ for (let i = 0; i < defNames.length; i++) {
517
+ setObjectMember(schemas, defNames[i], mapSchema(pub.$defs[defNames[i]], at('/$defs', defNames[i]), ctx));
518
+ }
519
+ /** @type {Record<string, unknown>[]} */
520
+ const operations = [];
521
+ /** @type {Set<string>} */
522
+ const sharedUsed = new Set();
523
+ for (let i = 0; i < ops.length; i++) {
524
+ const op = ops[i];
525
+ const base = at('/operations', op.id);
526
+ const src = pub.operations[op.id];
527
+ // walked in document order — $defs first, then per operation input,
528
+ // output, errors — so `dropped` reads like the document
529
+ /** @type {Record<string, any>} */
530
+ const projected = { input: undefined, output: undefined, errors: {} };
531
+ if (src.input !== undefined) projected.input = mapSchema(src.input, at(base, 'input'), ctx);
532
+ projected.output = mapSchema(src.output, at(base, 'output'), ctx);
533
+ const codes = Object.keys(src.errors ?? {});
534
+ for (let j = 0; j < codes.length; j++) {
535
+ const decl = src.errors[codes[j]];
536
+ setObjectMember(projected.errors, codes[j], {
537
+ schema: decl.schema === undefined ? undefined : mapSchema(decl.schema, at(at(at(base, 'errors'), codes[j]), 'schema'), ctx),
538
+ });
539
+ }
540
+ const view = operationView(op, projected, ctx);
541
+ for (const e of /** @type {any[]} */ (view.errors)) if (e.shared !== undefined) sharedUsed.add(e.shared);
542
+ operations.push(view);
543
+ }
544
+ /** @type {Record<string, unknown>} */
545
+ const shared = {};
546
+ for (let i = 0; i < SHARED_RESPONSES.length; i++) {
547
+ const s = SHARED_RESPONSES[i];
548
+ if (!sharedUsed.has(s.name)) continue;
549
+ shared[s.name] = {
550
+ status: String(s.status),
551
+ codes: s.codes.slice(),
552
+ description: `Answered by the binding, not the handler — ${s.codes.map((c) => `${c}: ${CONTRACT_CODES[/** @type {keyof typeof CONTRACT_CODES} */ (c)]}`).join('; ')}.`,
553
+ };
554
+ }
555
+ const input = {
556
+ info,
557
+ servers: options.servers === undefined ? [] : options.servers,
558
+ schemas,
559
+ operations,
560
+ shared,
561
+ };
562
+ const document = /** @type {Record<string, unknown>} */ (render(input));
563
+ return { document, dropped: ctx.dropped };
564
+ }