@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/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
|
|
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;
|
|
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 ?
|
|
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 ?
|
|
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
|
|
28
|
-
*
|
|
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)
|
|
40
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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:
|
|
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. `*`
|
|
253
|
-
*
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 -
|
|
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:
|
|
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
|
|
55
|
-
*
|
|
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
|
|
73
|
-
return
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
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 =
|
|
95
|
-
const id =
|
|
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:
|
|
194
|
+
return { state: 'new', ref: Object.freeze({ id, generation }) };
|
|
112
195
|
},
|
|
113
|
-
commit(ref, response) {
|
|
114
|
-
const record =
|
|
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 =
|
|
201
|
+
record.updatedAt = instant(now);
|
|
120
202
|
},
|
|
121
|
-
fail(ref, retryable, response) {
|
|
122
|
-
const record =
|
|
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 =
|
|
208
|
+
record.updatedAt = instant(now);
|
|
128
209
|
},
|
|
129
|
-
lookup({ op, scope, key }) {
|
|
130
|
-
const record = records.get(
|
|
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 <=
|
|
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 =
|
|
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` (
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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 },
|
package/src/local/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
if (
|
|
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
|
-
//
|
|
313
|
-
|
|
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 `{}`
|
|
225
|
-
* identity `JC0010`
|
|
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 ? '/{}' :
|
|
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
|
}
|