@jarenjs/contract 0.49.2 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +191 -30
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. package/src/stream/sse.js +2 -0
package/README.md CHANGED
@@ -51,8 +51,9 @@ published as JSON Schema in
51
51
  "$contract": "0.1",
52
52
  "id": "shop",
53
53
  "$defs": {
54
- "Product": { "type": "object", "required": ["id", "name"],
55
- "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }
54
+ "Product": { "type": "object",
55
+ "properties": { "id": { "type": "integer" }, "name": { "type": "string" } },
56
+ "required": ["id", "name"] }
56
57
  },
57
58
  "operations": {
58
59
  "catalog.load": {
@@ -64,9 +65,10 @@ published as JSON Schema in
64
65
  },
65
66
  "product.save": {
66
67
  "kind": "command",
67
- "input": { "type": "object", "required": ["id", "revision", "product"], "properties": {
68
+ "input": { "type": "object", "properties": {
68
69
  "id": { "type": "integer" }, "revision": { "type": "integer" },
69
- "product": { "$ref": "#/$defs/Product" } } },
70
+ "product": { "$ref": "#/$defs/Product" } },
71
+ "required": ["id", "revision", "product"] },
70
72
  "output": { "$ref": "#/$defs/Product" },
71
73
  "errors": { "conflict": { "status": 409 }, "not-found": { "status": 404 } },
72
74
  "policy": { "idempotency": "required", "revision": "input:/revision" },
@@ -74,7 +76,7 @@ published as JSON Schema in
74
76
  },
75
77
  "image.bytes": {
76
78
  "kind": "read",
77
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
79
+ "input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
78
80
  "output": true,
79
81
  "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
80
82
  }
@@ -88,7 +90,54 @@ a command by default, or an explicit `in` map). Path and query strings
88
90
  are decoded by a normalizer compiled over exactly those members; body
89
91
  members are never coerced. An operation without `http` is bound to the
90
92
  canonical `POST /<op-id>`. A non-JSON `media` marks an operation
91
- *opaque*: routed and matched, never validated as JSON.
93
+ *opaque*: routed and matched, never validated as JSON, its bytes
94
+ streamed both ways — the handler pulls the upload chunk by chunk and
95
+ may answer a stream, and the HTTP client reaches it through `bytes()`.
96
+
97
+ ## The same document, by code
98
+
99
+ `@jarenjs/linq/contract` is the pen that writes this format. The
100
+ builders are the schema pen's, every `named()` schema is hoisted into
101
+ the contract's `$defs`, the members land in the order §12.1 fixes, and
102
+ no default is written — so the document below is byte for byte the one
103
+ above:
104
+
105
+ ```js
106
+ import * as s from '@jarenjs/linq/schema';
107
+ import { command, defineContract, error, http, read } from '@jarenjs/linq/contract';
108
+
109
+ const Product = s.named('Product', s.object({ id: s.integer(), name: s.string() }).open());
110
+
111
+ export const shop = defineContract({ id: 'shop' }, {
112
+ 'catalog.load': read({
113
+ input: s.object({ since: s.string().format('date-time').optional() }).open(),
114
+ output: s.array(Product),
115
+ policy: { task: 'switch', cache: 'revision' },
116
+ http: http({ method: 'GET', path: '/api/catalog' }),
117
+ }),
118
+ 'product.save': command({
119
+ input: s.object({ id: s.integer(), revision: s.integer(), product: Product }).open(),
120
+ output: Product,
121
+ errors: { conflict: error({ status: 409 }), 'not-found': error({ status: 404 }) },
122
+ policy: { idempotency: 'required', revision: 'input:/revision' },
123
+ http: http({ method: 'PUT', path: '/api/products/{id}/master' }),
124
+ }),
125
+ 'image.bytes': read({
126
+ input: s.object({ id: s.integer() }).open(),
127
+ output: true,
128
+ http: http({ method: 'GET', path: '/api/images/{id}', media: 'application/octet-stream' }),
129
+ }),
130
+ });
131
+
132
+ compileContract(shop.document); // the same compile, the same errors
133
+ ```
134
+
135
+ The types come with it, without the `types` projection: `ContractOf<typeof
136
+ shop>` is the operation map, and `typedClient`, `typedHandlers` and
137
+ `typedTools` carry it onto a client, a handler table and an AI toolbox.
138
+ The pen's document is
139
+ [CONTRACT-PEN.md](../linq/docs/CONTRACT-PEN.md); it imports nothing of
140
+ this package.
92
141
 
93
142
  ## Compile once, use everywhere
94
143
 
@@ -150,6 +199,20 @@ response.headers['x-jaren-trace']; // the server trace
150
199
  JSON.parse(response.body); // the catalog
151
200
  ```
152
201
 
202
+ Every option has a default. The host facts among them come from a
203
+ `runtime` record (`@jarenjs/core/runtime`) when one is given — and every
204
+ binding takes one: `serveHttp`, `servePort` and `openLocalClient` mint
205
+ their trace from its `uuid`, `openPortClient` its client id, `openHttpClient`
206
+ its idempotency keys (its `now` stamps the key records and its `random`
207
+ draws the retry jitter), and `createMemoryLedger` stamps claims with its
208
+ `now`. So a server, its ledger, a client, a store and a job queue share
209
+ one record and a deterministic run is configured once; an explicit `trace`,
210
+ `keys` or `now` still wins over the record's member, and with no record
211
+ every binding reads the platform as it always did. Give the server and its
212
+ ledger the SAME record: a ledger built without a clock follows the
213
+ binding's instants, and one built with its own clock must not disagree
214
+ with the server that stamps its claims.
215
+
153
216
  The pipeline routes (404/405 with `Allow`), enforces the body limit
154
217
  (413) before reading, checks the media (415), parses (400), assembles the
155
218
  input from path, query and headers through a prototype-safe setter,
@@ -167,6 +230,34 @@ is refused at construction. `GET /.well-known/jaren-contract` answers
167
230
  `describe()`. The normative pipeline, taxonomy and ledger interface are
168
231
  [CONTRACT-FORMAT.md §7–§9](docs/CONTRACT-FORMAT.md#7-the-http-server-binding).
169
232
 
233
+ **The host lifecycle.** Every server binding takes the same two hooks
234
+ ([§7.7](docs/CONTRACT-FORMAT.md#77-the-host-lifecycle-identify-acquire-release-settle)):
235
+ `identify(meta)` runs after the route resolved and before a byte of the
236
+ body is read, and answers `{ host, release? }` — the host `scope(ctx)`
237
+ sees; `acquire(input, identity, enter)` runs after the input validated
238
+ and after a new idempotency claim, and calls `enter({ host, release?,
239
+ settlement? })` once — the host the handler sees as `ctx.host`, frozen
240
+ beside it. A host that opens a transaction around `enter` commits it
241
+ when `enter` resolves and rolls it back when it rejects, and a lease
242
+ whose `settlement` is `{ ledger: createDbLedger(tx), required: true }`
243
+ has the claim recorded inside `enter`, so the domain write and the
244
+ receipt commit together or not at all. Releases run once each, acquired
245
+ before identity, before the response is exposed — or when an opaque
246
+ body or an SSE stream is done. A hook fault is the host's (`JC2008`,
247
+ observed), a hook's `meta.fail(code)` a declared failure. The generated
248
+ `HandlerContext<Host, Carrier>` carries `ctx.host` and `ctx.carrier`;
249
+ port and local contexts spell the HTTP-only members as `null`.
250
+
251
+ ```js
252
+ const server = serveHttp(contract, handlers, {
253
+ ledger: createDbLedger(db),
254
+ identify: (meta) => ({ host: { tenant: meta.headers['x-tenant'] ?? null } }),
255
+ acquire: (input, identity, enter) => db.transaction(
256
+ (tx) => enter({ host: { db: tx }, settlement: { ledger: createDbLedger(tx), required: true } }),
257
+ { mode: 'immediate' }),
258
+ });
259
+ ```
260
+
170
261
  Without a declared resolver, `If-Match`/`If-None-Match` are applied
171
262
  **after** the handler and only when it armed a tag — a cache device,
172
263
  **never a write guard**. The `preconditions` option is the write guard:
@@ -205,8 +296,10 @@ beat the wildcard, and Fastify's own `bodyLimit` never answers — the
205
296
  operation's `policy.limits.maxBodyBytes` is the single body ceiling,
206
297
  refusing as the contract's coded `JC2003` instead of Fastify's
207
298
  `FST_ERR_CTP_BODY_TOO_LARGE`. Subscribe operations stream (the adapter
208
- calls `response.stream`) and a dropped peer reaches the handler as
209
- `ctx.signal` — the earlier buffer-parser recipe carried neither. To
299
+ calls `response.stream`, writing each event only after the previous one
300
+ draineda slow reader parks the source instead of growing a buffer)
301
+ and a dropped peer reaches the handler as `ctx.signal` — the earlier
302
+ buffer-parser recipe carried neither. To
210
303
  confine the contract, register the same route in an encapsulated plugin
211
304
  with `{ prefix }`; the prefix must then prefix the contract's declared
212
305
  paths (canonical bindings and the well-known path included). One
@@ -352,10 +445,10 @@ its frozen `capabilities`:
352
445
  |---|---|---|---|
353
446
  | `status` | yes | no (`error.status: null`) | no (`error.status: null`) |
354
447
  | `headers` | yes | no | no |
355
- | `media` (opaque operations) | yes | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
448
+ | `media` (opaque operations) | yes — streamed both ways; `client.bytes()` | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
356
449
  | `etag` | yes | no | no |
357
450
  | `idempotency` | with a `ledger` / always sent | no — declared policy inert, stated | no — `key` reserved in the frame grammar |
358
- | `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption | no (`JC1005` at invoke) | yes — push frames, per-client streams |
451
+ | `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption (paged replay, bounded queue) | no (`JC1005` at invoke) | yes — push frames, per-client streams (the same replay and bounds) |
359
452
  | `cancel` | `'signal'` | `'signal'` | `'message'` |
360
453
 
361
454
  The normative bindings are [CONTRACT-FORMAT.md §15–§16](docs/CONTRACT-FORMAT.md#15-the-local-binding),
@@ -426,12 +519,27 @@ And on the command line, the drift gate:
426
519
 
427
520
  ```sh
428
521
  jaren-contract openapi --contract shop.json --out api/ --info-title Shop
429
- jaren-contract types --contract shop.json --out src/shop.d.ts --check # exit 1 when stale
522
+ jaren-contract types --contract shop.js --out src/shop.d.ts --check # exit 1 when stale; the module IS the source
430
523
  jaren-contract docs --contract shop.json --out docs/
524
+ jaren-contract diff --from api/v1.json --to shop.js --fail-on breaking
431
525
  ```
432
526
 
527
+ Every document flag — `--contract`, `--from`, `--to` — takes a `.json`
528
+ file or a pure module (`.js`, `.mjs`, `.cjs`, and `.ts`/`.mts`/`.cts`
529
+ where Node strips types) whose `default` or `contract` export is the
530
+ document or a `@jarenjs/linq/contract` pen (its `toJSON()` is the
531
+ emission); the module is evaluated twice and refused (exit 2) when the
532
+ two emissions differ, so a clock or randomness in a contract module
533
+ never projects two different declarations. The loader is
534
+ `@jarenjs/json/node`, the Node-only subpath `jaren-db` shares — this
535
+ package imports neither `linq` nor `db`. `types --out … --check` against
536
+ the module is the types twin CI runs: exit 1 when the declaration is
537
+ missing or stale, 0 when current, and an ordinary run rewrites nothing
538
+ that did not change.
539
+
433
540
  `describe` and `public` print JSON; exit 0 current/written, 1 drift
434
- under `--check`, 2 on a compile refusal printed as `code docPath reason`.
541
+ under `--check`, 2 on a compile refusal printed as `code docPath reason`
542
+ or an unreadable, impure or undocumented input.
435
543
  The normative projection rules — the public projection's member order
436
544
  (the revision hashes those bytes), the OpenAPI mapping and keyword
437
545
  policy, the tool naming — are
@@ -477,6 +585,9 @@ isCompatible(clientContract, serverContract);
477
585
  jaren-contract diff --from api/v1.json --to api/v2.json --fail-on breaking # exit 1 on a breaking change
478
586
  ```
479
587
 
588
+ `--fail-on` requires at least one change class; a missing or empty value is a
589
+ usage error (exit 2), so an unset CI variable cannot silently disable the gate.
590
+
480
591
  The revision answers "is this byte-for-byte the contract I compiled
481
592
  against?"; `version`/`compat` answer "do the authors claim we speak?";
482
593
  the diff answers "what exactly moved, and does it break me?". They are
@@ -492,19 +603,19 @@ benchmark-figure gate so no number here is typed by hand:
492
603
 
493
604
  - **Route match**: the compiled matcher resolves the probe mix —
494
605
  static hot paths, variables, the static-beats-variable case, a miss —
495
- at <!--bm:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 180 ns<!--/bm-->;
496
- hono's TrieRouter is <!--bm:contract.match.vs-hono-->1.8x<!--/bm--> behind, and its RegExpRouter refuses this
606
+ at <!--fact:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 185 ns<!--/fact-->;
607
+ hono's TrieRouter is <!--fact:contract.match.vs-hono-->1.9x<!--/fact--> behind, and its RegExpRouter refuses this
497
608
  route table outright (a static path registered after a param sibling).
498
609
  - **Dispatch, in-process**: the whole pipeline (route, decode, validate
499
610
  input, handler, validate output,
500
- serialize) is <!--bm:contract.dispatch.vs-fastify-->2.8–15.1x<!--/bm-->
611
+ serialize) is <!--fact:contract.dispatch.vs-fastify-->2.8–11.9x<!--/fact-->
501
612
  faster than Fastify driven through its own `inject` — a number that
502
613
  includes Fastify's mock-stream harness, which is why the next row
503
614
  exists.
504
615
  - **The honest loss**: the bare pieces Fastify composes — find-my-way +
505
616
  Ajv + fast-json-stringify, called directly with no harness and no
506
617
  response validation
507
- — are <!--bm:contract.dispatch.losses-->2.47.5x<!--/bm--> faster than
618
+ — are <!--fact:contract.dispatch.losses-->2.511.4x<!--/fact--> faster than
508
619
  this pipeline. The wide end of that band is the bare `{ok:true}`
509
620
  route, where the rival's compiled serializer answers in ~200 ns and
510
621
  there is almost no work to amortize the pipeline against; on the
@@ -513,8 +624,16 @@ benchmark-figure gate so no number here is typed by hand:
513
624
  hostile input settles into a coded response) that also proves the
514
625
  server kept its own contract before a byte leaves. Over a real
515
626
  loopback socket the two stacks are level: the socket dominates both.
627
+ - **The optimization trigger**: on the heaviest in-process row (the
628
+ 5×4-body PUT), response serialization is <!--fact:contract.serialization.share-->11.5%<!--/fact-->
629
+ of the request and output validation is <!--fact:contract.validateOutput.share-->29%<!--/fact-->.
630
+ A schema-driven serializer stays unscheduled while serialization is below
631
+ 25%: even making that stage free would move the whole request by only about a
632
+ tenth. The suite republishes both shares on every measured run; for a large
633
+ cached representation, validate on rebuild and serve by revision instead of
634
+ paying validation on every request.
516
635
  - **Revision**: computing it
517
- costs <!--bm:contract.revision.ms-->1.4 ms<!--/bm--> for the 123-operation
636
+ costs <!--fact:contract.revision.ms-->2.8 ms<!--/fact--> for the 123-operation
518
637
  contract, once per process.
519
638
 
520
639
  ## What it is not
@@ -538,19 +657,31 @@ deliberate decision, not a gap:
538
657
  a nonce scheme and does not authenticate the sender.
539
658
  - **Bytes are not JSON.** A non-JSON `media` marks an operation opaque:
540
659
  routed and matched, path and query still decoded and validated, the
541
- body handed over raw and never modeled. Images and OAuth redirects
542
- are host paths, not JSON operations.
543
- - **No replication or durability.** The ledger and the command
544
- lifecycle ship as JSON documents (`$model`, `$fsm`) a host may open
545
- with `@jarenjs/db`; the in-memory ledger is for tests and
546
- single-process hosts. Durability is the host's and needs no
547
- dependency: [CONTRACT-FORMAT.md §8.1](docs/CONTRACT-FORMAT.md#81-a-durable-ledger-over-nodesqlite--an-example-not-an-export)
660
+ body streamed to the handler as a pull source that never yields past
661
+ the declared limit, and never modeled. `client.bytes()` is its typed
662
+ door a live response stream, never a JSON value — and `invoke`
663
+ refuses it. Images and OAuth redirects are host paths, not JSON
664
+ operations.
665
+ - **No replication, and no storage of its own.** The ledger and the
666
+ command lifecycle ship as JSON documents (`$model`, `$fsm`); the
667
+ in-memory ledger is for tests and single-process hosts. A durable
668
+ ledger is one import away and adds no dependency here:
669
+ `createDbLedger` in `@jarenjs/linq/db` implements the ledger over a
670
+ `@jarenjs/db` store (immediate claims, a persisted generation fence,
671
+ settlement inside the host's transaction), and
672
+ [CONTRACT-FORMAT.md §8.1](docs/CONTRACT-FORMAT.md#81-a-durable-ledger-over-nodesqlite--an-example-not-an-export)
548
673
  is a complete, tested ~60-line ledger over `node:sqlite` (built into
549
- Node ≥ 24), deliberately an example rather than an export.
550
- - **No automatic reconnect.** A stream that ends with a `network`
551
- outcome is re-entered by the host calling `subscribe` again with the
552
- last delivered seq (`lastSeq` is the hook); the backoff/resume/give-up
553
- policy is the open decision tracked in the repository ROADMAP.
674
+ Node ≥ 24) for a host without the store — an example, not an export.
675
+ - **Reconnect is opt-in, HTTP-only, and network-only.**
676
+ `subscribe(op, input, { reconnect: { max } })` re-establishes a
677
+ stream after a network loss a rejected request, a missed heartbeat,
678
+ the server's `JC2096`, a body that ends before `end` — from the last
679
+ delivered seq under the retry backoff, and ends with one `JC2097`
680
+ when the budget is spent; a declared failure, a contract outcome or
681
+ the server's `end` never reconnects, and the port client has no
682
+ network loss to reconnect from. Absent, a `network` outcome is
683
+ delivered as is and the host re-enters `subscribe` with the
684
+ subscription's `lastSeq`.
554
685
 
555
686
  ## What is here
556
687
 
@@ -560,7 +691,10 @@ Here: the document and its grammar, `compileContract`, `contract.match`,
560
691
  `fetch` and `node` adapters, the ledger interface with `createMemoryLedger`
561
692
  and the `idempotencyLedgerModel`/`commandLifecycleFsm` documents); the HTTP
562
693
  client (`openHttpClient`, the D6 outcomes with the `JC2050–JC2058` client
563
- codes, the client half of idempotency, retry, `negotiate`); the app
694
+ codes, the client half of idempotency, retry, `negotiate`, and `bytes`
695
+ for the opaque operations — a live response stream and a streamed
696
+ upload, typed as `HttpClient`/`ByteOperations` by the projection and
697
+ `typedHttpClient`/`OpaqueOf` by the pen); the app
564
698
  binding (`contractAppBinding`, `createContractEffect`); the projections
565
699
  (`publicProjection`, `toOpenApi` with `JC0060`, `toTypeScript`,
566
700
  `toMarkdown`, `contractTools`); `contract.revision()` with `JC0061`,
@@ -574,3 +708,30 @@ patches, `JC2090–JC2095`, the generated app subscription with
574
708
  `createContractSubscription`, and the one SSE codec of the suite in
575
709
  `@jarenjs/core/text/sse`); and the `contract/*` locale packs in all
576
710
  eleven `@jarenjs/locales` languages, key parity enforced by test.
711
+
712
+ ## Exports
713
+
714
+ Every subpath a consumer can import, derived from the manifest by
715
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
716
+
717
+ <!--fact:exports.contract-->
718
+ | Import | Kind | Declarations |
719
+ |---|---|---|
720
+ | `@jarenjs/contract` | JavaScript | declared |
721
+ | `@jarenjs/contract/http` | JavaScript | declared |
722
+ | `@jarenjs/contract/fetch` | JavaScript | declared |
723
+ | `@jarenjs/contract/node` | JavaScript | declared |
724
+ | `@jarenjs/contract/ledger` | JavaScript | declared |
725
+ | `@jarenjs/contract/diff` | JavaScript | declared |
726
+ | `@jarenjs/contract/client` | JavaScript | declared |
727
+ | `@jarenjs/contract/local` | JavaScript | declared |
728
+ | `@jarenjs/contract/port` | JavaScript | declared |
729
+ | `@jarenjs/contract/stream` | JavaScript | declared |
730
+ | `@jarenjs/contract/app` | JavaScript | declared |
731
+ | `@jarenjs/contract/project` | JavaScript | declared |
732
+ | `@jarenjs/contract/schemas/jaren-contract-port.draft-07.schema.json` | schema | — |
733
+ | `@jarenjs/contract/schemas/jaren-contract-port.schema.json` | schema | — |
734
+ | `@jarenjs/contract/schemas/jaren-contract.draft-07.schema.json` | schema | — |
735
+ | `@jarenjs/contract/schemas/jaren-contract.schema.json` | schema | — |
736
+ | `@jarenjs/contract/package.json` | metadata | — |
737
+ <!--/fact-->
@@ -5,16 +5,17 @@
5
5
  * Dependency-free and structurally typed: it needs only the platform's
6
6
  * `Request`, `Response` and `Headers`.
7
7
  *
8
- * The adapter reads the body only when the matched operation can carry
9
- * one (`text()` for a JSON operation, `arrayBuffer()` for an opaque one),
10
- * refuses a declared `content-length` above the operation's limit
11
- * BEFORE reading (the dispatcher answers the 413 from the header), never
12
- * reads an unmatched request's body, and hands everything else to
13
- * `dispatch`. `Headers` combines repeated field lines with `, `, so a
14
- * repeated scalar header member is invisible here (the node adapter sees
15
- * distinct lines); `text()` decodes with replacement, so invalid UTF-8
16
- * reaches the JSON parser as U+FFFD (the node adapter hands bytes over
17
- * and the dispatcher's strict decode answers `JC2005`).
8
+ * The adapter hands the request's body stream to the dispatcher as a
9
+ * pull source only when the matched operation can carry one a JSON
10
+ * operation drains it under its limit there (the strict UTF-8 decode
11
+ * decides `JC2005`, as through the node adapter), an opaque handler
12
+ * pulls it chunk by chunk refuses a declared `content-length` above
13
+ * the operation's limit BEFORE reading (the dispatcher answers the 413
14
+ * from the header), never reads an unmatched request's body, and hands
15
+ * everything else to `dispatch`. A streamed response body becomes a
16
+ * `ReadableStream` that pulls one chunk per demand. `Headers` combines
17
+ * repeated field lines with `, `, so a repeated scalar header member is
18
+ * invisible here (the node adapter sees distinct lines).
18
19
  */
19
20
  export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
20
21
  /**
@@ -6,19 +6,27 @@
6
6
  * `headersDistinct` (or `headers`) and a readable-stream event surface,
7
7
  * the response anything with `writeHead`/`end`.
8
8
  *
9
- * The body is collected chunk by chunk up to the matched operation's
10
- * `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
11
- * answered with `connection: close`, and once the response has flushed
12
- * the socket lingers draining and discarding the rest of the upload
13
- * (bounded by a grace timer) before it is destroyed, so the close is a
14
- * FIN the peer can read the 413 through, not an RST that discards it. A declared `content-length` above the limit
15
- * is never read at all; an unmatched request's body is never read (the
9
+ * The body reaches the dispatcher as a PULL SOURCE over the request's
10
+ * own chunks nothing is collected here: a JSON operation drains it
11
+ * under `policy.limits.maxBodyBytes` in the dispatcher (its strict
12
+ * UTF-8 decode decides `JC2005`), an opaque handler pulls it one chunk
13
+ * at a time through a source that never yields past the limit. When an
14
+ * upload was pulled and left unread (a limit crossing, a response
15
+ * before EOF) the answer carries `connection: close`, and once it has
16
+ * flushed the socket lingers — draining and discarding the rest of the
17
+ * upload (bounded by a grace timer) before it is destroyed, so the
18
+ * close is a FIN the peer can read the 413 through, not an RST that
19
+ * discards it. A declared `content-length` above the limit is never
20
+ * read at all; an unmatched request's body is never read (the
16
21
  * dispatcher answers 404/405 without it and the platform discards the
17
- * rest). Bytes are handed to the dispatcher as received for a JSON
18
- * operation too, so its strict UTF-8 decode decides `JC2005`. Repeated
22
+ * rest). A streamed response body is written chunk by chunk behind the
23
+ * socket's `drain`. Repeated
19
24
  * header lines reach the dispatcher as arrays (`headersDistinct`), which
20
25
  * is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
21
- * aborts when the client goes away before the response finished.
26
+ * aborts when the client goes away before the response finished. A
27
+ * streaming (SSE) response writes each event only after the previous
28
+ * one drained: a `res.write()` that answers `false` parks the pump
29
+ * until `drain`, so a slow reader never grows the process's buffers.
22
30
  */
23
31
  export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
24
32
  export type NodeRequestLike = {
@@ -37,9 +45,15 @@ export type NodeResponseLike = {
37
45
  writeHead: (status: number, headers?: Record<string, string>) => unknown;
38
46
  end: (body?: string | Uint8Array, callback?: () => void) => unknown;
39
47
  on: (event: string, listener: (...args: any[]) => void) => unknown;
48
+ once?: (event: string, listener: (...args: any[]) => void) => unknown;
49
+ removeListener?: (event: string, listener: (...args: any[]) => void) => unknown;
50
+ off?: (event: string, listener: (...args: any[]) => void) => unknown;
40
51
  write?: (chunk: string | Uint8Array) => unknown;
41
52
  flushHeaders?: () => unknown;
53
+ destroy?: (error?: Error) => unknown;
42
54
  writableFinished?: boolean;
55
+ writableEnded?: boolean;
56
+ destroyed?: boolean;
43
57
  headersSent?: boolean;
44
58
  };
45
59
  /**
@@ -15,10 +15,14 @@
15
15
  *
16
16
  * `invoke` NEVER rejects for anything a server or a network can do; it
17
17
  * throws only for the host's own mistakes (`JC1005`: an unknown or
18
- * opaque operation). `url(op, input)` builds the URL of any operation —
19
- * what an `<img src>` uses for an opaque one. `negotiate()` asks the
20
- * server's well-known description whether the two ends speak compatible
21
- * versions. Everything per operation is decided once at `open`.
18
+ * opaque operation). `bytes(op, input, ctx)` is the opaque twin: one
19
+ * request whose success value carries the status, the headers, the
20
+ * media and the LIVE response body as a `ReadableStream` — never
21
+ * `text()`, never collected and whose `ctx.body` streams an upload.
22
+ * `url(op, input)` builds the URL of any operation — what an `<img src>`
23
+ * uses for an opaque one. `negotiate()` asks the server's well-known
24
+ * description whether the two ends speak compatible versions.
25
+ * Everything per operation is decided once at `open`.
22
26
  */
23
27
  import { JarenValidator } from '@jarenjs/validate';
24
28
  import { CLIENT_ERRORS } from './outcome.js';
@@ -48,7 +52,8 @@ export type HttpClientOptions = {
48
52
  */
49
53
  headers?: Record<string, string>;
50
54
  /**
51
- * - the idempotency key generator; default `crypto.randomUUID`
55
+ * - the idempotency key generator; default
56
+ * the runtime record's `uuid`, itself `crypto.randomUUID` by default
52
57
  */
53
58
  keys?: () => string;
54
59
  /**
@@ -72,13 +77,20 @@ export type HttpClientOptions = {
72
77
  */
73
78
  wellKnown?: string;
74
79
  /**
75
- * - the clock stamped into key records; default `Date.now`
80
+ * - the clock stamped into key records; default
81
+ * the runtime record's `now`, itself `Date.now` by default
76
82
  */
77
83
  now?: () => number;
78
84
  /**
79
85
  * - the validator `url()` compiles its path/query check with
80
86
  */
81
87
  validator?: JarenValidator<any>;
88
+ /**
89
+ * - the host's runtime record: its `uuid` generates idempotency keys
90
+ * and its `now` stamps key records, each only where `keys` / `now` is
91
+ * absent, and its `random` draws the retry backoff jitter
92
+ */
93
+ runtime?: Partial<import('@jarenjs/core/runtime').Runtime>;
82
94
  };
83
95
  export type InvokeContext = {
84
96
  /**
@@ -106,6 +118,38 @@ export type InvokeContext = {
106
118
  */
107
119
  ifMatch?: string;
108
120
  };
121
+ export type ByteContext = {
122
+ /**
123
+ * - cancels the request (`kind: "cancelled"`)
124
+ */
125
+ signal?: AbortSignal;
126
+ /**
127
+ * - the caller's attempt id, echoed in `meta.attempt`, never sent
128
+ */
129
+ attempt?: unknown;
130
+ /**
131
+ * - per-call headers (over the static ones)
132
+ */
133
+ headers?: Record<string, string>;
134
+ /**
135
+ * - sent as `If-None-Match`
136
+ */
137
+ ifNoneMatch?: string;
138
+ /**
139
+ * - sent as `If-Match`
140
+ */
141
+ ifMatch?: string;
142
+ /**
143
+ * - the request body
144
+ */
145
+ body?: string | Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> | null;
146
+ };
147
+ export type ByteResponse = {
148
+ status: number;
149
+ headers: Record<string, string>;
150
+ media: string | null;
151
+ body: ReadableStream<Uint8Array> | null;
152
+ };
109
153
  export type Negotiation = {
110
154
  compatible: boolean;
111
155
  reason: 'same-version' | 'server-accepts' | 'client-accepts' | 'version-mismatch' | 'unreachable' | 'not-a-contract';
@@ -144,6 +188,9 @@ export type SubscribeOptions = {
144
188
  onSnapshot?: (value: unknown, info: {
145
189
  seq: number;
146
190
  resumed: boolean;
191
+ reset: boolean;
192
+ earliestAvailable: number | null;
193
+ highWatermark: number | null;
147
194
  }) => void;
148
195
  onPatch?: (emission: {
149
196
  patch: unknown[];
@@ -158,15 +205,35 @@ export type SubscribeOptions = {
158
205
  */
159
206
  signal?: AbortSignal;
160
207
  /**
161
- * - the resume seq (what a reconnect passes)
208
+ * - the resume seq (what a re-entered subscribe passes)
162
209
  */
163
210
  lastSeq?: number;
211
+ /**
212
+ * - opt into re-establishing the
213
+ * stream after a network loss: at most `max` further attempts, each
214
+ * after the retry backoff and from the last delivered seq; the budget
215
+ * spent, `onError` gets one `JC2097`. Absent (or `max: 0`), a network
216
+ * outcome is delivered as is
217
+ */
218
+ reconnect?: {
219
+ max: number;
220
+ };
221
+ };
222
+ export type Subscription = {
223
+ stop: () => void;
224
+ /**
225
+ * - read-only
226
+ */
227
+ lastSeq: number | null;
164
228
  };
165
229
  export type HttpClient = {
166
230
  invoke: (op: string, input?: unknown, ctx?: InvokeContext) => Promise<Outcome>;
167
- subscribe: (op: string, input?: unknown, options?: SubscribeOptions) => {
168
- stop: () => void;
169
- };
231
+ /**
232
+ * - an
233
+ * opaque operation: the outcome's value is a {@link ByteResponse} (§10.6)
234
+ */
235
+ bytes: (op: string, input?: unknown, ctx?: ByteContext) => Promise<Outcome>;
236
+ subscribe: (op: string, input?: unknown, options?: SubscribeOptions) => Subscription;
170
237
  url: (op: string, input?: unknown) => string;
171
238
  negotiate: (options?: {
172
239
  signal?: AbortSignal;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file Version compatibility — the one implementation of the
3
- * negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13.1): two ends
3
+ * negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13): two ends
4
4
  * speak when they declare the same `version`, or when either end's
5
5
  * `compat` list names the other's `version`. The client's `negotiate()`
6
6
  * and any server that wants to refuse an incompatible peer both call
@@ -61,6 +61,7 @@ export declare const CONTRACT_CODES: Readonly<{
61
61
  JC1008: "openHttpClient, openPortClient, client.url, createContractEffect, createContractSubscription or a projection (publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools): an argument or option is malformed (not a compiled contract, fetch/keys/sleep/createTaskEffect/projectError not a function, storage without read/write, a non-object input to url, an ops entry naming no or an opaque operation, a tool name outside ^[a-zA-Z0-9_-]{1,64}$ or shared by two operations)";
62
62
  JC1009: "encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id";
63
63
  JC1010: "client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)";
64
+ JC1011: "a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out";
64
65
  JC2001: "no operation matches the request method and path (404)";
65
66
  JC2002: "the path shape is served under other methods (405, Allow lists them)";
66
67
  JC2003: "the request body exceeds policy.limits.maxBodyBytes, by content-length or by read length (413)";
@@ -96,6 +97,8 @@ export declare const CONTRACT_CODES: Readonly<{
96
97
  JC2093: "the stream ended with a server error event whose code the operation does not declare (kind contract; a declared code is a failure outcome under its own code)";
97
98
  JC2094: "the stream went silent for twice policy.stream.heartbeatMs (kind network, client-side)";
98
99
  JC2095: "a requested resume was refused — informational, carried as resumed:false in the fresh snapshot's event data, never an outcome";
100
+ JC2096: "the stream's bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)";
101
+ JC2097: "the client's reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)";
99
102
  }>;
100
103
  /**
101
104
  * A defect in the contract document itself, raised while