@jarenjs/contract 0.43.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 (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
package/README.md ADDED
@@ -0,0 +1,508 @@
1
+ # @jarenjs/contract
2
+
3
+ Operation contracts for the Jaren suite. A **`$contract` document** — the
4
+ sibling of `$model`, `$fsm` and `jaren-app` — declares the operations two
5
+ Jaren ends may exchange: JSON in, JSON out, each with a *kind* (`read` or
6
+ `command`), an input object schema, an output schema, declared errors, a
7
+ behavior *policy* and an HTTP *binding*. `compileContract` compiles it
8
+ **once** into per-operation validators, transport normalizers and a path
9
+ matcher whose static segments beat variables regardless of registration
10
+ order; `serveHttp` puts it behind HTTP as a **total** dispatch pipeline —
11
+ plain request in, plain response out, every request-caused failure a
12
+ coded response — with a `fetch` and a `node` adapter and idempotency
13
+ through a ledger interface; `openHttpClient` calls it from the other end
14
+ with the same validator and resolves a JSON **outcome** for everything a
15
+ server or a network can do; `contractAppBinding` + `createContractEffect`
16
+ let a `@jarenjs/app` document call every operation through one generated
17
+ task slot per operation and one registered effect; and the
18
+ **projections** turn the same compiled contract into every artifact a
19
+ consumer wants beside the runtime — a browser-safe public subset that is
20
+ itself a `$contract` document, a valid OpenAPI 3.1 document, TypeScript
21
+ declarations with a typed operation map, Markdown reference docs and
22
+ `@jarenjs/ai` tool definitions — with a `jaren-contract` CLI whose
23
+ `--check` fails CI the moment an artifact drifts. The contract knows its
24
+ own identity: `contract.revision()` is the SHA-256 of the canonical
25
+ public projection, served at the well-known path and carried in every
26
+ outcome's `meta.revision`, and `diffContracts(a, b)` classifies what
27
+ changed between two versions — breaking, additive, neutral or honestly
28
+ **unknown** — by a published rule table, with `jaren-contract diff
29
+ --fail-on breaking` as the CI gate. A `subscribe` operation streams a
30
+ `@jarenjs/db` `live()`-shaped subscription — the snapshot, then
31
+ LIVE-FORMAT `{ patch, seq }` emissions — as Server-Sent Events over
32
+ http and as push frames over port, resumable by seq. Every wire error
33
+ speaks twelve languages: the `contract/*` message catalog ships English
34
+ in-package and all eleven `@jarenjs/locales` packs carry it, key for
35
+ key, enforced by the repository's parity tests.
36
+
37
+ Zero dependencies outside the suite: `@jarenjs/core`, `@jarenjs/json`,
38
+ `@jarenjs/validate`, and — reached only from the `./project` subpath, so
39
+ a bundle that never projects never carries it — `@jarenjs/emit`. No
40
+ `eval`, CSP-safe; the adapters need only the
41
+ platform's `Request`/`Response` or Node's `(req, res)`. The normative contract is
42
+ [docs/CONTRACT-FORMAT.md](docs/CONTRACT-FORMAT.md); the grammar is
43
+ published as JSON Schema in
44
+ [`schemas/jaren-contract.schema.json`](schemas/jaren-contract.schema.json)
45
+ (with a mechanically derived draft-07 twin).
46
+
47
+ ## The document in one glance
48
+
49
+ ```json
50
+ {
51
+ "$contract": "0.1",
52
+ "id": "shop",
53
+ "$defs": {
54
+ "Product": { "type": "object", "required": ["id", "name"],
55
+ "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }
56
+ },
57
+ "operations": {
58
+ "catalog.load": {
59
+ "kind": "read",
60
+ "input": { "type": "object", "properties": { "since": { "type": "string", "format": "date-time" } } },
61
+ "output": { "type": "array", "items": { "$ref": "#/$defs/Product" } },
62
+ "policy": { "task": "switch", "cache": "revision" },
63
+ "http": { "method": "GET", "path": "/api/catalog" }
64
+ },
65
+ "product.save": {
66
+ "kind": "command",
67
+ "input": { "type": "object", "required": ["id", "revision", "product"], "properties": {
68
+ "id": { "type": "integer" }, "revision": { "type": "integer" },
69
+ "product": { "$ref": "#/$defs/Product" } } },
70
+ "output": { "$ref": "#/$defs/Product" },
71
+ "errors": { "conflict": { "status": 409 }, "not-found": { "status": 404 } },
72
+ "policy": { "idempotency": "required", "revision": "input:/revision" },
73
+ "http": { "method": "PUT", "path": "/api/products/{id}/master" }
74
+ },
75
+ "image.bytes": {
76
+ "kind": "read",
77
+ "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
78
+ "output": true,
79
+ "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ One input schema per operation; the binding only says **where** its
86
+ members travel (`path` variables, then `query` for a read and `body` for
87
+ a command by default, or an explicit `in` map). Path and query strings
88
+ are decoded by a normalizer compiled over exactly those members; body
89
+ members are never coerced. An operation without `http` is bound to the
90
+ canonical `POST /<op-id>`. A non-JSON `media` marks an operation
91
+ *opaque*: routed and matched, never validated as JSON.
92
+
93
+ ## Compile once, use everywhere
94
+
95
+ ```js
96
+ import { compileContract } from '@jarenjs/contract';
97
+
98
+ const contract = compileContract(doc); // ContractCompileError (JC00xx, with docPath) on a bad document
99
+
100
+ const hit = contract.match('PUT', '/api/products/12/master');
101
+ hit.op.id; // 'product.save'
102
+ hit.params; // { id: '12' } — decoded strings
103
+
104
+ const op = contract.operations['product.save'];
105
+ op.http.in; // { id: 'path', revision: 'body', product: 'body' }
106
+ op.input.transport.normalize({ id: '12' }); // { id: 12 } — path/query strings → declared types
107
+ op.input.validate({ id: 12, revision: 3, product: { id: 12, name: 'x' } }).valid; // true
108
+ op.output.validate({ id: 12 }).valid; // false — the validator's collect-errors contract
109
+ op.policy; // every default materialized
110
+ op.errors.conflict.status; // 409
111
+
112
+ contract.describe(); // pure JSON: resolved bindings + policy, defaults marked `inferred`
113
+ ```
114
+
115
+ The compile is synchronous and total for a hostile document: an
116
+ unresolved `$ref`, an unknown member (the vocabulary is closed), a
117
+ `GET` with a body, two operations sharing a route shape, a reserved
118
+ template form — each is a `ContractCompileError` with a stable code and
119
+ the JSON Pointer of the member at fault, at compile, never at request
120
+ time.
121
+
122
+ ## Serve it over HTTP
123
+
124
+ ```js
125
+ import { compileContract } from '@jarenjs/contract';
126
+ import { serveHttp } from '@jarenjs/contract/http';
127
+ import { toNodeHandler } from '@jarenjs/contract/node'; // or toFetchHandler from '@jarenjs/contract/fetch'
128
+ import { createMemoryLedger } from '@jarenjs/contract/ledger';
129
+ import http from 'node:http';
130
+
131
+ const server = serveHttp(compileContract(doc), {
132
+ 'catalog.load': async (input, ctx) => { // input = { since? } — query strings already coerced
133
+ const catalog = await loadCatalog(input.since);
134
+ ctx.etag(String(catalog.revision)); // 304 on a matching If-None-Match, etag: W/"…" otherwise
135
+ return catalog; // validated against the output schema before it leaves
136
+ },
137
+ 'product.save': async (input, ctx) => { // input = { id, revision, product } — path + body assembled
138
+ const saved = await save(input);
139
+ return saved ?? ctx.fail('conflict', {}, { current: await current(input.id) }); // 409, the declared code on the wire
140
+ },
141
+ 'image.bytes': (input, ctx) => ({ status: 200, headers: { 'content-type': 'image/png' }, body: bytes(input.id) }), // opaque: raw
142
+ }, { ledger: createMemoryLedger() }); // required: product.save declares idempotency
143
+
144
+ http.createServer(toNodeHandler(server)).listen(8080);
145
+
146
+ // or drive it directly — a pure function over plain objects, no socket needed
147
+ const response = await server.dispatch({ method: 'GET', url: '/api/catalog?since=2026-01-01T00:00:00Z', headers: {}, body: null });
148
+ response.status; // 200
149
+ response.headers['x-jaren-trace']; // the server trace of this request
150
+ JSON.parse(response.body); // the catalog
151
+ ```
152
+
153
+ The pipeline routes (404/405 with `Allow`), enforces the body limit
154
+ (413) before reading, checks the media (415), parses (400), assembles the
155
+ input from path, query and headers through a prototype-safe setter,
156
+ normalizes the transport strings, validates (400 with `details` by
157
+ `policy.errors.details`), claims the idempotency key, calls the handler
158
+ through one promise boundary, validates the output (500 — the server
159
+ broke the contract), applies `If-Match`/`If-None-Match`, serializes.
160
+ Every non-2xx body is `{ code, message, requestId, details?, retryable }`
161
+ with `x-jaren-trace` on the response; a handler's thrown error never
162
+ reaches the wire (`onError` sees it). `server.capabilities` says what
163
+ the binding carries — `head`, `etag`, `idempotency`, `validatedOutput` —
164
+ and never degrades silently: an idempotent operation without a `ledger`
165
+ is refused at construction. `GET /.well-known/jaren-contract` answers
166
+ `describe()`. The normative pipeline, taxonomy and ledger interface are
167
+ [CONTRACT-FORMAT.md §7–§9](docs/CONTRACT-FORMAT.md#7-the-http-server-binding).
168
+
169
+ ### Recipes: Fastify, Hono, Express
170
+
171
+ None of these is a dependency; each recipe is executed by a test that
172
+ imports the framework from the benchmark workspace.
173
+
174
+ ```js
175
+ // Fastify — a catch-all route, the raw body handed to dispatch
176
+ const app = fastify();
177
+ app.removeAllContentTypeParsers();
178
+ app.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => done(null, body));
179
+ app.all('/*', async (req, reply) => {
180
+ const r = await server.dispatch({ method: req.method, url: req.url, headers: req.headers, body: req.body ?? null });
181
+ reply.code(r.status).headers(r.headers);
182
+ return r.body === null ? reply.send() : reply.send(r.body);
183
+ });
184
+ ```
185
+
186
+ ```js
187
+ // Hono — the fetch handler is the whole app (Bun.serve, Deno, workers alike)
188
+ const app = new Hono();
189
+ app.all('*', (c) => toFetchHandler(server)(c.req.raw));
190
+ ```
191
+
192
+ ```js
193
+ // Express — the node handler is middleware
194
+ const app = express();
195
+ app.use(toNodeHandler(server));
196
+ ```
197
+
198
+ ## Call it from the other end
199
+
200
+ ```js
201
+ import { openHttpClient } from '@jarenjs/contract/client';
202
+
203
+ const client = openHttpClient(contract, { baseUrl: 'https://shop.example', timeoutMs: 5000 });
204
+
205
+ const loaded = await client.invoke('catalog.load', { since: '2026-01-01T00:00:00Z' }, { attempt: 1 });
206
+ // { ok: true, value: [...], meta: { op, attempt: 1, trace: '<x-jaren-trace>', revision, etag: 'W/"…"', notModified: false } }
207
+ // meta.revision is the server's contract revision once negotiate() has learned it, null before
208
+
209
+ const saved = await client.invoke('product.save', { id: 12, revision: 3, product }); // validated with the SAME validator the server runs, then PUT /api/products/12/master with the body members as JSON and a generated Idempotency-Key
210
+ if (!saved.ok) {
211
+ saved.kind; // 'failure' (a declared error or a JC2xxx the server answered) | 'network' | 'contract' | 'cancelled'
212
+ saved.error; // { code: 'conflict', message, status: 409, details: { current }, retryable: false } — JSON, never an Error
213
+ }
214
+ client.url('image.bytes', { id: 7 }); // 'https://shop.example/api/images/7' — an opaque operation is a URL, not an invoke
215
+ await client.negotiate(); // { compatible, reason: 'same-version' | 'server-accepts' | 'client-accepts' | 'version-mismatch' | 'unreachable' | 'not-a-contract', server, error }
216
+ ```
217
+
218
+ `invoke` **never rejects** for anything a server or a network can do —
219
+ invalid input is refused before anything is sent (`JC2050`), a transport
220
+ failure is `network` (`JC2051`, the error's name only — never its text),
221
+ an abort is `cancelled` (`JC2052`), an invalid or undeclared response is
222
+ `contract` (`JC2053`/`JC2055`); it throws only for the host's own
223
+ mistake (`JC1005`: an unknown or opaque operation). The three identities
224
+ stay apart by construction: `meta.attempt` is the caller's and is never
225
+ read from a response, `meta.trace` is the server's `x-jaren-trace`, and
226
+ the idempotency key is generated here (`keys`, or `ctx.idempotencyKey`)
227
+ and travels only as `Idempotency-Key` — with a durable `storage` it is
228
+ recorded without the input and `client.pending()` lists what a restart
229
+ must reconcile. Retry runs only under a declared `policy.retry`. The
230
+ normative client is [CONTRACT-FORMAT.md §10](docs/CONTRACT-FORMAT.md#10-the-http-client-binding).
231
+
232
+ ## Bindings: the same contract with no wire, or a message channel
233
+
234
+ HTTP is one of three bindings; the other two carry the SAME operations,
235
+ handlers and outcomes where no HTTP exists. **`local`** is the pipeline
236
+ in-process — the test seam, SSR, a CLI calling its own operations — with
237
+ the client and the server as one object:
238
+
239
+ ```js
240
+ import { openLocalClient } from '@jarenjs/contract/local'; // serveLocal is the same factory
241
+
242
+ const client = openLocalClient(contract, handlers); // the serveHttp handler table, reused verbatim
243
+ const saved = await client.invoke('product.save', { id: 12, revision: 3, product });
244
+ // a declared failure: { ok: false, kind: 'failure', error: { code: 'conflict', …, status: null, … } }
245
+ // — status is null and PRESENT: this binding carries no statuses and says so, never omits the member
246
+ ```
247
+
248
+ The jaren website runs its whole data plane on this binding: a compiled
249
+ `$contract` declares the reads for its package census, build provenance,
250
+ benchmark artifacts and repository documents, the browser resolves every one
251
+ of them through `openLocalClient` with output validation on, and the build's
252
+ generators prove what they write against the output schema of the operation
253
+ the page will read it through. It is a static site — there is no server to
254
+ talk to — so the contract buys shape rather than transport: a drifted
255
+ artifact settles as a typed refusal instead of a wrong render.
256
+
257
+ **`port`** is request/response over a `MessagePort`, a `Worker`, a
258
+ `BroadcastChannel` or a worker's own `self` — JSON frames marked
259
+ `jaren: "contract/0.1"` (the grammar ships as
260
+ `schemas/jaren-contract-port.schema.json`), so contract traffic shares a
261
+ channel with anything else without touching it:
262
+
263
+ ```js
264
+ // inside the worker
265
+ import { servePort } from '@jarenjs/contract/port';
266
+ servePort(contract, handlers, { channel: self });
267
+
268
+ // in the page
269
+ import { openPortClient } from '@jarenjs/contract/port';
270
+ const client = openPortClient(contract, { channel: worker, timeoutMs: 15_000 });
271
+ const rows = await client.invoke('data.rows', { collection: 'notes' });
272
+ ```
273
+
274
+ Request ids are `"<clientId>:<seq>"` with a UUID per client instance,
275
+ and a client ignores every frame outside its own prefix — so two tabs
276
+ on one shared channel can never settle each other's requests, whatever
277
+ they fire concurrently (the repository's own data studio runs its
278
+ cross-tab db-owner protocol on exactly this). A handler fault answers
279
+ `JC2070` (kind `contract` — never dressed as a declared failure), an
280
+ unanswered request is `JC2072` after `timeoutMs`, cancellation crosses
281
+ as a `cancel` frame with the id scoping as the guarantee.
282
+ `createContractEffect` and `contractTools` take these clients unchanged
283
+ — they read `invoke` and nothing else. What each binding carries, from
284
+ its frozen `capabilities`:
285
+
286
+ | capability | `http` server / client | `local` | `port` |
287
+ |---|---|---|---|
288
+ | `status` | yes | no (`error.status: null`) | no (`error.status: null`) |
289
+ | `headers` | yes | no | no |
290
+ | `media` (opaque operations) | yes | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
291
+ | `etag` | yes | no | no |
292
+ | `idempotency` | with a `ledger` / always sent | no — declared policy inert, stated | no — `key` reserved in the frame grammar |
293
+ | `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption | no (`JC1005` at invoke) | yes — push frames, per-client streams |
294
+ | `cancel` | `'signal'` | `'signal'` | `'message'` |
295
+
296
+ The normative bindings are [CONTRACT-FORMAT.md §15–§16](docs/CONTRACT-FORMAT.md#15-the-local-binding),
297
+ the stream wire [§17–§19](docs/CONTRACT-FORMAT.md#17-subscribe-operations).
298
+
299
+ ## Call it from a @jarenjs/app document
300
+
301
+ ```js
302
+ import { createApp, createTaskEffect } from '@jarenjs/app';
303
+ import { contractAppBinding, createContractEffect } from '@jarenjs/contract/app';
304
+
305
+ const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
306
+ // slice → { 'catalog.load': { id: 0, status: 'idle', kind: null, value: null, error: null, meta: null }, … } pure JSON, mount at /contract
307
+ // actions → 'contract/catalog.load/start' + '/done' + '/reset' per operation — the TASKS.md id guard built in
308
+ // schema → the slice's JSON Schema for validateState (value = the output schema or null)
309
+
310
+ const app = createApp({ state: { contract: slice }, view, actions: { ...actions, ...own } }, {
311
+ effects: { contract: createContractEffect(client, { createTaskEffect }) }, // ONE effect; the task mode comes from policy.task
312
+ });
313
+ app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
314
+ ```
315
+
316
+ A `subscribe` operation becomes a **subscription** instead of a task:
317
+ the binding additionally returns `subs` (spread into the app document)
318
+ and `createContractSubscription(client)` is the one `contract-stream`
319
+ handler they run — `start` flips the slot live, the snapshot and every
320
+ patch land id- and seq-guarded, and the maintained document keeps
321
+ LIVE's structural sharing because the handler applies the emissions
322
+ with `@jarenjs/json/patch` ([CONTRACT-FORMAT.md §11.4](docs/CONTRACT-FORMAT.md#114-subscribe-operations-the-generated-subscription)).
323
+
324
+ No route strings, no hand-written wrappers, and no import of
325
+ `@jarenjs/app` from this package — the documents cross as JSON and the
326
+ task-effect factory crosses as a function the host passes in. A
327
+ superseded read dispatches once with the newer result, an out-of-order
328
+ older response is rejected by the id guard, a double-dispatched command
329
+ runs once and its result lands, a slot can always be released with
330
+ `reset`, and every failure lands in state as the same outcome shape with
331
+ its kind beside it.
332
+ The runnable walkthrough is [docs/APP-INTEGRATION.md](docs/APP-INTEGRATION.md);
333
+ the normative binding is [CONTRACT-FORMAT.md §11](docs/CONTRACT-FORMAT.md#11-the-app-binding).
334
+
335
+ ## Project it to everything else
336
+
337
+ One compiled contract, five artifacts — every one deterministic, every
338
+ one checkable in CI (`@jarenjs/contract/project`):
339
+
340
+ ```js
341
+ import { publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools } from '@jarenjs/contract/project';
342
+
343
+ publicProjection(contract); // the browser-safe subset — ITSELF a valid $contract document
344
+ // (server-audience operations, limits and error detail levels stripped;
345
+ // operations with `policy: { audience: 'server' }` never leave the server)
346
+ toOpenApi(contract, { info: { title: 'Shop', version: '5' } });
347
+ // → { document, dropped }: OpenAPI 3.1, validated in this repo against the
348
+ // official meta-schema; declared errors become enum-pinned wire-error
349
+ // schemas; policy rides along as x-jaren-policy; every keyword the dialect
350
+ // cannot carry is refused (JC0060) or — under lenient — dropped and REPORTED
351
+ toTypeScript(contract); // one .d.ts: CatalogLoadInput/Output per operation, a typed Operations map,
352
+ // Outcome<T>/Meta/WireError exactly as every binding builds them, and a
353
+ // typed Client and Handlers — invoke('product.save', …) is fully typed
354
+ toMarkdown(contract); // reference docs: operations table, per-operation sections, the type tables
355
+ contractTools(contract, client);
356
+ // @jarenjs/ai ToolDefs (WebMCP for free) without importing that package:
357
+ // name 'product_save', a self-contained inputSchema, execute → the outcome
358
+ ```
359
+
360
+ And on the command line, the drift gate:
361
+
362
+ ```sh
363
+ jaren-contract openapi --contract shop.json --out api/ --info-title Shop
364
+ jaren-contract types --contract shop.json --out src/shop.d.ts --check # exit 1 when stale
365
+ jaren-contract docs --contract shop.json --out docs/
366
+ ```
367
+
368
+ `describe` and `public` print JSON; exit 0 current/written, 1 drift
369
+ under `--check`, 2 on a compile refusal printed as `code docPath reason`.
370
+ The normative projection rules — the public projection's member order
371
+ (the revision hashes those bytes), the OpenAPI mapping and keyword
372
+ policy, the tool naming — are
373
+ [CONTRACT-FORMAT.md §12](docs/CONTRACT-FORMAT.md#12-projections).
374
+
375
+ ## Know what changed: revision and diff
376
+
377
+ ```js
378
+ import { diffContracts, isCompatible } from '@jarenjs/contract/diff';
379
+
380
+ await contract.revision();
381
+ // 64 lowercase hex: the SHA-256 over the RFC 8785 canonical bytes of the
382
+ // public projection — memoized, so it is computed at most once per process.
383
+ // Two compiles of equal documents agree across machines; a change a client
384
+ // can observe moves it; a server-audience operation or a policy.limits
385
+ // value does not. GET /.well-known/jaren-contract answers it (computed
386
+ // lazily on the first request), negotiate() learns it, and every outcome
387
+ // after that carries it in meta.revision — correlation data, never the
388
+ // compatibility decision.
389
+
390
+ const { breaking, additive, neutral, unknown } = diffContracts(v1, v2);
391
+ // every change classified by the CONTRACT-FORMAT §13 rule table (R1–R15):
392
+ // breaking — an operation or error removed, a binding member moved, a new
393
+ // required input member, a narrowed input, a removed/optional-
394
+ // ized/narrowed output member, idempotency now required, an
395
+ // operation withdrawn to audience: server
396
+ // additive — an operation/error added, an optional input member, a widened
397
+ // schema, a relaxed idempotency
398
+ // neutral — task mode, retry, cache, policy.revision, doc
399
+ // unknown — what the checker does not model (anyOf/if/not, a CHANGED
400
+ // pattern, an external $ref, an error details schema):
401
+ // REPORTED, never silently classed
402
+ // each Change = { kind, op, docPath, from?, to?, rule } — the docPath a
403
+ // validator error would name, $refs resolved
404
+
405
+ isCompatible(clientContract, serverContract);
406
+ // the negotiation rule as a pure function (same version, or either end's
407
+ // compat names the other's) — the SAME implementation negotiate() runs,
408
+ // exported so a server can refuse an incompatible peer too
409
+ ```
410
+
411
+ ```sh
412
+ jaren-contract diff --from api/v1.json --to api/v2.json --fail-on breaking # exit 1 on a breaking change
413
+ ```
414
+
415
+ The revision answers "is this byte-for-byte the contract I compiled
416
+ against?"; `version`/`compat` answer "do the authors claim we speak?";
417
+ the diff answers "what exactly moved, and does it break me?". They are
418
+ three different questions and none is derived from another —
419
+ [CONTRACT-FORMAT.md §13–§14](docs/CONTRACT-FORMAT.md#13-the-breaking-change-diff).
420
+
421
+ ## Benchmarks
422
+
423
+ Measured on the committed suite (`npm run benchmark:contract` — a real
424
+ 123-route table, 47 GET; recipes, fairness decisions and the correctness
425
+ gates are in the file's header), published through the repository's
426
+ benchmark-figure gate so no number here is typed by hand:
427
+
428
+ - **Route match**: the compiled matcher resolves the probe mix —
429
+ static hot paths, variables, the static-beats-variable case, a miss —
430
+ at <!--bm:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 180 ns<!--/bm-->;
431
+ hono's TrieRouter is <!--bm:contract.match.vs-hono-->1.8x<!--/bm--> behind, and its RegExpRouter refuses this
432
+ route table outright (a static path registered after a param sibling).
433
+ - **Dispatch, in-process**: the whole pipeline (route, decode, validate
434
+ input, handler, validate output,
435
+ serialize) is <!--bm:contract.dispatch.vs-fastify-->2.8–15.1x<!--/bm-->
436
+ faster than Fastify driven through its own `inject` — a number that
437
+ includes Fastify's mock-stream harness, which is why the next row
438
+ exists.
439
+ - **The honest loss**: the bare pieces Fastify composes — find-my-way +
440
+ Ajv + fast-json-stringify, called directly with no harness and no
441
+ response validation
442
+ — are <!--bm:contract.dispatch.losses-->2.4–7.5x<!--/bm--> faster than
443
+ this pipeline. The wide end of that band is the bare `{ok:true}`
444
+ route, where the rival's compiled serializer answers in ~200 ns and
445
+ there is almost no work to amortize the pipeline against; on the
446
+ request shapes with real bodies and validation the loss sits at the
447
+ narrow end. That is the measured price of a total dispatch (every
448
+ hostile input settles into a coded response) that also proves the
449
+ server kept its own contract before a byte leaves. Over a real
450
+ loopback socket the two stacks are level: the socket dominates both.
451
+ - **Revision**: computing it
452
+ costs <!--bm:contract.revision.ms-->1.4 ms<!--/bm--> for the 123-operation
453
+ contract, once per process.
454
+
455
+ ## What it is not
456
+
457
+ Boundaries, stated as plainly as the capabilities — each one a
458
+ deliberate decision, not a gap:
459
+
460
+ - **Not a server framework.** No process manager, no middleware stack,
461
+ no plugin system, no logger. The server binding is a pure dispatch
462
+ pipeline over plain request/response objects; bring `node:http`,
463
+ `Bun.serve`, or any framework through the ≤15-line adapter recipes
464
+ above.
465
+ - **No authentication or authorization.** A request that reaches the
466
+ pipeline is dispatched by route alone. Compose auth in front of the
467
+ handler table (the adapter seam is where a host's middleware already
468
+ runs) — the contract declares what may be said, not who may say it.
469
+ - **No transport encryption.** TLS belongs to the server or proxy that
470
+ terminates the socket.
471
+ - **No replay protection beyond idempotency keys.** `Idempotency-Key`
472
+ deduplicates a declared command through the host's ledger; it is not
473
+ a nonce scheme and does not authenticate the sender.
474
+ - **Bytes are not JSON.** A non-JSON `media` marks an operation opaque:
475
+ routed and matched, path and query still decoded and validated, the
476
+ body handed over raw and never modeled. Images and OAuth redirects
477
+ are host paths, not JSON operations.
478
+ - **No replication or durability.** The ledger and the command
479
+ lifecycle ship as JSON documents (`$model`, `$fsm`) a host may open
480
+ with `@jarenjs/db`; the in-memory ledger is for tests and
481
+ single-process hosts. Durability is the host's.
482
+ - **No automatic reconnect.** A stream that ends with a `network`
483
+ outcome is re-entered by the host calling `subscribe` again with the
484
+ last delivered seq (`lastSeq` is the hook); the backoff/resume/give-up
485
+ policy is the open decision tracked in the repository ROADMAP.
486
+
487
+ ## What is here
488
+
489
+ Here: the document and its grammar, `compileContract`, `contract.match`,
490
+ `describe()`, the `JC0001–JC0017` compile errors; the HTTP server binding
491
+ (`serveHttp`, the `JC2001–JC2015` wire taxonomy with its English catalog,
492
+ `fetch` and `node` adapters, the ledger interface with `createMemoryLedger`
493
+ and the `idempotencyLedgerModel`/`commandLifecycleFsm` documents); the HTTP
494
+ client (`openHttpClient`, the D6 outcomes with the `JC2050–JC2058` client
495
+ codes, the client half of idempotency, retry, `negotiate`); the app
496
+ binding (`contractAppBinding`, `createContractEffect`); the projections
497
+ (`publicProjection`, `toOpenApi` with `JC0060`, `toTypeScript`,
498
+ `toMarkdown`, `contractTools`); `contract.revision()` with `JC0061`,
499
+ `diffContracts`/`isCompatible` and the `jaren-contract` CLI with `diff
500
+ --fail-on`; the `local` and `port` bindings (`openLocalClient`/`serveLocal`,
501
+ `servePort`/`openPortClient`, the `JC2070–JC2074` codes and the
502
+ `jaren-contract-port` frame grammar with collision-free client-scoped
503
+ request ids); the `subscribe` kind with the `stream` binding
504
+ (`client.subscribe` over SSE and port push frames carrying LIVE-FORMAT
505
+ patches, `JC2090–JC2095`, the generated app subscription with
506
+ `createContractSubscription`, and the one SSE codec of the suite in
507
+ `@jarenjs/core/text/sse`); and the `contract/*` locale packs in all
508
+ eleven `@jarenjs/locales` languages, key parity enforced by test.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file The WHATWG adapter: `toFetchHandler(dispatcher)` puts the
3
+ * dispatcher behind `(Request) => Promise<Response>` — the lingua franca
4
+ * of Bun.serve, Deno, service workers and Cloudflare-style hosts.
5
+ * Dependency-free and structurally typed: it needs only the platform's
6
+ * `Request`, `Response` and `Headers`.
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`).
18
+ */
19
+ export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
20
+ /**
21
+ * Put a dispatcher behind the WHATWG request/response pair.
22
+ * @param {HttpDispatcher} dispatcher
23
+ * @returns {(request: Request) => Promise<Response>}
24
+ * @example
25
+ * Bun.serve({ fetch: toFetchHandler(serveHttp(contract, handlers)) });
26
+ */
27
+ export declare function toFetchHandler(dispatcher: HttpDispatcher): (request: Request) => Promise<Response>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file The Node adapter: `toNodeHandler(dispatcher)` puts the dispatcher
3
+ * behind `(req, res)` — `http.createServer`'s listener, and what Express
4
+ * mounts with `app.use`. Dependency-free and STRUCTURALLY typed: nothing
5
+ * here imports `node:http`; the request is anything with `method`, `url`,
6
+ * `headersDistinct` (or `headers`) and a readable-stream event surface,
7
+ * the response anything with `writeHead`/`end`.
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 the request is destroyed once
12
+ * the response has flushed. A declared `content-length` above the limit
13
+ * is never read at all; an unmatched request's body is never read (the
14
+ * dispatcher answers 404/405 without it and the platform discards the
15
+ * rest). Bytes are handed to the dispatcher as received — for a JSON
16
+ * operation too, so its strict UTF-8 decode decides `JC2005`. Repeated
17
+ * header lines reach the dispatcher as arrays (`headersDistinct`), which
18
+ * is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
19
+ * aborts when the client goes away before the response finished.
20
+ */
21
+ export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
22
+ export type NodeRequestLike = {
23
+ method?: string;
24
+ url?: string;
25
+ headersDistinct?: Record<string, string[] | undefined>;
26
+ headers: Record<string, string | string[] | undefined>;
27
+ on: (event: string, listener: (...args: any[]) => void) => unknown;
28
+ pause?: () => unknown;
29
+ destroy?: (error?: Error) => unknown;
30
+ };
31
+ export type NodeResponseLike = {
32
+ writeHead: (status: number, headers?: Record<string, string>) => unknown;
33
+ end: (body?: string | Uint8Array, callback?: () => void) => unknown;
34
+ on: (event: string, listener: (...args: any[]) => void) => unknown;
35
+ write?: (chunk: string | Uint8Array) => unknown;
36
+ flushHeaders?: () => unknown;
37
+ writableFinished?: boolean;
38
+ headersSent?: boolean;
39
+ };
40
+ /**
41
+ * Put a dispatcher behind Node's `(req, res)` listener.
42
+ * @param {HttpDispatcher} dispatcher
43
+ * @returns {(req: NodeRequestLike, res: NodeResponseLike) => void}
44
+ * @example
45
+ * http.createServer(toNodeHandler(serveHttp(contract, handlers))).listen(8080);
46
+ */
47
+ export declare function toNodeHandler(dispatcher: HttpDispatcher): (req: NodeRequestLike, res: NodeResponseLike) => void;