@jarenjs/contract 0.49.2 → 0.66.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 +191 -30
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/project/tools.d.ts +1 -1
- package/dist/types/project/typescript.d.ts +11 -0
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +617 -145
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/tools.js +9 -2
- package/src/project/typescript.js +91 -1
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- package/src/stream/sse.js +2 -0
package/src/pipeline.js
CHANGED
|
@@ -89,7 +89,12 @@ function contractResult(code, details, cause) {
|
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* A server trace id from a host generator. TOTAL: a generator that
|
|
92
|
-
* throws or answers a non-string is replaced by the platform's UUID
|
|
92
|
+
* throws or answers a non-string is replaced by the platform's UUID —
|
|
93
|
+
* the one last-resort platform read in this package, reached only when
|
|
94
|
+
* the injected generator (a `trace` option or the runtime record's
|
|
95
|
+
* `uuid`) has itself failed, so a request still carries a trace; a run
|
|
96
|
+
* whose generator fails was not the deterministic run the record
|
|
97
|
+
* configures, and the fallback says nothing about it.
|
|
93
98
|
* @param {() => string} trace
|
|
94
99
|
* @returns {string}
|
|
95
100
|
*/
|
|
@@ -161,6 +166,18 @@ function declaredResult(route, code, params, details, retryable) {
|
|
|
161
166
|
};
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Classify a declared failure a HOST hook answered (`identify`/`acquire`,
|
|
171
|
+
* docs/CONTRACT-FORMAT.md §7.7): the same rules as a handler's `ctx.fail`
|
|
172
|
+
* — the code must be declared, the details must pass the declaration.
|
|
173
|
+
* @param {PipelineRoute} route
|
|
174
|
+
* @param {import('./errors.js').ContractFailureValue} failure
|
|
175
|
+
* @returns {OperationResult}
|
|
176
|
+
*/
|
|
177
|
+
export function classifyDeclared(route, failure) {
|
|
178
|
+
return declaredResult(route, failure.code, failure.params, failure.details, failure.retryable);
|
|
179
|
+
}
|
|
180
|
+
|
|
164
181
|
/**
|
|
165
182
|
* Classify the handler's resolved value: a `ContractFailure` is a
|
|
166
183
|
* declared failure; anything else is the output, validated unless the
|
package/src/port/client.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
27
|
+
import { resolveHostRuntime } from '../runtime.js';
|
|
27
28
|
|
|
28
29
|
import { ContractHostError } from '../errors.js';
|
|
29
30
|
import { validateOperationInput, PORT_LOCAL_ERRORS } from '../pipeline.js';
|
|
@@ -52,6 +53,10 @@ export { PORT_LOCAL_ERRORS };
|
|
|
52
53
|
* @property {number} [timeoutMs] - per request; default 15000; `0` disables
|
|
53
54
|
* @property {Record<string, string | ((params: object) => string)>} [catalog]
|
|
54
55
|
* - a message catalog consulted before the English one
|
|
56
|
+
* @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime]
|
|
57
|
+
* - the host's runtime record: its `uuid` mints the client id every
|
|
58
|
+
* request id of this client is prefixed with; `crypto.randomUUID` by
|
|
59
|
+
* default
|
|
55
60
|
*/
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -79,12 +84,15 @@ export { PORT_LOCAL_ERRORS };
|
|
|
79
84
|
* There is no heartbeat on a port — delivery is in-process — so no
|
|
80
85
|
* silence watchdog runs here.
|
|
81
86
|
* @typedef {Object} PortSubscribeOptions
|
|
82
|
-
* @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
|
|
87
|
+
* @property {(value: unknown, info: { seq: number, resumed: boolean, reset: boolean, earliestAvailable: number | null, highWatermark: number | null }) => void} [onSnapshot]
|
|
83
88
|
* @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
|
|
84
89
|
* @property {(outcome: Outcome) => void} [onError]
|
|
85
90
|
* @property {(info: { reason: string }) => void} [onEnd]
|
|
86
91
|
* @property {AbortSignal} [signal] - stops the subscription silently
|
|
87
|
-
* @property {number} [lastSeq] - the resume seq (what a
|
|
92
|
+
* @property {number} [lastSeq] - the resume seq (what a re-entered subscribe passes)
|
|
93
|
+
* @property {{ max: number }} [reconnect] - validated as on the HTTP client, then
|
|
94
|
+
* nothing: a channel has no network loss to reconnect from (a closed channel is
|
|
95
|
+
* `JC2074`, final), so the same options object serves both clients
|
|
88
96
|
*/
|
|
89
97
|
|
|
90
98
|
/**
|
|
@@ -186,7 +194,19 @@ export function openPortClient(contract, options) {
|
|
|
186
194
|
routes.set(id, prepare(contract.operations[id]));
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
const
|
|
197
|
+
const runtime = resolveHostRuntime(options.runtime, host, 'JC1008');
|
|
198
|
+
// the record's generator is the host's; one that throws or answers no
|
|
199
|
+
// string is the host's own mistake, refused where it was passed
|
|
200
|
+
let clientId;
|
|
201
|
+
try {
|
|
202
|
+
clientId = runtime.uuid();
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
throw host('JC1008', `options.runtime: uuid() threw (${error instanceof Error ? error.message : String(error)})`);
|
|
206
|
+
}
|
|
207
|
+
if (typeof clientId !== 'string' || clientId.length === 0) {
|
|
208
|
+
throw host('JC1008', 'options.runtime: uuid() must answer a non-empty string, the client id every request is prefixed with');
|
|
209
|
+
}
|
|
190
210
|
const prefix = clientId + ':';
|
|
191
211
|
let seq = 0;
|
|
192
212
|
/** @type {Map<string, Pending>} */
|
|
@@ -412,6 +432,12 @@ export function openPortClient(contract, options) {
|
|
|
412
432
|
if (!Number.isInteger(options.lastSeq) || options.lastSeq < 0) throw host('JC1008', 'options.lastSeq must be a non-negative integer');
|
|
413
433
|
lastSeq = options.lastSeq;
|
|
414
434
|
}
|
|
435
|
+
if (options.reconnect !== undefined && options.reconnect !== null) {
|
|
436
|
+
const r = /** @type {any} */ (options.reconnect);
|
|
437
|
+
if (typeof r !== 'object' || !Number.isInteger(r.max) || r.max < 0) {
|
|
438
|
+
throw host('JC1008', 'options.reconnect must be { max } with a non-negative integer number of further attempts');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
415
441
|
const signal = options.signal === undefined || options.signal === null ? null : options.signal;
|
|
416
442
|
const meta = makeMeta(route.op.id, null, null);
|
|
417
443
|
const id = prefix + (++seq);
|
|
@@ -442,10 +468,17 @@ export function openPortClient(contract, options) {
|
|
|
442
468
|
}
|
|
443
469
|
}
|
|
444
470
|
};
|
|
471
|
+
/** @type {Subscription} */
|
|
472
|
+
const subscription = Object.freeze({
|
|
473
|
+
stop,
|
|
474
|
+
get lastSeq() {
|
|
475
|
+
return consumer.lastSeq();
|
|
476
|
+
},
|
|
477
|
+
});
|
|
445
478
|
|
|
446
479
|
if (closed || (signal !== null && signal.aborted)) {
|
|
447
480
|
queueMicrotask(() => consumer.cancel());
|
|
448
|
-
return
|
|
481
|
+
return subscription;
|
|
449
482
|
}
|
|
450
483
|
|
|
451
484
|
// validate before anything is posted — the shared pre-send refusal
|
|
@@ -463,7 +496,7 @@ export function openPortClient(contract, options) {
|
|
|
463
496
|
if (refusal !== null) {
|
|
464
497
|
const outcome = failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.op.id }, null, refusal), meta);
|
|
465
498
|
queueMicrotask(() => consumer.fail(outcome));
|
|
466
|
-
return
|
|
499
|
+
return subscription;
|
|
467
500
|
}
|
|
468
501
|
|
|
469
502
|
streams.set(id, consumer);
|
|
@@ -479,7 +512,7 @@ export function openPortClient(contract, options) {
|
|
|
479
512
|
const outcome = bindingOutcome('network', 'JC2074', { op: route.op.id }, meta);
|
|
480
513
|
queueMicrotask(() => consumer.fail(outcome));
|
|
481
514
|
}
|
|
482
|
-
return
|
|
515
|
+
return subscription;
|
|
483
516
|
}
|
|
484
517
|
|
|
485
518
|
/** @type {PortClientCapabilities} */
|
package/src/port/serve.js
CHANGED
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
24
24
|
|
|
25
25
|
import { ContractHostError, ContractFailure } from '../errors.js';
|
|
26
|
-
import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS } from '../pipeline.js';
|
|
27
|
-
import {
|
|
26
|
+
import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS, classifyDeclared } from '../pipeline.js';
|
|
27
|
+
import { resolveLifecycle, identify as identifyHost, acquire as acquireHost, once, RollbackCarrier } from '../host.js';
|
|
28
|
+
import { resolveHostRuntime } from '../runtime.js';
|
|
29
|
+
import { isSubscriptionLike, runSubscription, resolveStreamLimits, STREAM_ERRORS } from '../stream/server.js';
|
|
28
30
|
import { HTTP_ERRORS, renderMessage, declaredMessage } from '../http/wire.js';
|
|
29
31
|
import { isContractFrame, valueFrame, errorFrame, pushFrame, attach, isChannel } from './frame.js';
|
|
30
32
|
|
|
@@ -44,14 +46,32 @@ export { PORT_LOCAL_ERRORS };
|
|
|
44
46
|
/**
|
|
45
47
|
* @typedef {Object} ServePortOptions
|
|
46
48
|
* @property {ChannelLike} channel - the channel to serve (required)
|
|
47
|
-
* @property {() => string} [trace] - the server trace generator; default
|
|
49
|
+
* @property {() => string} [trace] - the server trace generator; default
|
|
50
|
+
* the runtime record's `uuid`, itself `crypto.randomUUID` by default
|
|
48
51
|
* @property {'always' | 'never'} [validateOutput] - `'never'` is a declared
|
|
49
52
|
* downgrade, reported in `capabilities.validatedOutput`
|
|
50
53
|
* @property {Record<string, string | ((params: object) => string)>} [catalog]
|
|
51
54
|
* - a message catalog consulted before the English one
|
|
52
55
|
* @property {(error: unknown, ctx: { op: string, trace: string } | null) => void} [onError]
|
|
56
|
+
* @property {(meta: import('../host.js').IdentifyMeta) => unknown} [identify]
|
|
57
|
+
* - the host lifecycle's first hook (docs/CONTRACT-FORMAT.md §7.7), run
|
|
58
|
+
* after the operation resolved and before the input is validated;
|
|
59
|
+
* `meta.carrier` is `'port'` and the request-line members are `null`
|
|
60
|
+
* @property {(input: unknown, identity: unknown, enter: (lease: unknown) => Promise<unknown>) => unknown} [acquire]
|
|
61
|
+
* - the second hook, run after the input validated; a `settlement` on
|
|
62
|
+
* its lease is accepted and unused — this binding carries no
|
|
63
|
+
* idempotency
|
|
53
64
|
* - observes the cause behind every `JC2070` frame, validator throws
|
|
54
65
|
* and a channel whose `postMessage` throws
|
|
66
|
+
* @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime]
|
|
67
|
+
* - the host's runtime record: its `uuid` generates the server trace
|
|
68
|
+
* where `trace` is absent
|
|
69
|
+
* @property {{ replay?: { limit?: number, maxBytes?: number }, queue?: { events?: number, bytes?: number } }} [streamLimits]
|
|
70
|
+
* - the bounds of every push-frame stream (docs/CONTRACT-FORMAT.md
|
|
71
|
+
* §18.1): a replay page asks for at most `replay.limit` emissions /
|
|
72
|
+
* `replay.maxBytes` patch bytes (default 256 / 1 MiB); the undelivered
|
|
73
|
+
* queue holds at most `queue.events` frames / `queue.bytes` frame
|
|
74
|
+
* bytes (default 256 / 1 MiB) before the stream ends with `JC2096`
|
|
55
75
|
*/
|
|
56
76
|
|
|
57
77
|
/**
|
|
@@ -169,7 +189,10 @@ export function servePort(contract, handlers, options) {
|
|
|
169
189
|
if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
|
|
170
190
|
throw host('JC1001', 'options.catalog must be a message catalog object');
|
|
171
191
|
}
|
|
172
|
-
const
|
|
192
|
+
const runtime = resolveHostRuntime(options.runtime, host, 'JC1001');
|
|
193
|
+
const streamLimits = resolveStreamLimits(options.streamLimits, (reason) => host('JC1001', reason));
|
|
194
|
+
const lifecycle = resolveLifecycle(options, (reason) => host('JC1001', reason));
|
|
195
|
+
const traceGen = options.trace === undefined ? runtime.uuid : options.trace;
|
|
173
196
|
const onError = options.onError === undefined ? null : options.onError;
|
|
174
197
|
/** @type {Catalog | null} */
|
|
175
198
|
const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
|
|
@@ -191,6 +214,77 @@ export function servePort(contract, handlers, options) {
|
|
|
191
214
|
const active = new Map();
|
|
192
215
|
let closed = false;
|
|
193
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The frozen context of one request or subscription on this carrier:
|
|
219
|
+
* the request-line members are `null` here, `etag`/`status` are not
|
|
220
|
+
* callable, and `host` is the identity's until `acquire` entered.
|
|
221
|
+
* @param {PortRoute} route
|
|
222
|
+
* @param {string} trace
|
|
223
|
+
* @param {AbortSignal} signal
|
|
224
|
+
* @param {unknown} hostValue
|
|
225
|
+
*/
|
|
226
|
+
const contextOf = (route, trace, signal, hostValue) => Object.freeze({
|
|
227
|
+
op: route.op, trace, carrier: /** @type {const} */ ('port'), host: hostValue, signal,
|
|
228
|
+
method: null, path: null, params: null, headers: NO_HEADERS, body: null,
|
|
229
|
+
fail: ContractFailure, idempotency: null, etag: null, status: null,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run the host lifecycle around a served request or subscription:
|
|
234
|
+
* identify before validation, the validation itself (`validate`),
|
|
235
|
+
* acquire after it, then `enter` with the handler's context. Every
|
|
236
|
+
* fault of a hook is the binding's host fault (`JC2070`); a declared
|
|
237
|
+
* failure is classified like a handler's. The answer is what `enter`
|
|
238
|
+
* (or a refusal) produced, plus the releases the caller runs at its
|
|
239
|
+
* own boundary.
|
|
240
|
+
* @param {PortRoute} route
|
|
241
|
+
* @param {string} trace
|
|
242
|
+
* @param {AbortSignal} signal
|
|
243
|
+
* @param {unknown} value - the input as the frame carried it
|
|
244
|
+
* @param {(error: unknown) => void} observed
|
|
245
|
+
* @param {(code: 'JC2006' | 'JC2070', details: unknown, cause: unknown) => void} refuse - a pre-handler refusal
|
|
246
|
+
* @param {(result: import('../pipeline.js').OperationResult) => void} failed - a declared failure of a hook
|
|
247
|
+
* @param {(hctx: any) => Promise<unknown>} enter
|
|
248
|
+
* @returns {Promise<{ entered: boolean, value: unknown, release: () => Promise<boolean> }>}
|
|
249
|
+
*/
|
|
250
|
+
async function lifecycleAround(route, trace, signal, value, observed, refuse, failed, enter) {
|
|
251
|
+
const meta = Object.freeze({ op: route.op, trace, signal, carrier: /** @type {const} */ ('port'), method: null, path: null, headers: null, fail: ContractFailure });
|
|
252
|
+
const identified = await identifyHost(lifecycle, meta);
|
|
253
|
+
const none = () => Promise.resolve(true);
|
|
254
|
+
if (identified.kind === 'fault') {
|
|
255
|
+
refuse('JC2070', undefined, identified.cause);
|
|
256
|
+
return { entered: false, value: undefined, release: none };
|
|
257
|
+
}
|
|
258
|
+
if (identified.kind === 'failure') {
|
|
259
|
+
failed(classifyDeclared(route, identified.failure));
|
|
260
|
+
return { entered: false, value: undefined, release: none };
|
|
261
|
+
}
|
|
262
|
+
const identity = once(identified.lease.release, observed);
|
|
263
|
+
/** @type {(() => Promise<boolean>) | null} */
|
|
264
|
+
let acquired = null;
|
|
265
|
+
const release = () => (acquired === null ? identity() : acquired().then((a) => identity().then((b) => a && b)));
|
|
266
|
+
const invalid = validateOperationInput(route, value);
|
|
267
|
+
if (invalid !== null && invalid.kind === 'contract') {
|
|
268
|
+
refuse('JC2006', invalid.details, invalid.cause);
|
|
269
|
+
return { entered: false, value: undefined, release };
|
|
270
|
+
}
|
|
271
|
+
const ictx = contextOf(route, trace, signal, identified.lease.host);
|
|
272
|
+
const out = await acquireHost(lifecycle, value, ictx, (lease) => {
|
|
273
|
+
acquired = once(lease.release, observed);
|
|
274
|
+
return enter(Object.freeze({ ...ictx, host: lease.host }));
|
|
275
|
+
});
|
|
276
|
+
if (out.kind === 'fault') {
|
|
277
|
+
refuse('JC2070', undefined, out.cause);
|
|
278
|
+
return { entered: false, value: undefined, release };
|
|
279
|
+
}
|
|
280
|
+
if (out.kind === 'failure') {
|
|
281
|
+
failed(classifyDeclared(route, out.failure));
|
|
282
|
+
return { entered: false, value: undefined, release };
|
|
283
|
+
}
|
|
284
|
+
if (out.afterFault !== undefined) observed(out.afterFault);
|
|
285
|
+
return { entered: true, value: out.result, release };
|
|
286
|
+
}
|
|
287
|
+
|
|
194
288
|
/**
|
|
195
289
|
* @param {unknown} error
|
|
196
290
|
* @param {{ op: string, trace: string } | null} ctx
|
|
@@ -238,37 +332,46 @@ export function servePort(contract, handlers, options) {
|
|
|
238
332
|
}
|
|
239
333
|
const opId = route.op.id;
|
|
240
334
|
const value = route.validateInput === null ? null : input === undefined ? null : input;
|
|
241
|
-
const invalid = validateOperationInput(route, value);
|
|
242
|
-
if (invalid !== null && invalid.kind === 'contract') {
|
|
243
|
-
if (invalid.cause !== undefined) observe(invalid.cause, { op: opId, trace });
|
|
244
|
-
post(errorFrame(id, 'JC2006', renderMessage(catalog, HTTP_ERRORS.JC2006.msgid, { op: opId }),
|
|
245
|
-
invalid.details, false, trace), { op: opId, trace });
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
335
|
const controller = new AbortController();
|
|
249
336
|
active.set(id, controller);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
});
|
|
254
|
-
settleOperation(route, value, ctx, validate).then((result) => {
|
|
255
|
-
if (active.get(id) === controller) active.delete(id);
|
|
256
|
-
// a cancelled request's client is gone and drops late responses
|
|
257
|
-
// anyway; not answering just keeps the channel quiet
|
|
258
|
-
if (controller.signal.aborted || closed) return;
|
|
337
|
+
const pushCtx = { op: opId, trace };
|
|
338
|
+
/** @param {import('../pipeline.js').OperationResult} result */
|
|
339
|
+
const answer = (result) => {
|
|
259
340
|
if (result.kind === 'value') {
|
|
260
|
-
post(valueFrame(id, result.value, trace),
|
|
341
|
+
post(valueFrame(id, result.value, trace), pushCtx);
|
|
261
342
|
}
|
|
262
343
|
else if (result.kind === 'failure') {
|
|
263
344
|
post(errorFrame(id, result.code, declaredMessage(catalog, opId, result.code, result.params),
|
|
264
|
-
result.details, result.retryable, trace),
|
|
345
|
+
result.details, result.retryable, trace), pushCtx);
|
|
265
346
|
}
|
|
266
347
|
else {
|
|
267
|
-
if (result.cause !== undefined) observe(result.cause,
|
|
348
|
+
if (result.cause !== undefined) observe(result.cause, pushCtx);
|
|
268
349
|
post(errorFrame(id, 'JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }),
|
|
269
|
-
undefined, false, trace),
|
|
350
|
+
undefined, false, trace), pushCtx);
|
|
270
351
|
}
|
|
271
|
-
}
|
|
352
|
+
};
|
|
353
|
+
lifecycleAround(route, trace, controller.signal, value,
|
|
354
|
+
(error) => observe(error, pushCtx),
|
|
355
|
+
(code, details, cause) => {
|
|
356
|
+
if (cause !== undefined) observe(cause, pushCtx);
|
|
357
|
+
post(errorFrame(id, code, renderMessage(catalog, code === 'JC2006' ? HTTP_ERRORS.JC2006.msgid : PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }),
|
|
358
|
+
details, false, trace), pushCtx);
|
|
359
|
+
},
|
|
360
|
+
answer,
|
|
361
|
+
// inside enter: the handler through the neutral pipeline; a host
|
|
362
|
+
// fault rejects enter with the rollback carrier so a transaction
|
|
363
|
+
// around it rolls back, the fault still the answer
|
|
364
|
+
(hctx) => settleOperation(route, value, hctx, validate).then((result) => {
|
|
365
|
+
if (result.kind === 'contract') throw new RollbackCarrier(result, result.cause);
|
|
366
|
+
return result;
|
|
367
|
+
}))
|
|
368
|
+
.then((run) => {
|
|
369
|
+
if (active.get(id) === controller) active.delete(id);
|
|
370
|
+
// a cancelled request's client is gone and drops late responses
|
|
371
|
+
// anyway; not answering just keeps the channel quiet
|
|
372
|
+
if (run.entered && !controller.signal.aborted && !closed) answer(/** @type {any} */ (run.value));
|
|
373
|
+
return run.release();
|
|
374
|
+
});
|
|
272
375
|
}
|
|
273
376
|
|
|
274
377
|
/**
|
|
@@ -314,67 +417,102 @@ export function servePort(contract, handlers, options) {
|
|
|
314
417
|
}
|
|
315
418
|
const opId = route.op.id;
|
|
316
419
|
const value = route.validateInput === null ? null : input === undefined ? null : input;
|
|
317
|
-
const invalid = validateOperationInput(route, value);
|
|
318
|
-
if (invalid !== null && invalid.kind === 'contract') {
|
|
319
|
-
if (invalid.cause !== undefined) observe(invalid.cause, { op: opId, trace });
|
|
320
|
-
post(pushFrame(id, 'error', 0, wireError('JC2006', renderMessage(catalog, HTTP_ERRORS.JC2006.msgid, { op: opId }), trace, invalid.details, false)), { op: opId, trace });
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
420
|
const lastSeq = Number.isInteger(lastSeqRaw) && /** @type {number} */ (lastSeqRaw) >= 0 ? /** @type {number} */ (lastSeqRaw) : null;
|
|
324
421
|
const entry = { stopped: false, stop: /** @type {(reason: string | null) => void} */ (() => { entry.stopped = true; }) };
|
|
325
422
|
streams.set(id, entry);
|
|
326
423
|
const controller = new AbortController();
|
|
327
|
-
const ctx = Object.freeze({
|
|
328
|
-
op: route.op, trace, signal: controller.signal, params: null, headers: NO_HEADERS,
|
|
329
|
-
fail: ContractFailure, idempotency: null,
|
|
330
|
-
});
|
|
331
424
|
const pushCtx = { op: opId, trace };
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// the settlement may still hold a live subscription — release it
|
|
336
|
-
if (result.kind === 'value' && isSubscriptionLike(result.value)) {
|
|
337
|
-
try {
|
|
338
|
-
result.value.close();
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
// a throwing close changes nothing for a gone client
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
425
|
+
/** @param {import('../pipeline.js').OperationResult} result */
|
|
426
|
+
const preStream = (result) => {
|
|
427
|
+
streams.delete(id);
|
|
346
428
|
if (result.kind === 'failure') {
|
|
347
|
-
streams.delete(id);
|
|
348
429
|
post(pushFrame(id, 'error', 0, wireError(result.code, declaredMessage(catalog, opId, result.code, result.params), trace, result.details, result.retryable)), pushCtx);
|
|
349
430
|
return;
|
|
350
431
|
}
|
|
351
432
|
if (result.kind === 'contract') {
|
|
352
|
-
streams.delete(id);
|
|
353
433
|
if (result.cause !== undefined) observe(result.cause, pushCtx);
|
|
354
434
|
post(pushFrame(id, 'error', 0, wireError('JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, undefined, false)), pushCtx);
|
|
355
|
-
return;
|
|
356
435
|
}
|
|
357
|
-
|
|
358
|
-
|
|
436
|
+
};
|
|
437
|
+
lifecycleAround(route, trace, controller.signal, value,
|
|
438
|
+
(error) => observe(error, pushCtx),
|
|
439
|
+
(code, details, cause) => {
|
|
359
440
|
streams.delete(id);
|
|
360
|
-
|
|
361
|
-
post(pushFrame(id, 'error', 0, wireError(
|
|
362
|
-
|
|
363
|
-
|
|
441
|
+
if (cause !== undefined) observe(cause, pushCtx);
|
|
442
|
+
post(pushFrame(id, 'error', 0, wireError(code, renderMessage(catalog, code === 'JC2006' ? HTTP_ERRORS.JC2006.msgid : PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, details, false)), pushCtx);
|
|
443
|
+
},
|
|
444
|
+
preStream,
|
|
445
|
+
(hctx) => settleOperation(route, value, hctx, false))
|
|
446
|
+
.then((run) => {
|
|
447
|
+
if (!run.entered) return run.release();
|
|
448
|
+
const result = /** @type {import('../pipeline.js').OperationResult} */ (run.value);
|
|
449
|
+
if (closed || entry.stopped) {
|
|
450
|
+
streams.delete(id);
|
|
451
|
+
// the settlement may still hold a live subscription — release it
|
|
452
|
+
if (result.kind === 'value' && isSubscriptionLike(result.value)) {
|
|
453
|
+
try {
|
|
454
|
+
result.value.close();
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// a throwing close changes nothing for a gone client
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return run.release();
|
|
461
|
+
}
|
|
462
|
+
if (result.kind !== 'value') {
|
|
463
|
+
preStream(result);
|
|
464
|
+
return run.release();
|
|
465
|
+
}
|
|
466
|
+
const sub = result.value;
|
|
467
|
+
if (!isSubscriptionLike(sub)) {
|
|
468
|
+
streams.delete(id);
|
|
469
|
+
observe(new TypeError(`the handler of subscribe operation '${opId}' did not answer a subscription ({ result | snapshot(), subscribe, close })`), pushCtx);
|
|
470
|
+
post(pushFrame(id, 'error', 0, wireError('JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, undefined, false)), pushCtx);
|
|
471
|
+
return run.release();
|
|
472
|
+
}
|
|
473
|
+
return streamSubscription(id, route, sub, lastSeq, trace, pushCtx, entry, run.release);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The runner over a live subscription on this channel; the leases are
|
|
479
|
+
* released after the runner's stop/close/done sequence.
|
|
480
|
+
* @param {string} id
|
|
481
|
+
* @param {PortRoute} route
|
|
482
|
+
* @param {any} sub
|
|
483
|
+
* @param {number | null} lastSeq
|
|
484
|
+
* @param {string} trace
|
|
485
|
+
* @param {{ op: string, trace: string }} pushCtx
|
|
486
|
+
* @param {{ stopped: boolean, stop: (reason: string | null) => void }} entry
|
|
487
|
+
* @param {() => Promise<boolean>} release
|
|
488
|
+
*/
|
|
489
|
+
function streamSubscription(id, route, sub, lastSeq, trace, pushCtx, entry, release) {
|
|
490
|
+
const opId = route.op.id;
|
|
491
|
+
{
|
|
364
492
|
const runner = runSubscription(route, sub, {
|
|
365
|
-
snapshot: (seq,
|
|
366
|
-
patch: (seq, emission) =>
|
|
367
|
-
error: (intent, cause, seq) => {
|
|
368
|
-
observe(cause, pushCtx);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
493
|
+
snapshot: (seq, data) => pushFrame(id, 'snapshot', seq, data),
|
|
494
|
+
patch: (seq, emission) => pushFrame(id, 'patch', seq, emission),
|
|
495
|
+
error: (intent, cause, seq, declared) => {
|
|
496
|
+
if (intent !== 'slow-consumer') observe(cause, pushCtx);
|
|
497
|
+
if (intent === 'declared' && declared !== null) {
|
|
498
|
+
return pushFrame(id, 'error', seq, wireError(declared.code, declaredMessage(catalog, opId, declared.code, {}), trace, declared.details, declared.retryable));
|
|
499
|
+
}
|
|
500
|
+
const code = intent === 'invalid-snapshot' ? 'JC2091' : intent === 'slow-consumer' ? 'JC2096' : 'JC2070';
|
|
501
|
+
const row = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091
|
|
502
|
+
: intent === 'slow-consumer' ? STREAM_ERRORS.JC2096 : PORT_LOCAL_ERRORS.JC2070;
|
|
503
|
+
return pushFrame(id, 'error', seq, wireError(code, renderMessage(catalog, row.msgid, { op: opId }), trace, undefined, row.retryable));
|
|
372
504
|
},
|
|
373
|
-
end: (reason, seq) =>
|
|
374
|
-
|
|
375
|
-
|
|
505
|
+
end: (reason, seq) => pushFrame(id, 'end', seq, { reason }),
|
|
506
|
+
// the byte account is the frame as posted: its JSON text
|
|
507
|
+
size: (frame) => JSON.stringify(frame).length,
|
|
508
|
+
write: (frame) => post(frame, pushCtx),
|
|
509
|
+
done: () => {
|
|
510
|
+
streams.delete(id);
|
|
511
|
+
return release().then(() => undefined);
|
|
512
|
+
},
|
|
513
|
+
}, { lastSeq, validate, limits: streamLimits });
|
|
376
514
|
entry.stop = (reason) => runner.stop(reason);
|
|
377
|
-
}
|
|
515
|
+
}
|
|
378
516
|
}
|
|
379
517
|
|
|
380
518
|
/** @param {any} event */
|
package/src/project/tools.js
CHANGED
|
@@ -35,8 +35,15 @@ import { bundleSameDocument } from '../bundle.js';
|
|
|
35
35
|
/**
|
|
36
36
|
* A client as the tools read it — any binding's client. The members are
|
|
37
37
|
* `any` so a client whose `invoke` narrows its own context type (the http
|
|
38
|
-
* client's `InvokeContext`) still assigns
|
|
39
|
-
*
|
|
38
|
+
* client's `InvokeContext`) still assigns, and `invoke` is declared as a
|
|
39
|
+
* METHOD rather than as a function-valued property: a method's parameters
|
|
40
|
+
* are bivariant, so a client whose `invoke` narrows its OPERATION type to
|
|
41
|
+
* a literal union — `@jarenjs/linq/contract`'s `typedClient`, whose whole
|
|
42
|
+
* purpose is that narrowing — assigns here too. Under
|
|
43
|
+
* `strictFunctionTypes` the property form rejects it, and there is
|
|
44
|
+
* nothing to reject: this module only ever CALLS `invoke`, with an id it
|
|
45
|
+
* read out of the contract the client was opened on.
|
|
46
|
+
* @typedef {{ invoke(op: string, input: any, ctx?: any): any }} ToolClient
|
|
40
47
|
*/
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -23,6 +23,17 @@
|
|
|
23
23
|
* stylesheet — the D6 shapes as every binding carries them
|
|
24
24
|
* (`OUTCOME_META_MEMBERS` / `OUTCOME_ERROR_MEMBERS` in the client module
|
|
25
25
|
* are the runtime twins; a test holds the text to them).
|
|
26
|
+
*
|
|
27
|
+
* One convention rides on top of emit's reading, the suite's: a string
|
|
28
|
+
* with `format: "date-time"` or `format: "date"` is the `DateTime`
|
|
29
|
+
* brand, so a consumer's generated types agree with `@jarenjs/db`'s
|
|
30
|
+
* entity types (`entityEmitModel`) and `@jarenjs/linq`'s schema pen,
|
|
31
|
+
* which both read a date format that way. Emit itself records a format
|
|
32
|
+
* only as a dropped constraint, so the brand is applied HERE, by
|
|
33
|
+
* rewriting date-formatted string nodes to a `$ref` of one shared
|
|
34
|
+
* definition before emit reads the document — in every position, an
|
|
35
|
+
* array item as much as a member — and giving that definition the brand
|
|
36
|
+
* intersection.
|
|
26
37
|
*/
|
|
27
38
|
|
|
28
39
|
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
@@ -66,6 +77,72 @@ function pascal(word) {
|
|
|
66
77
|
return out;
|
|
67
78
|
}
|
|
68
79
|
|
|
80
|
+
/** The two formats that carry the brand. */
|
|
81
|
+
const DATE_FORMATS = ['date-time', 'date'];
|
|
82
|
+
|
|
83
|
+
/** The definition name the brand takes when the contract leaves it free. */
|
|
84
|
+
const DATE_TIME = 'DateTime';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite every date-formatted string node to a reference to the shared
|
|
88
|
+
* brand definition, in every position. Nodes are rebuilt, never mutated:
|
|
89
|
+
* the schemas here are the compiled document's own frozen subtrees.
|
|
90
|
+
* @param {any} node
|
|
91
|
+
* @param {string} name - the brand definition's name in this document
|
|
92
|
+
* @returns {any}
|
|
93
|
+
*/
|
|
94
|
+
function brandDates(node, name) {
|
|
95
|
+
if (Array.isArray(node)) return node.map((item) => brandDates(item, name));
|
|
96
|
+
if (node === null || typeof node !== 'object') return node;
|
|
97
|
+
/** @type {Record<string, any>} */
|
|
98
|
+
const out = {};
|
|
99
|
+
const keys = Object.keys(node);
|
|
100
|
+
for (let i = 0; i < keys.length; i++) setObjectMember(out, keys[i], brandDates(node[keys[i]], name));
|
|
101
|
+
if (!DATE_FORMATS.includes(/** @type {any} */ (out.format))) return out;
|
|
102
|
+
const { type, format: _format, ...rest } = out;
|
|
103
|
+
if (type === 'string') return { $ref: `#/$defs/${name}`, ...rest };
|
|
104
|
+
// a nullable date: the brand or null, the rest of the node kept
|
|
105
|
+
if (Array.isArray(type) && type.length === 2 && type.includes('string') && type.includes('null')) {
|
|
106
|
+
return { anyOf: [{ $ref: `#/$defs/${name}` }, { type: 'null' }], ...rest };
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a document reaches a date-formatted string anywhere. */
|
|
112
|
+
function usesDates(node) {
|
|
113
|
+
if (Array.isArray(node)) return node.some(usesDates);
|
|
114
|
+
if (node === null || typeof node !== 'object') return false;
|
|
115
|
+
if (DATE_FORMATS.includes(node.format)) return true;
|
|
116
|
+
return Object.values(node).some(usesDates);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Replace the brand definition's compiled declaration — a plain
|
|
121
|
+
* `string` — with the brand intersection `string & { __jarenTag:
|
|
122
|
+
* 'date-time' }`, structurally identical to `@jarenjs/db`'s and
|
|
123
|
+
* `@jarenjs/linq`'s.
|
|
124
|
+
* @param {any} model
|
|
125
|
+
* @param {string} name
|
|
126
|
+
*/
|
|
127
|
+
function brandDeclaration(model, name) {
|
|
128
|
+
const declaration = model.declarations.find((/** @type {any} */ d) => d.name === name);
|
|
129
|
+
/* c8 ignore next -- the definition is added exactly when it is referenced */
|
|
130
|
+
if (declaration === undefined) return;
|
|
131
|
+
declaration.type = {
|
|
132
|
+
kind: 'intersection',
|
|
133
|
+
parts: [{ kind: 'primitive', primitive: 'string' }, {
|
|
134
|
+
kind: 'object',
|
|
135
|
+
members: [{
|
|
136
|
+
kind: 'member', name: '__jarenTag',
|
|
137
|
+
type: { kind: 'literal', value: 'date-time' }, required: true,
|
|
138
|
+
constraints: [], doc: [],
|
|
139
|
+
}],
|
|
140
|
+
}],
|
|
141
|
+
};
|
|
142
|
+
declaration.doc = ['An RFC 3339 string branded for the date operators;',
|
|
143
|
+
'structurally identical to the @jarenjs/linq and @jarenjs/db brand.'];
|
|
144
|
+
}
|
|
145
|
+
|
|
69
146
|
/**
|
|
70
147
|
* The type model of a contract: the synthetic `$defs` root, the row per
|
|
71
148
|
* operation the stylesheet renders, and the rendered declarations.
|
|
@@ -117,7 +194,20 @@ export function contractTypeModel(contract, ops, source) {
|
|
|
117
194
|
}
|
|
118
195
|
const names = Object.keys(contractDefs);
|
|
119
196
|
for (let i = 0; i < names.length; i++) setObjectMember(defs, names[i], contractDefs[names[i]]);
|
|
120
|
-
|
|
197
|
+
/** @type {Record<string, any>} */
|
|
198
|
+
let root = defs;
|
|
199
|
+
/** @type {string | null} */
|
|
200
|
+
let brand = null;
|
|
201
|
+
if (usesDates(defs)) {
|
|
202
|
+
// the brand takes its own name unless the contract already spells it
|
|
203
|
+
brand = taken.has(DATE_TIME) ? unique(DATE_TIME) : DATE_TIME;
|
|
204
|
+
root = { [brand]: { type: 'string' } };
|
|
205
|
+
const branded = brandDates(defs, brand);
|
|
206
|
+
const keys = Object.keys(branded);
|
|
207
|
+
for (let i = 0; i < keys.length; i++) setObjectMember(root, keys[i], branded[keys[i]]);
|
|
208
|
+
}
|
|
209
|
+
const model = compileEmitModel({ $defs: root }, { name: 'Contract', source });
|
|
210
|
+
if (brand !== null) brandDeclaration(model, brand);
|
|
121
211
|
const declarations = model.declarations.filter((d) => d.name !== model.root);
|
|
122
212
|
const types = renderTypeScript({ ...model, declarations }, { banner: false });
|
|
123
213
|
return { types, rows, model: { ...model, declarations } };
|