@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.
Files changed (52) hide show
  1. package/README.md +191 -30
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. 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
@@ -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 reconnect passes)
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 clientId = globalThis.crypto.randomUUID();
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 { stop };
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 { stop };
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 { stop };
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 { isSubscriptionLike, runSubscription, STREAM_ERRORS } from '../stream/server.js';
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 `crypto.randomUUID`
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 traceGen = options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace;
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 ctx = Object.freeze({
251
- op: route.op, trace, signal: controller.signal, params: null, headers: NO_HEADERS,
252
- fail: ContractFailure, idempotency: null,
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), { op: opId, 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), { op: opId, trace });
345
+ result.details, result.retryable, trace), pushCtx);
265
346
  }
266
347
  else {
267
- if (result.cause !== undefined) observe(result.cause, { op: opId, trace });
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), { op: opId, 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
- settleOperation(route, value, ctx, false).then((result) => {
333
- if (closed || entry.stopped) {
334
- streams.delete(id);
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
- const sub = result.value;
358
- if (!isSubscriptionLike(sub)) {
436
+ };
437
+ lifecycleAround(route, trace, controller.signal, value,
438
+ (error) => observe(error, pushCtx),
439
+ (code, details, cause) => {
359
440
  streams.delete(id);
360
- observe(new TypeError(`the handler of subscribe operation '${opId}' did not answer a subscription ({ result | snapshot(), subscribe, close })`), pushCtx);
361
- post(pushFrame(id, 'error', 0, wireError('JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, undefined, false)), pushCtx);
362
- return;
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, snapValue, resumed) => post(pushFrame(id, 'snapshot', seq, { value: snapValue, resumed }), pushCtx),
366
- patch: (seq, emission) => post(pushFrame(id, 'patch', seq, emission), pushCtx),
367
- error: (intent, cause, seq) => {
368
- observe(cause, pushCtx);
369
- const code = intent === 'invalid-snapshot' ? 'JC2091' : 'JC2070';
370
- const msgid = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091.msgid : PORT_LOCAL_ERRORS.JC2070.msgid;
371
- post(pushFrame(id, 'error', seq, wireError(code, renderMessage(catalog, msgid, { op: opId }), trace, undefined, false)), pushCtx);
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) => post(pushFrame(id, 'end', seq, { reason }), pushCtx),
374
- done: () => streams.delete(id),
375
- }, { lastSeq, validate });
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 */
@@ -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
- * @typedef {{ invoke: (op: string, input: any, ctx?: any) => any }} ToolClient
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
- const model = compileEmitModel({ $defs: defs }, { name: 'Contract', source });
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 } };