@jarenjs/contract 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/app/index.js
ADDED
|
@@ -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
|
+
}
|