@jarenjs/contract 0.56.0 → 0.67.0
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 +139 -25
- 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/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 +580 -128
- 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/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
|
@@ -299,7 +299,8 @@ and **canonicalized to `{name}`**, which is what `describe()` and every
|
|
|
299
299
|
projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
|
|
300
300
|
declared once per template. A static segment is any run of characters
|
|
301
301
|
except `/ { } : * ? #`, whitespace and control characters; a `%` in it
|
|
302
|
-
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).
|
|
303
304
|
|
|
304
305
|
Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
|
|
305
306
|
forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
|
|
@@ -322,15 +323,22 @@ The **shape** of a binding is its method plus its template with every
|
|
|
322
323
|
variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
|
|
323
324
|
NOT share a shape (`JC0010` at the second one, in document order); the
|
|
324
325
|
canonical binding takes part (`POST /<id>` may collide with a declared
|
|
325
|
-
`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}`.
|
|
326
330
|
|
|
327
331
|
### §4.5 Opaque operations
|
|
328
332
|
|
|
329
333
|
A `media` other than `application/json` (or a `+json` structured-syntax
|
|
330
334
|
suffix, parameters ignored) marks the operation **opaque**: it is routed
|
|
331
335
|
and matched, its path/query still decoded, its body neither decoded nor
|
|
332
|
-
validated by the contract, and it is excluded from
|
|
333
|
-
|
|
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
|
|
334
342
|
bytes the contract never decodes, an opaque operation MUST NOT declare a
|
|
335
343
|
**body-located member** — neither through `http.body`, nor `http.in`,
|
|
336
344
|
nor the `command` default (`JC0017` at the member that placed it there,
|
|
@@ -498,10 +506,12 @@ is the operation's output. `ctx` is frozen per request:
|
|
|
498
506
|
|---|---|
|
|
499
507
|
| `op` | the compiled operation |
|
|
500
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 |
|
|
501
511
|
| `method`, `path` | the request line, path without the query |
|
|
502
512
|
| `params` | the raw decoded path strings, frozen |
|
|
503
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 |
|
|
504
|
-
| `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` |
|
|
505
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` |
|
|
506
516
|
| `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
|
|
507
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` |
|
|
@@ -511,6 +521,26 @@ is the operation's output. `ctx` is frozen per request:
|
|
|
511
521
|
An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
|
|
512
522
|
ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
|
|
513
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.
|
|
514
544
|
Its transport members are decoded and normalized into `input` and
|
|
515
545
|
validated like any other input (`JC2006`) — they **are** its whole
|
|
516
546
|
input, since an opaque operation cannot declare a body-located member
|
|
@@ -528,8 +558,10 @@ is `JC2008`.
|
|
|
528
558
|
### §7.2 The pipeline, in order
|
|
529
559
|
|
|
530
560
|
1. **The request object.** `method`/`url` strings, `headers` an object,
|
|
531
|
-
`body` a string, `Uint8Array
|
|
532
|
-
|
|
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.
|
|
533
565
|
2. **Route.** `url` is split at the first `?`; the path goes to
|
|
534
566
|
`contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
|
|
535
567
|
is tried, then `GET`. No match: an undecodable path (a malformed
|
|
@@ -538,10 +570,21 @@ is `JC2008`.
|
|
|
538
570
|
(with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
|
|
539
571
|
`Allow`; else `JC2001`. A matched operation without a handler (a
|
|
540
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.
|
|
541
579
|
3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
|
|
542
580
|
is `JC2003` **before** any read (the adapters honor this too, §9); a
|
|
543
|
-
body whose
|
|
544
|
-
|
|
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.
|
|
545
588
|
4. **Opaque** → the transport input validated as in step 8 when no
|
|
546
589
|
member is body-located (`JC2006`), then the raw handler through the
|
|
547
590
|
same boundary as step 11; done.
|
|
@@ -552,9 +595,10 @@ is `JC2008`.
|
|
|
552
595
|
accepted for `application/json`); else `JC2004`. A body-less
|
|
553
596
|
operation with a body **ignores** the body. An empty body needs no
|
|
554
597
|
media.
|
|
555
|
-
6. **Parse.**
|
|
556
|
-
|
|
557
|
-
|
|
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`).
|
|
558
602
|
7. **Assemble** the input object through a prototype-safe setter only, in
|
|
559
603
|
this order: path members (raw decoded strings), query members
|
|
560
604
|
(`URLSearchParams` semantics — `+` is a space; a member listed in
|
|
@@ -579,7 +623,13 @@ is `JC2008`.
|
|
|
579
623
|
(its `policy.idempotency` is `none` by construction).
|
|
580
624
|
9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
|
|
581
625
|
`Idempotency-Key` is `JC2007` under `required` and runs plainly under
|
|
582
|
-
`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`.
|
|
583
633
|
10. **Preconditions, opt-in** (§7.5): when the operation has a
|
|
584
634
|
`preconditions` resolver, the CURRENT tag is resolved BEFORE the
|
|
585
635
|
handler — a command consults it only under a conditional header, a
|
|
@@ -621,6 +671,19 @@ is `JC2008`.
|
|
|
621
671
|
charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
|
|
622
672
|
a `204` carries no body; a HEAD carries the `content-length` of the
|
|
623
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).
|
|
624
687
|
|
|
625
688
|
Every step's failure path returns a response. `dispatch` never rejects
|
|
626
689
|
for request content; a defect of the binding itself is caught last and
|
|
@@ -709,6 +772,7 @@ wire response:
|
|
|
709
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) |
|
|
710
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) |
|
|
711
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 |
|
|
712
776
|
|
|
713
777
|
### §7.4 Headers
|
|
714
778
|
|
|
@@ -777,15 +841,135 @@ running the handler and dropping the body (a declared `HEAD` operation
|
|
|
777
841
|
wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
|
|
778
842
|
path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
|
|
779
843
|
answers `describe()` — `revision: null` until the revision lands, `compat`
|
|
780
|
-
present — for negotiation. `trace` (default `
|
|
781
|
-
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);
|
|
782
846
|
`partial` allows missing handlers; `validateOutput` is `"always" |
|
|
783
847
|
"never"`; `preconditions` maps operation ids to pre-handler tag
|
|
784
848
|
resolvers (§7.5); `errorBody(wire, ctx)` and `onError(err, ctx)` are the
|
|
785
849
|
two host hooks (`ctx` is `null` before an operation is matched);
|
|
786
850
|
`catalog` is a message catalog (templates or compiled renderers)
|
|
787
851
|
consulted before the English one; `now` is the clock stamped into ledger
|
|
788
|
-
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.
|
|
789
973
|
|
|
790
974
|
## §8 Idempotency and the ledger
|
|
791
975
|
|
|
@@ -805,11 +989,22 @@ is the same request. The binding then calls the ledger:
|
|
|
805
989
|
```jsonc
|
|
806
990
|
// the Ledger interface — every method may return its value or a promise of it
|
|
807
991
|
{ "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
|
|
808
|
-
"commit": "(ref, response) → void",
|
|
809
|
-
"fail": "(ref, retryable, response?) → void",
|
|
810
|
-
"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" }
|
|
811
995
|
```
|
|
812
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
|
+
|
|
813
1008
|
Semantics the binding relies on: same key + same hash → `replay` — the
|
|
814
1009
|
stored `{ status, headers, body }` **verbatim** with a fresh
|
|
815
1010
|
`x-jaren-trace` and `idempotent-replayed: true`; same key + different
|
|
@@ -824,19 +1019,29 @@ declared failure is recorded as **failed** with its response and its
|
|
|
824
1019
|
key as retryable with no response; a POST-handler `JC2014` (the handler
|
|
825
1020
|
already ran and may have mutated) is recorded as **failed**, not
|
|
826
1021
|
retryable, with its 412 — a blind retry under the same key replays the
|
|
827
|
-
412 instead of running the handler again. `now` on a claim
|
|
828
|
-
|
|
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
|
|
829
1029
|
operations bypass the ledger; reads never carry a key. A ledger that
|
|
830
1030
|
throws or rejects is reported to `onError` and the response still goes
|
|
831
1031
|
out (a throwing `claim` is `JC2008`).
|
|
832
1032
|
|
|
833
|
-
`createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
|
|
1033
|
+
`createMemoryLedger({ ttlMs = 86_400_000, now, runtime })` (`@jarenjs/contract/ledger`)
|
|
834
1034
|
is the reference implementation over a `Map`: synchronous,
|
|
835
|
-
single-process, expiring on `claim` and `lookup`, with `sweep()` for a
|
|
836
|
-
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:
|
|
837
1041
|
|
|
838
1042
|
```jsonc
|
|
839
|
-
{ "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
|
|
840
1045
|
"op": "product.save", "scope": "tenant-a", "key": "k-1",
|
|
841
1046
|
"hash": "9f2a…", // 64 lowercase hex characters
|
|
842
1047
|
"status": "committed", // started | committed | failed
|
|
@@ -845,12 +1050,26 @@ host timer and `size`. The record it keeps is:
|
|
|
845
1050
|
"createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
|
|
846
1051
|
```
|
|
847
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
|
+
|
|
848
1063
|
Two documents ship the same shape as **data**, for a host that wants
|
|
849
1064
|
durability (this package imports neither `@jarenjs/db` nor
|
|
850
1065
|
`@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
|
|
851
|
-
collection `ledger`, key `/id`, that record as its schema (closed
|
|
852
|
-
indexes on `expiresAt` and `status` — a host
|
|
853
|
-
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`
|
|
854
1073
|
is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
|
|
855
1074
|
committed` on `commit`, `started → failed` on `fail`, `failed → started`
|
|
856
1075
|
on `claim` guarded by `$.context.retryable` — which the memory ledger
|
|
@@ -861,82 +1080,88 @@ walks exactly.
|
|
|
861
1080
|
A host that retries commands needs a ledger that survives a restart, and
|
|
862
1081
|
it does NOT need `@jarenjs/db` for that: `node:sqlite` is built into
|
|
863
1082
|
Node ≥ 24 — the suite's floor — so the ~60 lines below are as
|
|
864
|
-
dependency-free as the package
|
|
865
|
-
`
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
874
1095
|
deduplicates DELIVERY — the domain's own durable records stay
|
|
875
1096
|
authoritative for business state.
|
|
876
1097
|
|
|
877
1098
|
```js
|
|
878
1099
|
import { DatabaseSync } from 'node:sqlite';
|
|
1100
|
+
import { ledgerId } from '@jarenjs/contract/ledger';
|
|
879
1101
|
|
|
880
1102
|
/** A durable Ledger over one SQLite file — a host's example, not an export. */
|
|
881
1103
|
export function createSqliteLedger(path, { ttlMs = 86_400_000, now: clock = Date.now } = {}) {
|
|
882
1104
|
const db = new DatabaseSync(path);
|
|
883
1105
|
db.exec(`
|
|
884
1106
|
CREATE TABLE IF NOT EXISTS ledger (
|
|
885
|
-
|
|
886
|
-
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,
|
|
887
1108
|
hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('started', 'committed', 'failed')),
|
|
888
1109
|
response TEXT, retryable INTEGER,
|
|
889
1110
|
createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
|
|
890
1111
|
CREATE INDEX IF NOT EXISTS ledger_by_expires ON ledger (expiresAt);
|
|
891
1112
|
CREATE INDEX IF NOT EXISTS ledger_by_status ON ledger (status);`);
|
|
892
1113
|
const one = db.prepare('SELECT * FROM ledger WHERE id = ?');
|
|
893
|
-
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', ?, ?, ?)");
|
|
894
1115
|
const drop = db.prepare('DELETE FROM ledger WHERE id = ?');
|
|
895
|
-
|
|
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'");
|
|
896
1119
|
const reap = db.prepare('DELETE FROM ledger WHERE expiresAt <= ?');
|
|
897
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
|
+
};
|
|
898
1125
|
return {
|
|
899
1126
|
claim({ op, scope, key, hash, now }) {
|
|
900
|
-
const
|
|
901
|
-
const id = `${op}|${scope}|${key}`;
|
|
1127
|
+
const id = ledgerId(op, scope, key);
|
|
902
1128
|
db.exec('BEGIN IMMEDIATE'); // one writer: two processes cannot both claim the key
|
|
903
1129
|
try {
|
|
904
|
-
const row = one.get(id);
|
|
1130
|
+
const row = (one.get(id));
|
|
905
1131
|
if (row !== undefined) {
|
|
906
|
-
if (row.expiresAt <= at) drop.run(id);
|
|
1132
|
+
if (row.expiresAt <= at(now)) drop.run(id);
|
|
907
1133
|
else if (row.hash !== hash) { db.exec('COMMIT'); return { state: 'mismatch' }; }
|
|
908
1134
|
else if (row.status === 'started') { db.exec('COMMIT'); return { state: 'in-progress' }; }
|
|
909
1135
|
else if (row.status === 'committed') { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
910
1136
|
else if (row.retryable !== 1 && row.response !== null) { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
911
1137
|
else drop.run(id); // a retryable failure: the key runs again
|
|
912
1138
|
}
|
|
913
|
-
const
|
|
1139
|
+
const generation = crypto.randomUUID();
|
|
1140
|
+
put.run(id, generation, op, scope, key, hash, at(now), at(now), at(now) + ttlMs);
|
|
914
1141
|
db.exec('COMMIT');
|
|
915
|
-
return { state: 'new', ref };
|
|
1142
|
+
return { state: 'new', ref: { id, generation } };
|
|
916
1143
|
}
|
|
917
1144
|
catch (err) {
|
|
918
1145
|
db.exec('ROLLBACK');
|
|
919
1146
|
throw err;
|
|
920
1147
|
}
|
|
921
1148
|
},
|
|
922
|
-
commit(ref, response) {
|
|
923
|
-
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);
|
|
924
1151
|
},
|
|
925
|
-
fail(ref, retryable, response) {
|
|
926
|
-
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);
|
|
927
1154
|
},
|
|
928
|
-
lookup({ op, scope, key }) {
|
|
929
|
-
const row = one.get(
|
|
1155
|
+
lookup({ op, scope, key, now }) {
|
|
1156
|
+
const row = (one.get(ledgerId(op, scope, key)));
|
|
930
1157
|
if (row === undefined) return null;
|
|
931
|
-
if (row.expiresAt <=
|
|
1158
|
+
if (row.expiresAt <= at(now)) {
|
|
932
1159
|
drop.run(row.id);
|
|
933
1160
|
return null;
|
|
934
1161
|
}
|
|
935
|
-
|
|
936
|
-
delete record.seq; // the record shape is exactly LedgerRecord (§8)
|
|
937
|
-
return record;
|
|
1162
|
+
return { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 }; // exactly LedgerRecord (§8)
|
|
938
1163
|
},
|
|
939
|
-
sweep: () => Number(reap.run(
|
|
1164
|
+
sweep: (now) => Number(reap.run(at(now)).changes),
|
|
940
1165
|
close: () => db.close(),
|
|
941
1166
|
};
|
|
942
1167
|
}
|
|
@@ -962,26 +1187,53 @@ the platform:
|
|
|
962
1187
|
- **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
|
|
963
1188
|
`(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
|
|
964
1189
|
Cloudflare-style hosts, and Hono. It lowercases the headers into a
|
|
965
|
-
plain object, matches the operation first (cheap) to decide
|
|
966
|
-
body is
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
`
|
|
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.
|
|
971
1200
|
- **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
|
|
972
|
-
res)` — `http.createServer`'s listener and Express middleware.
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
(
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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.
|
|
985
1237
|
|
|
986
1238
|
Fastify, Hono and Express are recipes in the README, each ≤15 lines and
|
|
987
1239
|
executed by a test that imports the framework from the benchmark
|
|
@@ -994,27 +1246,28 @@ streaming and peer abort behave identically through all three.
|
|
|
994
1246
|
|
|
995
1247
|
`openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
|
|
996
1248
|
client half of the http driver pair: `open(contract, options) → Client`
|
|
997
|
-
with `Client = { invoke, url, negotiate, pending, capabilities,
|
|
998
|
-
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
|
|
999
1251
|
binding (§11) and later the AI tools read only `invoke`, `contract` and
|
|
1000
1252
|
`capabilities` — and **total in behavior**: `invoke` resolves an
|
|
1001
1253
|
**outcome** for everything a server or a network can do and rejects
|
|
1002
1254
|
only for the host's own mistake (`JC1005`: an operation the contract
|
|
1003
1255
|
does not declare, or an opaque one — `invoke` carries JSON; an opaque
|
|
1004
|
-
operation is reached through `url`).
|
|
1256
|
+
operation is reached through `bytes` (§10.6) and `url`).
|
|
1005
1257
|
|
|
1006
1258
|
```jsonc
|
|
1007
1259
|
// options — every one has a default
|
|
1008
1260
|
{ "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
|
|
1009
1261
|
"baseUrl": "", // prefixed to every path; '' = relative
|
|
1010
1262
|
"headers": {}, // static headers, merged UNDER per-call ones
|
|
1011
|
-
"keys": "
|
|
1263
|
+
"keys": "runtime.uuid", // the idempotency key generator (crypto.randomUUID with no record)
|
|
1012
1264
|
"storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
|
|
1013
1265
|
"timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
|
|
1014
1266
|
"sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
|
|
1015
1267
|
"catalog": null, // a message catalog consulted before the English one
|
|
1016
1268
|
"wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
|
|
1017
|
-
"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
|
|
1018
1271
|
```
|
|
1019
1272
|
|
|
1020
1273
|
`capabilities` is `{ name: "http", status: true, headers: true, media:
|
|
@@ -1177,6 +1430,14 @@ is the server-side half). A store that throws on the pre-send write is
|
|
|
1177
1430
|
`JC2054` and nothing is sent; a store that throws on the drop leaves
|
|
1178
1431
|
the record (the conservative side) and the outcome is unaffected.
|
|
1179
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
|
+
|
|
1180
1441
|
### §10.4 Retry
|
|
1181
1442
|
|
|
1182
1443
|
Only under a declared `policy.retry` (`{ max, on }`); never for an
|
|
@@ -1217,6 +1478,38 @@ itself (`null` when unreachable or not a contract). **Nothing else is
|
|
|
1217
1478
|
inferred**: an unrelated service on the port is exactly `not-a-contract`;
|
|
1218
1479
|
two unversioned contracts are `same-version`.
|
|
1219
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
|
+
|
|
1220
1513
|
## §11 The app binding
|
|
1221
1514
|
|
|
1222
1515
|
`contractAppBinding(contract, { namespace = "contract/", statePath =
|
|
@@ -1556,13 +1849,28 @@ name), the reachable `$defs` once, then — fixed text in a JTLT
|
|
|
1556
1849
|
stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
|
|
1557
1850
|
typed operation map: kind, input, output, the declared error codes as a
|
|
1558
1851
|
literal union), `UrlOperations` (opaque operations included, for
|
|
1559
|
-
`Client.url`),
|
|
1560
|
-
`
|
|
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
|
|
1561
1866
|
`WireError` spell **exactly** the fixed D6 shapes (§10.1) —
|
|
1562
1867
|
`OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
|
|
1563
1868
|
a test holds the text to them; `details` is `unknown` and `status`
|
|
1564
1869
|
`number | null`, never optional members. An input-less operation's
|
|
1565
|
-
`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.
|
|
1566
1874
|
|
|
1567
1875
|
One convention rides on top of emit's reading, and it is the suite's:
|
|
1568
1876
|
a string with `format: "date-time"` or `format: "date"` is declared as
|
|
@@ -1774,14 +2082,19 @@ no `negotiate`, no `pending`.
|
|
|
1774
2082
|
refusal is the pre-send `JC2050` outcome (kind `contract`, details
|
|
1775
2083
|
by `policy.errors.details`) and nothing ran — the same refusal every
|
|
1776
2084
|
client binding shares;
|
|
1777
|
-
3. the
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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;
|
|
1785
2098
|
4. the outcome (§10.1 shapes, assembled by the same assembler):
|
|
1786
2099
|
|
|
1787
2100
|
| settlement | outcome |
|
|
@@ -1791,7 +2104,7 @@ no `negotiate`, no `pending`.
|
|
|
1791
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 |
|
|
1792
2105
|
| `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
|
|
1793
2106
|
|
|
1794
|
-
Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
|
|
2107
|
+
Options: `trace`, `runtime` (the record `trace` defaults from), `validateOutput` (`'never'` is a declared downgrade,
|
|
1795
2108
|
reported in `capabilities.validatedOutput`; the output is validated
|
|
1796
2109
|
ONCE, in the pipeline — the assembler does not re-validate what never
|
|
1797
2110
|
crossed a wire), `catalog`, `onError`. Capabilities:
|
|
@@ -1826,9 +2139,20 @@ The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
|
|
|
1826
2139
|
|
|
1827
2140
|
## §16 The port binding
|
|
1828
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
|
+
|
|
1829
2153
|
`servePort(contract, handlers, { channel, trace?, validateOutput?,
|
|
1830
|
-
catalog?, onError? })` and `openPortClient(contract, { channel,
|
|
1831
|
-
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
|
|
1832
2156
|
`postMessage` and a message-listener surface: a `MessagePort` (started
|
|
1833
2157
|
automatically), a `Worker`, a `BroadcastChannel`, a worker's own
|
|
1834
2158
|
`self`, or a plain object of that shape. The server prepares the same
|
|
@@ -1859,7 +2183,10 @@ Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
|
|
|
1859
2183
|
```
|
|
1860
2184
|
|
|
1861
2185
|
**Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
|
|
1862
|
-
`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
|
|
1863
2190
|
two clients on one shared channel can never collide, and a client
|
|
1864
2191
|
ignores every frame whose id does not start with its own `clientId +
|
|
1865
2192
|
":"` (one cheap prefix test before any map lookup). A late response
|
|
@@ -1953,11 +2280,26 @@ Subscription = {
|
|
|
1953
2280
|
result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
|
|
1954
2281
|
subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
|
|
1955
2282
|
close(), // release the registration
|
|
1956
|
-
replay?(
|
|
2283
|
+
replay?(after, { limit, maxBytes, signal }), // optional: ONE page of the emissions after `after`
|
|
2284
|
+
// → { items, next?, earliestAvailable, highWatermark, hasMore, resetRequired }
|
|
1957
2285
|
mode?, // ignored by the binding
|
|
1958
2286
|
}
|
|
1959
2287
|
```
|
|
1960
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
|
+
|
|
1961
2303
|
— a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
|
|
1962
2304
|
+ `subscribe` + `close`; it has no `replay`), so a handler is one line:
|
|
1963
2305
|
`(input) => store.collection('x').live(doc, { externals: input })`. No
|
|
@@ -1976,7 +2318,50 @@ the server broke the contract; the cause goes to `onError`, never the
|
|
|
1976
2318
|
wire), forwards each emission verbatim (the binding never mutates a
|
|
1977
2319
|
patch), and calls `stop()` then `close()` **exactly once** — on peer
|
|
1978
2320
|
disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
|
|
1979
|
-
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.
|
|
1980
2365
|
|
|
1981
2366
|
## §18 The stream wire
|
|
1982
2367
|
|
|
@@ -1993,11 +2378,15 @@ Response: `200`, `content-type: text/event-stream`, `cache-control:
|
|
|
1993
2378
|
no-store`, `x-jaren-trace`. Events, in order:
|
|
1994
2379
|
|
|
1995
2380
|
- `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
|
|
1996
|
-
that names none), data `{ "value": <snapshot>, "resumed": false
|
|
2381
|
+
that names none), data `{ "value": <snapshot>, "resumed": false,
|
|
2382
|
+
"reset": false, "earliestAvailable": null, "highWatermark": null }`.
|
|
1997
2383
|
The envelope exists because a resume verdict cannot ride *inside* the
|
|
1998
2384
|
snapshot value without breaking a closed output schema; `resumed:
|
|
1999
2385
|
false` states this snapshot is a fresh document (a refused resume —
|
|
2000
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.
|
|
2001
2390
|
- `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
|
|
2002
2391
|
LIVE-FORMAT emission verbatim.
|
|
2003
2392
|
- heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
|
|
@@ -2012,20 +2401,50 @@ at that emission's seq — the consumer swaps its document instead of
|
|
|
2012
2401
|
patching it; nothing is dropped.
|
|
2013
2402
|
|
|
2014
2403
|
**Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
|
|
2015
|
-
resume. Under `resume: "replay"` the
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
(no snapshot)
|
|
2019
|
-
|
|
2020
|
-
|
|
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`,
|
|
2021
2421
|
informational, never an outcome).
|
|
2022
2422
|
|
|
2023
|
-
`
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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).
|
|
2029
2448
|
|
|
2030
2449
|
### §18.2 Port: push frames
|
|
2031
2450
|
|
|
@@ -2066,8 +2485,10 @@ other range:
|
|
|
2066
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 |
|
|
2067
2486
|
| `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
|
|
2068
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 |
|
|
2069
2490
|
|
|
2070
|
-
`
|
|
2491
|
+
`JC2098–JC2109` are reserved for later stream codes. `JC1009` (an SSE
|
|
2071
2492
|
data string the frame cannot carry) and `JC1010` (`subscribe` of a
|
|
2072
2493
|
non-subscribe operation) are the stream's host programming errors
|
|
2073
2494
|
(§7.3's host table).
|
|
@@ -2075,32 +2496,63 @@ non-subscribe operation) are the stream's host programming errors
|
|
|
2075
2496
|
## §19 The client: `subscribe`
|
|
2076
2497
|
|
|
2077
2498
|
`client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
|
|
2078
|
-
signal, lastSeq }) → { stop() }` — on the `http`
|
|
2079
|
-
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
|
|
2080
2501
|
`capabilities.stream: false` and needs no handler for a subscribe
|
|
2081
2502
|
operation (`invoke` of one throws `JC1005` there). A non-subscribe
|
|
2082
2503
|
operation is `JC1010`, thrown — the host named the wrong operation.
|
|
2083
2504
|
|
|
2084
|
-
- `onSnapshot(value, { seq, resumed
|
|
2085
|
-
|
|
2086
|
-
§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.
|
|
2087
2512
|
- `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
|
|
2088
2513
|
client forwards patches; the app binding (§11.4) and the consumer
|
|
2089
2514
|
apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
|
|
2090
2515
|
the stream ends with `JC2092`.
|
|
2091
2516
|
- `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
|
|
2092
|
-
declared error event; `network` for a transport failure
|
|
2093
|
-
heartbeat
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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.
|
|
2097
2524
|
- `onEnd({ reason })` — the server's `end` event; a stream that ends
|
|
2098
2525
|
without one is reported as `reason: "closed"`.
|
|
2099
2526
|
|
|
2100
2527
|
Every callback is optional and total for the client: a callback that
|
|
2101
2528
|
throws does not break the stream machinery. `signal` aborts the
|
|
2102
2529
|
subscription silently (the caller asked); `stop()` does the same and,
|
|
2103
|
-
on `port`, posts the `unsubscribe` frame.
|
|
2104
|
-
|
|
2105
|
-
the
|
|
2106
|
-
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.
|