@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,190 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `createContractEffect(client, options)`: ONE `@jarenjs/app`
4
+ * effect handler (`run: "contract"`) that calls any operation of a
5
+ * client and settles every descriptor with a D6 outcome
6
+ * (docs/CONTRACT-FORMAT.md §11). It owns one task effect per distinct
7
+ * `policy.task` mode the contract uses — built lazily through the
8
+ * `createTaskEffect` factory the host hands in from `@jarenjs/app` —
9
+ * and routes each descriptor `{ op, input, id, done, fail?, slot? }` to
10
+ * the mode's inner effect, so the per-operation concurrency semantics
11
+ * come from the contract's policy and never appear in the app document.
12
+ * This package imports nothing from `@jarenjs/app`; the factory crosses
13
+ * as a function, the documents as JSON.
14
+ *
15
+ * Settlement: the inner effect's `run` invokes the client with the
16
+ * slot's signal and the descriptor's `id` as the attempt; an outcome of
17
+ * `kind: "cancelled"` is turned into an `AbortError` so the task effect
18
+ * dispatches nothing (a superseded task is dead by design); every other
19
+ * outcome resolves and lands as `{ id, result: outcome }`; a host throw
20
+ * is projected — never flattened to a string — into a `JC2058` outcome
21
+ * that lands as `{ id, error: outcome }`.
22
+ */
23
+
24
+ import { ContractHostError } from '../errors.js';
25
+ import { isOutcome, hostFailureOutcome } from '../client/outcome.js';
26
+
27
+ /**
28
+ * @typedef {import('../compile.js').Contract} Contract
29
+ * @typedef {import('../client/outcome.js').Outcome} Outcome
30
+ */
31
+
32
+ /**
33
+ * A client binding: what every `open*` returns. The effect reads
34
+ * `invoke` and `contract` (for the task mode of each operation).
35
+ * @typedef {{ invoke: (op: string, input: unknown, ctx: { signal: AbortSignal, attempt: unknown }) => Promise<Outcome> | Outcome, contract: Contract }} ClientLike
36
+ */
37
+
38
+ /**
39
+ * The `createTaskEffect` factory of `@jarenjs/app`, as the host hands it in.
40
+ * @typedef {(run: (props: any, signal: AbortSignal) => any, options: { mode: string, projectError: (err: unknown, props: any) => any }) => TaskEffectLike} TaskEffectFactory
41
+ */
42
+
43
+ /**
44
+ * What a task effect exposes.
45
+ * @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & { cancel: (slot?: string) => void, cancelAll: () => void, dispose: () => void }} TaskEffectLike
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} ContractEffectOptions
50
+ * @property {TaskEffectFactory} createTaskEffect - `createTaskEffect` from `@jarenjs/app` (required)
51
+ * @property {(err: unknown, props: any) => any} [projectError] - consulted
52
+ * first when the host throws; a result that is not an outcome falls
53
+ * back to the `JC2058` outcome
54
+ * @property {Record<string, (params: object) => string> | null} [catalog] - compiled catalog for the `JC2058` message
55
+ */
56
+
57
+ /**
58
+ * The contract effect: an effect handler with the task-effect controls.
59
+ * @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
60
+ * cancel: (slot?: string) => void, cancelAll: () => void, dispose: () => void }} ContractEffect
61
+ */
62
+
63
+ /**
64
+ * @param {string} code
65
+ * @param {string} reason
66
+ * @returns {ContractHostError}
67
+ */
68
+ function host(code, reason) {
69
+ return new ContractHostError(code, `createContractEffect: ${reason}`);
70
+ }
71
+
72
+ /**
73
+ * An `AbortError`-named error: what `run` throws for a cancelled outcome
74
+ * so the task effect dispatches nothing.
75
+ * @returns {Error}
76
+ */
77
+ function abortError() {
78
+ const err = new Error('the operation was cancelled');
79
+ err.name = 'AbortError';
80
+ return err;
81
+ }
82
+
83
+ /**
84
+ * Make the `contract` effect of an app over a client. Register it as
85
+ * `effects: { contract: createContractEffect(client, { createTaskEffect }) }`;
86
+ * the generated actions of `contractAppBinding` invoke it.
87
+ *
88
+ * @param {ClientLike} client - an open client (`openHttpClient`, …)
89
+ * @param {ContractEffectOptions} options
90
+ * @returns {ContractEffect}
91
+ * @throws {ContractHostError} `JC1008` for a malformed client or option
92
+ * @example
93
+ * // createApp and createTaskEffect are the host's, imported from @jarenjs/app;
94
+ * // openHttpClient from '@jarenjs/contract/client', createContractEffect from '@jarenjs/contract/app'
95
+ * const client = openHttpClient(contract, { baseUrl });
96
+ * const effect = createContractEffect(client, { createTaskEffect });
97
+ * createApp(doc, { effects: { contract: effect } });
98
+ */
99
+ export function createContractEffect(client, options) {
100
+ if (client === null || typeof client !== 'object' || typeof client.invoke !== 'function'
101
+ || client.contract === null || typeof client.contract !== 'object' || client.contract.operations === null
102
+ || typeof client.contract.operations !== 'object') {
103
+ throw host('JC1008', 'the first argument must be an open client ({ invoke, contract })');
104
+ }
105
+ if (options === null || typeof options !== 'object' || typeof options.createTaskEffect !== 'function') {
106
+ throw host('JC1008', 'options.createTaskEffect is required — pass createTaskEffect from @jarenjs/app (this package does not import it)');
107
+ }
108
+ const factory = options.createTaskEffect;
109
+ const userProject = options.projectError === undefined ? null : options.projectError;
110
+ if (userProject !== null && typeof userProject !== 'function') throw host('JC1008', 'options.projectError must be a function');
111
+ const catalog = options.catalog === undefined ? null : options.catalog;
112
+ const operations = client.contract.operations;
113
+
114
+ /**
115
+ * The inner `run`: invoke with the slot's signal and the descriptor's
116
+ * id as the attempt; a cancelled outcome becomes an abort; a value that
117
+ * is not an outcome (a foreign client) becomes a `JC2058` outcome.
118
+ * @param {any} props
119
+ * @param {AbortSignal} signal
120
+ * @returns {Promise<Outcome>}
121
+ */
122
+ function run(props, signal) {
123
+ return Promise.resolve(client.invoke(props.op, props.input, { signal, attempt: props.id })).then((outcome) => {
124
+ if (!isOutcome(outcome)) return hostFailureOutcome(props.op, props.id, catalog);
125
+ if (!outcome.ok && outcome.kind === 'cancelled') throw abortError();
126
+ return outcome;
127
+ });
128
+ }
129
+
130
+ /**
131
+ * The settlement projector: a thrown host value becomes an outcome —
132
+ * the host's own projector first, the `JC2058` outcome otherwise — so
133
+ * the completion action always reads an outcome.
134
+ * @param {unknown} err
135
+ * @param {any} props
136
+ * @returns {Outcome}
137
+ */
138
+ function projectError(err, props) {
139
+ if (userProject !== null) {
140
+ try {
141
+ const projected = userProject(err, props);
142
+ if (isOutcome(projected)) return projected;
143
+ }
144
+ catch {
145
+ // fall through to the default projection
146
+ }
147
+ }
148
+ return hostFailureOutcome(typeof props?.op === 'string' ? props.op : '', props?.id, catalog);
149
+ }
150
+
151
+ /** @type {Map<string, TaskEffectLike>} */
152
+ const modes = new Map();
153
+
154
+ /**
155
+ * @param {string} mode
156
+ * @returns {TaskEffectLike}
157
+ */
158
+ function inner(mode) {
159
+ let effect = modes.get(mode);
160
+ if (effect === undefined) {
161
+ effect = factory(run, { mode, projectError });
162
+ modes.set(mode, effect);
163
+ }
164
+ return effect;
165
+ }
166
+
167
+ /** @type {any} */
168
+ const contractEffect = function contractEffect(/** @type {any} */ props, /** @type {(name: string, payload?: any) => void} */ dispatch) {
169
+ const op = props === null || typeof props !== 'object' ? undefined : props.op;
170
+ if (typeof op !== 'string' || !Object.hasOwn(operations, op)) {
171
+ throw new ContractHostError('JC1005', `createContractEffect: the effect props must name an operation of the contract in 'op', got ${JSON.stringify(op)}`);
172
+ }
173
+ inner(operations[op].policy.task)(props, dispatch);
174
+ };
175
+
176
+ /** @param {string} [slot] */
177
+ contractEffect.cancel = function cancel(slot) {
178
+ for (const effect of modes.values()) effect.cancel(slot);
179
+ };
180
+
181
+ contractEffect.cancelAll = function cancelAll() {
182
+ for (const effect of modes.values()) effect.cancelAll();
183
+ };
184
+
185
+ contractEffect.dispose = function dispose() {
186
+ for (const effect of modes.values()) effect.dispose();
187
+ };
188
+
189
+ return contractEffect;
190
+ }
@@ -0,0 +1,26 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The `@jarenjs/app` binding of a contract (docs/CONTRACT-FORMAT.md
4
+ * §11): `contractAppBinding` generates the state slice, the
5
+ * start/done/reset actions and the slice schema as pure JSON (the
6
+ * state-side guards derived from `policy.task`, the mode never named);
7
+ * `createContractEffect`
8
+ * makes the one `contract` effect those actions invoke, over any open
9
+ * client. Neither imports `@jarenjs/app` — the documents cross as JSON
10
+ * and the task-effect factory crosses as a function the host passes in.
11
+ */
12
+
13
+ export { contractAppBinding } from './binding.js';
14
+ export { createContractEffect } from './effect.js';
15
+ export { createContractSubscription } from './subscription.js';
16
+
17
+ /**
18
+ * @typedef {import('./binding.js').ContractAppBinding} ContractAppBinding
19
+ * @typedef {import('./binding.js').ContractAppBindingOptions} ContractAppBindingOptions
20
+ * @typedef {import('./binding.js').TaskSlot} TaskSlot
21
+ * @typedef {import('./binding.js').StreamSlot} StreamSlot
22
+ * @typedef {import('./effect.js').ContractEffect} ContractEffect
23
+ * @typedef {import('./effect.js').ContractEffectOptions} ContractEffectOptions
24
+ * @typedef {import('./effect.js').ClientLike} ClientLike
25
+ * @typedef {import('./subscription.js').StreamClientLike} StreamClientLike
26
+ */
@@ -0,0 +1,130 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `createContractSubscription(client)`: the ONE `contract-stream`
4
+ * subscription handler the generated subs entries of
5
+ * `contractAppBinding` run (docs/CONTRACT-FORMAT.md §11.4). A handler is
6
+ * `(props, dispatch) => cleanup` — the `@jarenjs/app` subscription
7
+ * shape — so no task-effect factory is needed and this package still
8
+ * imports nothing from `@jarenjs/app`: the entry documents cross as
9
+ * JSON and the handler crosses as a function over a client.
10
+ *
11
+ * The handler subscribes through `client.subscribe`, maintains the
12
+ * document HOST-SIDE by applying each `{ patch, seq }` emission with
13
+ * `@jarenjs/json/patch` (copy-on-write — structural sharing is
14
+ * preserved end to end, which is what keeps the O(k) renderer's fast
15
+ * paths alive), and dispatches the named actions with `{ id, … }`
16
+ * payloads — the generated actions guard on the id (and, for `patch`,
17
+ * on a strictly greater `seq`), so a stale instance's dispatch is a
18
+ * provable no-op in state. An `onError` outcome lands in the error
19
+ * action as-is; a server `end` lands there too, as a `network`-kind
20
+ * outcome with the channel-closed code — the stream is gone and the
21
+ * slot must say so, so a view can offer a reconnect (a new `start`).
22
+ */
23
+
24
+ import { compileJSONPatch } from '@jarenjs/json/patch';
25
+
26
+ import { ContractHostError } from '../errors.js';
27
+ import { renderMessage } from '../http/wire.js';
28
+ import { CLIENT_ERRORS, makeMeta, failedOutcome, outcomeError, clientError } from '../client/outcome.js';
29
+ import { PORT_LOCAL_ERRORS } from '../pipeline.js';
30
+
31
+ /**
32
+ * @typedef {import('../compile.js').Contract} Contract
33
+ * @typedef {import('../client/outcome.js').Outcome} Outcome
34
+ * @typedef {import('../http/wire.js').Catalog} Catalog
35
+ */
36
+
37
+ /**
38
+ * A client binding that carries streams: what `openHttpClient` and
39
+ * `openPortClient` return. The handler reads `subscribe` only.
40
+ * @typedef {{ subscribe: (op: string, input: unknown, options: object) => { stop: () => void }, contract: Contract }} StreamClientLike
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} ContractSubscriptionOptions
45
+ * @property {Catalog | null} [catalog] - compiled catalog for the end/apply messages
46
+ */
47
+
48
+ /**
49
+ * The props of one generated subs entry: the operation, the slot id and
50
+ * input resolved from state, and the action names to dispatch.
51
+ * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
52
+ */
53
+
54
+ /**
55
+ * Make the `contract-stream` subscription handler of an app over a
56
+ * client. Register it as `subs: { 'contract-stream':
57
+ * createContractSubscription(client) }`; the generated entries of
58
+ * `contractAppBinding` run it.
59
+ *
60
+ * @param {StreamClientLike} client - an open client whose `capabilities.stream` is true
61
+ * @param {ContractSubscriptionOptions} [options]
62
+ * @returns {(props: StreamProps, dispatch: (name: string, payload?: any) => void) => (() => void)}
63
+ * @throws {ContractHostError} `JC1008` for a malformed client or option
64
+ * @example
65
+ * createApp({ ...doc, subs: [...binding.subs] }, {
66
+ * effects: { contract: createContractEffect(client, { createTaskEffect }) },
67
+ * subs: { 'contract-stream': createContractSubscription(client) },
68
+ * });
69
+ */
70
+ export function createContractSubscription(client, options = {}) {
71
+ if (client === null || typeof client !== 'object' || typeof client.subscribe !== 'function'
72
+ || client.contract === null || typeof client.contract !== 'object') {
73
+ throw new ContractHostError('JC1008', 'createContractSubscription: the first argument must be an open client with subscribe ({ subscribe, contract })');
74
+ }
75
+ if (options === null || typeof options !== 'object') {
76
+ throw new ContractHostError('JC1008', 'createContractSubscription: options must be an object');
77
+ }
78
+ const catalog = options.catalog === undefined ? null : options.catalog;
79
+
80
+ return function contractStream(props, dispatch) {
81
+ const p = /** @type {any} */ (props);
82
+ if (p === null || typeof p !== 'object' || typeof p.op !== 'string'
83
+ || typeof p.snapshot !== 'string' || typeof p.patch !== 'string' || typeof p.error !== 'string') {
84
+ throw new ContractHostError('JC1008', 'contract-stream: props must carry op and the snapshot/patch/error action names (a contractAppBinding subs entry)');
85
+ }
86
+ const { op, id } = p;
87
+ /** @type {unknown} */
88
+ let doc = null;
89
+ const sub = client.subscribe(op, p.input === undefined ? null : p.input, {
90
+ onSnapshot: (/** @type {unknown} */ value, /** @type {{ seq: number }} */ info) => {
91
+ doc = value;
92
+ dispatch(p.snapshot, { id, value, seq: info.seq });
93
+ },
94
+ onPatch: (/** @type {{ patch: any[], seq: number }} */ emission) => {
95
+ try {
96
+ doc = compileJSONPatch(/** @type {any} */ (emission.patch))(doc);
97
+ }
98
+ catch {
99
+ // the emission does not apply to the document the stream built —
100
+ // one end broke the patch contract; the error dispatch flips the
101
+ // slot off 'live' and the app's own reconciliation runs the
102
+ // cleanup, so the client's stop() still runs exactly once
103
+ dispatch(p.error, {
104
+ id,
105
+ outcome: failedOutcome('contract',
106
+ clientError(catalog, 'JC2053', { op }, null, [{ path: '', keyword: 'patch' }]), makeMeta(op, null, null)),
107
+ });
108
+ return;
109
+ }
110
+ dispatch(p.patch, { id, value: doc, seq: emission.seq });
111
+ },
112
+ onError: (/** @type {Outcome} */ outcome) => dispatch(p.error, { id, outcome }),
113
+ onEnd: () => {
114
+ // the server ended the stream: the slot must say the channel is
115
+ // gone, so a view can offer a reconnect (a fresh start)
116
+ dispatch(p.error, {
117
+ id,
118
+ outcome: failedOutcome('network',
119
+ outcomeError('JC2074', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2074.msgid, { op }), null, null, false),
120
+ makeMeta(op, null, null)),
121
+ });
122
+ },
123
+ });
124
+ return () => sub.stop();
125
+ };
126
+ }
127
+
128
+ // re-exported so a host settling its own outcomes beside the handler
129
+ // needs no second import path
130
+ export { CLIENT_ERRORS };
package/src/bundle.js ADDED
@@ -0,0 +1,168 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Same-document schema reachability and bundling — the one place in
4
+ * the suite that answers "which `$defs` does this schema need?" and
5
+ * "make this schema stand alone". The public projection keeps exactly
6
+ * the reachable `$defs` of the retained operations (05 hashes that); a
7
+ * tool definition carries its input schema with the reachable `$defs`
8
+ * inlined under the schema's own `$defs`, because a tool schema must be
9
+ * self-contained.
10
+ *
11
+ * Reachability follows same-document `$ref`s (`#`, `#/…` pointers,
12
+ * `#anchor`) with the resolver `compileContract` itself uses
13
+ * (`@jarenjs/validate/normalize`), so a reference the compiler accepted
14
+ * resolves here by the same rules. A target is attributed to the `$defs`
15
+ * entry that CONTAINS it — `#/$defs/Product/properties/id` needs
16
+ * `Product` — and the entry is walked in turn. References that do not
17
+ * land inside `$defs` (`#`, a pointer into another operation's schema,
18
+ * an absolute `$id`) are left as written: the document states where
19
+ * shared schemas live, and that is `$defs`.
20
+ */
21
+
22
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
23
+ import { collectSameDocumentAnchors, resolveSameDocumentRef } from '@jarenjs/validate/normalize';
24
+ import { parseJSONPointer } from '@jarenjs/json/pointer';
25
+
26
+ /** JSON-value keywords whose content is data, not schema — not walked for `$ref`. */
27
+ const DATA_KEYWORDS = new Set(['const', 'enum', 'default', 'examples']);
28
+
29
+ /**
30
+ * The containment map of a document's `$defs`: every object or array node
31
+ * inside a `$defs` entry → the entry's name (the entry's own node
32
+ * included). First entry wins for a node shared by two entries (a
33
+ * snapshot never shares, but a hand-built document may).
34
+ * @param {any} defs
35
+ * @returns {Map<object, string>}
36
+ */
37
+ function containment(defs) {
38
+ /** @type {Map<object, string>} */
39
+ const owner = new Map();
40
+ if (!isJsonObject(defs)) return owner;
41
+ const names = Object.keys(defs);
42
+ for (let i = 0; i < names.length; i++) {
43
+ const name = names[i];
44
+ /** @param {any} node */
45
+ const mark = (node) => {
46
+ if (node === null || typeof node !== 'object' || owner.has(node)) return;
47
+ owner.set(node, name);
48
+ if (Array.isArray(node)) {
49
+ for (let j = 0; j < node.length; j++) mark(node[j]);
50
+ return;
51
+ }
52
+ const keys = Object.keys(node);
53
+ for (let j = 0; j < keys.length; j++) mark(node[keys[j]]);
54
+ };
55
+ mark(defs[name]);
56
+ }
57
+ return owner;
58
+ }
59
+
60
+ /**
61
+ * The `$defs` entry a same-document reference lands in, or `undefined`
62
+ * when it lands elsewhere (the root, an operation, nowhere). A pointer
63
+ * under `#/$defs/` names its entry directly (so a boolean entry, which
64
+ * has no identity to look up, is found too); anything else — an anchor,
65
+ * a pointer that reaches a `$defs` node by another route — is attributed
66
+ * by containment.
67
+ * @param {string} ref
68
+ * @param {Record<string, any>} defs
69
+ * @param {Map<object, string>} owner
70
+ * @param {any} doc
71
+ * @param {Map<string, object>} anchors
72
+ * @returns {string | undefined}
73
+ */
74
+ function defOf(ref, defs, owner, doc, anchors) {
75
+ if (ref.startsWith('#/$defs/')) {
76
+ let tokens;
77
+ try {
78
+ tokens = parseJSONPointer(ref.slice(1));
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ const name = String(tokens[1]);
84
+ if (Object.hasOwn(defs, name) && resolveSameDocumentRef(ref, doc, anchors) !== undefined) return name;
85
+ return undefined;
86
+ }
87
+ const target = resolveSameDocumentRef(ref, doc, anchors);
88
+ return target !== null && typeof target === 'object' ? owner.get(target) : undefined;
89
+ }
90
+
91
+ /**
92
+ * The names of the document's `$defs` reachable from `roots` by
93
+ * same-document `$ref`, transitively, in first-reference order: the
94
+ * roots are walked in the order given, depth-first in member order, and
95
+ * a `$defs` entry is walked at the moment it is first reached. Data
96
+ * keywords (`const`, `enum`, `default`, `examples`) are not walked.
97
+ * @param {readonly unknown[]} roots - schemas (objects or booleans) rooted in `doc`
98
+ * @param {any} doc - the document holding `$defs` (a contract document, or any schema root)
99
+ * @returns {string[]}
100
+ */
101
+ export function reachableDefs(roots, doc) {
102
+ const defs = isJsonObject(doc) && isJsonObject(doc.$defs) ? doc.$defs : null;
103
+ if (defs === null) return [];
104
+ const owner = containment(defs);
105
+ const anchors = collectSameDocumentAnchors(doc);
106
+ /** @type {string[]} */
107
+ const names = [];
108
+ const seenNames = new Set();
109
+ /** @type {Set<object>} */
110
+ const visited = new Set();
111
+ /** @param {any} node */
112
+ const walk = (node) => {
113
+ if (node === null || typeof node !== 'object' || visited.has(node)) return;
114
+ visited.add(node);
115
+ if (Array.isArray(node)) {
116
+ for (let i = 0; i < node.length; i++) walk(node[i]);
117
+ return;
118
+ }
119
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#')) {
120
+ const name = defOf(node.$ref, defs, owner, doc, anchors);
121
+ if (name !== undefined) {
122
+ if (!seenNames.has(name)) {
123
+ seenNames.add(name);
124
+ names.push(name);
125
+ }
126
+ walk(defs[name]);
127
+ }
128
+ }
129
+ const keys = Object.keys(node);
130
+ for (let i = 0; i < keys.length; i++) {
131
+ if (DATA_KEYWORDS.has(keys[i])) continue;
132
+ walk(node[keys[i]]);
133
+ }
134
+ };
135
+ for (let i = 0; i < roots.length; i++) walk(roots[i]);
136
+ return names;
137
+ }
138
+
139
+ /**
140
+ * Make a schema stand alone: the schema with the `$defs` it reaches
141
+ * (transitively, first-reference order) copied under its own `$defs`, so
142
+ * every `#/$defs/X` it carries resolves inside the returned document. A
143
+ * boolean schema is returned as is; a schema that reaches nothing gains no
144
+ * `$defs`. A schema that declared its own `$defs` member loses it — at the
145
+ * new root that member would shadow the document's, and its entries were
146
+ * only addressable by a pointer through the operation anyway.
147
+ * @param {any} schema - a schema rooted in `doc`
148
+ * @param {any} doc - the document holding `$defs`
149
+ * @returns {any} a fresh top-level object sharing the schema's subtrees
150
+ */
151
+ export function bundleSameDocument(schema, doc) {
152
+ if (!isJsonObject(schema)) return schema;
153
+ const names = reachableDefs([schema], doc);
154
+ /** @type {Record<string, any>} */
155
+ const out = {};
156
+ const keys = Object.keys(schema);
157
+ for (let i = 0; i < keys.length; i++) {
158
+ if (keys[i] === '$defs') continue;
159
+ setObjectMember(out, keys[i], schema[keys[i]]);
160
+ }
161
+ if (names.length > 0) {
162
+ /** @type {Record<string, any>} */
163
+ const defs = {};
164
+ for (let i = 0; i < names.length; i++) setObjectMember(defs, names[i], doc.$defs[names[i]]);
165
+ out.$defs = defs;
166
+ }
167
+ return out;
168
+ }