@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/docs/CONTRACT-FORMAT.md
CHANGED
|
@@ -17,6 +17,15 @@ declared errors, a behavior policy and an HTTP binding. It is compiled
|
|
|
17
17
|
path matcher, and it is the single source every artifact around it is
|
|
18
18
|
projected from.
|
|
19
19
|
|
|
20
|
+
A contract document is written by hand, or by code: `@jarenjs/linq/
|
|
21
|
+
contract` is the pen that writes exactly this format — the same
|
|
22
|
+
operations, schemas, policies and bindings, in §12.1's member order,
|
|
23
|
+
with the operations' named schemas hoisted into `$defs` — and it types
|
|
24
|
+
the client, the handler table and the AI tools from the same builders,
|
|
25
|
+
without running the TypeScript projection of §12.3. The three worked
|
|
26
|
+
examples below are rebuilt through it, byte for byte, by its own test
|
|
27
|
+
suite.
|
|
28
|
+
|
|
20
29
|
Format 0.1 covers the document, its compilation, the HTTP binding's
|
|
21
30
|
*shape* (§2–§6), the HTTP **server** binding that carries it (§7–§9:
|
|
22
31
|
the request pipeline and its wire errors, idempotency and the ledger
|
|
@@ -48,22 +57,22 @@ coming lines of this package and will append their sections here.
|
|
|
48
57
|
"version": "5",
|
|
49
58
|
"compat": ["4"],
|
|
50
59
|
"$defs": {
|
|
60
|
+
"Catalog": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"revision": { "type": "integer" },
|
|
64
|
+
"products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
|
|
65
|
+
},
|
|
66
|
+
"required": ["revision", "products"]
|
|
67
|
+
},
|
|
51
68
|
"Product": {
|
|
52
69
|
"type": "object",
|
|
53
|
-
"required": ["id", "name", "price"],
|
|
54
70
|
"properties": {
|
|
55
71
|
"id": { "type": "integer" },
|
|
56
72
|
"name": { "type": "string", "minLength": 1 },
|
|
57
73
|
"price": { "type": "number", "minimum": 0 }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
"Catalog": {
|
|
61
|
-
"type": "object",
|
|
62
|
-
"required": ["revision", "products"],
|
|
63
|
-
"properties": {
|
|
64
|
-
"revision": { "type": "integer" },
|
|
65
|
-
"products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
|
|
66
|
-
}
|
|
74
|
+
},
|
|
75
|
+
"required": ["id", "name", "price"]
|
|
67
76
|
},
|
|
68
77
|
"Conflict": { "type": "object", "properties": { "current": { "$ref": "#/$defs/Product" } } }
|
|
69
78
|
},
|
|
@@ -81,12 +90,12 @@ coming lines of this package and will append their sections here.
|
|
|
81
90
|
"kind": "command",
|
|
82
91
|
"input": {
|
|
83
92
|
"type": "object",
|
|
84
|
-
"required": ["id", "revision", "product"],
|
|
85
93
|
"properties": {
|
|
86
94
|
"id": { "type": "integer" },
|
|
87
95
|
"revision": { "type": "integer" },
|
|
88
96
|
"product": { "$ref": "#/$defs/Product" }
|
|
89
|
-
}
|
|
97
|
+
},
|
|
98
|
+
"required": ["id", "revision", "product"]
|
|
90
99
|
},
|
|
91
100
|
"output": { "$ref": "#/$defs/Product" },
|
|
92
101
|
"errors": {
|
|
@@ -102,7 +111,7 @@ coming lines of this package and will append their sections here.
|
|
|
102
111
|
},
|
|
103
112
|
"image.bytes": {
|
|
104
113
|
"kind": "read",
|
|
105
|
-
"input": { "type": "object", "
|
|
114
|
+
"input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
|
|
106
115
|
"output": true,
|
|
107
116
|
"http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
|
|
108
117
|
}
|
|
@@ -290,7 +299,8 @@ and **canonicalized to `{name}`**, which is what `describe()` and every
|
|
|
290
299
|
projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
|
|
291
300
|
declared once per template. A static segment is any run of characters
|
|
292
301
|
except `/ { } : * ? #`, whitespace and control characters; a `%` in it
|
|
293
|
-
MUST open a well-formed escape
|
|
302
|
+
MUST open a well-formed escape, and escaped bytes MUST decode as UTF-8
|
|
303
|
+
(`JC0008` at the path when they do not).
|
|
294
304
|
|
|
295
305
|
Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
|
|
296
306
|
forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
|
|
@@ -313,15 +323,22 @@ The **shape** of a binding is its method plus its template with every
|
|
|
313
323
|
variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
|
|
314
324
|
NOT share a shape (`JC0010` at the second one, in document order); the
|
|
315
325
|
canonical binding takes part (`POST /<id>` may collide with a declared
|
|
316
|
-
`POST /<id>`).
|
|
326
|
+
`POST /<id>`). Static segments compare in decoded space, so `/a` and
|
|
327
|
+
`/%61` share a shape, as do differently cased percent-escapes. An escaped
|
|
328
|
+
slash stays inside its segment, and escaped braces stay static text:
|
|
329
|
+
`/a%2Fb` differs from `/a/b`, and `/%7B%7D` differs from `/{id}`.
|
|
317
330
|
|
|
318
331
|
### §4.5 Opaque operations
|
|
319
332
|
|
|
320
333
|
A `media` other than `application/json` (or a `+json` structured-syntax
|
|
321
334
|
suffix, parameters ignored) marks the operation **opaque**: it is routed
|
|
322
335
|
and matched, its path/query still decoded, its body neither decoded nor
|
|
323
|
-
validated by the contract, and it is excluded from
|
|
324
|
-
|
|
336
|
+
validated by the contract, and it is excluded from `invoke` and from the
|
|
337
|
+
generated `Operations` map — an HTTP client reaches it through `bytes`
|
|
338
|
+
(§10.6), whose success owns the live response stream, and through `url`.
|
|
339
|
+
Its bytes are **streamed** in both directions: the handler pulls the
|
|
340
|
+
upload one chunk at a time and may answer a pull source of its own
|
|
341
|
+
(§7.1). `image.bytes` in §2 is one. Because its body is
|
|
325
342
|
bytes the contract never decodes, an opaque operation MUST NOT declare a
|
|
326
343
|
**body-located member** — neither through `http.body`, nor `http.in`,
|
|
327
344
|
nor the `command` default (`JC0017` at the member that placed it there,
|
|
@@ -427,20 +444,20 @@ The three worked examples this document is tested against, complete:
|
|
|
427
444
|
"kind": "command",
|
|
428
445
|
"input": {
|
|
429
446
|
"type": "object",
|
|
430
|
-
"required": ["id", "doc"],
|
|
431
447
|
"properties": {
|
|
432
448
|
"id": { "type": "string" },
|
|
433
449
|
"doc": { "type": "array", "items": { "type": "object" } },
|
|
434
450
|
"dry": { "type": "boolean" }
|
|
435
|
-
}
|
|
451
|
+
},
|
|
452
|
+
"required": ["id", "doc"]
|
|
436
453
|
},
|
|
437
454
|
"output": true,
|
|
438
455
|
"policy": { "idempotency": "optional" },
|
|
439
|
-
"http": { "method": "PUT", "path": "/docs/:id", "
|
|
456
|
+
"http": { "method": "PUT", "path": "/docs/:id", "in": { "dry": "query" }, "body": "doc", "status": 204 }
|
|
440
457
|
},
|
|
441
458
|
"doc.remove": {
|
|
442
459
|
"kind": "command",
|
|
443
|
-
"input": { "type": "object", "
|
|
460
|
+
"input": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] },
|
|
444
461
|
"output": true
|
|
445
462
|
}
|
|
446
463
|
}
|
|
@@ -489,10 +506,12 @@ is the operation's output. `ctx` is frozen per request:
|
|
|
489
506
|
|---|---|
|
|
490
507
|
| `op` | the compiled operation |
|
|
491
508
|
| `trace` | the server trace id of this request (also `x-jaren-trace` on the response and `requestId` in an error body) |
|
|
509
|
+
| `carrier` | `"http"` — the binding this context comes from (`"port"` and `"local"` on theirs, §15–§16) |
|
|
510
|
+
| `host` | the host lifecycle's value (§7.7): what `acquire` entered with — `null` by default, the identity's host when only `identify` is named; the value itself is the host's and is not deep-frozen |
|
|
492
511
|
| `method`, `path` | the request line, path without the query |
|
|
493
512
|
| `params` | the raw decoded path strings, frozen |
|
|
494
513
|
| `headers` | **declared header members only** (by header name, string values) plus `if-match`/`if-none-match` when present — the binding reads no other request header on a handler's behalf |
|
|
495
|
-
| `body` | the raw request body of an **opaque** operation (`
|
|
514
|
+
| `body` | the raw request body of an **opaque** operation: text, bytes, or — when the adapter handed the request over as a stream — a pull source (`AsyncIterable<Uint8Array>`) that never yields a byte past `policy.limits.maxBodyBytes` (the chunk that would cross it throws a `BodyLimitError`, exported by `@jarenjs/contract/http`, to the puller); `null` for a JSON operation, whose body was decoded into `input` |
|
|
496
515
|
| `signal` | the request's `AbortSignal` when the adapter has one (the node adapter aborts it when the client goes away before the response finished), else `null` |
|
|
497
516
|
| `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
|
|
498
517
|
| `fail(code, params?, details?, { retryable? }?)` | a declared failure by code — returns a `ContractFailure` value the handler returns; `params` feed the message catalog, `details` become the wire `details` (validated against the declaration's schema when it has one), `retryable` overrides the default taken from `policy.retry.on` |
|
|
@@ -502,6 +521,26 @@ is the operation's output. `ctx` is frozen per request:
|
|
|
502
521
|
An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
|
|
503
522
|
ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
|
|
504
523
|
bypasses media, parse, body assembly, idempotency and output validation.
|
|
524
|
+
**The bytes stream.** Through the adapters (§9) `ctx.body` is a pull
|
|
525
|
+
source: the handler reads it with `for await`, one chunk at a time, and
|
|
526
|
+
nothing is collected on its behalf. The source counts: the chunk that
|
|
527
|
+
would cross `policy.limits.maxBodyBytes` is never yielded — the upstream
|
|
528
|
+
is cancelled once and a `BodyLimitError` is thrown to the puller. A
|
|
529
|
+
handler that lets it propagate answers `JC2003` (the request's fault,
|
|
530
|
+
not a host fault — `onError` does not see it); one that catches it
|
|
531
|
+
decides for itself. The handler's `body` may likewise be a pull source
|
|
532
|
+
(an async iterable, or a Web `ReadableStream`, normalized): the adapter
|
|
533
|
+
writes it chunk by chunk behind the socket's backpressure, and under
|
|
534
|
+
HEAD it is cancelled once, never drained. A plain (non-stream) response
|
|
535
|
+
answered with the upload still unread cancels the upload once before the
|
|
536
|
+
response is exposed; a streamed response keeps the upload alive — the
|
|
537
|
+
handler may be transforming it — and releases it once when the response
|
|
538
|
+
reaches EOF, throws, or is cancelled by the consumer. A limit crossing
|
|
539
|
+
met by such a transform after the headers went out cuts the response
|
|
540
|
+
body (the status cannot be rewritten) and is reported to `onError`.
|
|
541
|
+
Effects a handler made before a chunked upload crossed its limit are its
|
|
542
|
+
own to undo; the host lifecycle's acquired transaction (a later order's
|
|
543
|
+
seam) is where such a rollback belongs.
|
|
505
544
|
Its transport members are decoded and normalized into `input` and
|
|
506
545
|
validated like any other input (`JC2006`) — they **are** its whole
|
|
507
546
|
input, since an opaque operation cannot declare a body-located member
|
|
@@ -519,8 +558,10 @@ is `JC2008`.
|
|
|
519
558
|
### §7.2 The pipeline, in order
|
|
520
559
|
|
|
521
560
|
1. **The request object.** `method`/`url` strings, `headers` an object,
|
|
522
|
-
`body` a string, `Uint8Array
|
|
523
|
-
|
|
561
|
+
`body` a string, `Uint8Array`, a pull source (`AsyncIterable<
|
|
562
|
+
Uint8Array>`, or a Web `ReadableStream`, normalized to one) or `null`
|
|
563
|
+
(an absent body is `null`) — a malformed object is `JC1004`,
|
|
564
|
+
**rejected**, never a response.
|
|
524
565
|
2. **Route.** `url` is split at the first `?`; the path goes to
|
|
525
566
|
`contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
|
|
526
567
|
is tried, then `GET`. No match: an undecodable path (a malformed
|
|
@@ -529,10 +570,21 @@ is `JC2008`.
|
|
|
529
570
|
(with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
|
|
530
571
|
`Allow`; else `JC2001`. A matched operation without a handler (a
|
|
531
572
|
`partial` server) is `JC2013`.
|
|
573
|
+
Then **identify** (§7.7): the host lifecycle's first hook runs with
|
|
574
|
+
the operation, the trace, the signal and the raw transport facts —
|
|
575
|
+
before any byte of the body is read — and answers the identity
|
|
576
|
+
lease (its `host` is what `scope` sees) or a declared failure; a
|
|
577
|
+
hook fault is `JC2008`. Every later refusal in this list releases the
|
|
578
|
+
identity before it is exposed.
|
|
532
579
|
3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
|
|
533
580
|
is `JC2003` **before** any read (the adapters honor this too, §9); a
|
|
534
|
-
body whose
|
|
535
|
-
|
|
581
|
+
text or byte body whose length exceeds the limit is `JC2003` after; a
|
|
582
|
+
pull source is measured as it is pulled — a JSON operation drains it
|
|
583
|
+
under the limit and answers `JC2003` at the first byte past it (the
|
|
584
|
+
source cancelled once, the crossing chunk never retained), an opaque
|
|
585
|
+
handler's source throws `BodyLimitError` there (§7.1). Applies to
|
|
586
|
+
every matched operation, opaque and body-less included; a body-less
|
|
587
|
+
operation releases a source without pulling it.
|
|
536
588
|
4. **Opaque** → the transport input validated as in step 8 when no
|
|
537
589
|
member is body-located (`JC2006`), then the raw handler through the
|
|
538
590
|
same boundary as step 11; done.
|
|
@@ -543,9 +595,10 @@ is `JC2008`.
|
|
|
543
595
|
accepted for `application/json`); else `JC2004`. A body-less
|
|
544
596
|
operation with a body **ignores** the body. An empty body needs no
|
|
545
597
|
media.
|
|
546
|
-
6. **Parse.**
|
|
547
|
-
|
|
548
|
-
|
|
598
|
+
6. **Parse.** A pull source is drained whole first (a source that fails
|
|
599
|
+
or is aborted before EOF never arrived whole: `JC2005`); bytes are
|
|
600
|
+
decoded as strict UTF-8 (invalid → `JC2005`; a leading BOM is
|
|
601
|
+
stripped by the decoder); then `JSON.parse` (a failure is `JC2005`).
|
|
549
602
|
7. **Assemble** the input object through a prototype-safe setter only, in
|
|
550
603
|
this order: path members (raw decoded strings), query members
|
|
551
604
|
(`URLSearchParams` semantics — `+` is a space; a member listed in
|
|
@@ -570,7 +623,13 @@ is `JC2008`.
|
|
|
570
623
|
(its `policy.idempotency` is `none` by construction).
|
|
571
624
|
9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
|
|
572
625
|
`Idempotency-Key` is `JC2007` under `required` and runs plainly under
|
|
573
|
-
`optional`; otherwise the input is hashed and the ledger claimed.
|
|
626
|
+
`optional`; otherwise the input is hashed and the ledger claimed. A
|
|
627
|
+
`replay`, `mismatch` or `in-progress` answer returns here — the host
|
|
628
|
+
lifecycle's `acquire` is never called for it.
|
|
629
|
+
Then **acquire** (§7.7): the second hook runs with the validated
|
|
630
|
+
input and the identity context, and calls `enter` with the lease the
|
|
631
|
+
handler runs under; the handler's context is the identity context
|
|
632
|
+
with the acquired `host`, frozen. Steps 10–14 run inside `enter`.
|
|
574
633
|
10. **Preconditions, opt-in** (§7.5): when the operation has a
|
|
575
634
|
`preconditions` resolver, the CURRENT tag is resolved BEFORE the
|
|
576
635
|
handler — a command consults it only under a conditional header, a
|
|
@@ -612,6 +671,19 @@ is `JC2008`.
|
|
|
612
671
|
charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
|
|
613
672
|
a `204` carries no body; a HEAD carries the `content-length` of the
|
|
614
673
|
body it dropped and no body. Then the ledger claim is settled (§8).
|
|
674
|
+
A raw handler's body passes through as it is: text, bytes, or a pull
|
|
675
|
+
source the adapter streams (no `content-length`; HEAD cancels it).
|
|
676
|
+
Under a lease that **requires settlement** (§7.7) the claim is
|
|
677
|
+
recorded through the lease's ledger inside `enter`, before `enter`
|
|
678
|
+
resolves, and the root ledger stands down; a host fault or a
|
|
679
|
+
pre-handler refusal makes `enter` reject — a host transaction
|
|
680
|
+
around it rolls back — and the root ledger releases the key
|
|
681
|
+
retryable outside it.
|
|
682
|
+
15. **Release.** The acquired lease, then the identity, each once: before
|
|
683
|
+
the response is exposed (a release that fails there is `JC2008`,
|
|
684
|
+
its cause observed), or — for a pull-source body and an SSE stream
|
|
685
|
+
— when the body settles or the stream is done (a failure then is
|
|
686
|
+
observed only).
|
|
615
687
|
|
|
616
688
|
Every step's failure path returns a response. `dispatch` never rejects
|
|
617
689
|
for request content; a defect of the binding itself is caught last and
|
|
@@ -700,6 +772,7 @@ wire response:
|
|
|
700
772
|
| `JC1008` | `openHttpClient`, `openPortClient`, `client.url`, `createContractEffect`, `createContractSubscription` or a projection (`publicProjection`, `toOpenApi`, `toTypeScript`, `toMarkdown`, `contractTools`): an argument or option is malformed (§10, §11, §12, §16) |
|
|
701
773
|
| `JC1009` | the stream wire's SSE encoder was handed text the frame cannot carry: a bare carriage return inside `data`, a line terminator inside `event` or `id` (§18) |
|
|
702
774
|
| `JC1010` | `client.subscribe` was asked for an operation that is not a subscribe operation (§19) |
|
|
775
|
+
| `JC1011` | a ledger `commit`/`fail` named a ref that settles no started record — expired, reclaimed under a newer generation, or settled already (§8); refused by the ledger, reported to `onError` by the binding |
|
|
703
776
|
|
|
704
777
|
### §7.4 Headers
|
|
705
778
|
|
|
@@ -768,15 +841,135 @@ running the handler and dropping the body (a declared `HEAD` operation
|
|
|
768
841
|
wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
|
|
769
842
|
path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
|
|
770
843
|
answers `describe()` — `revision: null` until the revision lands, `compat`
|
|
771
|
-
present — for negotiation. `trace` (default `
|
|
772
|
-
the server trace; `scope(ctx)` derives the idempotency scope (§8);
|
|
844
|
+
present — for negotiation. `trace` (default the runtime record's `uuid`,
|
|
845
|
+
`crypto.randomUUID` with no record) generates the server trace; `scope(ctx)` derives the idempotency scope (§8);
|
|
773
846
|
`partial` allows missing handlers; `validateOutput` is `"always" |
|
|
774
847
|
"never"`; `preconditions` maps operation ids to pre-handler tag
|
|
775
848
|
resolvers (§7.5); `errorBody(wire, ctx)` and `onError(err, ctx)` are the
|
|
776
849
|
two host hooks (`ctx` is `null` before an operation is matched);
|
|
777
850
|
`catalog` is a message catalog (templates or compiled renderers)
|
|
778
851
|
consulted before the English one; `now` is the clock stamped into ledger
|
|
779
|
-
claims
|
|
852
|
+
claims; `runtime` is the host's runtime record (`@jarenjs/core/runtime`),
|
|
853
|
+
whose `uuid` and `now` apply where `trace` and `now` are absent. Every
|
|
854
|
+
binding of this format takes the same `runtime` option with the same
|
|
855
|
+
precedence — an explicit option wins over the record's member, which
|
|
856
|
+
wins over the platform default — so one record configures a server, its
|
|
857
|
+
ledger and a client together: `servePort` and `openLocalClient` read its
|
|
858
|
+
`uuid` for their trace, `openPortClient` for its client id,
|
|
859
|
+
`openHttpClient` reads `uuid` for idempotency keys, `now` for key-record
|
|
860
|
+
stamps and `random` for retry jitter, and `createMemoryLedger` reads
|
|
861
|
+
`now` for claim stamps.
|
|
862
|
+
|
|
863
|
+
### §7.7 The host lifecycle: identify, acquire, release, settle
|
|
864
|
+
|
|
865
|
+
A host owns resources a handler needs — a principal, a tenant's store, a
|
|
866
|
+
transaction — and the binding owns the moments they may be taken and
|
|
867
|
+
must be given back. `serveHttp`, `servePort` and `openLocalClient` take
|
|
868
|
+
the same two hooks, validated at construction (`JC1001` otherwise) and
|
|
869
|
+
defaulted exactly:
|
|
870
|
+
|
|
871
|
+
```
|
|
872
|
+
identify(meta) → { host, release? } | declared failure | Promise<…>
|
|
873
|
+
acquire(input, identity, enter) → enter({ host, release?, settlement? }) | declared failure | Promise<…>
|
|
874
|
+
settlement := { ledger, required: true }
|
|
875
|
+
|
|
876
|
+
default identify → { host: null }
|
|
877
|
+
default acquire → enter({ host: identity.host })
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
`meta` is `{ op, trace, signal, carrier, method, path, headers, fail }`
|
|
881
|
+
— the matched operation, the trace, the request signal, the carrier
|
|
882
|
+
name, the request line and the raw request headers on HTTP (`null` on
|
|
883
|
+
port and local), and the declared-failure factory. It carries **no
|
|
884
|
+
parsed input and no authentication vocabulary**: what an identity is
|
|
885
|
+
made of is the host's. `identity` is the frozen identity context (the
|
|
886
|
+
request context with the identity's `host`); `scope(ctx)` sees that
|
|
887
|
+
context. A lease is an object with an own `host` — the value the
|
|
888
|
+
handler sees as `ctx.host` — and an optional `release` function; the
|
|
889
|
+
acquired lease may add `settlement`. A host that names only `identify`
|
|
890
|
+
sees its host in `scope` and in the handler; one that names `acquire`
|
|
891
|
+
owns the handler's host.
|
|
892
|
+
|
|
893
|
+
**Order.** (1) the request shape, the route and the trace; (2)
|
|
894
|
+
`identify`; (3) media, parse, assembly and validation; (4) the
|
|
895
|
+
idempotency key, scope, hash and claim of an HTTP JSON command; (5) a
|
|
896
|
+
replay, mismatch or in-progress answer returns here, `acquire` never
|
|
897
|
+
called; (6) `acquire(validatedInput, identity, enter)`; (7) inside
|
|
898
|
+
`enter`: the frozen handler context, the precondition, the handler, the
|
|
899
|
+
output validation and the serialization; (8) a required settlement,
|
|
900
|
+
then `enter` resolves and the host's continuation around it settles;
|
|
901
|
+
(9) the response is exposed, and the releases run at the lifetime
|
|
902
|
+
boundary. Opaque, subscribe, port and local paths skip the ledger steps
|
|
903
|
+
that do not apply and keep the order otherwise.
|
|
904
|
+
|
|
905
|
+
**Faults.** A hook that throws or rejects, answers something that is
|
|
906
|
+
not a lease (no own `host`, a `release` that is not a function, a
|
|
907
|
+
`settlement` without a ledger that commits and fails), never calls
|
|
908
|
+
`enter`, calls it twice, or resolves before `enter` settled is the
|
|
909
|
+
host's fault: observed through `onError` and answered as the binding's
|
|
910
|
+
host fault — `JC2008` on HTTP, `JC2070` on port and local. A declared
|
|
911
|
+
failure is recognized by the `ContractFailure` brand only (`meta.fail`,
|
|
912
|
+
or the package's `ContractFailure`), never by shape, and is validated
|
|
913
|
+
against the operation exactly as a handler's `ctx.fail` is. A
|
|
914
|
+
shape-compatible object is a malformed lease.
|
|
915
|
+
|
|
916
|
+
**Release.** The acquired release runs, then the identity's, each at
|
|
917
|
+
most once, on every exit that reached it: an ordinary response releases
|
|
918
|
+
after the whole pipeline (the required settlement included) and before
|
|
919
|
+
the response is exposed; a pull-source body of an opaque operation
|
|
920
|
+
carries the releases and runs them once at EOF, on a throw, on the
|
|
921
|
+
consumer's cancel, on HEAD's discard and on a disconnect; an SSE stream
|
|
922
|
+
and a port subscription release after the runner's stop/close/done
|
|
923
|
+
sequence. The identity releases on every early refusal too — malformed
|
|
924
|
+
JSON, unsupported media, the body limit, a malformed query or header,
|
|
925
|
+
invalid input, replay, mismatch, in-progress — and the acquired release
|
|
926
|
+
never runs when `acquire` did not enter. A release that fails before
|
|
927
|
+
the response is exposed is observed and answered as the host fault; one
|
|
928
|
+
that fails after exposure is observed only. A release failure after a
|
|
929
|
+
committed settlement therefore answers `JC2008` for work that was done:
|
|
930
|
+
under an idempotency key the retry replays the committed response.
|
|
931
|
+
|
|
932
|
+
**Required settlement.** An `acquire` that hands `enter` a lease with
|
|
933
|
+
`settlement: { ledger, required: true }` — the ledger being one over
|
|
934
|
+
the host's own transaction, `createDbLedger(tx)` from
|
|
935
|
+
`@jarenjs/linq/db` (§8) — has the claim of a newly claimed JSON command
|
|
936
|
+
recorded through that ledger inside `enter`: a success commits, a
|
|
937
|
+
declared failure and a post-handler `JC2014` record the same failed
|
|
938
|
+
receipt and retryability `settleClaim` would, and only then does
|
|
939
|
+
`enter` resolve, so a host transaction opened around it commits the
|
|
940
|
+
domain write and the receipt together or not at all. A pre-handler
|
|
941
|
+
refusal, a handler throw, an invalid output, a fault of the binding's
|
|
942
|
+
own continuation or a settlement that throws makes `enter` reject with
|
|
943
|
+
a private carrier of the intended wire fault: the host transaction rolls
|
|
944
|
+
back, the dispatcher releases the root claim retryable outside it, and
|
|
945
|
+
the fault is the answer. The lease's ledger never settles a replay or a
|
|
946
|
+
claim it did not enter for; a settlement on a non-idempotent operation,
|
|
947
|
+
on port or on local is accepted and unused. Without a required
|
|
948
|
+
settlement the root ledger settles after `enter`, best-effort, as it
|
|
949
|
+
always did.
|
|
950
|
+
|
|
951
|
+
```js
|
|
952
|
+
import { open, createDbLedger } from '@jarenjs/linq/db';
|
|
953
|
+
|
|
954
|
+
const db = await open(model, { driver: nodeDriver(), path: 'app.db' });
|
|
955
|
+
const server = serveHttp(contract, handlers, {
|
|
956
|
+
ledger: createDbLedger(db), // the root claim: immediate, one writer
|
|
957
|
+
identify: (meta) => ({ host: { tenant: meta.headers['x-tenant'] ?? null } }),
|
|
958
|
+
acquire: (input, identity, enter) => db.transaction( // one transaction around the handler
|
|
959
|
+
(tx) => enter({ host: { db: tx, tenant: identity.host.tenant }, settlement: { ledger: createDbLedger(tx), required: true } }),
|
|
960
|
+
{ mode: 'immediate' }),
|
|
961
|
+
});
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
**What this is, exactly.** The claim is taken before the transaction
|
|
965
|
+
and recovered outside it: a rolled-back `enter` leaves the key
|
|
966
|
+
retryable, a crashed host leaves it `started` until it expires, and a
|
|
967
|
+
retry under the same key finds the recorded receipt or a fresh claim.
|
|
968
|
+
Atomicity is the store's: a ledger and a domain write on ONE store
|
|
969
|
+
commit together; a ledger on one store and a write on another are two
|
|
970
|
+
commits, and nothing here makes an external effect — a mail, a payment
|
|
971
|
+
— exactly-once. The receipt says the command's response was recorded,
|
|
972
|
+
not that the world outside the store agrees.
|
|
780
973
|
|
|
781
974
|
## §8 Idempotency and the ledger
|
|
782
975
|
|
|
@@ -796,11 +989,22 @@ is the same request. The binding then calls the ledger:
|
|
|
796
989
|
```jsonc
|
|
797
990
|
// the Ledger interface — every method may return its value or a promise of it
|
|
798
991
|
{ "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
|
|
799
|
-
"commit": "(ref, response) → void",
|
|
800
|
-
"fail": "(ref, retryable, response?) → void",
|
|
801
|
-
"lookup": "({ op, scope, key }) → record | null" }
|
|
992
|
+
"commit": "(ref, response, now?) → void",
|
|
993
|
+
"fail": "(ref, retryable, response?, now?) → void",
|
|
994
|
+
"lookup": "({ op, scope, key, now? }) → record | null" }
|
|
802
995
|
```
|
|
803
996
|
|
|
997
|
+
The `ref` a `new` claim hands back is `{ id, generation }` — the
|
|
998
|
+
record's id and the **generation** the claim minted for it — and is
|
|
999
|
+
portable: it names the record across processes rather than holding it.
|
|
1000
|
+
A settlement is fenced by both: `commit`/`fail` settle the record whose
|
|
1001
|
+
`id` AND `generation` the ref names while it is still `started`, and a
|
|
1002
|
+
ref whose record expired, was reclaimed under a newer generation, or was
|
|
1003
|
+
settled already is refused with `JC1011` (thrown or rejected) — the
|
|
1004
|
+
binding reports it to `onError` and the response still goes out, so a
|
|
1005
|
+
stale settlement is visible instead of silently landing on a later
|
|
1006
|
+
claim's record.
|
|
1007
|
+
|
|
804
1008
|
Semantics the binding relies on: same key + same hash → `replay` — the
|
|
805
1009
|
stored `{ status, headers, body }` **verbatim** with a fresh
|
|
806
1010
|
`x-jaren-trace` and `idempotent-replayed: true`; same key + different
|
|
@@ -815,19 +1019,29 @@ declared failure is recorded as **failed** with its response and its
|
|
|
815
1019
|
key as retryable with no response; a POST-handler `JC2014` (the handler
|
|
816
1020
|
already ran and may have mutated) is recorded as **failed**, not
|
|
817
1021
|
retryable, with its 412 — a blind retry under the same key replays the
|
|
818
|
-
412 instead of running the handler again. `now` on a claim
|
|
819
|
-
|
|
1022
|
+
412 instead of running the handler again. `now` on a claim, a commit, a
|
|
1023
|
+
failure and a lookup is the binding's clock (`options.now`, or the
|
|
1024
|
+
runtime record's): ONE clock judges a record from claim to expiry, so a
|
|
1025
|
+
ledger without a clock of its own follows the binding's instants, and a
|
|
1026
|
+
ledger with one is given the same `runtime` as the binding — a claim
|
|
1027
|
+
stamped by an injected server clock and expired by the platform's is a
|
|
1028
|
+
command that runs twice. Opaque
|
|
820
1029
|
operations bypass the ledger; reads never carry a key. A ledger that
|
|
821
1030
|
throws or rejects is reported to `onError` and the response still goes
|
|
822
1031
|
out (a throwing `claim` is `JC2008`).
|
|
823
1032
|
|
|
824
|
-
`createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
|
|
1033
|
+
`createMemoryLedger({ ttlMs = 86_400_000, now, runtime })` (`@jarenjs/contract/ledger`)
|
|
825
1034
|
is the reference implementation over a `Map`: synchronous,
|
|
826
|
-
single-process, expiring on `claim` and `lookup`, with `sweep()` for a
|
|
827
|
-
host timer and `size`.
|
|
1035
|
+
single-process, expiring on `claim` and `lookup`, with `sweep(now?)` for a
|
|
1036
|
+
host timer and `size`. Built without `now` or `runtime` it keeps time by
|
|
1037
|
+
the instants the binding passes it (a host-side `lookup`/`sweep` that
|
|
1038
|
+
passes none uses the latest one); built with either, that clock judges
|
|
1039
|
+
every record; the runtime record's `uuid` mints each generation, and
|
|
1040
|
+
`lookup` answers a copy. The record it keeps is:
|
|
828
1041
|
|
|
829
1042
|
```jsonc
|
|
830
|
-
{ "id": "product.save
|
|
1043
|
+
{ "id": "1:[\"product.save\",\"tenant-a\",\"k-1\"]", // ledgerId(op, scope, key): version 1, the JSON tuple
|
|
1044
|
+
"generation": "0f3c…", // minted per started record; what a ref names
|
|
831
1045
|
"op": "product.save", "scope": "tenant-a", "key": "k-1",
|
|
832
1046
|
"hash": "9f2a…", // 64 lowercase hex characters
|
|
833
1047
|
"status": "committed", // started | committed | failed
|
|
@@ -836,12 +1050,26 @@ host timer and `size`. The record it keeps is:
|
|
|
836
1050
|
"createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
|
|
837
1051
|
```
|
|
838
1052
|
|
|
1053
|
+
`ledgerId(op, scope, key)` (`@jarenjs/contract/ledger`) spells the id:
|
|
1054
|
+
the version `1`, a colon, the JSON array of the tuple — injective, so a
|
|
1055
|
+
`|`, a control character or any Unicode inside a member cannot collide
|
|
1056
|
+
with another tuple. A record written under the earlier
|
|
1057
|
+
`"<op>|<scope>|<key>"` spelling is matched by no claim again: it
|
|
1058
|
+
expires by its own `expiresAt` (a `sweep` drops it), and a host that
|
|
1059
|
+
must keep such records reachable rewrites their `id` once
|
|
1060
|
+
(`ledgerId(record.op, record.scope, record.key)`) before the new
|
|
1061
|
+
version serves.
|
|
1062
|
+
|
|
839
1063
|
Two documents ship the same shape as **data**, for a host that wants
|
|
840
1064
|
durability (this package imports neither `@jarenjs/db` nor
|
|
841
1065
|
`@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
|
|
842
|
-
collection `ledger`, key `/id`, that record as its schema (closed
|
|
843
|
-
indexes on `expiresAt` and `status` — a host
|
|
844
|
-
and implements the interface over the
|
|
1066
|
+
collection `ledger`, key `/id`, that record as its schema (closed, the
|
|
1067
|
+
`generation` required), indexes on `expiresAt` and `status` — a host
|
|
1068
|
+
opens it with `openStore` and implements the interface over the
|
|
1069
|
+
collection, or takes `createDbLedger` from `@jarenjs/linq/db`, which is
|
|
1070
|
+
that implementation over the typed client (root claims under
|
|
1071
|
+
`mode: 'immediate'`, a transaction client's settlements inside the
|
|
1072
|
+
host's own transaction; DB-CLIENT.md §2.6); `commandLifecycleFsm`
|
|
845
1073
|
is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
|
|
846
1074
|
committed` on `commit`, `started → failed` on `fail`, `failed → started`
|
|
847
1075
|
on `claim` guarded by `$.context.retryable` — which the memory ledger
|
|
@@ -852,82 +1080,88 @@ walks exactly.
|
|
|
852
1080
|
A host that retries commands needs a ledger that survives a restart, and
|
|
853
1081
|
it does NOT need `@jarenjs/db` for that: `node:sqlite` is built into
|
|
854
1082
|
Node ≥ 24 — the suite's floor — so the ~60 lines below are as
|
|
855
|
-
dependency-free as the package
|
|
856
|
-
`
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1083
|
+
dependency-free as the package (a host on `@jarenjs/db` takes
|
|
1084
|
+
`createDbLedger` from `@jarenjs/linq/db` instead). Two design points
|
|
1085
|
+
carry the semantics: `BEGIN IMMEDIATE` makes each `claim` one writer
|
|
1086
|
+
(two processes cannot both claim a key), and a settlement names the
|
|
1087
|
+
record's id AND the generation the claim minted — persisted with the
|
|
1088
|
+
record — so a stale ref matches no row and is refused `JC1011` rather
|
|
1089
|
+
than settling over a record a later claim re-created. Everything else
|
|
1090
|
+
mirrors `createMemoryLedger` exactly: the same `ledgerId`, expiry on
|
|
1091
|
+
`claim` and `lookup`, `sweep()` for a host timer, mismatch before
|
|
1092
|
+
status, a retryable failure handing the key back, a non-retryable one
|
|
1093
|
+
replaying its stored response; the shared ledger contract in the test
|
|
1094
|
+
suite runs all three. Remember the boundary (§8): this ledger
|
|
865
1095
|
deduplicates DELIVERY — the domain's own durable records stay
|
|
866
1096
|
authoritative for business state.
|
|
867
1097
|
|
|
868
1098
|
```js
|
|
869
1099
|
import { DatabaseSync } from 'node:sqlite';
|
|
1100
|
+
import { ledgerId } from '@jarenjs/contract/ledger';
|
|
870
1101
|
|
|
871
1102
|
/** A durable Ledger over one SQLite file — a host's example, not an export. */
|
|
872
1103
|
export function createSqliteLedger(path, { ttlMs = 86_400_000, now: clock = Date.now } = {}) {
|
|
873
1104
|
const db = new DatabaseSync(path);
|
|
874
1105
|
db.exec(`
|
|
875
1106
|
CREATE TABLE IF NOT EXISTS ledger (
|
|
876
|
-
|
|
877
|
-
id TEXT NOT NULL UNIQUE, op TEXT NOT NULL, scope TEXT NOT NULL, key TEXT NOT NULL,
|
|
1107
|
+
id TEXT PRIMARY KEY, generation TEXT NOT NULL, op TEXT NOT NULL, scope TEXT NOT NULL, key TEXT NOT NULL,
|
|
878
1108
|
hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('started', 'committed', 'failed')),
|
|
879
1109
|
response TEXT, retryable INTEGER,
|
|
880
1110
|
createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
|
|
881
1111
|
CREATE INDEX IF NOT EXISTS ledger_by_expires ON ledger (expiresAt);
|
|
882
1112
|
CREATE INDEX IF NOT EXISTS ledger_by_status ON ledger (status);`);
|
|
883
1113
|
const one = db.prepare('SELECT * FROM ledger WHERE id = ?');
|
|
884
|
-
const put = db.prepare("INSERT INTO ledger (id, op, scope, key, hash, status, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, 'started', ?, ?, ?)");
|
|
1114
|
+
const put = db.prepare("INSERT INTO ledger (id, generation, op, scope, key, hash, status, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, 'started', ?, ?, ?)");
|
|
885
1115
|
const drop = db.prepare('DELETE FROM ledger WHERE id = ?');
|
|
886
|
-
|
|
1116
|
+
// the fence: a settlement names the id AND the generation of the claim
|
|
1117
|
+
// that started the record, so a ref of an earlier claim matches no row
|
|
1118
|
+
const settle = db.prepare("UPDATE ledger SET status = ?, response = ?, retryable = ?, updatedAt = ? WHERE id = ? AND generation = ? AND status = 'started'");
|
|
887
1119
|
const reap = db.prepare('DELETE FROM ledger WHERE expiresAt <= ?');
|
|
888
1120
|
const stored = (row) => (row.response === null ? null : JSON.parse(row.response));
|
|
1121
|
+
const at = (now) => (typeof now === 'number' ? now : clock());
|
|
1122
|
+
const settled = (ref, changes) => {
|
|
1123
|
+
if (changes !== 1) throw Object.assign(new Error(`ledger: ${ref?.id ?? 'a foreign ref'} settles no started record`), { code: 'JC1011' });
|
|
1124
|
+
};
|
|
889
1125
|
return {
|
|
890
1126
|
claim({ op, scope, key, hash, now }) {
|
|
891
|
-
const
|
|
892
|
-
const id = `${op}|${scope}|${key}`;
|
|
1127
|
+
const id = ledgerId(op, scope, key);
|
|
893
1128
|
db.exec('BEGIN IMMEDIATE'); // one writer: two processes cannot both claim the key
|
|
894
1129
|
try {
|
|
895
|
-
const row = one.get(id);
|
|
1130
|
+
const row = (one.get(id));
|
|
896
1131
|
if (row !== undefined) {
|
|
897
|
-
if (row.expiresAt <= at) drop.run(id);
|
|
1132
|
+
if (row.expiresAt <= at(now)) drop.run(id);
|
|
898
1133
|
else if (row.hash !== hash) { db.exec('COMMIT'); return { state: 'mismatch' }; }
|
|
899
1134
|
else if (row.status === 'started') { db.exec('COMMIT'); return { state: 'in-progress' }; }
|
|
900
1135
|
else if (row.status === 'committed') { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
901
1136
|
else if (row.retryable !== 1 && row.response !== null) { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
902
1137
|
else drop.run(id); // a retryable failure: the key runs again
|
|
903
1138
|
}
|
|
904
|
-
const
|
|
1139
|
+
const generation = crypto.randomUUID();
|
|
1140
|
+
put.run(id, generation, op, scope, key, hash, at(now), at(now), at(now) + ttlMs);
|
|
905
1141
|
db.exec('COMMIT');
|
|
906
|
-
return { state: 'new', ref };
|
|
1142
|
+
return { state: 'new', ref: { id, generation } };
|
|
907
1143
|
}
|
|
908
1144
|
catch (err) {
|
|
909
1145
|
db.exec('ROLLBACK');
|
|
910
1146
|
throw err;
|
|
911
1147
|
}
|
|
912
1148
|
},
|
|
913
|
-
commit(ref, response) {
|
|
914
|
-
settle.run('committed', JSON.stringify(response), null,
|
|
1149
|
+
commit(ref, response, now) {
|
|
1150
|
+
settled(ref, settle.run('committed', JSON.stringify(response), null, at(now), (ref)?.id ?? '', (ref)?.generation ?? '').changes);
|
|
915
1151
|
},
|
|
916
|
-
fail(ref, retryable, response) {
|
|
917
|
-
settle.run('failed', response === undefined ? null : JSON.stringify(response), retryable === true ? 1 : 0,
|
|
1152
|
+
fail(ref, retryable, response, now) {
|
|
1153
|
+
settled(ref, settle.run('failed', response === undefined ? null : JSON.stringify(response), retryable === true ? 1 : 0, at(now), (ref)?.id ?? '', (ref)?.generation ?? '').changes);
|
|
918
1154
|
},
|
|
919
|
-
lookup({ op, scope, key }) {
|
|
920
|
-
const row = one.get(
|
|
1155
|
+
lookup({ op, scope, key, now }) {
|
|
1156
|
+
const row = (one.get(ledgerId(op, scope, key)));
|
|
921
1157
|
if (row === undefined) return null;
|
|
922
|
-
if (row.expiresAt <=
|
|
1158
|
+
if (row.expiresAt <= at(now)) {
|
|
923
1159
|
drop.run(row.id);
|
|
924
1160
|
return null;
|
|
925
1161
|
}
|
|
926
|
-
|
|
927
|
-
delete record.seq; // the record shape is exactly LedgerRecord (§8)
|
|
928
|
-
return record;
|
|
1162
|
+
return { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 }; // exactly LedgerRecord (§8)
|
|
929
1163
|
},
|
|
930
|
-
sweep: () => Number(reap.run(
|
|
1164
|
+
sweep: (now) => Number(reap.run(at(now)).changes),
|
|
931
1165
|
close: () => db.close(),
|
|
932
1166
|
};
|
|
933
1167
|
}
|
|
@@ -953,26 +1187,53 @@ the platform:
|
|
|
953
1187
|
- **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
|
|
954
1188
|
`(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
|
|
955
1189
|
Cloudflare-style hosts, and Hono. It lowercases the headers into a
|
|
956
|
-
plain object, matches the operation first (cheap) to decide
|
|
957
|
-
body is
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
`
|
|
1190
|
+
plain object, matches the operation first (cheap) to decide whether
|
|
1191
|
+
the body is handed over at all — the request's stream reaches the
|
|
1192
|
+
dispatcher as a pull source for a matched body-carrying operation
|
|
1193
|
+
(a JSON operation drains it under its limit there, an opaque handler
|
|
1194
|
+
pulls it chunk by chunk), and **not at all** for an unmatched request
|
|
1195
|
+
or a declared `content-length` above the operation's limit (the
|
|
1196
|
+
dispatcher answers the 413 from the header) — forwards
|
|
1197
|
+
`request.signal`, and builds the `Response` from the dispatcher's
|
|
1198
|
+
status, headers and body; a streamed body becomes a `ReadableStream`
|
|
1199
|
+
that pulls one chunk per read and cancels the source once.
|
|
962
1200
|
- **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
|
|
963
|
-
res)` — `http.createServer`'s listener and Express middleware.
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
(
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1201
|
+
res)` — `http.createServer`'s listener and Express middleware. The
|
|
1202
|
+
request reaches the dispatcher as a pull source over its own chunks —
|
|
1203
|
+
nothing is collected in the adapter, and an unpulled upload never
|
|
1204
|
+
fills memory. When an upload was pulled and left unread (a limit
|
|
1205
|
+
crossing, a response answered before EOF) the answer carries
|
|
1206
|
+
`connection: close`, then the socket lingers — draining and discarding
|
|
1207
|
+
the rest of the upload, bounded by a grace timer
|
|
1208
|
+
(`toNodeHandler(dispatcher, { lingerMs })`, default 1000 ms) — before
|
|
1209
|
+
the request is destroyed, so the close is a FIN the client can read
|
|
1210
|
+
the 413 through rather than an RST that discards it (winsock drops
|
|
1211
|
+
buffered receive data on RST); a declared `content-length` above the
|
|
1212
|
+
limit is never read; an unmatched request's body is never read. Bytes
|
|
1213
|
+
reach the dispatcher as received (its strict UTF-8 decode decides
|
|
1214
|
+
`JC2005`); repeated header lines arrive as arrays (`headersDistinct`);
|
|
1215
|
+
`ctx.signal` aborts when the client goes away before the response
|
|
1216
|
+
finished; `content-length` is set on every text or byte body, and a
|
|
1217
|
+
streamed body is written chunk by chunk behind the socket's `drain`,
|
|
1218
|
+
its source cancelled once when the peer goes away.
|
|
1219
|
+
|
|
1220
|
+
**A streaming response is written behind backpressure.** The dispatcher
|
|
1221
|
+
hands the adapter's sink to `createAwaitedSink` (`@jarenjs/core/async`),
|
|
1222
|
+
so every SSE event is written only after the previous write settled,
|
|
1223
|
+
and the adapter's `write` says when that is: the node adapter answers
|
|
1224
|
+
nothing when `res.write()` took the chunk and a promise resolved on the
|
|
1225
|
+
next `drain` when it answered `false` (rejected when the response
|
|
1226
|
+
closes or errors first, its listeners removed either way); the fetch
|
|
1227
|
+
adapter's `ReadableStream` produces on demand — a write settles when
|
|
1228
|
+
the consumer's `pull` takes the chunk, never from an eager loop in
|
|
1229
|
+
`start()` — and `cancel()` rejects the write waiting for demand and
|
|
1230
|
+
stops the subscription exactly once. Behind either, the carrier-neutral
|
|
1231
|
+
runner calls its hooks one at a time (§17.1), so a slow reader parks
|
|
1232
|
+
the source's emissions instead of growing the process's buffers. An
|
|
1233
|
+
adapter of your own supplies `{ write, end, abort? }` where `write` may
|
|
1234
|
+
answer a promise; `response.stream(sink)` answers `{ stop, done }` —
|
|
1235
|
+
`stop()` ends the stream when the consumer cancels, `done` settles once
|
|
1236
|
+
the subscription is released and the sink has ended.
|
|
976
1237
|
|
|
977
1238
|
Fastify, Hono and Express are recipes in the README, each ≤15 lines and
|
|
978
1239
|
executed by a test that imports the framework from the benchmark
|
|
@@ -985,27 +1246,28 @@ streaming and peer abort behave identically through all three.
|
|
|
985
1246
|
|
|
986
1247
|
`openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
|
|
987
1248
|
client half of the http driver pair: `open(contract, options) → Client`
|
|
988
|
-
with `Client = { invoke, url, negotiate, pending, capabilities,
|
|
989
|
-
describe(), close() }`. It is **binding-agnostic in shape** — the app
|
|
1249
|
+
with `Client = { invoke, bytes, url, negotiate, pending, capabilities,
|
|
1250
|
+
contract, describe(), close() }`. It is **binding-agnostic in shape** — the app
|
|
990
1251
|
binding (§11) and later the AI tools read only `invoke`, `contract` and
|
|
991
1252
|
`capabilities` — and **total in behavior**: `invoke` resolves an
|
|
992
1253
|
**outcome** for everything a server or a network can do and rejects
|
|
993
1254
|
only for the host's own mistake (`JC1005`: an operation the contract
|
|
994
1255
|
does not declare, or an opaque one — `invoke` carries JSON; an opaque
|
|
995
|
-
operation is reached through `url`).
|
|
1256
|
+
operation is reached through `bytes` (§10.6) and `url`).
|
|
996
1257
|
|
|
997
1258
|
```jsonc
|
|
998
1259
|
// options — every one has a default
|
|
999
1260
|
{ "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
|
|
1000
1261
|
"baseUrl": "", // prefixed to every path; '' = relative
|
|
1001
1262
|
"headers": {}, // static headers, merged UNDER per-call ones
|
|
1002
|
-
"keys": "
|
|
1263
|
+
"keys": "runtime.uuid", // the idempotency key generator (crypto.randomUUID with no record)
|
|
1003
1264
|
"storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
|
|
1004
1265
|
"timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
|
|
1005
1266
|
"sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
|
|
1006
1267
|
"catalog": null, // a message catalog consulted before the English one
|
|
1007
1268
|
"wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
|
|
1008
|
-
"now": "
|
|
1269
|
+
"now": "runtime.now", // the clock stamped into key records (Date.now with no record)
|
|
1270
|
+
"runtime": "createRuntime()" } // the host's runtime record; its random draws the retry jitter
|
|
1009
1271
|
```
|
|
1010
1272
|
|
|
1011
1273
|
`capabilities` is `{ name: "http", status: true, headers: true, media:
|
|
@@ -1168,6 +1430,14 @@ is the server-side half). A store that throws on the pre-send write is
|
|
|
1168
1430
|
`JC2054` and nothing is sent; a store that throws on the drop leaves
|
|
1169
1431
|
the record (the conservative side) and the outcome is unaffected.
|
|
1170
1432
|
|
|
1433
|
+
Concurrent writes and releases are serialized across clients sharing the
|
|
1434
|
+
same storage adapter object, so each mutation sees the previous write.
|
|
1435
|
+
A failed mutation does not block later ones. Separate adapter objects,
|
|
1436
|
+
tabs and processes need coordination in the storage implementation.
|
|
1437
|
+
`pending()` has no ordering guarantee: concurrent requests can finish hashing
|
|
1438
|
+
in either order before entering the storage queue. Callers that need a display
|
|
1439
|
+
order sort the returned records themselves.
|
|
1440
|
+
|
|
1171
1441
|
### §10.4 Retry
|
|
1172
1442
|
|
|
1173
1443
|
Only under a declared `policy.retry` (`{ max, on }`); never for an
|
|
@@ -1208,6 +1478,38 @@ itself (`null` when unreachable or not a contract). **Nothing else is
|
|
|
1208
1478
|
inferred**: an unrelated service on the port is exactly `not-a-contract`;
|
|
1209
1479
|
two unversioned contracts are `same-version`.
|
|
1210
1480
|
|
|
1481
|
+
### §10.6 `bytes(op, input, ctx)` — the opaque operations
|
|
1482
|
+
|
|
1483
|
+
`bytes(op, input, ctx) → Promise<Outcome>` is `invoke`'s twin for an
|
|
1484
|
+
**opaque** operation (§4.5): the same pre-send validation of the
|
|
1485
|
+
transport members (`JC2050`), the same URL and header assembly, one
|
|
1486
|
+
request through the injected `fetch`, and a D6 outcome — but its
|
|
1487
|
+
success owns a **live stream**, never a JSON value. `op` must be opaque
|
|
1488
|
+
(`JC1005` for a JSON operation: use `invoke`); `ctx` is `{ signal?,
|
|
1489
|
+
attempt?, headers?, ifNoneMatch?, ifMatch?, body? }` — no idempotency
|
|
1490
|
+
key, an opaque operation carries none — where `body` is the request
|
|
1491
|
+
body to send: text, bytes, a Web `ReadableStream`, an async iterable of
|
|
1492
|
+
`Uint8Array` chunks (wrapped in a stream that pulls one chunk per
|
|
1493
|
+
demand and cancels the iterator once), or none (`JC1008` for anything
|
|
1494
|
+
else). A streamed upload goes out with `duplex: "half"`; a body without
|
|
1495
|
+
a caller's `content-type` is sent as the operation's `media`.
|
|
1496
|
+
|
|
1497
|
+
The outcome: a `2xx` is `{ ok: true, value: { status, headers, media,
|
|
1498
|
+
body }, meta }` — `headers` the response headers under lowercase names,
|
|
1499
|
+
`media` its `content-type` (`null` when none), `body` the response's
|
|
1500
|
+
`ReadableStream<Uint8Array>` (`null` when the platform has none) which
|
|
1501
|
+
the **caller** reads; the client never calls `text()` or
|
|
1502
|
+
`arrayBuffer()` on a success. A `304` is `ok: true` with `body: null`,
|
|
1503
|
+
`media: null` and `meta.notModified`. Every other status is classified
|
|
1504
|
+
exactly as §10.1 classifies an `invoke` answer — a declared or taxonomy
|
|
1505
|
+
code is a `failure`, anything else `contract` `JC2055` — from the error
|
|
1506
|
+
body's text. A transport rejection before the headers is `network`
|
|
1507
|
+
(`JC2051`), an abort `cancelled` (`JC2052`). **`bytes` never retries**,
|
|
1508
|
+
whatever `policy.retry` declares: an upload stream cannot be replayed
|
|
1509
|
+
and a body already exposed cannot be re-read — a failure after the
|
|
1510
|
+
headers reaches the caller as the rejection of its own read of `body`.
|
|
1511
|
+
`meta` carries the trace, the attempt and the `etag`.
|
|
1512
|
+
|
|
1211
1513
|
## §11 The app binding
|
|
1212
1514
|
|
|
1213
1515
|
`contractAppBinding(contract, { namespace = "contract/", statePath =
|
|
@@ -1547,13 +1849,39 @@ name), the reachable `$defs` once, then — fixed text in a JTLT
|
|
|
1547
1849
|
stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
|
|
1548
1850
|
typed operation map: kind, input, output, the declared error codes as a
|
|
1549
1851
|
literal union), `UrlOperations` (opaque operations included, for
|
|
1550
|
-
`Client.url`),
|
|
1551
|
-
`
|
|
1852
|
+
`Client.url`), `ByteOperations` (the opaque operations only, for
|
|
1853
|
+
`HttpClient.bytes`), and `Meta`, `WireError`, `Outcome<T>`,
|
|
1854
|
+
`InvokeContext`, `Client`, `ByteContext`, `ByteResponse`, `HttpClient`
|
|
1855
|
+
(`Client` plus `bytes` over `ByteOperations`, §10.6 — the
|
|
1856
|
+
binding-neutral `Client` never requires a byte method), `Failure`,
|
|
1857
|
+
`CarrierName`, `HandlerContextBase<Host>`, `HttpHandlerContext<Host>`,
|
|
1858
|
+
`ChannelHandlerContext<Host, Carrier>`, `HandlerContext<Host = null,
|
|
1859
|
+
Carrier = 'http'>` and `Handlers<Host = null, Carrier = 'http'>` — the
|
|
1860
|
+
handler context selected by carrier (§7.7): omitted generics are the
|
|
1861
|
+
HTTP context with `host: null`, exactly the shape it always was plus
|
|
1862
|
+
`carrier` and `host`; a port or local context spells the request-line
|
|
1863
|
+
members, the body, the key, `etag` and `status` as `null` rather than
|
|
1864
|
+
omitting them, so an HTTP-only member is a compile error there; a
|
|
1865
|
+
carrier union is a discriminated union to narrow on `ctx.carrier`. `Meta` and
|
|
1552
1866
|
`WireError` spell **exactly** the fixed D6 shapes (§10.1) —
|
|
1553
1867
|
`OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
|
|
1554
1868
|
a test holds the text to them; `details` is `unknown` and `status`
|
|
1555
1869
|
`number | null`, never optional members. An input-less operation's
|
|
1556
|
-
`input` is `null`; an opaque operation appears
|
|
1870
|
+
`input` is `null`; an opaque operation appears in `UrlOperations` and
|
|
1871
|
+
`ByteOperations`, never in `Operations`; a contract without one still
|
|
1872
|
+
declares an empty `ByteOperations`, so `bytes` is uncallable rather
|
|
1873
|
+
than absent.
|
|
1874
|
+
|
|
1875
|
+
One convention rides on top of emit's reading, and it is the suite's:
|
|
1876
|
+
a string with `format: "date-time"` or `format: "date"` is declared as
|
|
1877
|
+
`DateTime` — `string & { __jarenTag: 'date-time' }` — rendered once per
|
|
1878
|
+
document and referenced from every position, an array item as much as a
|
|
1879
|
+
member. Emit itself records a format only as a dropped constraint; the
|
|
1880
|
+
brand is applied by this projection so a consumer's generated types
|
|
1881
|
+
agree with `@jarenjs/db`'s entity types (`entityEmitModel`) and with
|
|
1882
|
+
`@jarenjs/linq`'s schema and contract pens, which read a date format the
|
|
1883
|
+
same way. A contract that already declares a `$defs` entry named
|
|
1884
|
+
`DateTime` keeps it; the brand takes the next free name.
|
|
1557
1885
|
|
|
1558
1886
|
### §12.4 Markdown
|
|
1559
1887
|
|
|
@@ -1754,14 +2082,19 @@ no `negotiate`, no `pending`.
|
|
|
1754
2082
|
refusal is the pre-send `JC2050` outcome (kind `contract`, details
|
|
1755
2083
|
by `policy.errors.details`) and nothing ran — the same refusal every
|
|
1756
2084
|
client binding shares;
|
|
1757
|
-
3. the
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
2085
|
+
3. the host lifecycle's `identify` runs (§7.7, `carrier: "local"`,
|
|
2086
|
+
the request-line members `null`), then the validation, then
|
|
2087
|
+
`acquire`, and the handler runs inside `enter` through the neutral
|
|
2088
|
+
pipeline with the frozen context `{ op, trace, carrier: "local",
|
|
2089
|
+
host, signal, method: null, path: null, params: null, headers: {},
|
|
2090
|
+
body: null, fail, idempotency: null, etag: null, status: null }` —
|
|
2091
|
+
`trace` from the `trace` option (default `crypto.randomUUID`),
|
|
2092
|
+
`signal` the caller's composed with the client's closer. `etag`,
|
|
2093
|
+
`status` and `body` are `null`, never callable: statuses, entity tags
|
|
2094
|
+
and bytes do not exist here, and a handler that reaches for them
|
|
2095
|
+
fails honestly (`JC2070`) instead of pretending; a hook fault is
|
|
2096
|
+
`JC2070` too, a hook's declared failure is a `failure` outcome, and
|
|
2097
|
+
the releases run before the outcome is exposed;
|
|
1765
2098
|
4. the outcome (§10.1 shapes, assembled by the same assembler):
|
|
1766
2099
|
|
|
1767
2100
|
| settlement | outcome |
|
|
@@ -1771,7 +2104,7 @@ no `negotiate`, no `pending`.
|
|
|
1771
2104
|
| any handler fault — a throw, a rejection, an undeclared code, an output or error-details schema violation | kind `contract` `JC2070`, message `contract/local-handler-failed`; the distinguishing cause goes to `onError(error, { op, trace })`, never into the outcome |
|
|
1772
2105
|
| `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
|
|
1773
2106
|
|
|
1774
|
-
Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
|
|
2107
|
+
Options: `trace`, `runtime` (the record `trace` defaults from), `validateOutput` (`'never'` is a declared downgrade,
|
|
1775
2108
|
reported in `capabilities.validatedOutput`; the output is validated
|
|
1776
2109
|
ONCE, in the pipeline — the assembler does not re-validate what never
|
|
1777
2110
|
crossed a wire), `catalog`, `onError`. Capabilities:
|
|
@@ -1806,9 +2139,20 @@ The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
|
|
|
1806
2139
|
|
|
1807
2140
|
## §16 The port binding
|
|
1808
2141
|
|
|
2142
|
+
A request and a subscription on this carrier run the host lifecycle
|
|
2143
|
+
of §7.7 with `carrier: "port"`: `identify` after the operation
|
|
2144
|
+
resolved and before the input is validated, `acquire` after it, the
|
|
2145
|
+
handler inside `enter` with the frozen context `{ op, trace, carrier:
|
|
2146
|
+
"port", host, signal, method: null, path: null, params: null,
|
|
2147
|
+
headers: {}, body: null, fail, idempotency: null, etag: null, status:
|
|
2148
|
+
null }`. A hook fault is `JC2070`, a hook's declared failure the
|
|
2149
|
+
declared error frame; the releases run after the response frame was
|
|
2150
|
+
posted, and for a subscription after the runner's stop/close/done
|
|
2151
|
+
sequence.
|
|
2152
|
+
|
|
1809
2153
|
`servePort(contract, handlers, { channel, trace?, validateOutput?,
|
|
1810
|
-
catalog?, onError? })` and `openPortClient(contract, { channel,
|
|
1811
|
-
timeoutMs = 15000, catalog? })` — request/response over anything with
|
|
2154
|
+
catalog?, onError?, runtime? })` and `openPortClient(contract, { channel,
|
|
2155
|
+
timeoutMs = 15000, catalog?, runtime? })` — request/response over anything with
|
|
1812
2156
|
`postMessage` and a message-listener surface: a `MessagePort` (started
|
|
1813
2157
|
automatically), a `Worker`, a `BroadcastChannel`, a worker's own
|
|
1814
2158
|
`self`, or a plain object of that shape. The server prepares the same
|
|
@@ -1839,7 +2183,10 @@ Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
|
|
|
1839
2183
|
```
|
|
1840
2184
|
|
|
1841
2185
|
**Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
|
|
1842
|
-
`clientId` a
|
|
2186
|
+
`clientId` a fresh identifier per client instance from the runtime
|
|
2187
|
+
record's `uuid` (a v4 UUID with no record; a deterministic record MUST
|
|
2188
|
+
still answer a distinct value per client, or two clients on one channel
|
|
2189
|
+
take each other's frames), `seq` a per-client counter — so
|
|
1843
2190
|
two clients on one shared channel can never collide, and a client
|
|
1844
2191
|
ignores every frame whose id does not start with its own `clientId +
|
|
1845
2192
|
":"` (one cheap prefix test before any map lookup). A late response
|
|
@@ -1933,11 +2280,26 @@ Subscription = {
|
|
|
1933
2280
|
result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
|
|
1934
2281
|
subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
|
|
1935
2282
|
close(), // release the registration
|
|
1936
|
-
replay?(
|
|
2283
|
+
replay?(after, { limit, maxBytes, signal }), // optional: ONE page of the emissions after `after`
|
|
2284
|
+
// → { items, next?, earliestAvailable, highWatermark, hasMore, resetRequired }
|
|
1937
2285
|
mode?, // ignored by the binding
|
|
1938
2286
|
}
|
|
1939
2287
|
```
|
|
1940
2288
|
|
|
2289
|
+
`replay` answers a **page**, never an array — the exact shape
|
|
2290
|
+
`@jarenjs/db`'s `changes.page()` answers (LIVE-FORMAT §5), so a store's
|
|
2291
|
+
bounded change reader is a replay source as returned: `items` are
|
|
2292
|
+
`{ patch, seq }` emissions in ascending seq above `after`, at most
|
|
2293
|
+
`limit` of them and at most `maxBytes` serialized patch bytes; `next`
|
|
2294
|
+
is the seq to continue from; `earliestAvailable`/`highWatermark` are
|
|
2295
|
+
the log's watermarks; `hasMore` says records remain; `resetRequired:
|
|
2296
|
+
true` is the total refusal — `items` empty, `next` absent — for a
|
|
2297
|
+
cursor that fell behind the log's retention. Every member is read
|
|
2298
|
+
defensively: a page that breaks the shape or its bounds is a host fault
|
|
2299
|
+
(`JC2008` / `JC2070`) that ends the stream, and a `replay` that answers
|
|
2300
|
+
an array is that fault too (an array would materialize a history the
|
|
2301
|
+
bounds exist to keep out).
|
|
2302
|
+
|
|
1941
2303
|
— a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
|
|
1942
2304
|
+ `subscribe` + `close`; it has no `replay`), so a handler is one line:
|
|
1943
2305
|
`(input) => store.collection('x').live(doc, { externals: input })`. No
|
|
@@ -1956,7 +2318,50 @@ the server broke the contract; the cause goes to `onError`, never the
|
|
|
1956
2318
|
wire), forwards each emission verbatim (the binding never mutates a
|
|
1957
2319
|
patch), and calls `stop()` then `close()` **exactly once** — on peer
|
|
1958
2320
|
disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
|
|
1959
|
-
close, and after an `error` emission ends the stream.
|
|
2321
|
+
close, and after an `error` emission ends the stream. Both may answer
|
|
2322
|
+
a promise: the binding awaits `stop()`, then `close()`, then releases
|
|
2323
|
+
the carrier, and reports completion only after all three settled. The
|
|
2324
|
+
carrier's writes are serialized — one event at a time, the next only
|
|
2325
|
+
after the previous write settled (`createAwaitedSink`,
|
|
2326
|
+
`@jarenjs/core/async`) — so an emission that arrives while the carrier
|
|
2327
|
+
is waiting on the socket is queued in order, never overlapped and never
|
|
2328
|
+
dropped; a carrier write that fails (the peer dropped the socket, the
|
|
2329
|
+
consumer cancelled the stream) releases the subscription silently. The
|
|
2330
|
+
queue is **bounded** (§18.1): what it holds is charged until each write
|
|
2331
|
+
settled, and the emission that would cross the bound ends the stream
|
|
2332
|
+
with `JC2096` instead of growing memory or dropping an event.
|
|
2333
|
+
|
|
2334
|
+
An `{ error }` emission ends the stream, and the binding classifies it
|
|
2335
|
+
once, carrier-neutrally. When the error's `code` — read guardedly — is
|
|
2336
|
+
a string the operation **declares**, the stream ends with that declared
|
|
2337
|
+
failure: the `error` event carries the code, the operation's declared
|
|
2338
|
+
message rendered from the host catalog (never the error's own text),
|
|
2339
|
+
the error's `details` when they are JSON-safe, and `retryable` — the
|
|
2340
|
+
error's own boolean, else whether `policy.retry.on` names the code —
|
|
2341
|
+
and the client delivers a `failure` outcome under that code. Any other
|
|
2342
|
+
error — an undeclared code, a code that is not a string, a hostile
|
|
2343
|
+
accessor — is the host fault (`JC2008` / `JC2070`): the cause goes to
|
|
2344
|
+
`onError`, the wire carries the generic message, and the client reports
|
|
2345
|
+
`JC2093`. Declared codes are lowercase by grammar (`JC0011`), so a
|
|
2346
|
+
store's own coded error — `@jarenjs/db`'s `JD2060` when a live query
|
|
2347
|
+
crosses `live.maxMaintained` — is declared by **mapping** it in the
|
|
2348
|
+
handler, the cause riding along for the observer:
|
|
2349
|
+
|
|
2350
|
+
```js
|
|
2351
|
+
// errors: { overflow: { status: 507 } } declared on the operation
|
|
2352
|
+
'data.live': async (input) => {
|
|
2353
|
+
const live = await store.collection(input.collection).live(query);
|
|
2354
|
+
return {
|
|
2355
|
+
get result() { return live.result; },
|
|
2356
|
+
subscribe: (cb) => live.subscribe((e) => cb(
|
|
2357
|
+
'error' in e && e.error?.code === 'JD2060' ? { error: { code: 'overflow', cause: e.error } } : e)),
|
|
2358
|
+
close: () => live.close(),
|
|
2359
|
+
};
|
|
2360
|
+
},
|
|
2361
|
+
```
|
|
2362
|
+
|
|
2363
|
+
Returned as is, the same error reaches the client as `JC2093` and the
|
|
2364
|
+
server's `onError` as the `DbRuntimeError` it is.
|
|
1960
2365
|
|
|
1961
2366
|
## §18 The stream wire
|
|
1962
2367
|
|
|
@@ -1973,11 +2378,15 @@ Response: `200`, `content-type: text/event-stream`, `cache-control:
|
|
|
1973
2378
|
no-store`, `x-jaren-trace`. Events, in order:
|
|
1974
2379
|
|
|
1975
2380
|
- `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
|
|
1976
|
-
that names none), data `{ "value": <snapshot>, "resumed": false
|
|
2381
|
+
that names none), data `{ "value": <snapshot>, "resumed": false,
|
|
2382
|
+
"reset": false, "earliestAvailable": null, "highWatermark": null }`.
|
|
1977
2383
|
The envelope exists because a resume verdict cannot ride *inside* the
|
|
1978
2384
|
snapshot value without breaking a closed output schema; `resumed:
|
|
1979
2385
|
false` states this snapshot is a fresh document (a refused resume —
|
|
1980
2386
|
`JC2095` — looks exactly like this, which is how the client learns).
|
|
2387
|
+
The shape is stable: `reset: true` marks the snapshot that re-seeds a
|
|
2388
|
+
consumer whose cursor fell behind the server's retention (below), and
|
|
2389
|
+
the two watermarks are the replay source's when it reported them.
|
|
1981
2390
|
- `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
|
|
1982
2391
|
LIVE-FORMAT emission verbatim.
|
|
1983
2392
|
- heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
|
|
@@ -1992,20 +2401,50 @@ at that emission's seq — the consumer swaps its document instead of
|
|
|
1992
2401
|
patching it; nothing is dropped.
|
|
1993
2402
|
|
|
1994
2403
|
**Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
|
|
1995
|
-
resume. Under `resume: "replay"` the
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
(no snapshot)
|
|
1999
|
-
|
|
2000
|
-
|
|
2404
|
+
resume. Under `resume: "replay"` with a `replay` on the subscription
|
|
2405
|
+
the binding **pages**: it subscribes live first, then calls
|
|
2406
|
+
`replay(seq, { limit, maxBytes, signal })` and delivers each page's
|
|
2407
|
+
items as `patch` events (no snapshot), continuing from `next` until the
|
|
2408
|
+
**first page's** `highWatermark` is reached or the log has no more —
|
|
2409
|
+
a watermark that keeps rising on later pages cannot make replay chase a
|
|
2410
|
+
busy writer forever — and then continues live, discarding the
|
|
2411
|
+
emissions that arrived meanwhile whose seq the pages already covered.
|
|
2412
|
+
A page with `resetRequired: true` ends the replay without a suffix:
|
|
2413
|
+
the binding reads a fresh snapshot and emits it with `resumed: false`,
|
|
2414
|
+
`reset: true`, `earliestAvailable`, and `highWatermark` — where the
|
|
2415
|
+
event id and the `highWatermark` are the higher of the page's watermark
|
|
2416
|
+
and the highest live emission already buffered, because the snapshot
|
|
2417
|
+
just read reflects those emissions (§17.1's contract), and replaying
|
|
2418
|
+
one of them would apply a change twice. The consumer resumes from that
|
|
2419
|
+
id. Otherwise — `resume: "snapshot"`, or no `replay` — the stream starts
|
|
2420
|
+
with a fresh `snapshot` whose data carries `resumed: false` (`JC2095`,
|
|
2001
2421
|
informational, never an outcome).
|
|
2002
2422
|
|
|
2003
|
-
`
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2423
|
+
**Bounds.** `serveHttp`/`servePort` take `streamLimits: { replay: {
|
|
2424
|
+
limit, maxBytes }, queue: { events, bytes } }` (defaults 256 / 1 MiB
|
|
2425
|
+
for both): a replay page asks for at most `replay.limit` emissions and
|
|
2426
|
+
`replay.maxBytes` serialized patch bytes; the undelivered queue — the
|
|
2427
|
+
events that arrived while a carrier write was pending or a page was
|
|
2428
|
+
loading, the SSE text or port frame as it will go on the wire — holds
|
|
2429
|
+
at most `queue.events` frames and `queue.bytes` bytes, each charged
|
|
2430
|
+
until its write settled. When the next event would cross either bound
|
|
2431
|
+
the stream ends with the terminal `error` event `JC2096` (`kind:
|
|
2432
|
+
network`, retryable — the consumer reads slower than the source
|
|
2433
|
+
emits), then the subscription is released and the carrier tears its
|
|
2434
|
+
sink down (the node adapter destroys the socket, the fetch bridge
|
|
2435
|
+
errors the stream) rather than wait for a consumer that stopped
|
|
2436
|
+
reading; nothing is dropped silently, oldest or newest.
|
|
2437
|
+
|
|
2438
|
+
`toNodeHandler` writes SSE with `flushHeaders()` + `res.write`, waits
|
|
2439
|
+
for `drain` whenever `res.write()` answered `false` before the next
|
|
2440
|
+
event, and ends on close; `toFetchHandler` answers a `ReadableStream`
|
|
2441
|
+
body that produces on the consumer's `pull`; both abort `ctx.signal`
|
|
2442
|
+
when the peer goes away (`request.signal`, `req` close), which runs the
|
|
2443
|
+
exactly-once `stop()`/`close()`, and both tear the connection down
|
|
2444
|
+
after a `JC2096`. A heartbeat is never queued behind a
|
|
2445
|
+
heartbeat: while one waits on the sink, the interval skips. A
|
|
2446
|
+
dispatcher's `close()` ends every live SSE stream with `end`
|
|
2447
|
+
(`server-shutdown`) before releasing it (§9 has the adapter contract).
|
|
2009
2448
|
|
|
2010
2449
|
### §18.2 Port: push frames
|
|
2011
2450
|
|
|
@@ -2046,8 +2485,10 @@ other range:
|
|
|
2046
2485
|
| `JC2093` | contract | `contract/stream-error` | no | the stream ended with a server `error` event whose code the operation does not declare — a **declared** code lands as a `failure` outcome under its own code instead |
|
|
2047
2486
|
| `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
|
|
2048
2487
|
| `JC2095` | — | — | — | a requested resume was refused; informational, carried as `resumed: false` in the fresh snapshot's event data, never an outcome |
|
|
2488
|
+
| `JC2096` | network | `contract/slow-consumer` | yes | the stream's bounded queue would overflow — the consumer reads slower than the source emits; sent as the terminal `error` event, then the carrier tears the connection down |
|
|
2489
|
+
| `JC2097` | network | `contract/reconnect-exhausted` | no | the HTTP client's reconnect budget is spent: every further attempt after a network loss ended in another loss (client-side); `details` is `{ attempts, lastCode }` — the further attempts made and the last loss's code |
|
|
2049
2490
|
|
|
2050
|
-
`
|
|
2491
|
+
`JC2098–JC2109` are reserved for later stream codes. `JC1009` (an SSE
|
|
2051
2492
|
data string the frame cannot carry) and `JC1010` (`subscribe` of a
|
|
2052
2493
|
non-subscribe operation) are the stream's host programming errors
|
|
2053
2494
|
(§7.3's host table).
|
|
@@ -2055,32 +2496,63 @@ non-subscribe operation) are the stream's host programming errors
|
|
|
2055
2496
|
## §19 The client: `subscribe`
|
|
2056
2497
|
|
|
2057
2498
|
`client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
|
|
2058
|
-
signal, lastSeq }) → { stop() }` — on the `http`
|
|
2059
|
-
alike (`capabilities.stream: true`); `serveLocal` keeps
|
|
2499
|
+
signal, lastSeq, reconnect }) → { stop(), lastSeq }` — on the `http`
|
|
2500
|
+
and `port` clients alike (`capabilities.stream: true`); `serveLocal` keeps
|
|
2060
2501
|
`capabilities.stream: false` and needs no handler for a subscribe
|
|
2061
2502
|
operation (`invoke` of one throws `JC1005` there). A non-subscribe
|
|
2062
2503
|
operation is `JC1010`, thrown — the host named the wrong operation.
|
|
2063
2504
|
|
|
2064
|
-
- `onSnapshot(value, { seq, resumed
|
|
2065
|
-
|
|
2066
|
-
§18.1 defines
|
|
2505
|
+
- `onSnapshot(value, { seq, resumed, reset, earliestAvailable,
|
|
2506
|
+
highWatermark })` — a fresh, validated snapshot; the consumer
|
|
2507
|
+
replaces its document. `resumed` is `false` exactly as §18.1 defines
|
|
2508
|
+
it; `reset: true` marks the re-seed after a retention gap — its `seq`
|
|
2509
|
+
is the cursor to resume from — and the watermarks are the server
|
|
2510
|
+
log's when it reported them, `null` otherwise. The shape is the same
|
|
2511
|
+
for every snapshot.
|
|
2067
2512
|
- `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
|
|
2068
2513
|
client forwards patches; the app binding (§11.4) and the consumer
|
|
2069
2514
|
apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
|
|
2070
2515
|
the stream ends with `JC2092`.
|
|
2071
2516
|
- `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
|
|
2072
|
-
declared error event; `network` for a transport failure
|
|
2073
|
-
heartbeat
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2517
|
+
declared error event; `network` for a transport failure, a missed
|
|
2518
|
+
heartbeat, the server's `JC2096` — the consumer fell behind the
|
|
2519
|
+
stream's bounded queue — or `JC2097`, a spent reconnect budget;
|
|
2520
|
+
`contract` for `JC2090`/`JC2092`/`JC2093`, an invalid snapshot value,
|
|
2521
|
+
or a pre-send input refusal `JC2050`). Its `error` and `meta` carry
|
|
2522
|
+
every member (`status: null` where the wire has none). After
|
|
2523
|
+
`onError` the stream is finished and cleaned up.
|
|
2077
2524
|
- `onEnd({ reason })` — the server's `end` event; a stream that ends
|
|
2078
2525
|
without one is reported as `reason: "closed"`.
|
|
2079
2526
|
|
|
2080
2527
|
Every callback is optional and total for the client: a callback that
|
|
2081
2528
|
throws does not break the stream machinery. `signal` aborts the
|
|
2082
2529
|
subscription silently (the caller asked); `stop()` does the same and,
|
|
2083
|
-
on `port`, posts the `unsubscribe` frame.
|
|
2084
|
-
|
|
2085
|
-
the
|
|
2086
|
-
re-entered
|
|
2530
|
+
on `port`, posts the `unsubscribe` frame. The subscription's read-only
|
|
2531
|
+
`lastSeq` is the last delivered seq — `null` before the first event,
|
|
2532
|
+
the passed `lastSeq` until an event moves it — and the `lastSeq` option
|
|
2533
|
+
is what a re-entered `subscribe` passes (§18's resumption).
|
|
2534
|
+
|
|
2535
|
+
**Reconnection is opt-in and HTTP-only.** `reconnect: { max }` — a
|
|
2536
|
+
non-negative integer of *further* attempts, `0` when absent — makes the
|
|
2537
|
+
HTTP client re-establish the stream after a **network loss**: a
|
|
2538
|
+
rejected request (`JC2051`), a missed heartbeat (`JC2094`), the
|
|
2539
|
+
server's `JC2096`, or a body that ends before an `end` event. Each
|
|
2540
|
+
further attempt waits the retry backoff of §10.4 (`min(1000 · 2^n,
|
|
2541
|
+
8000)` ms plus up to 250 ms of the runtime's jitter, `n` counting from
|
|
2542
|
+
0) and sends the last delivered seq as `Last-Event-ID` — the seq a
|
|
2543
|
+
reset snapshot advanced included — with no callback for the loss in
|
|
2544
|
+
between; every attempt is a fresh request with a consumer, reader and
|
|
2545
|
+
watchdog of its own, so a stale attempt's late bytes and settlements
|
|
2546
|
+
reach no callback and close no newer reader. A declared failure, a
|
|
2547
|
+
contract outcome, the server's `end`, `stop()` and the signal are
|
|
2548
|
+
terminal on every setting. When the budget is spent the subscription
|
|
2549
|
+
ends with one `onError` — `JC2097` (`kind: network`, not retryable),
|
|
2550
|
+
its `details` `{ attempts, lastCode }` naming the further attempts
|
|
2551
|
+
made and the last loss's code — after the reader, the watchdog and the
|
|
2552
|
+
backoff were released. Without `reconnect` (or with `max: 0`) a
|
|
2553
|
+
`network` outcome is delivered as is and re-entering is the host's;
|
|
2554
|
+
a body that ends without an `end` event is then `onEnd({ reason:
|
|
2555
|
+
"closed" })`, not a loss. The port client validates the option exactly
|
|
2556
|
+
as the HTTP client does and then does nothing with it: a channel has no
|
|
2557
|
+
network loss to reconnect from (a closed channel is `JC2074`, final),
|
|
2558
|
+
and one options object serves both clients.
|