@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/http/serve.js CHANGED
@@ -19,10 +19,14 @@
19
19
  import { compileMessageCatalog } from '@jarenjs/core/message';
20
20
 
21
21
  import { ContractHostError } from '../errors.js';
22
+ import { resolveHostRuntime } from '../runtime.js';
23
+ import { resolveStreamLimits } from '../stream/server.js';
24
+ import { resolveLifecycle } from '../host.js';
22
25
  import { dispatch } from './dispatch.js';
23
26
  import { HTTP_ERRORS, WELL_KNOWN_PATH } from './wire.js';
27
+ import { BodyLimitError } from './body.js';
24
28
 
25
- export { HTTP_ERRORS, WELL_KNOWN_PATH };
29
+ export { HTTP_ERRORS, WELL_KNOWN_PATH, BodyLimitError };
26
30
 
27
31
  /**
28
32
  * @typedef {import('./wire.js').HttpRequest} HttpRequest
@@ -41,7 +45,8 @@ export { HTTP_ERRORS, WELL_KNOWN_PATH };
41
45
  /**
42
46
  * The options of `serveHttp`; every one has a default.
43
47
  * @typedef {Object} ServeHttpOptions
44
- * @property {() => string} [trace] - the server trace generator; default `crypto.randomUUID`
48
+ * @property {() => string} [trace] - the server trace generator; default
49
+ * the runtime record's `uuid`, itself `crypto.randomUUID` by default
45
50
  * @property {Ledger | null} [ledger] - the idempotency ledger; `null` refuses
46
51
  * (`JC1003`) any operation whose `policy.idempotency` is not `none`
47
52
  * @property {(ctx: RequestContext) => string} [scope] - the idempotency scope
@@ -66,7 +71,31 @@ export { HTTP_ERRORS, WELL_KNOWN_PATH };
66
71
  * - observes `JC2008`/`JC2010` causes and ledger faults; the response never carries them
67
72
  * @property {Record<string, string | ((params: object) => string)>} [catalog]
68
73
  * - a message catalog (templates or compiled renderers) consulted before the English one
69
- * @property {() => number} [now] - the clock stamped into ledger claims; default `Date.now`
74
+ * @property {() => number} [now] - the clock stamped into ledger claims;
75
+ * default the runtime record's `now`, itself `Date.now` by default
76
+ * @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime]
77
+ * - the host's runtime record: its `uuid` generates the server trace
78
+ * and its `now` is the clock, each only where `trace` / `now` is absent
79
+ * @property {{ replay?: { limit?: number, maxBytes?: number }, queue?: { events?: number, bytes?: number } }} [streamLimits]
80
+ * - the bounds of every SSE stream (docs/CONTRACT-FORMAT.md §18.1):
81
+ * a replay page asks for at most `replay.limit` emissions / `replay.
82
+ * maxBytes` patch bytes (default 256 / 1 MiB); the undelivered queue
83
+ * holds at most `queue.events` frames / `queue.bytes` SSE bytes
84
+ * (default 256 / 1 MiB) before the stream ends with `JC2096`
85
+ * @property {(meta: import('../host.js').IdentifyMeta) => unknown} [identify]
86
+ * - the host lifecycle's first hook (docs/CONTRACT-FORMAT.md §7.7):
87
+ * runs after the route resolved and before any byte of the body is
88
+ * read; answers a lease `{ host, release? }` — `host` is what `scope`
89
+ * and, without `acquire`, the handler see as `ctx.host` — or a
90
+ * declared failure (`meta.fail`); default `{ host: null }`
91
+ * @property {(input: unknown, identity: RequestContext, enter: (lease: unknown) => Promise<unknown>) => unknown} [acquire]
92
+ * - the second hook: runs after the input validated and, on a claimed
93
+ * command, after the claim answered `new`; calls `enter({ host,
94
+ * release?, settlement? })` exactly once and answers what `enter`
95
+ * answers — a host that opens a transaction around `enter` commits it
96
+ * when `enter` resolves and rolls it back when it rejects; `settlement:
97
+ * { ledger, required: true }` records the claim through that ledger
98
+ * inside `enter`; default `enter({ host: identity.host })`
70
99
  */
71
100
 
72
101
  /**
@@ -287,12 +316,15 @@ export function serveHttp(contract, handlers, options = {}) {
287
316
  if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
288
317
  throw host('JC1001', 'options.catalog must be a message catalog object');
289
318
  }
319
+ const runtime = resolveHostRuntime(options.runtime, host, 'JC1001');
320
+ const streamLimits = resolveStreamLimits(options.streamLimits, (reason) => host('JC1001', reason));
321
+ const lifecycle = resolveLifecycle(options, (reason) => host('JC1001', reason));
290
322
 
291
323
  /** @type {Server} */
292
324
  const server = {
293
325
  contract,
294
326
  routes,
295
- trace: options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace,
327
+ trace: options.trace === undefined ? runtime.uuid : options.trace,
296
328
  ledger,
297
329
  scope: options.scope === undefined ? () => '' : options.scope,
298
330
  head,
@@ -301,9 +333,11 @@ export function serveHttp(contract, handlers, options = {}) {
301
333
  errorBody: options.errorBody === undefined ? null : options.errorBody,
302
334
  onError: options.onError === undefined ? null : options.onError,
303
335
  catalog: options.catalog === undefined ? null : compileMessageCatalog(options.catalog),
304
- now: options.now === undefined ? Date.now : options.now,
336
+ now: options.now === undefined ? runtime.now : options.now,
305
337
  described: { text: null },
306
338
  streams: new Set(),
339
+ streamLimits,
340
+ lifecycle,
307
341
  };
308
342
 
309
343
  /** @type {HttpCapabilities} */
package/src/http/wire.js CHANGED
@@ -24,30 +24,44 @@ import { contractCatalogEn } from '../messages.js';
24
24
  * test hands to `dispatch` directly. `url` is origin-less: the path plus
25
25
  * an optional `?query`; header names are lowercase; a header value is a
26
26
  * string, or an array of strings when the adapter can see repeated field
27
- * lines; `body` is the bytes/text as received (`null` for none);
28
- * `signal` is the request's abort signal when the host has one.
27
+ * lines; `body` is the text or bytes as received, or a pull source of
28
+ * chunks (an async iterable, or a Web `ReadableStream` the dispatcher
29
+ * normalizes) the pipeline drains under the operation's limit for a
30
+ * JSON operation and hands an opaque handler one chunk at a time
31
+ * (`null` for none); `signal` is the request's abort signal when the
32
+ * host has one.
29
33
  * @typedef {Object} HttpRequest
30
34
  * @property {string} method
31
35
  * @property {string} url
32
36
  * @property {Readonly<Record<string, string | readonly string[]>>} headers
33
- * @property {string | Uint8Array | null} body
37
+ * @property {string | Uint8Array | AsyncIterable<Uint8Array> | ReadableStream<Uint8Array> | null} body
34
38
  * @property {AbortSignal | null} [signal]
35
39
  */
36
40
 
37
41
  /**
38
42
  * A response as the binding answers it: a status, lowercase header names,
39
- * and a body that is a string (JSON text), bytes (an opaque operation) or
40
- * `null` (HEAD, 204, 304). A streaming response (a subscribe operation
43
+ * and a body that is a string (JSON text), bytes (an opaque operation), a
44
+ * pull source of chunks (an opaque handler's streamed body: the adapter
45
+ * writes it chunk by chunk behind the socket's backpressure and cancels
46
+ * it once when the peer goes away) or `null` (HEAD, 204, 304). A
47
+ * streaming response (a subscribe operation
41
48
  * under `accept: text/event-stream`) carries `body: null` plus `stream`:
42
49
  * the adapter writes the headers, then MUST call `stream` exactly once
43
50
  * with its sink — the pump writes SSE text through `sink.write` and
44
- * calls `sink.end()` when the stream terminates; the returned function
45
- * stops the stream when the consumer cancels.
51
+ * calls `sink.end()` when the stream terminates. The sink is a
52
+ * `SinkLike<string>` (`@jarenjs/core/async`): `write` may answer a
53
+ * promise that settles when the platform has taken the chunk — a Node
54
+ * response that answered `false` resolves on `drain`; a Web stream
55
+ * bridge resolves on the consumer's pull — and the pump writes the next
56
+ * chunk only after that; the optional `abort(reason)` is how the pump
57
+ * tears the carrier down when it must. The pump answers `{ stop, done }`:
58
+ * `stop()` ends the stream when the consumer cancels, `done` settles
59
+ * once the subscription is released and the sink has ended.
46
60
  * @typedef {Object} HttpResponse
47
61
  * @property {number} status
48
62
  * @property {Readonly<Record<string, string>>} headers
49
- * @property {string | Uint8Array | null} body
50
- * @property {(sink: { write: (chunk: string) => void, end: () => void }) => (() => void)} [stream]
63
+ * @property {string | Uint8Array | AsyncIterable<Uint8Array> | null} body
64
+ * @property {(sink: import('@jarenjs/core/async').SinkLike<string>) => { stop: () => void, done: Promise<void> }} [stream]
51
65
  */
52
66
 
53
67
  /**
@@ -249,8 +263,8 @@ export function exceedsBytes(body, limit) {
249
263
  //#region entity tags
250
264
 
251
265
  /**
252
- * Parse an `If-Match`/`If-None-Match` field into its opaque tags. `*` is
253
- * reported as `null` in the list; a weak indicator is dropped
266
+ * Parse an `If-Match`/`If-None-Match` field into its opaque tags. `*` sets
267
+ * `any`; commas inside quoted tags are retained. A weak indicator is dropped
254
268
  * (`W/"x"` → `x`) — the caller decides weak/strong comparison because a
255
269
  * strong comparison must reject weak tags, which `weak[i]` records.
256
270
  * @param {string} value
@@ -262,9 +276,14 @@ export function parseEntityTags(value) {
262
276
  /** @type {boolean[]} */
263
277
  const weak = [];
264
278
  let any = false;
265
- const parts = value.split(',');
266
- for (let i = 0; i < parts.length; i++) {
267
- let part = parts[i].trim();
279
+ let start = 0;
280
+ let quoted = false;
281
+ for (let i = 0; i <= value.length; i++) {
282
+ const code = value.charCodeAt(i);
283
+ if (code === 0x22) quoted = !quoted;
284
+ if (i < value.length && (code !== 0x2c || quoted)) continue;
285
+ let part = value.slice(start, i).trim();
286
+ start = i + 1;
268
287
  if (part === '*') {
269
288
  any = true;
270
289
  continue;
package/src/ledger.js CHANGED
@@ -16,11 +16,19 @@
16
16
  * fresh one.
17
17
  */
18
18
 
19
+ import { resolveRuntime } from '@jarenjs/core/runtime';
20
+
21
+ import { ContractHostError } from './errors.js';
22
+
19
23
  /**
20
24
  * The record a ledger keeps per `(op, scope, key)`; the schema of
21
25
  * `idempotencyLedgerModel`'s collection.
22
26
  * @typedef {Object} LedgerRecord
23
- * @property {string} id - `"<op>|<scope>|<key>"`
27
+ * @property {string} id - {@link ledgerId} of the tuple
28
+ * @property {string} generation - the identity of the claim that started
29
+ * this record: minted per `started` record, carried by the `ref`, and
30
+ * verified by `commit`/`fail` — a ref of an earlier generation settles
31
+ * nothing (`JC1011`)
24
32
  * @property {string} op
25
33
  * @property {string} scope
26
34
  * @property {string} key
@@ -33,11 +41,18 @@
33
41
  * @property {number} expiresAt - epoch ms
34
42
  */
35
43
 
44
+ /**
45
+ * The ref a `new` claim hands back and a settlement names: the record's
46
+ * `id` and the `generation` the claim minted — portable across
47
+ * processes, and stale the moment the key expires or is reclaimed.
48
+ * @typedef {{ readonly id: string, readonly generation: string }} LedgerRef
49
+ */
50
+
36
51
  /**
37
52
  * The claim result: `new` hands back a `ref` to commit or fail; `replay`
38
53
  * carries the stored response; `in-progress` and `mismatch` are the two
39
54
  * 409 answers.
40
- * @typedef {{ state: 'new', ref: unknown }
55
+ * @typedef {{ state: 'new', ref: LedgerRef }
41
56
  * | { state: 'replay', response: any }
42
57
  * | { state: 'in-progress' }
43
58
  * | { state: 'mismatch' }} ClaimResult
@@ -51,26 +66,50 @@
51
66
  * different hash → `mismatch`; `started` and unexpired → `in-progress`;
52
67
  * `failed` with `retryable: true` → `new` (the key may be retried);
53
68
  * `failed` and not retryable → `replay` of the stored failure. `now` on
54
- * a claim is the binding's clock (epoch ms) a ledger may prefer to its
55
- * own.
69
+ * a claim, a commit, a failure and a lookup is the binding's clock
70
+ * (epoch ms): ONE clock must judge a record from claim to expiry, so a
71
+ * ledger that has no clock of its own follows the binding's, and a
72
+ * ledger with its own clock is given the same record as the binding
73
+ * (`runtime`) rather than a second one. A `commit`/`fail` whose ref
74
+ * settles no `started` record — expired, reclaimed under a newer
75
+ * generation, or settled already — throws (or rejects) `JC1011`; the
76
+ * binding reports it to `onError` and the response still goes out.
56
77
  * @typedef {Object} Ledger
57
78
  * @property {(claim: { op: string, scope: string, key: string, hash: string, now?: number }) => ClaimResult | Promise<ClaimResult>} claim
58
- * @property {(ref: unknown, response: any) => void | Promise<void>} commit
59
- * @property {(ref: unknown, retryable: boolean, response?: any) => void | Promise<void>} fail
60
- * @property {(key: { op: string, scope: string, key: string }) => LedgerRecord | null | Promise<LedgerRecord | null>} lookup
79
+ * @property {(ref: unknown, response: any, now?: number) => void | Promise<void>} commit
80
+ * @property {(ref: unknown, retryable: boolean, response?: any, now?: number) => void | Promise<void>} fail
81
+ * @property {(key: { op: string, scope: string, key: string, now?: number }) => LedgerRecord | null | Promise<LedgerRecord | null>} lookup
61
82
  */
62
83
 
63
84
  /** One day, the default retention of a key. */
64
85
  const DEFAULT_TTL_MS = 86_400_000;
65
86
 
66
87
  /**
88
+ * The id of one `(op, scope, key)` tuple: the version `1`, a colon, the
89
+ * JSON array of the three. Injective — a `|`, a control character or
90
+ * any Unicode inside a member cannot spell another tuple — and readable
91
+ * in a store. A record written under the legacy `"<op>|<scope>|<key>"`
92
+ * spelling is matched by no claim again: it expires by its own
93
+ * `expiresAt` (`sweep`), or a host rewrites its `id` once
94
+ * (docs/CONTRACT-FORMAT.md §8).
67
95
  * @param {string} op
68
96
  * @param {string} scope
69
97
  * @param {string} key
70
98
  * @returns {string}
71
99
  */
72
- function idOf(op, scope, key) {
73
- return `${op}|${scope}|${key}`;
100
+ export function ledgerId(op, scope, key) {
101
+ return `1:${JSON.stringify([op, scope, key])}`;
102
+ }
103
+
104
+ /**
105
+ * The `JC1011` refusal of a settlement whose ref settles no started record.
106
+ * @param {unknown} ref
107
+ * @returns {ContractHostError}
108
+ */
109
+ function staleSettlement(ref) {
110
+ const r = /** @type {any} */ (ref);
111
+ const named = r !== null && typeof r === 'object' && typeof r.id === 'string' ? r.id : 'a ref this ledger did not issue';
112
+ return new ContractHostError('JC1011', `ledger: ${named} settles no started record — the key expired, was reclaimed under a newer generation, or was settled already`);
74
113
  }
75
114
 
76
115
  /**
@@ -78,21 +117,64 @@ function idOf(op, scope, key) {
78
117
  * expiring on `claim` (a record past `expiresAt` is dropped and the key
79
118
  * is `new` again). `sweep()` drops every expired record — a host may
80
119
  * call it on a timer.
81
- * @param {{ ttlMs?: number, now?: () => number }} [options]
82
- * @returns {Ledger & { sweep(): number, size: number }}
120
+ * ONE clock judges a record from claim to expiry. The ledger's own clock
121
+ * is `now`, else the runtime record's `now` (`@jarenjs/core/runtime`);
122
+ * given neither, the ledger has no clock of its own and FOLLOWS the
123
+ * binding: every stamp and every expiry decision uses the instant the
124
+ * binding passed with the call, and a host-side `lookup`/`sweep` that
125
+ * passes none uses the latest instant a binding reported. (A record
126
+ * stamped by an injected server clock and judged by the platform's was
127
+ * dropped as expired the moment `sweep()` ran, and the command ran
128
+ * twice.) A ledger WITH its own clock uses it for everything and is
129
+ * given the same record as the binding, never a second one. The runtime
130
+ * record's `uuid` mints each record's `generation`; `lookup` answers a
131
+ * copy, never the ledger's own record.
132
+ * @param {{ ttlMs?: number, now?: () => number,
133
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} [options]
134
+ * @returns {Ledger & { sweep(now?: number): number, size: number }}
83
135
  */
84
136
  export function createMemoryLedger(options = {}) {
85
137
  const ttlMs = options.ttlMs === undefined ? DEFAULT_TTL_MS : options.ttlMs;
86
138
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('createMemoryLedger: ttlMs must be a positive number');
87
- const clock = options.now === undefined ? Date.now : options.now;
139
+ let runtime;
140
+ try {
141
+ runtime = resolveRuntime(options.runtime);
142
+ }
143
+ catch (error) {
144
+ throw new TypeError(`createMemoryLedger: runtime: ${error instanceof Error ? error.message : String(error)}`);
145
+ }
146
+ const clock = options.now === undefined ? runtime.now : options.now;
88
147
  if (typeof clock !== 'function') throw new TypeError('createMemoryLedger: now must be a function');
148
+ const ownClock = options.now !== undefined || options.runtime !== undefined;
149
+ /** The latest instant a binding reported, for a ledger without a clock. */
150
+ let latest = null;
151
+ /** The instant a call is judged and stamped at. */
152
+ const instant = (/** @type {number | undefined} */ given) => {
153
+ if (typeof given === 'number') {
154
+ if (!ownClock) latest = latest === null ? given : Math.max(latest, given);
155
+ return given;
156
+ }
157
+ return ownClock || latest === null ? clock() : latest;
158
+ };
89
159
  /** @type {Map<string, LedgerRecord>} */
90
160
  const records = new Map();
91
161
 
162
+ /**
163
+ * The started record a ref settles, or the `JC1011` refusal.
164
+ * @param {unknown} ref
165
+ * @returns {LedgerRecord}
166
+ */
167
+ function settling(ref) {
168
+ const r = /** @type {any} */ (ref);
169
+ const record = r !== null && typeof r === 'object' && typeof r.id === 'string' ? records.get(r.id) : undefined;
170
+ if (record === undefined || record.generation !== r.generation || record.status !== 'started') throw staleSettlement(ref);
171
+ return record;
172
+ }
173
+
92
174
  return {
93
175
  claim({ op, scope, key, hash, now }) {
94
- const at = typeof now === 'number' ? now : clock();
95
- const id = idOf(op, scope, key);
176
+ const at = instant(now);
177
+ const id = ledgerId(op, scope, key);
96
178
  const existing = records.get(id);
97
179
  if (existing !== undefined) {
98
180
  if (existing.expiresAt <= at) records.delete(id);
@@ -102,41 +184,40 @@ export function createMemoryLedger(options = {}) {
102
184
  else if (existing.retryable !== true && existing.response !== null) return { state: 'replay', response: existing.response };
103
185
  else records.delete(id);
104
186
  }
187
+ const generation = runtime.uuid();
105
188
  /** @type {LedgerRecord} */
106
189
  const record = {
107
- id, op, scope, key, hash, status: 'started', response: null, retryable: null,
190
+ id, generation, op, scope, key, hash, status: 'started', response: null, retryable: null,
108
191
  createdAt: at, updatedAt: at, expiresAt: at + ttlMs,
109
192
  };
110
193
  records.set(id, record);
111
- return { state: 'new', ref: record };
194
+ return { state: 'new', ref: Object.freeze({ id, generation }) };
112
195
  },
113
- commit(ref, response) {
114
- const record = /** @type {LedgerRecord} */ (ref);
115
- if (records.get(record.id) !== record) return;
196
+ commit(ref, response, now = undefined) {
197
+ const record = settling(ref);
116
198
  record.status = 'committed';
117
199
  record.response = response;
118
200
  record.retryable = null;
119
- record.updatedAt = clock();
201
+ record.updatedAt = instant(now);
120
202
  },
121
- fail(ref, retryable, response) {
122
- const record = /** @type {LedgerRecord} */ (ref);
123
- if (records.get(record.id) !== record) return;
203
+ fail(ref, retryable, response, now = undefined) {
204
+ const record = settling(ref);
124
205
  record.status = 'failed';
125
206
  record.retryable = retryable === true;
126
207
  record.response = response === undefined ? null : response;
127
- record.updatedAt = clock();
208
+ record.updatedAt = instant(now);
128
209
  },
129
- lookup({ op, scope, key }) {
130
- const record = records.get(idOf(op, scope, key));
210
+ lookup({ op, scope, key, now = undefined }) {
211
+ const record = records.get(ledgerId(op, scope, key));
131
212
  if (record === undefined) return null;
132
- if (record.expiresAt <= clock()) {
213
+ if (record.expiresAt <= instant(now)) {
133
214
  records.delete(record.id);
134
215
  return null;
135
216
  }
136
- return record;
217
+ return { ...record };
137
218
  },
138
- sweep() {
139
- const at = clock();
219
+ sweep(now = undefined) {
220
+ const at = instant(now);
140
221
  let dropped = 0;
141
222
  for (const [id, record] of records) {
142
223
  if (record.expiresAt <= at) {
@@ -154,11 +235,12 @@ export function createMemoryLedger(options = {}) {
154
235
 
155
236
  /**
156
237
  * The `$model` 0.1 document of a durable ledger: one collection,
157
- * `ledger`, keyed by `/id` (`"<op>|<scope>|<key>"`), indexed on
158
- * `expiresAt` (the sweep) and `status` (the in-flight scan). A host
159
- * opens it with `@jarenjs/db`'s `openStore` and implements the `Ledger`
160
- * interface over the collection; the record shape is exactly what
161
- * `createMemoryLedger` keeps.
238
+ * `ledger`, keyed by `/id` ({@link ledgerId}), indexed on `expiresAt`
239
+ * (the sweep) and `status` (the in-flight scan). A host opens it with
240
+ * `@jarenjs/db`'s `openStore` and implements the `Ledger` interface over
241
+ * the collection `createDbLedger` in `@jarenjs/linq/db` is that
242
+ * implementation over the typed client; the record shape is exactly
243
+ * what `createMemoryLedger` keeps, the `generation` included.
162
244
  */
163
245
  export const idempotencyLedgerModel = Object.freeze({
164
246
  $model: '0.1',
@@ -166,9 +248,10 @@ export const idempotencyLedgerModel = Object.freeze({
166
248
  ledger: {
167
249
  schema: {
168
250
  type: 'object',
169
- required: ['id', 'op', 'scope', 'key', 'hash', 'status', 'response', 'retryable', 'createdAt', 'updatedAt', 'expiresAt'],
251
+ required: ['id', 'generation', 'op', 'scope', 'key', 'hash', 'status', 'response', 'retryable', 'createdAt', 'updatedAt', 'expiresAt'],
170
252
  properties: {
171
253
  id: { type: 'string', minLength: 1 },
254
+ generation: { type: 'string', minLength: 1 },
172
255
  op: { type: 'string', minLength: 1 },
173
256
  scope: { type: 'string' },
174
257
  key: { type: 'string', minLength: 1 },
@@ -33,7 +33,9 @@
33
33
  import { compileMessageCatalog } from '@jarenjs/core/message';
34
34
 
35
35
  import { ContractHostError, ContractFailure } from '../errors.js';
36
- import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS } from '../pipeline.js';
36
+ import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS, classifyDeclared } from '../pipeline.js';
37
+ import { resolveLifecycle, identify as identifyHost, acquire as acquireHost, once, RollbackCarrier } from '../host.js';
38
+ import { resolveHostRuntime } from '../runtime.js';
37
39
  import { renderMessage, declaredMessage } from '../http/wire.js';
38
40
  import {
39
41
  prepareOutcomeRoute, assembleOutcome, makeMeta, failedOutcome, outcomeError, clientError,
@@ -53,7 +55,11 @@ export { PORT_LOCAL_ERRORS };
53
55
 
54
56
  /**
55
57
  * @typedef {Object} LocalOptions
56
- * @property {() => string} [trace] - the trace generator; default `crypto.randomUUID`
58
+ * @property {() => string} [trace] - the trace generator; default the
59
+ * runtime record's `uuid`, itself `crypto.randomUUID` by default
60
+ * @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime]
61
+ * - the host's runtime record: its `uuid` generates the trace where
62
+ * `trace` is absent
57
63
  * @property {'always' | 'never'} [validateOutput] - `'never'` is a declared
58
64
  * downgrade, reported in `capabilities.validatedOutput`
59
65
  * @property {Record<string, string | ((params: object) => string)>} [catalog]
@@ -152,6 +158,12 @@ function prepare(op, handler) {
152
158
  function race(settled, signal) {
153
159
  return new Promise((resolve) => {
154
160
  const onAbort = () => resolve(ABORTED);
161
+ // a signal already aborted when the race begins fires no event: it
162
+ // is an abort all the same, and the settlement lands into nothing
163
+ if (signal.aborted) {
164
+ resolve(ABORTED);
165
+ return;
166
+ }
155
167
  signal.addEventListener('abort', onAbort, { once: true });
156
168
  settled.then((result) => {
157
169
  signal.removeEventListener('abort', onAbort);
@@ -210,7 +222,9 @@ export function openLocalClient(contract, handlers, options = {}) {
210
222
  if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
211
223
  throw host('JC1001', 'options.catalog must be a message catalog object');
212
224
  }
213
- const trace = options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace;
225
+ const runtime = resolveHostRuntime(options.runtime, host, 'JC1001');
226
+ const lifecycle = resolveLifecycle(options, (reason) => host('JC1001', reason));
227
+ const trace = options.trace === undefined ? runtime.uuid : options.trace;
214
228
  const onError = options.onError === undefined ? null : options.onError;
215
229
  /** @type {Catalog | null} */
216
230
  const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
@@ -279,13 +293,59 @@ export function openLocalClient(contract, handlers, options = {}) {
279
293
  const meta = makeMeta(route.outcome.id, ctx.attempt, null);
280
294
  const caller = ctx.signal === undefined || ctx.signal === null ? null : ctx.signal;
281
295
  if ((caller !== null && caller.aborted) || closed) return cancelled(route, meta);
296
+ const signal = caller === null ? closer.signal : AbortSignal.any([closer.signal, caller]);
297
+ const id = safeTrace(trace);
298
+ meta.trace = id;
299
+ const opId = route.outcome.id;
300
+ const observed = (/** @type {unknown} */ error) => observe(error, { op: opId, trace: id });
301
+ /** @param {import('../pipeline.js').OperationResult} result */
302
+ const outcomeOf = (result) => {
303
+ if (result.kind === 'contract') {
304
+ if (result.cause !== undefined) observed(result.cause);
305
+ return failedOutcome('contract', outcomeError('JC2070',
306
+ renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), null, null, false), meta);
307
+ }
308
+ if (result.kind === 'failure') {
309
+ return assembleOutcome(route.outcome, {
310
+ status: null,
311
+ headers: null,
312
+ error: {
313
+ code: result.code,
314
+ message: declaredMessage(catalog, opId, result.code, result.params),
315
+ details: result.details,
316
+ retryable: result.retryable,
317
+ },
318
+ }, meta, catalog);
319
+ }
320
+ return assembleOutcome(route.outcome, { status: null, headers: null, value: result.value }, meta, catalog);
321
+ };
322
+ /** The binding's host fault as an outcome. @param {unknown} cause */
323
+ const hostFault = (cause) => {
324
+ observed(cause);
325
+ return failedOutcome('contract', outcomeError('JC2070',
326
+ renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), null, null, false), meta);
327
+ };
328
+
329
+ // 1. identify — before the input is judged (§7.7)
330
+ const identified = await identifyHost(lifecycle, Object.freeze({
331
+ op: route.op, trace: id, signal, carrier: /** @type {const} */ ('local'), method: null, path: null, headers: null, fail: ContractFailure,
332
+ }));
333
+ if (identified.kind === 'fault') return hostFault(identified.cause);
334
+ if (identified.kind === 'failure') return outcomeOf(classifyDeclared(route, identified.failure));
335
+ const identity = once(identified.lease.release, observed);
336
+ /** @type {(() => Promise<boolean>) | null} */
337
+ let acquired = null;
338
+ const release = () => (acquired === null ? identity() : acquired().then((a) => identity().then((b) => a && b)));
339
+ /** Release, then answer: a release that fails before the outcome is exposed is the host's fault — its cause was observed as it failed. @param {import('../client/outcome.js').Outcome} outcome */
340
+ const expose = async (outcome) => ((await release()) ? outcome : failedOutcome('contract', outcomeError('JC2070',
341
+ renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), null, null, false), meta));
282
342
 
283
- // 1. validate — the same verdict the pipeline would reach, once
343
+ // 2. validate — the same verdict the pipeline would reach, once
284
344
  let value;
285
345
  if (!route.hasInput) {
286
346
  if (input !== undefined && input !== null) {
287
- return failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.outcome.id }, null,
288
- [{ path: '', keyword: 'input' }]), meta);
347
+ return expose(failedOutcome('contract', clientError(catalog, 'JC2050', { op: opId }, null,
348
+ [{ path: '', keyword: 'input' }]), meta));
289
349
  }
290
350
  value = null;
291
351
  }
@@ -294,40 +354,36 @@ export function openLocalClient(contract, handlers, options = {}) {
294
354
  const invalid = validateOperationInput(route, value);
295
355
  if (invalid !== null && invalid.kind === 'contract') {
296
356
  if (invalid.cause !== undefined) observe(invalid.cause, null);
297
- return failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.outcome.id }, null, invalid.details), meta);
357
+ return expose(failedOutcome('contract', clientError(catalog, 'JC2050', { op: opId }, null, invalid.details), meta));
298
358
  }
299
359
  }
360
+ // an abort that landed while identity ran is the race's to settle: the
361
+ // handler is still invoked with the aborted signal — told to stop, as
362
+ // a superseded attempt always was — and its settlement is dropped
300
363
 
301
- // 2. run the neutral pipeline under the composed signal
302
- const signal = caller === null ? closer.signal : AbortSignal.any([closer.signal, caller]);
303
- const id = safeTrace(trace);
304
- meta.trace = id;
305
- const handlerCtx = Object.freeze({
306
- op: route.op, trace: id, signal, params: null, headers: NO_HEADERS,
307
- fail: ContractFailure, idempotency: null,
364
+ // 3. acquire, then the neutral pipeline inside enter under the composed signal
365
+ const identityCtx = Object.freeze({
366
+ op: route.op, trace: id, carrier: /** @type {const} */ ('local'), host: identified.lease.host, signal,
367
+ method: null, path: null, params: null, headers: NO_HEADERS, body: null,
368
+ fail: ContractFailure, idempotency: null, etag: null, status: null,
369
+ });
370
+ const out = await acquireHost(lifecycle, value, identityCtx, (lease) => {
371
+ acquired = once(lease.release, observed);
372
+ const handlerCtx = Object.freeze({ ...identityCtx, host: lease.host });
373
+ return race(settleOperation(route, value, handlerCtx, validate), signal).then((result) => {
374
+ // a host fault rejects enter with the carrier: a transaction around it rolls back, the fault stands
375
+ if (result !== ABORTED && result.kind === 'contract') throw new RollbackCarrier(result, result.cause);
376
+ return result;
377
+ });
308
378
  });
309
- const result = await race(settleOperation(route, value, handlerCtx, validate), signal);
310
- if (result === ABORTED) return cancelled(route, meta);
379
+ if (out.kind === 'fault') return expose(hostFault(out.cause));
380
+ if (out.kind === 'failure') return expose(outcomeOf(classifyDeclared(route, out.failure)));
381
+ if (out.afterFault !== undefined) observed(out.afterFault);
382
+ const result = /** @type {import('../pipeline.js').OperationResult | typeof ABORTED} */ (out.result);
383
+ if (result === ABORTED) return expose(cancelled(route, meta));
311
384
 
312
- // 3. assemble the D6 outcome
313
- if (result.kind === 'contract') {
314
- if (result.cause !== undefined) observe(result.cause, { op: route.outcome.id, trace: id });
315
- return failedOutcome('contract', outcomeError('JC2070',
316
- renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: route.outcome.id }), null, null, false), meta);
317
- }
318
- if (result.kind === 'failure') {
319
- return assembleOutcome(route.outcome, {
320
- status: null,
321
- headers: null,
322
- error: {
323
- code: result.code,
324
- message: declaredMessage(catalog, route.outcome.id, result.code, result.params),
325
- details: result.details,
326
- retryable: result.retryable,
327
- },
328
- }, meta, catalog);
329
- }
330
- return assembleOutcome(route.outcome, { status: null, headers: null, value: result.value }, meta, catalog);
385
+ // 4. assemble the D6 outcome
386
+ return expose(outcomeOf(result));
331
387
  }
332
388
 
333
389
  /** @type {LocalCapabilities} */
package/src/messages.js CHANGED
@@ -62,6 +62,8 @@ export const contractMessagesEn = Object.freeze({
62
62
  'contract/seq-regression': 'the stream of operation {op} violated its seq order',
63
63
  'contract/stream-error': 'the stream of operation {op} ended with a server error ({code})',
64
64
  'contract/heartbeat-missed': 'the stream of operation {op} went silent for {ms}ms',
65
+ 'contract/slow-consumer': 'the stream of operation {op} ended: the consumer fell behind its bounded queue',
66
+ 'contract/reconnect-exhausted': 'the stream of operation {op} could not be re-established after {attempts} attempts (last: {lastCode})',
65
67
  });
66
68
 
67
69
  /** The compiled English catalog (module-level singleton). */
package/src/path.js CHANGED
@@ -211,6 +211,9 @@ export function parsePathTemplate(source) {
211
211
  else {
212
212
  checkStaticSegment(source, pos, end);
213
213
  const text = source.slice(pos, end);
214
+ if (decodeSegment(text) === null) {
215
+ throw new TypeError(`a malformed percent-escape in segment "${text}"`);
216
+ }
214
217
  segments.push({ variable: false, text });
215
218
  canonical += `/${text}`;
216
219
  }
@@ -221,8 +224,10 @@ export function parsePathTemplate(source) {
221
224
  }
222
225
 
223
226
  /**
224
- * The shape of a template with every variable normalized to `{}` — the
225
- * identity `JC0010` is decided on.
227
+ * The shape of a template with every variable normalized to `{}` and
228
+ * static delimiters re-escaped from decoded text — the identity `JC0010`
229
+ * is decided on. Escaped separators and braces stay inside their static
230
+ * segment, distinct from path boundaries and variable markers.
226
231
  * @param {ParsedPathTemplate} parsed
227
232
  * @returns {string}
228
233
  */
@@ -231,7 +236,8 @@ export function pathShape(parsed) {
231
236
  let out = '';
232
237
  for (let i = 0; i < parsed.segments.length; i++) {
233
238
  const s = parsed.segments[i];
234
- out += s.variable ? '/{}' : `/${s.text}`;
239
+ out += s.variable ? '/{}' : '/' + decodeURIComponent(s.text).replace(/[%/{}]/g,
240
+ (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
235
241
  }
236
242
  return out;
237
243
  }