@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.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
|
@@ -0,0 +1,1923 @@
|
|
|
1
|
+
# The jaren-contract format — normative
|
|
2
|
+
|
|
3
|
+
The contract between this document and the code: `compileContract` in
|
|
4
|
+
`@jarenjs/contract` implements exactly what is written here, and a test
|
|
5
|
+
holds the two together (every ```json block below is a complete document
|
|
6
|
+
that must validate against the published grammar and compile; the error
|
|
7
|
+
table in §6 must equal the package's `CONTRACT_CODES`). Section numbers
|
|
8
|
+
are stable — later sections are appended, never renumbered.
|
|
9
|
+
|
|
10
|
+
## §1 Purpose
|
|
11
|
+
|
|
12
|
+
A **contract** is a Jaren document — the sibling of `$model`, `$fsm` and
|
|
13
|
+
`jaren-app` — that declares the *operations* two Jaren ends may exchange:
|
|
14
|
+
JSON in, JSON out, each with a kind, an input schema, an output schema,
|
|
15
|
+
declared errors, a behavior policy and an HTTP binding. It is compiled
|
|
16
|
+
**once** into per-operation validators, transport normalizers and one
|
|
17
|
+
path matcher, and it is the single source every artifact around it is
|
|
18
|
+
projected from.
|
|
19
|
+
|
|
20
|
+
Format 0.1 covers the document, its compilation, the HTTP binding's
|
|
21
|
+
*shape* (§2–§6), the HTTP **server** binding that carries it (§7–§9:
|
|
22
|
+
the request pipeline and its wire errors, idempotency and the ledger
|
|
23
|
+
interface, the `fetch` and `node` adapters), the HTTP **client** binding
|
|
24
|
+
(§10: outcomes, the client half of idempotency, retry, negotiation), the
|
|
25
|
+
`@jarenjs/app` binding (§11: the generated documents and the one
|
|
26
|
+
effect) and the **projections** (§12: the public projection every other
|
|
27
|
+
artifact is built on, OpenAPI 3.1, TypeScript, Markdown, the AI tool
|
|
28
|
+
definitions, and the `jaren-contract` CLI). The in-process, message-port
|
|
29
|
+
and stream bindings, the revision hash and the locale catalogs are the
|
|
30
|
+
coming lines of this package and will append their sections here.
|
|
31
|
+
|
|
32
|
+
### §1.1 What the format is not
|
|
33
|
+
|
|
34
|
+
- Not a second schema language: `input`, `output` and error schemas are
|
|
35
|
+
ordinary JSON Schema, compiled by `@jarenjs/validate`.
|
|
36
|
+
- Not a router DSL: the path template dialect is RFC 6570 level 1 (`{name}`),
|
|
37
|
+
what OpenAPI uses; `:name` is accepted and canonicalized.
|
|
38
|
+
- Not a place to describe bytes: a non-JSON `media` marks an operation
|
|
39
|
+
**opaque** — routed and matched, its path/query still decoded, its body
|
|
40
|
+
neither decoded nor validated by the contract.
|
|
41
|
+
|
|
42
|
+
## §2 The document
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"$contract": "0.1",
|
|
47
|
+
"id": "shop",
|
|
48
|
+
"version": "5",
|
|
49
|
+
"compat": ["4"],
|
|
50
|
+
"$defs": {
|
|
51
|
+
"Product": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["id", "name", "price"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"id": { "type": "integer" },
|
|
56
|
+
"name": { "type": "string", "minLength": 1 },
|
|
57
|
+
"price": { "type": "number", "minimum": 0 }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"Catalog": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"required": ["revision", "products"],
|
|
63
|
+
"properties": {
|
|
64
|
+
"revision": { "type": "integer" },
|
|
65
|
+
"products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"Conflict": { "type": "object", "properties": { "current": { "$ref": "#/$defs/Product" } } }
|
|
69
|
+
},
|
|
70
|
+
"operations": {
|
|
71
|
+
"catalog.load": {
|
|
72
|
+
"kind": "read",
|
|
73
|
+
"input": { "type": "object", "properties": { "since": { "type": "string", "format": "date-time" } } },
|
|
74
|
+
"output": { "$ref": "#/$defs/Catalog" },
|
|
75
|
+
"errors": { "stale": { "status": 409 } },
|
|
76
|
+
"policy": { "task": "switch", "cache": "revision" },
|
|
77
|
+
"http": { "method": "GET", "path": "/api/catalog" },
|
|
78
|
+
"doc": "The whole catalog snapshot."
|
|
79
|
+
},
|
|
80
|
+
"product.save": {
|
|
81
|
+
"kind": "command",
|
|
82
|
+
"input": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["id", "revision", "product"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"id": { "type": "integer" },
|
|
87
|
+
"revision": { "type": "integer" },
|
|
88
|
+
"product": { "$ref": "#/$defs/Product" }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"output": { "$ref": "#/$defs/Product" },
|
|
92
|
+
"errors": {
|
|
93
|
+
"conflict": { "status": 409, "schema": { "$ref": "#/$defs/Conflict" } },
|
|
94
|
+
"not-found": { "status": 404 }
|
|
95
|
+
},
|
|
96
|
+
"policy": { "task": "exhaust", "idempotency": "required", "revision": "input:/revision" },
|
|
97
|
+
"http": {
|
|
98
|
+
"method": "PUT",
|
|
99
|
+
"path": "/api/products/{id}/master",
|
|
100
|
+
"in": { "revision": "body", "product": "body" }
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"image.bytes": {
|
|
104
|
+
"kind": "read",
|
|
105
|
+
"input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
|
|
106
|
+
"output": true,
|
|
107
|
+
"http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### §2.1 Members
|
|
114
|
+
|
|
115
|
+
| member | required | meaning |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `$contract` | yes | MUST be `"0.1"`. |
|
|
118
|
+
| `id` | no | An identifier for the contract (`[A-Za-z_][A-Za-z0-9_-]*`): a tool prefix, a file name. |
|
|
119
|
+
| `version` | no | The consumer's version string — a compatibility claim, unrelated to any content hash. |
|
|
120
|
+
| `compat` | no | Peer `version` strings this contract accepts. |
|
|
121
|
+
| `$defs` | no | Named schemas the operations reference as `#/$defs/<name>`. Every value MUST be a schema (an object or a boolean). |
|
|
122
|
+
| `operations` | yes | Operation id → operation. MUST carry at least one. |
|
|
123
|
+
|
|
124
|
+
**Every object in this format has a closed vocabulary.** The root, an
|
|
125
|
+
operation, `policy` and its `limits`/`errors`/`retry`/`stream`, `http`,
|
|
126
|
+
and an error declaration accept exactly the members listed for them; an
|
|
127
|
+
unknown member is `JC0013` at that member. A silently ignored `policy` is a
|
|
128
|
+
behavior bug, so this format refuses rather than ignores.
|
|
129
|
+
|
|
130
|
+
The document MUST be JSON: a member that is a function, a symbol, a
|
|
131
|
+
bigint, a non-finite number, a class instance, or part of a cycle is
|
|
132
|
+
`JC0001` at that member; a member whose accessor throws is `JC0001` at
|
|
133
|
+
that member too. `undefined` members are absent. `compileContract` reads
|
|
134
|
+
the document once, through guarded access, into a plain snapshot; the
|
|
135
|
+
`Contract` keeps that snapshot (deep-frozen) as `doc`, and the caller's
|
|
136
|
+
object is neither frozen nor mutated.
|
|
137
|
+
|
|
138
|
+
### §2.2 Operation ids
|
|
139
|
+
|
|
140
|
+
An operation id MUST match `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` —
|
|
141
|
+
dotted lowercase words (`catalog.load`, `product.save`). The same string
|
|
142
|
+
is a valid JSON member, a tool name, a file name and a URL path segment,
|
|
143
|
+
so it is stable across every projection. In a `docPath` an id is one
|
|
144
|
+
reference token: `/operations/product.save/http/path`.
|
|
145
|
+
|
|
146
|
+
### §2.3 `$ref`
|
|
147
|
+
|
|
148
|
+
A `$ref` inside `input`, `output`, an error schema or `$defs` resolves
|
|
149
|
+
**within the document** (`#`, `#/…` JSON Pointer, `#anchor`) or against
|
|
150
|
+
a schema passed as `compileContract(doc, { schemas })` by its `$id`
|
|
151
|
+
(with an optional pointer/anchor fragment). Anything unresolved is
|
|
152
|
+
`JC0007` at compile — never at request time. The compiler registers the
|
|
153
|
+
document with its validator under a synthetic id and compiles every
|
|
154
|
+
operation schema as a reference into it, so `#/$defs/Product` inside an
|
|
155
|
+
operation means the contract's own `$defs`, exactly as a reader expects.
|
|
156
|
+
That registration is **one per compile**: the document is added to the
|
|
157
|
+
(possibly host-injected, `compileContract(doc, { validator })`) validator
|
|
158
|
+
under `urn:jaren:contract:<n>`, `n` a process-wide counter, and is never
|
|
159
|
+
removed — a host sharing one validator across many compiles keeps them
|
|
160
|
+
all; a host that compiles many documents passes a validator per compile
|
|
161
|
+
or accepts the growth.
|
|
162
|
+
|
|
163
|
+
## §3 Operations, policy and the defaults
|
|
164
|
+
|
|
165
|
+
An operation is `{ kind, input?, output, errors?, policy?, http?, doc? }`.
|
|
166
|
+
|
|
167
|
+
| member | required | meaning |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `kind` | yes | `"read"`, `"command"` or `"subscribe"`. A subscribe operation's `output` is its **snapshot** schema and its emissions travel the stream binding (§17–§19). |
|
|
170
|
+
| `input` | no | A JSON Schema whose **effective type is `object`** — `"type": "object"` on the schema itself or on the schema a `$ref` chain reaches (`JC0005`). Its top-level `properties` are the members the HTTP binding places (§4). Absent means the operation takes no input. |
|
|
171
|
+
| `output` | yes | Any JSON Schema, `true` included (`JC0006` when absent). |
|
|
172
|
+
| `errors` | no | `code → { status?, schema? }`. A code matches `^[a-z][a-z0-9-]*$`; `status` is an integer in 100–599 (default **400**); `schema` a JSON Schema for the error's details (`JC0011`). |
|
|
173
|
+
| `policy` | no | The declared behavior — the table below. |
|
|
174
|
+
| `http` | no | The REST binding (§4). Absent means the **canonical binding**. |
|
|
175
|
+
| `doc` | no | A string for projections. |
|
|
176
|
+
|
|
177
|
+
### §3.1 Policy
|
|
178
|
+
|
|
179
|
+
Every policy member has a default. **Defaults are materialized into the
|
|
180
|
+
compiled operation, never into the source document**; `describe()` shows
|
|
181
|
+
the resolved value and marks it inferred.
|
|
182
|
+
|
|
183
|
+
| member | values | default | meaning |
|
|
184
|
+
|---|---|---|---|
|
|
185
|
+
| `task` | `switch` \| `exhaust` \| `concat` \| `parallel` | `switch` for a read or a subscribe, `exhaust` for a command | Which task mode a host effect runs the operation in: replace an in-flight attempt, let the first one finish, queue, or run concurrently. A **subscribe MUST be `switch`** (`JC0018`) — a subscription slot is replaced, never queued. |
|
|
186
|
+
| `idempotency` | `none` \| `optional` \| `required` | `none` | Whether a command carries an idempotency key. A **read MUST be `none`** (`JC0014`); a **subscribe MUST be `none`** (`JC0020`). |
|
|
187
|
+
| `revision` | `"input:<json-pointer>"` | absent (`null`) | Where in the input the revision a command asserts lives, as an RFC 6901 pointer after `input:` (`JC0014` on malformed). The operation MUST declare `input`, and the pointer's **first reference token** MUST name a member of `input.properties` (`JC0014` otherwise — "revision points at '/x' but input declares no member 'x'"); deeper tokens are not checked (a member's schema may be a `$ref` or open), and the empty pointer (`"input:"`) addresses the whole input. |
|
|
188
|
+
| `cache` | `none` \| `revision` | `none` | Whether a read's result may be cached by revision. |
|
|
189
|
+
| `limits.maxBodyBytes` | positive integer | `1048576` | The request-body ceiling a server binding enforces. |
|
|
190
|
+
| `errors.details` | `none` \| `paths` \| `full` | `paths` | How much of a validation failure crosses the wire: nothing, instance path + keyword, or the raw validator errors. |
|
|
191
|
+
| `retry` | `{ max: integer ≥ 0, on: [codes] }` | absent (`null`) | Which error codes a client may retry (declared codes, or `JC2xxx` taxonomy codes), and how often beyond the first attempt; a network failure is always retried under a declared `retry`. A **command MUST declare `idempotency: "required"` to carry `retry`** (`JC0014`) — a retried command without a key the server deduplicates on runs twice; a read is idempotent by nature. |
|
|
192
|
+
| `stream` | `{ resume?, heartbeatMs?, maxPatchBytes? }` | `{ resume: "snapshot", heartbeatMs: 15000 }` on a subscribe | The stream policy of a **subscribe** operation (`JC0014` on any other kind): `resume` is `snapshot` \| `replay` (§18's resumption rule), `heartbeatMs` an integer ≥ 1000 (the SSE heartbeat interval; a client treats `2 × heartbeatMs` of silence as `JC2094`), `maxPatchBytes` a positive integer — an emission whose serialized patch exceeds it is replaced by a fresh `snapshot` event (§18). |
|
|
193
|
+
| `audience` | `public` \| `server` | `public` | Who may see the operation. A `server` operation is served and handled like any other, but is **kept out of the public projection** (§12.1) and therefore out of every projection built on it — OpenAPI, TypeScript, Markdown, the AI tools — and out of the revision. |
|
|
194
|
+
|
|
195
|
+
The compiled `policy` is always
|
|
196
|
+
`{ task, idempotency, revision, cache, limits: { maxBodyBytes }, errors: { details }, retry, stream, audience }` —
|
|
197
|
+
`stream` the materialized `{ resume, heartbeatMs, maxPatchBytes }` on a
|
|
198
|
+
subscribe operation and `null` on every other kind.
|
|
199
|
+
|
|
200
|
+
### §3.2 `describe()`
|
|
201
|
+
|
|
202
|
+
`contract.describe()` is a pure-JSON summary with a stable member order:
|
|
203
|
+
|
|
204
|
+
```jsonc
|
|
205
|
+
{
|
|
206
|
+
"$contract": "0.1", "id": "shop", "version": "5", "compat": ["4"],
|
|
207
|
+
"revision": null, // reserved for the revision hash
|
|
208
|
+
"operations": [{
|
|
209
|
+
"id": "product.save", "kind": "command",
|
|
210
|
+
"method": "PUT", "path": "/api/products/{id}/master",
|
|
211
|
+
"status": 200, "media": "application/json", "opaque": false,
|
|
212
|
+
"in": { "id": "path", "revision": "body", "product": "body" },
|
|
213
|
+
"body": null,
|
|
214
|
+
"task": "exhaust", "idempotency": "required", "cache": "none",
|
|
215
|
+
"inferred": { "http": false, "status": true, "media": true, "in": [],
|
|
216
|
+
"task": false, "idempotency": false, "cache": true }
|
|
217
|
+
}]
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`inferred` tells declared from defaulted: `http` is true when the whole
|
|
222
|
+
binding is the canonical one; `in` lists the members whose location the
|
|
223
|
+
compiler chose; the booleans mark defaulted `status`, `media`, `task`,
|
|
224
|
+
`idempotency`, `cache`. Operations appear in document order. A subscribe
|
|
225
|
+
operation additionally shows its resolved `stream` policy (`{ resume,
|
|
226
|
+
heartbeatMs, maxPatchBytes }`) and its forced `media`
|
|
227
|
+
(`text/event-stream`).
|
|
228
|
+
|
|
229
|
+
## §4 The HTTP binding and member locations
|
|
230
|
+
|
|
231
|
+
`http` is `{ method, path, in?, body?, status?, media? }`.
|
|
232
|
+
|
|
233
|
+
| member | required | rule | code |
|
|
234
|
+
|---|---|---|---|
|
|
235
|
+
| `method` | yes | An **uppercase** token of `GET HEAD POST PUT PATCH DELETE OPTIONS`. | `JC0012` |
|
|
236
|
+
| `path` | yes | A path template (§4.2). | `JC0008` |
|
|
237
|
+
| `in` | no | Input member → `path` \| `query` \| `header` \| `body`. | `JC0009` |
|
|
238
|
+
| `body` | no | The name of the input member whose value **is** the request body — how a route whose body is a raw array or scalar is described. | `JC0009` |
|
|
239
|
+
| `status` | no | The success status, an integer in 200–299. Default `200`. | `JC0012` |
|
|
240
|
+
| `media` | no | The response media type. Default `application/json`. | `JC0012` |
|
|
241
|
+
|
|
242
|
+
### §4.1 One input schema; the binding says where members travel
|
|
243
|
+
|
|
244
|
+
The `input` schema is the contract; **location is metadata**. Every
|
|
245
|
+
top-level `input.properties` member gets exactly one location:
|
|
246
|
+
|
|
247
|
+
1. a member named by a path variable → `path` (mapping it elsewhere is `JC0009`);
|
|
248
|
+
2. the member named by `http.body` → `body` (no other member may be
|
|
249
|
+
body-located then — map the rest to `query` or `header`, `JC0009`);
|
|
250
|
+
3. a member named in `http.in` → that location (`JC0009` when it names no
|
|
251
|
+
input member, an unknown location, or `path` for a member the template
|
|
252
|
+
does not declare);
|
|
253
|
+
4. otherwise the default: `query` for a `read`, `body` for a `command`.
|
|
254
|
+
|
|
255
|
+
Path, query and header members arrive as strings and are decoded by a
|
|
256
|
+
normalizer compiled **over those members only** with `coerceTypes`
|
|
257
|
+
(`@jarenjs/validate/normalize`); **a body member is never coerced**. The
|
|
258
|
+
compiled operation carries this as
|
|
259
|
+
`input.transport = { normalize, members: { path, query, header, repeated }, schemas, required }`
|
|
260
|
+
(`null` when nothing travels as a string) beside `input.effective` —
|
|
261
|
+
the object schema the declared `input` resolves to (itself, or the end
|
|
262
|
+
of its `$ref` chain), whose `properties` are the operation's members;
|
|
263
|
+
`schemas` holds each
|
|
264
|
+
transport member's declared schema and `required` the transport members
|
|
265
|
+
the input requires (what a URL builder validates without the body), and
|
|
266
|
+
`repeated` lists the
|
|
267
|
+
query and header members whose effective schema type is `array` — a
|
|
268
|
+
decoder collects repeats of those into an array (a repeated query key; a
|
|
269
|
+
repeated header line or a comma-separated header list, RFC 9110 §5.3)
|
|
270
|
+
before normalizing; every other query member is last-wins and every
|
|
271
|
+
other header member is one line (§7.4). The server validates the
|
|
272
|
+
reassembled input object with the operation's compiled validator; the
|
|
273
|
+
client validates the same object before it splits it.
|
|
274
|
+
|
|
275
|
+
An operation bound to `GET` or `HEAD` MUST NOT carry a body-located
|
|
276
|
+
member (`JC0016`) — including a `command` whose members default to the
|
|
277
|
+
body: map them to `query` explicitly.
|
|
278
|
+
|
|
279
|
+
Members outside `input.properties` (a `patternProperties` or
|
|
280
|
+
`additionalProperties` match) have no location; they belong to the body
|
|
281
|
+
object.
|
|
282
|
+
|
|
283
|
+
### §4.2 The path template
|
|
284
|
+
|
|
285
|
+
A template is a leading `/`, then non-empty segments separated by `/`.
|
|
286
|
+
The root template `/` has no segments and is the only empty path (a
|
|
287
|
+
trailing `/` anywhere else is an empty segment, `JC0008`). A **variable
|
|
288
|
+
is a whole segment**, `{name}` (RFC 6570 level 1) or `:name` — accepted
|
|
289
|
+
and **canonicalized to `{name}`**, which is what `describe()` and every
|
|
290
|
+
projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
|
|
291
|
+
declared once per template. A static segment is any run of characters
|
|
292
|
+
except `/ { } : * ? #`, whitespace and control characters; a `%` in it
|
|
293
|
+
MUST open a well-formed escape.
|
|
294
|
+
|
|
295
|
+
Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
|
|
296
|
+
forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
|
|
297
|
+
`{=name}`, the modifiers `{name*}` `{name:3}` `{a,b}`, the `{name+}` tail,
|
|
298
|
+
the `*` wildcard, `:name?`/`:name*`/`:name+`, and a variable that is only
|
|
299
|
+
part of a segment (`{id}.json`).
|
|
300
|
+
|
|
301
|
+
Every path variable MUST be a member of `input.properties` (`JC0009`).
|
|
302
|
+
|
|
303
|
+
### §4.3 The canonical binding
|
|
304
|
+
|
|
305
|
+
When `http` is absent, the operation is bound to **`POST /<op-id>`**,
|
|
306
|
+
every input member in the body, status `200`, `application/json`. A
|
|
307
|
+
declared `http` is used as written — a declared path is never rewritten
|
|
308
|
+
to fit the canonical form.
|
|
309
|
+
|
|
310
|
+
### §4.4 Route shapes
|
|
311
|
+
|
|
312
|
+
The **shape** of a binding is its method plus its template with every
|
|
313
|
+
variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
|
|
314
|
+
NOT share a shape (`JC0010` at the second one, in document order); the
|
|
315
|
+
canonical binding takes part (`POST /<id>` may collide with a declared
|
|
316
|
+
`POST /<id>`).
|
|
317
|
+
|
|
318
|
+
### §4.5 Opaque operations
|
|
319
|
+
|
|
320
|
+
A `media` other than `application/json` (or a `+json` structured-syntax
|
|
321
|
+
suffix, parameters ignored) marks the operation **opaque**: it is routed
|
|
322
|
+
and matched, its path/query still decoded, its body neither decoded nor
|
|
323
|
+
validated by the contract, and it is excluded from generated clients
|
|
324
|
+
except as a URL builder. `image.bytes` in §2 is one. Because its body is
|
|
325
|
+
bytes the contract never decodes, an opaque operation MUST NOT declare a
|
|
326
|
+
**body-located member** — neither through `http.body`, nor `http.in`,
|
|
327
|
+
nor the `command` default (`JC0017` at the member that placed it there,
|
|
328
|
+
or at `http.media` when the default did): map the member to `query` or
|
|
329
|
+
`header`, or make the operation JSON. Its transport members are
|
|
330
|
+
therefore always its whole input, and are validated like any other
|
|
331
|
+
input (§7.1).
|
|
332
|
+
|
|
333
|
+
## §5 The path matcher
|
|
334
|
+
|
|
335
|
+
`contract.match(method, path)` resolves a request line to
|
|
336
|
+
`{ op, params } | null`, where `params` holds the path variables by name
|
|
337
|
+
as **decoded strings** (the transport normalizer of §4.1 turns them into
|
|
338
|
+
their declared types). It receives the path only; the binding splits the
|
|
339
|
+
query off first. Rules:
|
|
340
|
+
|
|
341
|
+
- One static-segment tree per method; a walk tries **statics first, then
|
|
342
|
+
the variable child, and backtracks** on a dead end. Therefore a static
|
|
343
|
+
segment beats a variable **regardless of registration order**:
|
|
344
|
+
`GET /api/production-runs/prefill` resolves to its own operation whether
|
|
345
|
+
it was declared before or after `GET /api/production-runs/{id}`.
|
|
346
|
+
- Matching is **exact on the trailing slash**: `/a/` is a different shape
|
|
347
|
+
from `/a`, and since no template has an empty segment it never matches.
|
|
348
|
+
A variable never binds an empty segment.
|
|
349
|
+
- Each segment is **percent-decoded once**; statics are compared in decoded
|
|
350
|
+
space; a decoded `/` (`a%2Fb` → `a/b`) never re-splits.
|
|
351
|
+
- A malformed escape makes `match` return `null` — it never throws across
|
|
352
|
+
the binding boundary (a request is hostile input; a binding maps `null`
|
|
353
|
+
to its not-found answer).
|
|
354
|
+
- The method is a case-sensitive token: `get` matches nothing.
|
|
355
|
+
- Variables bind through a prototype-safe setter, so a template variable
|
|
356
|
+
or a request can never write `__proto__`.
|
|
357
|
+
|
|
358
|
+
`contract.allowed(path)` is the matcher's second question — the methods
|
|
359
|
+
under which this path shape reaches an operation, sorted (`[]` for none,
|
|
360
|
+
for a malformed escape, or for a non-path) — what a server answers in a
|
|
361
|
+
405's `Allow` (§7.2). It walks every method tree and is off the hot path.
|
|
362
|
+
|
|
363
|
+
The matcher is package-private (`compileRoutes` is not exported); it is
|
|
364
|
+
reached only through `contract.match` and `contract.allowed`. Its
|
|
365
|
+
measured cost on the reference 123-route table is published by the
|
|
366
|
+
benchmark suite when that lands.
|
|
367
|
+
|
|
368
|
+
## §6 Error codes
|
|
369
|
+
|
|
370
|
+
Compile errors are `ContractCompileError` — `{ code, reason, message,
|
|
371
|
+
docPath }` on the coded-error contract of `@jarenjs/core` — thrown by
|
|
372
|
+
`compileContract`. `docPath` is an RFC 6901 pointer into the document
|
|
373
|
+
(`''` is the root; only `~` and `/` are escaped, so a dotted id is one
|
|
374
|
+
token). This table is the normative list; `CONTRACT_CODES` in the package
|
|
375
|
+
carries the same codes and a test holds them equal.
|
|
376
|
+
|
|
377
|
+
| code | condition |
|
|
378
|
+
|---|---|
|
|
379
|
+
| JC0001 | the document is not a well-formed contract object: not an object, `$contract` is not `"0.1"`, `$defs` is not a map of schemas, a member is not a JSON value, or a member threw when read |
|
|
380
|
+
| JC0002 | `operations` is not an object with at least one member, or an operation declaration is not an object |
|
|
381
|
+
| JC0003 | an operation id does not match `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` |
|
|
382
|
+
| JC0004 | `kind` is none of `read`, `command`, `subscribe` |
|
|
383
|
+
| JC0005 | `input` is not a schema whose effective type is `object` |
|
|
384
|
+
| JC0006 | `output` is absent or not a schema |
|
|
385
|
+
| JC0007 | a `$ref` resolves neither within the document nor against the registered schemas |
|
|
386
|
+
| JC0008 | `http.path` is not a valid path template (§4.2; the message names the reserved form) |
|
|
387
|
+
| JC0009 | a path variable, `http.in` key or `http.body` names no input member, or a member is mapped to a location it cannot travel in (§4.1) |
|
|
388
|
+
| JC0010 | two operations share method and canonical path shape (§4.4) |
|
|
389
|
+
| JC0011 | `errors` is malformed: not an object, a code is not `^[a-z][a-z0-9-]*$`, a status is not a 100–599 integer, or a schema is not a schema |
|
|
390
|
+
| JC0012 | `http.method` is not an uppercase token of the supported set, `http.status` is not a 200–299 integer, or `http.media` is not a media type |
|
|
391
|
+
| JC0013 | an unknown member in a closed object (the document root, an operation, `policy`, `limits`, `retry`, `policy.errors`, `http`, or an error declaration) |
|
|
392
|
+
| JC0014 | a `policy` member is mistyped or outside its declared set (§3.1), a read declares `idempotency`, or a command declares `retry` without `idempotency: "required"` |
|
|
393
|
+
| JC0015 | `id`, `version`, `compat` or an operation `doc` is mistyped |
|
|
394
|
+
| JC0016 | an operation bound to `GET` or `HEAD` carries a body-located member (a GET body) |
|
|
395
|
+
| JC0017 | an opaque operation (a non-JSON `http.media`) declares a body-located member — its body is bytes the contract never decodes, so the member could never be validated (§4.5) |
|
|
396
|
+
| JC0018 | a subscribe operation declares a `policy.task` other than `switch` — a subscription slot is replaced, never queued (§17) |
|
|
397
|
+
| JC0019 | a subscribe operation is bound to a method other than `GET` — a stream is fetched, not sent (§17) |
|
|
398
|
+
| JC0020 | a subscribe operation declares a `policy.idempotency` other than `none` — a subscription registers, it does not commit (§17) |
|
|
399
|
+
|
|
400
|
+
`JC0021–JC0049` are reserved for further document-level rules and are
|
|
401
|
+
appended to this table when they land; `JC0050–JC0069` are the binding-
|
|
402
|
+
declaration and projection compile codes (`JC0060`, the OpenAPI keyword
|
|
403
|
+
policy, is in §12.2's table). `JC1001–JC1049` are host
|
|
404
|
+
programming errors (`ContractHostError`, a thrown `TypeError` with `code`
|
|
405
|
+
and `reason`) and `JC2001–JC2049` the HTTP request-time errors
|
|
406
|
+
(`ContractRuntimeError` in-process, a wire error on the response) — both
|
|
407
|
+
tables are in §7. Later ranges: `JC2050–JC2069` client-side (§10.3),
|
|
408
|
+
`JC2070–JC2089` port/local (§15–§16), `JC2090–JC2109` stream (§18).
|
|
409
|
+
|
|
410
|
+
The three worked examples this document is tested against, complete:
|
|
411
|
+
|
|
412
|
+
```json
|
|
413
|
+
{
|
|
414
|
+
"$contract": "0.1",
|
|
415
|
+
"operations": {
|
|
416
|
+
"health.check": { "kind": "read", "output": { "type": "object" }, "http": { "method": "GET", "path": "/api/health" } }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
```json
|
|
422
|
+
{
|
|
423
|
+
"$contract": "0.1",
|
|
424
|
+
"id": "docs",
|
|
425
|
+
"operations": {
|
|
426
|
+
"doc.put": {
|
|
427
|
+
"kind": "command",
|
|
428
|
+
"input": {
|
|
429
|
+
"type": "object",
|
|
430
|
+
"required": ["id", "doc"],
|
|
431
|
+
"properties": {
|
|
432
|
+
"id": { "type": "string" },
|
|
433
|
+
"doc": { "type": "array", "items": { "type": "object" } },
|
|
434
|
+
"dry": { "type": "boolean" }
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
"output": true,
|
|
438
|
+
"policy": { "idempotency": "optional" },
|
|
439
|
+
"http": { "method": "PUT", "path": "/docs/:id", "body": "doc", "in": { "dry": "query" }, "status": 204 }
|
|
440
|
+
},
|
|
441
|
+
"doc.remove": {
|
|
442
|
+
"kind": "command",
|
|
443
|
+
"input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } },
|
|
444
|
+
"output": true
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## §7 The HTTP server binding
|
|
451
|
+
|
|
452
|
+
`serveHttp(contract, handlers, options)` (`@jarenjs/contract/http`) is a
|
|
453
|
+
**binding**, modeled like a `@jarenjs/db` driver: it has a `name`, a
|
|
454
|
+
frozen `capabilities` table that states what it cannot carry, and one
|
|
455
|
+
method a host calls per request — `dispatch(request) → Promise<response>`
|
|
456
|
+
over plain JSON-ish objects. It routes, decodes, normalizes, validates,
|
|
457
|
+
calls the handler, validates the output, applies idempotency and
|
|
458
|
+
entity-tag policy, and answers with the declared statuses and one stable
|
|
459
|
+
error body. **Everything a request can do wrong is a coded response;
|
|
460
|
+
nothing a request can do escapes as a throw** — `dispatch` rejects only
|
|
461
|
+
for a malformed request *object* (`JC1004`), which is an adapter author's
|
|
462
|
+
mistake.
|
|
463
|
+
|
|
464
|
+
```jsonc
|
|
465
|
+
// HttpRequest — what an adapter builds and what a test hands to dispatch
|
|
466
|
+
{ "method": "PUT", "url": "/api/products/12/master?dry=true", // origin-less path + optional ?query
|
|
467
|
+
"headers": { "content-type": "application/json", "idempotency-key": "k-1" }, // lowercase names; a value is a string, or an array when the adapter saw repeated field lines
|
|
468
|
+
"body": "{\"revision\":4,...}" } // string | Uint8Array | null, as received; an optional `signal` (AbortSignal) rides along
|
|
469
|
+
// HttpResponse — what dispatch answers
|
|
470
|
+
{ "status": 200, "headers": { "content-type": "application/json; charset=utf-8", "x-jaren-trace": "…" }, "body": "{…}" }
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The dispatcher is `{ dispatch, capabilities, contract, describe(),
|
|
474
|
+
close() }` — `close()` ends every live SSE stream (§18.1);
|
|
475
|
+
`capabilities` is `{ name: "http", status: true, headers: true, media: true,
|
|
476
|
+
head, etag: true, idempotency: <ledger present>, validatedOutput:
|
|
477
|
+
<validateOutput === "always">, stream: true, cancel: "signal" }` — a
|
|
478
|
+
declared downgrade (`head: false`, `validatedOutput: false`,
|
|
479
|
+
`idempotency: false`) is reported here, never silent.
|
|
480
|
+
|
|
481
|
+
### §7.1 Handlers and the request context
|
|
482
|
+
|
|
483
|
+
`handlers` maps operation id → `Handler = (input, ctx) => value |
|
|
484
|
+
Promise<value> | ContractFailure`. `input` is the reassembled, validated
|
|
485
|
+
input object (`null` when the operation declares no `input`); the value
|
|
486
|
+
is the operation's output. `ctx` is frozen per request:
|
|
487
|
+
|
|
488
|
+
| member | meaning |
|
|
489
|
+
|---|---|
|
|
490
|
+
| `op` | the compiled operation |
|
|
491
|
+
| `trace` | the server trace id of this request (also `x-jaren-trace` on the response and `requestId` in an error body) |
|
|
492
|
+
| `method`, `path` | the request line, path without the query |
|
|
493
|
+
| `params` | the raw decoded path strings, frozen |
|
|
494
|
+
| `headers` | **declared header members only** (by header name, string values) plus `if-match`/`if-none-match` when present — the binding reads no other request header on a handler's behalf |
|
|
495
|
+
| `body` | the raw request body of an **opaque** operation (`string | Uint8Array | null`); `null` for a JSON operation, whose body was decoded into `input` |
|
|
496
|
+
| `signal` | the request's `AbortSignal` when the adapter has one (the node adapter aborts it when the client goes away before the response finished), else `null` |
|
|
497
|
+
| `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
|
|
498
|
+
| `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` |
|
|
499
|
+
| `etag(tag, { strong? }?)` | arm the entity-tag path (§7.5); `tag` is the opaque tag without quotes |
|
|
500
|
+
| `status(n)` | override the success status; must be an integer in 200–299 (`JC1006` otherwise — a host error the handler boundary settles into `JC2008`, seen by `onError`) |
|
|
501
|
+
|
|
502
|
+
An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
|
|
503
|
+
ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
|
|
504
|
+
bypasses media, parse, body assembly, idempotency and output validation.
|
|
505
|
+
Its transport members are decoded and normalized into `input` and
|
|
506
|
+
validated like any other input (`JC2006`) — they **are** its whole
|
|
507
|
+
input, since an opaque operation cannot declare a body-located member
|
|
508
|
+
(`JC0017`, §4.5). `input` is `null` when the operation
|
|
509
|
+
declares none. Its response is passed through verbatim plus
|
|
510
|
+
`x-jaren-trace`; a value that is not `{ status, headers?, body? }` is
|
|
511
|
+
`JC2010`. It may still `ctx.fail` a declared code (answered as JSON like
|
|
512
|
+
every other error).
|
|
513
|
+
|
|
514
|
+
A handler may also **throw** a `ContractRuntimeError` whose `code` the
|
|
515
|
+
operation declares — that is a declared failure too (`params` and
|
|
516
|
+
`retryable` are read from it). Any other throw, rejection or hostile value
|
|
517
|
+
is `JC2008`.
|
|
518
|
+
|
|
519
|
+
### §7.2 The pipeline, in order
|
|
520
|
+
|
|
521
|
+
1. **The request object.** `method`/`url` strings, `headers` an object,
|
|
522
|
+
`body` a string, `Uint8Array` or `null` (an absent body is `null`) — a
|
|
523
|
+
malformed object is `JC1004`, **rejected**, never a response.
|
|
524
|
+
2. **Route.** `url` is split at the first `?`; the path goes to
|
|
525
|
+
`contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
|
|
526
|
+
is tried, then `GET`. No match: an undecodable path (a malformed
|
|
527
|
+
percent-escape) is `JC2011`; the `wellKnown` path answers `describe()`
|
|
528
|
+
under GET/HEAD (405 otherwise); `contract.allowed(path)` non-empty
|
|
529
|
+
(with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
|
|
530
|
+
`Allow`; else `JC2001`. A matched operation without a handler (a
|
|
531
|
+
`partial` server) is `JC2013`.
|
|
532
|
+
3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
|
|
533
|
+
is `JC2003` **before** any read (the adapters honor this too, §9); a
|
|
534
|
+
body whose byte length exceeds the limit is `JC2003` after. Applies to
|
|
535
|
+
every matched operation, opaque and body-less included.
|
|
536
|
+
4. **Opaque** → the transport input validated as in step 8 when no
|
|
537
|
+
member is body-located (`JC2006`), then the raw handler through the
|
|
538
|
+
same boundary as step 10; done.
|
|
539
|
+
5. **Media.** A body-carrying operation (a body-located member or a
|
|
540
|
+
whole-body member) with a non-empty body requires a `content-type`
|
|
541
|
+
whose `type/subtype` is the operation's `http.media` (parameters
|
|
542
|
+
ignored, case-insensitive; a `+json` structured-syntax suffix is
|
|
543
|
+
accepted for `application/json`); else `JC2004`. A body-less
|
|
544
|
+
operation with a body **ignores** the body. An empty body needs no
|
|
545
|
+
media.
|
|
546
|
+
6. **Parse.** Bytes are decoded as strict UTF-8 first (invalid → `JC2005`;
|
|
547
|
+
a leading BOM is stripped by the decoder); then `JSON.parse` (a failure
|
|
548
|
+
is `JC2005`).
|
|
549
|
+
7. **Assemble** the input object through a prototype-safe setter only, in
|
|
550
|
+
this order: path members (raw decoded strings), query members
|
|
551
|
+
(`URLSearchParams` semantics — `+` is a space; a member listed in
|
|
552
|
+
`transport.members.repeated` collects every occurrence into an array,
|
|
553
|
+
every other member is last-wins; an **undeclared query key is
|
|
554
|
+
ignored, never merged**; an undecodable query is `JC2012`), declared
|
|
555
|
+
header members (by their lowercased name; §7.4), then the transport
|
|
556
|
+
normalizer over exactly those members (`coerceTypes`), then the body,
|
|
557
|
+
**never coerced**: `http.body` names a member → the parsed value is
|
|
558
|
+
that member; otherwise the parsed value must be an object (`JC2006`
|
|
559
|
+
with `path: ""` otherwise) and each of its own members is set unless
|
|
560
|
+
it names a path/query/header member (which is **ignored** — the body
|
|
561
|
+
cannot override a location the request already answered); an
|
|
562
|
+
undeclared body member is set and left for the validator to judge
|
|
563
|
+
under the schema's own `additionalProperties`. A JSON body's own
|
|
564
|
+
`__proto__` member becomes an own data property, never a prototype.
|
|
565
|
+
8. **Validate** the input with the operation's compiled validator →
|
|
566
|
+
`JC2006`, `details` by `policy.errors.details` (§7.3). A
|
|
567
|
+
**subscribe** operation branches here: with `accept:
|
|
568
|
+
text/event-stream` the response is the SSE stream, without it the
|
|
569
|
+
one-shot snapshot read (§18.1); the steps below never run for it
|
|
570
|
+
(its `policy.idempotency` is `none` by construction).
|
|
571
|
+
9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
|
|
572
|
+
`Idempotency-Key` is `JC2007` under `required` and runs plainly under
|
|
573
|
+
`optional`; otherwise the input is hashed and the ledger claimed.
|
|
574
|
+
10. **The handler**, through **one uniform promise boundary** — a
|
|
575
|
+
synchronous throw, a non-promise return and a rejection settle
|
|
576
|
+
alike. A `ContractFailure` (from `ctx.fail`) or a thrown
|
|
577
|
+
`ContractRuntimeError` with a declared code → the declared error
|
|
578
|
+
response; anything else → `JC2008`, its cause handed to `onError`.
|
|
579
|
+
A value whose `then` accessor throws is a rejection here — the
|
|
580
|
+
hostile-value case of `JC2008`.
|
|
581
|
+
11. **Output validation** (`validateOutput: "always"`, the default): the
|
|
582
|
+
value against the operation's output validator → `JC2010` on failure
|
|
583
|
+
or on a throwing accessor; the validator's errors reach `onError`,
|
|
584
|
+
never the wire. `"never"` is a declared downgrade
|
|
585
|
+
(`capabilities.validatedOutput: false`).
|
|
586
|
+
12. **Entity tags** when the handler armed one (§7.5): `If-Match` first
|
|
587
|
+
(strong comparison; mismatch → `JC2014`), then `If-None-Match` (weak
|
|
588
|
+
comparison; match → `304` on GET/HEAD, `JC2014` on other methods).
|
|
589
|
+
Because the tag is known only after the handler runs, both are
|
|
590
|
+
evaluated **after** step 10 and only when a tag was armed — a
|
|
591
|
+
handler that wants a pre-execution precondition compares
|
|
592
|
+
`ctx.headers["if-match"]` itself.
|
|
593
|
+
13. **Serialize.** `JSON.stringify(value)`; a value JSON cannot carry
|
|
594
|
+
(a cycle, a BigInt) is `JC2010`; `undefined` answers no body. Status
|
|
595
|
+
is `ctx.status()` or `http.status`; headers `content-type: <media>;
|
|
596
|
+
charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
|
|
597
|
+
a `204` carries no body; a HEAD carries the `content-length` of the
|
|
598
|
+
body it dropped and no body. Then the ledger claim is settled (§8).
|
|
599
|
+
|
|
600
|
+
Every step's failure path returns a response. `dispatch` never rejects
|
|
601
|
+
for request content; a defect of the binding itself is caught last and
|
|
602
|
+
answered `JC2008` too, so a server never sees an unhandled rejection.
|
|
603
|
+
|
|
604
|
+
### §7.3 The wire error
|
|
605
|
+
|
|
606
|
+
Every non-2xx JSON response body is:
|
|
607
|
+
|
|
608
|
+
```jsonc
|
|
609
|
+
{ "code": "JC2006", // a JC2xxx code, or the DECLARED error code ("conflict")
|
|
610
|
+
"message": "the input of operation product.save is invalid",
|
|
611
|
+
"requestId": "3f2c…", // the server trace, equal to the x-jaren-trace header
|
|
612
|
+
"details": [ { "path": "/revision", "keyword": "type" } ], // by policy.errors.details; absent under "none"
|
|
613
|
+
"retryable": false }
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
`message` is rendered from the msgid through the catalog (the host
|
|
617
|
+
`catalog` option first, the English catalog in the package second) and
|
|
618
|
+
**never interpolates a request value** — its parameters are the operation
|
|
619
|
+
id, a declared limit, a media type, a method list, a declared header
|
|
620
|
+
member name or a declared error code. `details` follows
|
|
621
|
+
`policy.errors.details` for a validation failure: `none` → absent;
|
|
622
|
+
`paths` → `[{ path, keyword }]` (instance path + keyword, no values, no
|
|
623
|
+
schema); `full` → the validator's own error records with their `params`.
|
|
624
|
+
For a **declared** failure, `details` is what the handler gave to
|
|
625
|
+
`ctx.fail`, validated against `errors[code].schema` when the declaration
|
|
626
|
+
has one (a mismatch is `JC2010` — the handler broke its own error
|
|
627
|
+
contract; without a schema, the details must still be a JSON value) and
|
|
628
|
+
crossing regardless of `policy.errors.details` (that policy governs
|
|
629
|
+
validation failures; a declared error's details are the operation's own
|
|
630
|
+
contract). A declared failure's message is `contract/error/<code>` from
|
|
631
|
+
the host catalog when it defines one, else the generic
|
|
632
|
+
`contract/handler-error` (`operation {op} failed with {code}`); its
|
|
633
|
+
`retryable` is the failure's own, or whether `policy.retry.on` names the
|
|
634
|
+
code. The `errorBody` option projects the wire record (the body plus
|
|
635
|
+
`status`) into another JSON shape for legacy consumers; a projector that
|
|
636
|
+
throws or answers non-JSON falls back to the shape above.
|
|
637
|
+
|
|
638
|
+
Every response carries `x-jaren-trace: <trace>`; every error response
|
|
639
|
+
also `cache-control: no-store`; a 405 carries `Allow`; a 409
|
|
640
|
+
`in-progress` carries `retry-after: 1`; a 412 from `If-None-Match`
|
|
641
|
+
carries the `etag`. Header names are lowercase.
|
|
642
|
+
|
|
643
|
+
The taxonomy — code, status, msgid, retryable — is the normative table
|
|
644
|
+
below; `HTTP_ERRORS` (`@jarenjs/contract/http`) is the same table as data,
|
|
645
|
+
`CONTRACT_CODES` lists every code, the English catalog
|
|
646
|
+
(`contractMessagesEn`) has exactly these msgids plus
|
|
647
|
+
`contract/handler-error`, and a test holds the four equal.
|
|
648
|
+
|
|
649
|
+
| code | status | msgid | retryable | when |
|
|
650
|
+
|---|---:|---|---|---|
|
|
651
|
+
| `JC2001` | 404 | `contract/not-found` | no | no operation matches method + path (a lowercase method token, an unknown path, a trailing slash) |
|
|
652
|
+
| `JC2002` | 405 | `contract/method-not-allowed` | no | the path shape is served under other methods; `Allow` lists them (`HEAD` beside `GET` when `head` is on) |
|
|
653
|
+
| `JC2003` | 413 | `contract/body-too-large` | no | `content-length` or read length > `policy.limits.maxBodyBytes` |
|
|
654
|
+
| `JC2004` | 415 | `contract/unsupported-media` | no | a body-carrying operation with a non-empty body whose `content-type` is not the declared media (parameters ignored, `+json` accepted for JSON) |
|
|
655
|
+
| `JC2005` | 400 | `contract/malformed-json` | no | body present and not valid JSON, or not valid UTF-8 |
|
|
656
|
+
| `JC2006` | 400 | `contract/invalid-input` | no | the reassembled input fails the operation's input validator; also a non-object body under `body:"*"` (`path: ""`), and a body the canonicalizer refuses (a lone surrogate; keyword `canonical`) |
|
|
657
|
+
| `JC2007` | 400 | `contract/idempotency-key-required` | no | `policy.idempotency: "required"` and no (or an empty) `idempotency-key` header |
|
|
658
|
+
| `JC2008` | 500 | `contract/handler-failed` | no | the handler threw a non-declared error, rejected, answered an undeclared code or a hostile value, or the binding itself faulted; `onError(err, ctx)` sees the cause |
|
|
659
|
+
| `JC2009` | 409 | `contract/idempotency-conflict` | see §8 | the ledger says `in-progress` (retryable, `retry-after: 1`) or `mismatch` (not retryable, `details: [{ "kind": "mismatch" }]`) |
|
|
660
|
+
| `JC2010` | 500 | `contract/invalid-output` | no | the handler value fails the output validator, cannot be serialized, a raw response is malformed, or a declared error's details fail their schema — the server broke the contract |
|
|
661
|
+
| `JC2011` | 400 | `contract/malformed-path` | no | the path carries a malformed percent-escape |
|
|
662
|
+
| `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (a malformed escape, invalid UTF-8) |
|
|
663
|
+
| `JC2013` | 501 | `contract/not-implemented` | no | a `partial` server has no handler for the operation |
|
|
664
|
+
| `JC2014` | 412 | `contract/precondition-failed` | no | `If-Match` does not match the armed tag (strong comparison), or `If-None-Match` matches on a non-GET/HEAD |
|
|
665
|
+
| `JC2015` | 400 | `contract/invalid-header` | no | a declared scalar header member arrived repeated, or a header value is not a string |
|
|
666
|
+
|
|
667
|
+
`JC2016–JC2049` are reserved for later http-side codes; a new one is
|
|
668
|
+
added to `CONTRACT_CODES`, this table and the English catalog in one
|
|
669
|
+
change.
|
|
670
|
+
|
|
671
|
+
Host programming errors are `ContractHostError` — a `TypeError` with
|
|
672
|
+
`code` and `reason`, **thrown** at construction or from `ctx`, never a
|
|
673
|
+
wire response:
|
|
674
|
+
|
|
675
|
+
| code | when |
|
|
676
|
+
|---|---|
|
|
677
|
+
| `JC1001` | `serveHttp`, `serveLocal` or `servePort`: `handlers` is not an object, a key names no operation of the contract, a value is not a function, or an option (a channel without `postMessage`, say) is malformed |
|
|
678
|
+
| `JC1002` | `serveHttp`, `serveLocal` or `servePort`: an operation has no handler (on `serveHttp`, unless `partial` is set; on the status-less bindings an opaque operation is exempt — §15) |
|
|
679
|
+
| `JC1003` | a binding cannot carry a declared feature: an operation declares `policy.idempotency` and no `ledger` was given (say so, never degrade) |
|
|
680
|
+
| `JC1004` | `dispatch` received a malformed request object |
|
|
681
|
+
| `JC1005` | a client (`invoke`, `url`) or the contract effect was asked for an operation the contract does not declare, or `invoke` for an opaque operation (§10; on `local`/`port` the binding cannot carry it at all — §15, §16) |
|
|
682
|
+
| `JC1006` | `ctx.status(n)` with `n` not an integer in 200–299 |
|
|
683
|
+
| `JC1007` | `contractAppBinding`: `ops` names an operation the contract does not declare, or `namespace`/`statePath` is malformed (§11) |
|
|
684
|
+
| `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) |
|
|
685
|
+
| `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) |
|
|
686
|
+
| `JC1010` | `client.subscribe` was asked for an operation that is not a subscribe operation (§19) |
|
|
687
|
+
|
|
688
|
+
### §7.4 Headers
|
|
689
|
+
|
|
690
|
+
The binding reads, on the request: `content-type` and `content-length`
|
|
691
|
+
(steps 3, 5), `idempotency-key` (§8), `if-match` and `if-none-match`
|
|
692
|
+
(§7.5), and the **declared header members** — an input member mapped to
|
|
693
|
+
`header` travels as the header named by the member's name lowercased
|
|
694
|
+
(declare the member `x-tenant` to read `X-Tenant`). A scalar member takes
|
|
695
|
+
one line (a repeated line is `JC2015`; the `fetch` adapter cannot see
|
|
696
|
+
repeats — the platform combines them — while the `node` adapter passes
|
|
697
|
+
distinct lines as an array); an array-typed member (listed in
|
|
698
|
+
`transport.members.repeated`) collects repeated lines, or splits one line
|
|
699
|
+
on commas (RFC 9110 list syntax). No other request header is read, and
|
|
700
|
+
none is echoed. `x-jaren-trace` on a request is never read — the trace is
|
|
701
|
+
the server's; `x-attempt` or any client attempt id is never read either.
|
|
702
|
+
|
|
703
|
+
### §7.5 Entity tags and conditionals
|
|
704
|
+
|
|
705
|
+
`ctx.etag(tag)` arms a weak tag (`etag: W/"tag"`), `ctx.etag(tag, {
|
|
706
|
+
strong: true })` a strong one (`etag: "tag"`). `If-None-Match` is
|
|
707
|
+
compared weakly (`*` matches; `W/` indicators are ignored) → `304` with
|
|
708
|
+
the `etag` header and no body on GET/HEAD, `412` (`JC2014`) on other
|
|
709
|
+
methods; `If-Match` is compared strongly (`*` matches; a weak candidate
|
|
710
|
+
or a weak armed tag never matches) → `412` on a mismatch. Both are
|
|
711
|
+
evaluated after the handler ran, and only when it armed a tag.
|
|
712
|
+
|
|
713
|
+
### §7.6 HEAD, the well-known path, options
|
|
714
|
+
|
|
715
|
+
`head: true` (default) answers `HEAD` for every `GET` operation by
|
|
716
|
+
running the handler and dropping the body (a declared `HEAD` operation
|
|
717
|
+
wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
|
|
718
|
+
path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
|
|
719
|
+
answers `describe()` — `revision: null` until the revision lands, `compat`
|
|
720
|
+
present — for negotiation. `trace` (default `crypto.randomUUID`) generates
|
|
721
|
+
the server trace; `scope(ctx)` derives the idempotency scope (§8);
|
|
722
|
+
`partial` allows missing handlers; `validateOutput` is `"always" |
|
|
723
|
+
"never"`; `errorBody(wire, ctx)` and `onError(err, ctx)` are the two host
|
|
724
|
+
hooks (`ctx` is `null` before an operation is matched); `catalog` is a
|
|
725
|
+
message catalog (templates or compiled renderers) consulted before the
|
|
726
|
+
English one; `now` is the clock stamped into ledger claims.
|
|
727
|
+
|
|
728
|
+
## §8 Idempotency and the ledger
|
|
729
|
+
|
|
730
|
+
An operation with `policy.idempotency` of `optional` or `required` runs
|
|
731
|
+
under an **idempotency key**: the caller's, sent as `Idempotency-Key`,
|
|
732
|
+
scoped by the host's `scope(ctx)` (an installation, a principal — never a
|
|
733
|
+
rotating token) — one of the three identities this format keeps apart
|
|
734
|
+
(the **trace** is the server's per request; the **attempt** id is the
|
|
735
|
+
caller's per dispatch and never crosses). `serveHttp` refuses (`JC1003`)
|
|
736
|
+
an idempotent operation without a `ledger`.
|
|
737
|
+
|
|
738
|
+
The **request hash** is the lowercase hex SHA-256 over the RFC 8785
|
|
739
|
+
canonical bytes of the validated input (`canonicalSha256` in
|
|
740
|
+
`@jarenjs/json/canonical`) — so a body with its members in another order
|
|
741
|
+
is the same request. The binding then calls the ledger:
|
|
742
|
+
|
|
743
|
+
```jsonc
|
|
744
|
+
// the Ledger interface — every method may return its value or a promise of it
|
|
745
|
+
{ "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
|
|
746
|
+
"commit": "(ref, response) → void",
|
|
747
|
+
"fail": "(ref, retryable, response?) → void",
|
|
748
|
+
"lookup": "({ op, scope, key }) → record | null" }
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Semantics the binding relies on: same key + same hash → `replay` — the
|
|
752
|
+
stored `{ status, headers, body }` **verbatim** with a fresh
|
|
753
|
+
`x-jaren-trace` and `idempotent-replayed: true`; same key + different
|
|
754
|
+
hash → `mismatch` (409, `details: [{ "kind": "mismatch" }]`, not
|
|
755
|
+
retryable); `started` and not expired → `in-progress` (409, `retry-after:
|
|
756
|
+
1`, retryable); `failed` with `retryable: true` → treated as `new` (the
|
|
757
|
+
key may be retried); `failed` and not retryable → `replay` of the stored
|
|
758
|
+
failure. After the handler: a success **commits** the response; a
|
|
759
|
+
declared failure is recorded as **failed** with its response and its
|
|
760
|
+
`retryable`; a server fault (`JC2008`, `JC2010`, `JC2014`) **releases**
|
|
761
|
+
the key as retryable with no response. `now` on a claim is the binding's
|
|
762
|
+
clock (`options.now`), which a ledger may prefer to its own. Opaque
|
|
763
|
+
operations bypass the ledger; reads never carry a key. A ledger that
|
|
764
|
+
throws or rejects is reported to `onError` and the response still goes
|
|
765
|
+
out (a throwing `claim` is `JC2008`).
|
|
766
|
+
|
|
767
|
+
`createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
|
|
768
|
+
is the reference implementation over a `Map`: synchronous,
|
|
769
|
+
single-process, expiring on `claim` and `lookup`, with `sweep()` for a
|
|
770
|
+
host timer and `size`. The record it keeps is:
|
|
771
|
+
|
|
772
|
+
```jsonc
|
|
773
|
+
{ "id": "product.save|tenant-a|k-1", // "<op>|<scope>|<key>"
|
|
774
|
+
"op": "product.save", "scope": "tenant-a", "key": "k-1",
|
|
775
|
+
"hash": "9f2a…", // 64 lowercase hex characters
|
|
776
|
+
"status": "committed", // started | committed | failed
|
|
777
|
+
"response": { "status": 200, "headers": { "…": "…" }, "body": "{…}" }, // or null
|
|
778
|
+
"retryable": null, // of a failed record
|
|
779
|
+
"createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
Two documents ship the same shape as **data**, for a host that wants
|
|
783
|
+
durability (this package imports neither `@jarenjs/db` nor
|
|
784
|
+
`@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
|
|
785
|
+
collection `ledger`, key `/id`, that record as its schema (closed),
|
|
786
|
+
indexes on `expiresAt` and `status` — a host opens it with `openStore`
|
|
787
|
+
and implements the interface over the collection; `commandLifecycleFsm`
|
|
788
|
+
is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
|
|
789
|
+
committed` on `commit`, `started → failed` on `fail`, `failed → started`
|
|
790
|
+
on `claim` guarded by `$.context.retryable` — which the memory ledger
|
|
791
|
+
walks exactly.
|
|
792
|
+
|
|
793
|
+
## §9 Adapters
|
|
794
|
+
|
|
795
|
+
Two dependency-free, structurally typed adapters put a dispatcher behind
|
|
796
|
+
the platform:
|
|
797
|
+
|
|
798
|
+
- **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
|
|
799
|
+
`(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
|
|
800
|
+
Cloudflare-style hosts, and Hono. It lowercases the headers into a
|
|
801
|
+
plain object, matches the operation first (cheap) to decide how the
|
|
802
|
+
body is read — `text()` for a JSON operation, `arrayBuffer()` for an
|
|
803
|
+
opaque one, and **not at all** for an unmatched request or a declared
|
|
804
|
+
`content-length` above the operation's limit (the dispatcher answers
|
|
805
|
+
the 413 from the header) — forwards `request.signal`, and builds the
|
|
806
|
+
`Response` from the dispatcher's status, headers and body.
|
|
807
|
+
- **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
|
|
808
|
+
res)` — `http.createServer`'s listener and Express middleware. It
|
|
809
|
+
collects the body chunk by chunk up to the operation's limit; on
|
|
810
|
+
overflow it stops reading, answers the 413 with `connection: close`
|
|
811
|
+
and destroys the request once the response has flushed; a declared
|
|
812
|
+
`content-length` above the limit is never read; an unmatched request's
|
|
813
|
+
body is never read. Bytes reach the dispatcher as received (its strict
|
|
814
|
+
UTF-8 decode decides `JC2005`); repeated header lines arrive as arrays
|
|
815
|
+
(`headersDistinct`); `ctx.signal` aborts when the client goes away
|
|
816
|
+
before the response finished; `content-length` is set on every body.
|
|
817
|
+
|
|
818
|
+
Fastify, Hono and Express are recipes in the README, each ≤15 lines and
|
|
819
|
+
executed by a test that imports the framework from the benchmark
|
|
820
|
+
workspace only — no framework is a dependency of this package.
|
|
821
|
+
|
|
822
|
+
## §10 The HTTP client binding
|
|
823
|
+
|
|
824
|
+
`openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
|
|
825
|
+
client half of the http driver pair: `open(contract, options) → Client`
|
|
826
|
+
with `Client = { invoke, url, negotiate, pending, capabilities, contract,
|
|
827
|
+
describe(), close() }`. It is **binding-agnostic in shape** — the app
|
|
828
|
+
binding (§11) and later the AI tools read only `invoke`, `contract` and
|
|
829
|
+
`capabilities` — and **total in behavior**: `invoke` resolves an
|
|
830
|
+
**outcome** for everything a server or a network can do and rejects
|
|
831
|
+
only for the host's own mistake (`JC1005`: an operation the contract
|
|
832
|
+
does not declare, or an opaque one — `invoke` carries JSON; an opaque
|
|
833
|
+
operation is reached through `url`).
|
|
834
|
+
|
|
835
|
+
```jsonc
|
|
836
|
+
// options — every one has a default
|
|
837
|
+
{ "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
|
|
838
|
+
"baseUrl": "", // prefixed to every path; '' = relative
|
|
839
|
+
"headers": {}, // static headers, merged UNDER per-call ones
|
|
840
|
+
"keys": "crypto.randomUUID", // the idempotency key generator
|
|
841
|
+
"storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
|
|
842
|
+
"timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
|
|
843
|
+
"sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
|
|
844
|
+
"catalog": null, // a message catalog consulted before the English one
|
|
845
|
+
"wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
|
|
846
|
+
"now": "Date.now" } // the clock stamped into key records
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
`capabilities` is `{ name: "http", status: true, headers: true, media:
|
|
850
|
+
true, etag: true, idempotency: true, durableKeys: <storage given>,
|
|
851
|
+
stream: true, cancel: "signal" }` — frozen, the driver rule; `stream`
|
|
852
|
+
is the `subscribe` half (§19).
|
|
853
|
+
|
|
854
|
+
### §10.1 The outcome and the three identities
|
|
855
|
+
|
|
856
|
+
`invoke(op, input, ctx) → Promise<Outcome>` with `ctx = { signal?,
|
|
857
|
+
attempt?, idempotencyKey?, headers?, ifNoneMatch?, ifMatch? }`. An
|
|
858
|
+
outcome is **JSON** (no `Error`, `Response`, `Headers` or
|
|
859
|
+
`AbortController` ever), tagged:
|
|
860
|
+
|
|
861
|
+
```jsonc
|
|
862
|
+
{ "ok": true, "value": { "...": "the output, validated" }, "meta": { "...": "" } }
|
|
863
|
+
{ "ok": false, "kind": "failure" | "network" | "contract" | "cancelled",
|
|
864
|
+
"error": { "code": "conflict", "message": "…", "status": 409, "details": null, "retryable": false },
|
|
865
|
+
"meta": { "op": "product.save", "attempt": 3, "trace": "3f2c…", "revision": null, "etag": null, "notModified": false } }
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
- `failure` — the server answered a **declared** operation error, or a
|
|
869
|
+
`JC2xxx` taxonomy error (§7.3): the peer spoke the contract and said no;
|
|
870
|
+
- `network` — the transport rejected or timed out (`JC2051`, retryable);
|
|
871
|
+
- `contract` — the peer (or, pre-send, the input) violated the contract:
|
|
872
|
+
invalid input (`JC2050`), an invalid success body (`JC2053`), a key
|
|
873
|
+
store that threw (`JC2054`), an undeclared response (`JC2055`);
|
|
874
|
+
- `cancelled` — `ctx.signal` aborted or `close()` was called (`JC2052`).
|
|
875
|
+
|
|
876
|
+
**The fixed shapes — every binding (D6).** An outcome never carries an
|
|
877
|
+
`undefined` member: an optional member that is absent is `null`
|
|
878
|
+
(`isJsonValue` — the predicate `@jarenjs/app`'s task effect and state
|
|
879
|
+
honor — rejects `undefined`, and an outcome with one would fall back to
|
|
880
|
+
a string in the task effect). `error` is always
|
|
881
|
+
|
|
882
|
+
`{ code, message, status, details, retryable }` — `OUTCOME_ERROR_MEMBERS`
|
|
883
|
+
|
|
884
|
+
and `meta` is always
|
|
885
|
+
|
|
886
|
+
`{ op, attempt, trace, revision, etag, notModified }` — `OUTCOME_META_MEMBERS`
|
|
887
|
+
|
|
888
|
+
in that member order, on **every** binding — http, and the `local`/
|
|
889
|
+
`port`/`stream` bindings that follow. A binding that cannot carry a
|
|
890
|
+
member carries `null` (`status` on a binding without statuses, `etag`,
|
|
891
|
+
`trace`) or `false` (`notModified`) and says so in its `capabilities`;
|
|
892
|
+
it **never omits the member**. The two lists are exported from
|
|
893
|
+
`@jarenjs/contract/client` (frozen arrays) so a binding asserts against
|
|
894
|
+
them rather than restating them, and `isOutcome` refuses a value whose
|
|
895
|
+
`error` or `meta` lacks a member or carries `undefined` in one.
|
|
896
|
+
|
|
897
|
+
`meta` keeps the three identities apart, by construction: **`attempt`** is
|
|
898
|
+
the caller's (`ctx.attempt`, echoed verbatim, `null` when none) — it is
|
|
899
|
+
never sent and **never read from any response header**; **`trace`** is
|
|
900
|
+
the server's `x-jaren-trace` (`null` when the wire carried none) — it is
|
|
901
|
+
never generated here; the **idempotency key** (§10.3) is the client's
|
|
902
|
+
and travels only as `Idempotency-Key`. `revision` is reserved for the
|
|
903
|
+
contract revision; `etag` carries a success's entity tag; `notModified`
|
|
904
|
+
is true exactly for a 304.
|
|
905
|
+
|
|
906
|
+
### §10.2 What `invoke` does, in order
|
|
907
|
+
|
|
908
|
+
1. **Route.** An unknown `op` or an opaque one throws `JC1005`.
|
|
909
|
+
2. **Validate** `input` with the operation's compiled input validator —
|
|
910
|
+
the SAME validator the server runs. `null`/`undefined` is `{}` for an
|
|
911
|
+
operation with input; an input-less operation refuses any non-null
|
|
912
|
+
input. A failure resolves `kind: "contract"` `JC2050` with `details`
|
|
913
|
+
by `policy.errors.details`; **nothing was sent**.
|
|
914
|
+
3. **Split by location** (`http.in`): path variables → `encodeURIComponent`
|
|
915
|
+
per segment into the canonical template; query members →
|
|
916
|
+
`URLSearchParams` (an array-typed member repeats the key per element;
|
|
917
|
+
`null`/`undefined` members are omitted; a scalar is its string, a
|
|
918
|
+
non-scalar its JSON); header members → the header named by the
|
|
919
|
+
member lowercased (an array as a `, `-joined list); the body: `http.body`
|
|
920
|
+
names a member → `JSON.stringify` of that member's value; otherwise
|
|
921
|
+
the object of the body-located members, stringified. `method` from
|
|
922
|
+
`http.method`; `content-type: <http.media>` only when a body is sent.
|
|
923
|
+
Static `headers`, then `ctx.headers`, then the declared header
|
|
924
|
+
members, then the protocol headers the client owns (`if-none-match`,
|
|
925
|
+
`if-match`, `content-type`, `idempotency-key`) — later wins. A value
|
|
926
|
+
JSON or a URL cannot carry is `JC2050` (`keyword: "encoding"`).
|
|
927
|
+
4. **Idempotency** (§10.3) when `policy.idempotency` is `optional` or
|
|
928
|
+
`required`: key = `ctx.idempotencyKey ?? keys()`, sent as
|
|
929
|
+
`Idempotency-Key`; with `storage`, recorded before the send (`JC2054`
|
|
930
|
+
when the store throws — nothing is sent blind).
|
|
931
|
+
5. **Send** through `fetch` with the composed signal (`ctx.signal`,
|
|
932
|
+
`timeoutMs`, the client's `close()`). A rejection is `kind:
|
|
933
|
+
"cancelled"` when the caller's signal aborted (or the rejection is an
|
|
934
|
+
`AbortError`), else `kind: "network"` `JC2051` whose message carries
|
|
935
|
+
the error's **name only** — never its text, which may embed the URL
|
|
936
|
+
and credentials. A timeout is therefore `network`, not `cancelled`.
|
|
937
|
+
6. **Assemble** the outcome from status + headers + body (the table
|
|
938
|
+
below); `meta.trace` from `x-jaren-trace`, `meta.etag` from `etag`.
|
|
939
|
+
7. **Retry** (§10.4) only under a declared `policy.retry`.
|
|
940
|
+
8. Settle the durable record (§10.3) and resolve.
|
|
941
|
+
|
|
942
|
+
The assembly, row by row — `assembleOutcome` in the package is this
|
|
943
|
+
table as code; the test column names the test case that pins the row
|
|
944
|
+
(`test/contract/client-outcomes.test.js`, checked against this table):
|
|
945
|
+
|
|
946
|
+
| status | body | kind | code | retryable | test |
|
|
947
|
+
|---|---|---|---|---|---|
|
|
948
|
+
| 2xx | empty; the output schema accepts `null` | ok, `value: null` | — | — | `2xx empty body is a null value when the output allows it` |
|
|
949
|
+
| 2xx | empty; the output schema rejects `null` | contract | `JC2053` | no | `2xx empty body against a non-null output is JC2053` |
|
|
950
|
+
| 2xx | JSON that validates against `output` | ok | — | — | `2xx valid JSON is ok with meta.etag from the header` |
|
|
951
|
+
| 2xx | JSON that fails `output` | contract | `JC2053` (details by policy) | no | `2xx invalid output is JC2053 with details by policy` |
|
|
952
|
+
| 2xx | not JSON | contract | `JC2053` | no | `2xx non-JSON is JC2053` |
|
|
953
|
+
| 304 | — | ok, `value: null`, `meta.notModified`, `meta.etag` | — | — | `304 is ok null with notModified and the etag` |
|
|
954
|
+
| other | JSON `{ code }` the operation declares | failure | the declared code; `status`, `details`, `message` from the body (else rendered), `retryable` from the body (else `policy.retry.on`) | body | `an error body with a declared code is a failure` |
|
|
955
|
+
| other | JSON `{ code }` a `JC2xxx` taxonomy code | failure | the taxonomy code; `status`, `retryable` from the body | body | `an error body with a taxonomy code is a failure` |
|
|
956
|
+
| other | JSON with an unknown/undeclared `code` | contract | `JC2055` (`status` kept) | 5xx/429 | `an undeclared code is JC2055` |
|
|
957
|
+
| other | JSON without a string `code` | contract | `JC2055` | 5xx/429 | `an error body without a code is JC2055` |
|
|
958
|
+
| other | not JSON, or empty | contract | `JC2055` | 5xx/429 | `a non-JSON error body is JC2055` |
|
|
959
|
+
| none | the transport rejected | network | `JC2051` | yes | `a transport rejection is a network outcome named by the error name only` |
|
|
960
|
+
| none | the caller aborted | cancelled | `JC2052` | no | `an abort is a cancelled outcome` |
|
|
961
|
+
| none | the input failed pre-send | contract | `JC2050` | no | `invalid input is JC2050 and nothing is sent` |
|
|
962
|
+
| none | the key store threw pre-send | contract | `JC2054` | no | `a throwing key store is JC2054 and nothing is sent` |
|
|
963
|
+
|
|
964
|
+
"other" is every status that is neither 2xx nor 304 — 1xx, a redirect
|
|
965
|
+
the platform did not follow, 4xx, 5xx. A status outside 100–599 on the
|
|
966
|
+
response object is `JC2053` (the transport is broken, not the server).
|
|
967
|
+
|
|
968
|
+
### §10.3 The client codes and the durable keys
|
|
969
|
+
|
|
970
|
+
The client codes — `CLIENT_ERRORS` (`@jarenjs/contract/client`) is this
|
|
971
|
+
table as data; `CONTRACT_CODES` lists every code; the English catalog
|
|
972
|
+
has exactly these msgids beside §7's; a test holds them equal:
|
|
973
|
+
|
|
974
|
+
| code | kind | msgid | retryable | when |
|
|
975
|
+
|---|---|---|---|---|
|
|
976
|
+
| `JC2050` | contract | `contract/client-invalid-input` | no | the input fails the operation's input validator (or cannot be encoded) before anything was sent |
|
|
977
|
+
| `JC2051` | network | `contract/network` | yes | the transport rejected or the per-request timeout fired; the message names the error's name only |
|
|
978
|
+
| `JC2052` | cancelled | `contract/cancelled` | no | `ctx.signal` aborted, the client was closed, or an abort interrupted a retry backoff |
|
|
979
|
+
| `JC2053` | contract | `contract/invalid-response` | no | a 2xx body is not JSON or fails the output validator; a response object whose status is not 100–599 |
|
|
980
|
+
| `JC2054` | contract | `contract/key-storage-failed` | no | the durable key store threw before the send |
|
|
981
|
+
| `JC2055` | contract | `contract/undeclared-response` | 5xx/429 | an error response whose body is not a declared or taxonomy code; `error.status` is kept |
|
|
982
|
+
| `JC2056` | — | `contract/not-a-contract` | no | `negotiate`: no description at the well-known path, not a `$contract: "0.1"` description, or another contract `id` |
|
|
983
|
+
| `JC2057` | — | `contract/incompatible` | no | `negotiate`: a version neither end declares compatible |
|
|
984
|
+
| `JC2058` | contract | `contract/host-failed` | no | the contract effect (§11) projected a thrown host value into an outcome |
|
|
985
|
+
|
|
986
|
+
`JC2059–JC2069` are reserved for later client-side codes.
|
|
987
|
+
|
|
988
|
+
**The idempotency key**, client half. For an operation whose
|
|
989
|
+
`policy.idempotency` is `optional` or `required` the client **always**
|
|
990
|
+
sends a key — `ctx.idempotencyKey` when given (a caller that retries by
|
|
991
|
+
hand keeps the same key), else `keys()` — so the server's ledger (§8)
|
|
992
|
+
can deduplicate every command the client sends, and a declared `retry`
|
|
993
|
+
on a command is safe (the compiler refuses `retry` on a command whose
|
|
994
|
+
idempotency is not `required`, `JC0014`). With a `storage` (the
|
|
995
|
+
`@jarenjs/app` `createDocStore` adapter shape, `{ read(), write(value) }`,
|
|
996
|
+
sync or async) the client records `{ op, key, hash, at }` under
|
|
997
|
+
`<store>["jaren-contract"][<contract id>][<op>][<key>]` **before the
|
|
998
|
+
request** — `hash` is the request hash of §8 (the same SHA-256 over the
|
|
999
|
+
canonical input the server computes), `at` is `now()`, and the input
|
|
1000
|
+
itself is **never stored** — and drops the record after a terminal
|
|
1001
|
+
outcome: `ok`, any `failure`, or a `contract` outcome that is not
|
|
1002
|
+
retryable. A `network` or `cancelled` outcome **leaves it**, so a
|
|
1003
|
+
process that restarts can ask `client.pending() → [{ op, key }]` and
|
|
1004
|
+
reconcile each with the server (the ledger's `lookup` by op/scope/key
|
|
1005
|
+
is the server-side half). A store that throws on the pre-send write is
|
|
1006
|
+
`JC2054` and nothing is sent; a store that throws on the drop leaves
|
|
1007
|
+
the record (the conservative side) and the outcome is unaffected.
|
|
1008
|
+
|
|
1009
|
+
### §10.4 Retry
|
|
1010
|
+
|
|
1011
|
+
Only under a declared `policy.retry` (`{ max, on }`); never for an
|
|
1012
|
+
operation without one. Retried: a `network` outcome, and a `failure`
|
|
1013
|
+
outcome whose `error.code` is in `retry.on` (declared codes and
|
|
1014
|
+
`JC2xxx` taxonomy codes alike — `JC2009` in-progress is a natural
|
|
1015
|
+
member). At most `max` further attempts; the backoff before attempt
|
|
1016
|
+
`n+1` is `min(1000 · 2^n, 8000)` ms plus up to 250 ms of jitter through
|
|
1017
|
+
`sleep(ms, signal)`; an abort during the backoff resolves `cancelled`.
|
|
1018
|
+
The idempotency key stays the same across the attempts (that is what
|
|
1019
|
+
makes them safe); `meta.attempt` stays the caller's — retries are
|
|
1020
|
+
inside one attempt, not new ones.
|
|
1021
|
+
|
|
1022
|
+
### §10.5 `url(op, input)` and `negotiate()`
|
|
1023
|
+
|
|
1024
|
+
`url(op, input)` builds `baseUrl + path + ?query` for **any** operation,
|
|
1025
|
+
opaque ones included, validating only the path/query members of the
|
|
1026
|
+
input (against their declared schemas, compiled on first use);
|
|
1027
|
+
`JC1005` for an unknown operation, `JC1008` for a non-object input or
|
|
1028
|
+
members that fail their schema. It is what an `<img src>` or a link uses
|
|
1029
|
+
for an opaque operation; the bytes themselves are the host's to fetch.
|
|
1030
|
+
|
|
1031
|
+
`negotiate({ signal })` fetches the server's well-known description
|
|
1032
|
+
(§7.6) and answers `{ compatible, reason, server, error }` with `reason`
|
|
1033
|
+
one of:
|
|
1034
|
+
|
|
1035
|
+
| reason | when | compatible | error |
|
|
1036
|
+
|---|---|---|---|
|
|
1037
|
+
| `same-version` | `server.version === contract.version` (both `null` included) | yes | — |
|
|
1038
|
+
| `server-accepts` | `contract.version ∈ server.compat` | yes | — |
|
|
1039
|
+
| `client-accepts` | `server.version ∈ contract.compat` | yes | — |
|
|
1040
|
+
| `version-mismatch` | none of the above | no | `JC2057` |
|
|
1041
|
+
| `not-a-contract` | no 200, not JSON, not a `$contract: "0.1"` description with an `operations` array, or the server's `id` differs from the client's (both non-null) | no | `JC2056` |
|
|
1042
|
+
| `unreachable` | the transport rejected | no | `JC2051` |
|
|
1043
|
+
|
|
1044
|
+
`server` is `{ id, version, compat, revision }` as the server described
|
|
1045
|
+
itself (`null` when unreachable or not a contract). **Nothing else is
|
|
1046
|
+
inferred**: an unrelated service on the port is exactly `not-a-contract`;
|
|
1047
|
+
two unversioned contracts are `same-version`.
|
|
1048
|
+
|
|
1049
|
+
## §11 The app binding
|
|
1050
|
+
|
|
1051
|
+
`contractAppBinding(contract, { namespace = "contract/", statePath =
|
|
1052
|
+
"/contract", ops = contract.ids })` (`@jarenjs/contract/app`) returns
|
|
1053
|
+
**pure JSON** — `{ slice, actions, subs, schema, effect: "contract",
|
|
1054
|
+
subscription: "contract-stream" }` — the `fsmToApp`/`liveAppBinding`
|
|
1055
|
+
shape: a state slice the host mounts at `statePath`, action documents
|
|
1056
|
+
it spreads into its `actions`, subscription entries it spreads into its
|
|
1057
|
+
`subs` (one per subscribe operation, `[]` otherwise — §11.4), and the
|
|
1058
|
+
slice's JSON Schema for `validateState`. Neither package imports the
|
|
1059
|
+
other; the documents cross as JSON and the task-effect factory
|
|
1060
|
+
(`createTaskEffect` from `@jarenjs/app`) crosses as a function the host
|
|
1061
|
+
passes to `createContractEffect`. `ops` may name a subset (`JC1007` for
|
|
1062
|
+
an operation the contract does not declare).
|
|
1063
|
+
|
|
1064
|
+
### §11.1 The generated document
|
|
1065
|
+
|
|
1066
|
+
Per operation, **one task slot** in the slice — `{ id: 0, status: "idle",
|
|
1067
|
+
kind: null, value: null, error: null, meta: null }` — and **three
|
|
1068
|
+
actions**, in the async-task convention of `@jarenjs/app`'s TASKS.md
|
|
1069
|
+
(state-side identity, guard-first completion):
|
|
1070
|
+
|
|
1071
|
+
```jsonc
|
|
1072
|
+
// <namespace><op>/start — $payload is the operation's input
|
|
1073
|
+
{ "patch": [
|
|
1074
|
+
{ "op": "replace", "path": "/contract/catalog.load/id", "value": { "$add": ["$.contract['catalog.load'].id", 1] } },
|
|
1075
|
+
{ "op": "replace", "path": "/contract/catalog.load/status", "value": "loading" },
|
|
1076
|
+
{ "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
|
|
1077
|
+
{ "op": "replace", "path": "/contract/catalog.load/error", "value": null } ],
|
|
1078
|
+
"effects": [ { "run": "contract", "with": {
|
|
1079
|
+
"op": "catalog.load", "input": "$payload",
|
|
1080
|
+
"id": { "$add": ["$.contract['catalog.load'].id", 1] }, // the SAME increment as the patch — everything evaluates pre-transition
|
|
1081
|
+
"done": "contract/catalog.load/done", "slot": "catalog.load" } } ] }
|
|
1082
|
+
|
|
1083
|
+
// <namespace><op>/start of an operation whose policy.task is exhaust — the same document behind a state-side guard
|
|
1084
|
+
{ "$if": [ { "$ne": ["$.contract['product.save'].status", "loading"] }, // a $if without else is the empty sequence (APP-FORMAT §3.2):
|
|
1085
|
+
{ "patch": [ "…the four replaces…" ], "effects": [ "…the one contract effect…" ] } ] } // no patch, no effect, no render while loading
|
|
1086
|
+
|
|
1087
|
+
// <namespace><op>/done — $payload is { id, result: outcome } (or { id, error: outcome } for a projected host throw)
|
|
1088
|
+
{ "$if": [ { "$eq": ["$payload.id", "$.contract['catalog.load'].id"] }, // the id guard: a stale response is the empty sequence
|
|
1089
|
+
{ "$let": { "outcome": { "$coalesce": ["$payload.result", "$payload.error"] } },
|
|
1090
|
+
"$return": { "$if": [ { "$eq": ["$outcome.ok", true] },
|
|
1091
|
+
{ "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "done" },
|
|
1092
|
+
{ "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
|
|
1093
|
+
{ "op": "replace", "path": "/contract/catalog.load/value", "value": "$outcome.value" },
|
|
1094
|
+
{ "op": "replace", "path": "/contract/catalog.load/meta", "value": "$outcome.meta" },
|
|
1095
|
+
{ "op": "replace", "path": "/contract/catalog.load/error", "value": null } ] },
|
|
1096
|
+
{ "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "error" },
|
|
1097
|
+
{ "op": "replace", "path": "/contract/catalog.load/kind", "value": "$outcome.kind" },
|
|
1098
|
+
{ "op": "replace", "path": "/contract/catalog.load/error", "value": "$outcome.error" },
|
|
1099
|
+
{ "op": "replace", "path": "/contract/catalog.load/meta", "value": "$outcome.meta" } ] } ] } } ] }
|
|
1100
|
+
|
|
1101
|
+
// <namespace><op>/reset — releases the slot; id, value and meta are untouched
|
|
1102
|
+
{ "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "idle" },
|
|
1103
|
+
{ "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
|
|
1104
|
+
{ "op": "replace", "path": "/contract/catalog.load/error", "value": null } ] }
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
Rules the generator keeps:
|
|
1108
|
+
|
|
1109
|
+
- The slot id read is `$<statePath>['<op>']` — a dotted operation id is
|
|
1110
|
+
a bracketed RFC 9535 member name, so `statePath` MUST be a chain of
|
|
1111
|
+
identifier-safe segments (`JC1007` otherwise); the patch paths are
|
|
1112
|
+
plain JSON Pointers (`/contract/catalog.load/id`). Action names carry
|
|
1113
|
+
the operation id verbatim (`contract/catalog.load/start`); the app
|
|
1114
|
+
places no restriction on action names.
|
|
1115
|
+
- **The id guard is the guarantee.** The `done` action compares
|
|
1116
|
+
`$payload.id` with the slot's id first; a payload whose id is not the
|
|
1117
|
+
current one yields the empty sequence — no state change, no render, no
|
|
1118
|
+
subscriber — so an out-of-order older response can never overwrite a
|
|
1119
|
+
newer one. Cancellation (below) is the optimization.
|
|
1120
|
+
- **The state slot and the effect agree in every mode (D10).** A task
|
|
1121
|
+
mode is never *named* in the document or in the effect props; the
|
|
1122
|
+
generator derives the document's state-side guards from `policy.task`
|
|
1123
|
+
exactly as it derives the document's shape from `kind`. With one slot
|
|
1124
|
+
per operation, what a second `start` does while the slot is `loading`:
|
|
1125
|
+
|
|
1126
|
+
| `policy.task` | in state | in the effect | which completion lands |
|
|
1127
|
+
|---|---|---|---|
|
|
1128
|
+
| `switch` | the id increments (newest wins) | the predecessor is aborted (its cancelled outcome dispatches nothing) | the newest; a predecessor's completion that arrives anyway is rejected by the guard |
|
|
1129
|
+
| `exhaust` | **nothing** — the `start` is wrapped in `$if: [{ $ne: [<slot>.status, "loading"] }, …]`, so the id stays, no effect runs, no render | the duplicate start would be ignored (TASKS.md); it never reaches the effect | the one in flight: it carries the current id and **lands** — a double-click runs once and its result reaches state |
|
|
1130
|
+
| `concat` | the id increments | the start queues and runs after its predecessors, in order | **latest wins**: only the completion carrying the current id lands; earlier results and errors are dropped from state; `status` stays `loading` until the newest lands |
|
|
1131
|
+
| `parallel` | the id increments | the start runs concurrently | **latest wins**, as `concat` — the newest id lands whenever it arrives, the others are provable no-ops |
|
|
1132
|
+
|
|
1133
|
+
A consumer that needs every result of a `parallel` fan-out needs a
|
|
1134
|
+
slot per key (one slot per operation is the rule here; keyed slots
|
|
1135
|
+
are a roadmap candidate).
|
|
1136
|
+
- **`reset` releases a slot.** It writes `status: "idle"`, `kind: null`,
|
|
1137
|
+
`error: null` and leaves `id`, `value` and `meta` alone — the id stays
|
|
1138
|
+
monotonic so a late completion of a cancelled attempt is still
|
|
1139
|
+
rejected, and the last good value survives a reset as it survives an
|
|
1140
|
+
error. It is the one way out of a slot a host `cancel(slot)` left
|
|
1141
|
+
`loading` on an `exhaust` operation (a cancelled outcome dispatches
|
|
1142
|
+
nothing, and the guarded `start` is a no-op while `loading`), and the
|
|
1143
|
+
ordinary "dismiss the error" action, mode-independent.
|
|
1144
|
+
- **A failed reload keeps the last good `value`**: the error branch
|
|
1145
|
+
writes `status`, `kind`, `error`, `meta` and touches `value` not at all.
|
|
1146
|
+
- **`kind` says which class of failure the slot holds** — the outcome's
|
|
1147
|
+
`kind` (`"failure"` a declared or taxonomy error, `"network"`,
|
|
1148
|
+
`"contract"`; `"cancelled"` never lands) while `status` is `"error"`,
|
|
1149
|
+
`null` otherwise (`start`, the ok branch and `reset` write `null`) —
|
|
1150
|
+
so a view shows "you are offline" beside "the server refused this"
|
|
1151
|
+
without parsing `error.code`.
|
|
1152
|
+
- `schema` is the slice's JSON Schema: per operation `id` (integer ≥ 0),
|
|
1153
|
+
`status` (`idle | loading | done | error`), `kind` (`null | failure |
|
|
1154
|
+
network | contract` — the enum is pinned, the cross-member invariant
|
|
1155
|
+
"`kind` is `null` exactly when `status` is not `error`" is not: a JSON
|
|
1156
|
+
Schema `if/then` would cost every transition and the generated actions
|
|
1157
|
+
are the only writer), `value` (the operation's output schema **or
|
|
1158
|
+
null**), `error` (the §10.1 error object or null), `meta` (the §10.1
|
|
1159
|
+
meta or null); when the contract has `$defs` the schema carries them
|
|
1160
|
+
under its own `$defs` and declares an `$id`
|
|
1161
|
+
(`urn:jaren:contract-app:<contract id>`) so the output schemas'
|
|
1162
|
+
`#/$defs/…` references resolve wherever the host mounts the slice
|
|
1163
|
+
schema inside its `validateState` schema.
|
|
1164
|
+
|
|
1165
|
+
### §11.2 The effect
|
|
1166
|
+
|
|
1167
|
+
`createContractEffect(client, { createTaskEffect, projectError?, catalog? })`
|
|
1168
|
+
returns **one** effect handler — register it as `effects: { contract: … }`
|
|
1169
|
+
— carrying `cancel(slot)`, `cancelAll()` and `dispose()` (which
|
|
1170
|
+
`app.destroy()` calls). It owns one `createTaskEffect` per distinct
|
|
1171
|
+
`policy.task` mode the client's contract uses, built lazily on the
|
|
1172
|
+
first descriptor of that mode, each with `run = (props, signal) =>
|
|
1173
|
+
client.invoke(props.op, props.input, { signal, attempt: props.id })`;
|
|
1174
|
+
a descriptor is routed by `props.op` to its operation's mode (a `switch`
|
|
1175
|
+
read and an `exhaust` command live in one effect; the mode never
|
|
1176
|
+
appears in the app document). An unknown `op` is `JC1005` — a
|
|
1177
|
+
`TypeError` the loop reports as `JA2007`. `props` reach the inner
|
|
1178
|
+
effect unchanged (`id`, `done`, `fail`, `slot` are TASKS.md's).
|
|
1179
|
+
|
|
1180
|
+
Settlement, exactly: `run` resolves the outcome → the task effect
|
|
1181
|
+
dispatches `done` with `{ id, result: outcome }`; a `kind: "cancelled"`
|
|
1182
|
+
outcome makes `run` throw an `AbortError` → **nothing is dispatched**
|
|
1183
|
+
(a superseded task is dead by design) — a slot left `loading` by a host
|
|
1184
|
+
`cancel(slot)` is released by `<namespace><op>/reset` (or by the next
|
|
1185
|
+
`start` on a non-`exhaust` operation); `dispose()` is terminal, nothing
|
|
1186
|
+
to release; a value that is not an outcome
|
|
1187
|
+
(a foreign client) and a **thrown** host value go through `projectError`
|
|
1188
|
+
— the host's own projector first, then the `JC2058` outcome — so the
|
|
1189
|
+
error member of `{ id, error }` is always an outcome, never a string,
|
|
1190
|
+
and the generated `done` action reads it through the same `$coalesce`.
|
|
1191
|
+
A `JC2058` message carries nothing of the thrown value.
|
|
1192
|
+
|
|
1193
|
+
### §11.3 Composition
|
|
1194
|
+
|
|
1195
|
+
```js
|
|
1196
|
+
import { createApp, createTaskEffect } from '@jarenjs/app';
|
|
1197
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
1198
|
+
import { openHttpClient } from '@jarenjs/contract/client';
|
|
1199
|
+
import { contractAppBinding, createContractEffect } from '@jarenjs/contract/app';
|
|
1200
|
+
|
|
1201
|
+
const client = openHttpClient(contract, { baseUrl });
|
|
1202
|
+
const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
|
|
1203
|
+
const validate = new JarenValidator().compile({ type: 'object', required: ['contract'], properties: { contract: schema } });
|
|
1204
|
+
|
|
1205
|
+
const app = createApp({ state: { contract: slice, draft: null }, view, actions: { ...actions, ...own } }, {
|
|
1206
|
+
effects: { contract: createContractEffect(client, { createTaskEffect }) },
|
|
1207
|
+
validateState: (state) => validate(state),
|
|
1208
|
+
});
|
|
1209
|
+
app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
|
|
1210
|
+
// … later: app.getState().contract['catalog.load'] → { id: 1, status: 'done', kind: null, value: {…}, error: null, meta: {…} }
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
The runnable version of this composition, driven against a real
|
|
1214
|
+
`serveHttp` dispatcher, is [APP-INTEGRATION.md](APP-INTEGRATION.md),
|
|
1215
|
+
executed verbatim by the test suite.
|
|
1216
|
+
|
|
1217
|
+
### §11.4 Subscribe operations: the generated subscription
|
|
1218
|
+
|
|
1219
|
+
A subscribe operation becomes a **subscription**, not a task slot. Its
|
|
1220
|
+
slice member is `{ id, status: "idle" | "live" | "error", kind, input,
|
|
1221
|
+
value, error, meta, seq }` — `input` is what `start` was dispatched
|
|
1222
|
+
with, kept in state so the generated subscription entry can resolve it;
|
|
1223
|
+
`value` is the maintained snapshot; `seq` the last applied emission's.
|
|
1224
|
+
The generated actions:
|
|
1225
|
+
|
|
1226
|
+
- `<ns><op>/start` — guarded on `status !== "live"` (the state-first
|
|
1227
|
+
rule of an exhaust command's start: no second subscription while one
|
|
1228
|
+
is live): increments `id`, sets `status: "live"`, clears
|
|
1229
|
+
`kind`/`error`, stores `$payload` as `input`. It emits **nothing** —
|
|
1230
|
+
the subscription below starts because its `when` sees the liveness.
|
|
1231
|
+
- `<ns><op>/stop` — `status: "idle"`; the app's own reconciliation runs
|
|
1232
|
+
the subscription's cleanup (the client's `stop()`, exactly once).
|
|
1233
|
+
- `<ns><op>/snapshot` — guarded on `$payload.id` against the slot id;
|
|
1234
|
+
sets `value` and `seq`.
|
|
1235
|
+
- `<ns><op>/patch` — guarded on the id **and** `$payload.seq` strictly
|
|
1236
|
+
greater than the slot's; replaces `value` with `$payload.value` and
|
|
1237
|
+
advances `seq`. The payload's `value` is the WHOLE patched document:
|
|
1238
|
+
an action's `patch` member is a literal op list whose members are
|
|
1239
|
+
query expressions — it cannot splice a runtime array of RFC 6902 ops
|
|
1240
|
+
— so the handler applies the emission with `@jarenjs/json/patch`
|
|
1241
|
+
(copy-on-write; structural sharing preserved) and the action replaces.
|
|
1242
|
+
- `<ns><op>/error` — guarded on the id; `status: "error"`, the
|
|
1243
|
+
outcome's `kind`/`error`/`meta`.
|
|
1244
|
+
- `<ns><op>/reset` — as for tasks: `status "idle"`, `kind`/`error`
|
|
1245
|
+
cleared, everything else kept.
|
|
1246
|
+
|
|
1247
|
+
The binding additionally returns `subs` — one entry per subscribe
|
|
1248
|
+
operation — and names its handler in `subscription`:
|
|
1249
|
+
|
|
1250
|
+
```jsonc
|
|
1251
|
+
{ "run": "contract-stream",
|
|
1252
|
+
"when": { "$eq": ["$.contract['data.live'].status", "live"] },
|
|
1253
|
+
"withQuery": { "op": "data.live", "id": "$.contract['data.live'].id",
|
|
1254
|
+
"input": "$.contract['data.live'].input",
|
|
1255
|
+
"snapshot": "contract/data.live/snapshot",
|
|
1256
|
+
"patch": "contract/data.live/patch",
|
|
1257
|
+
"error": "contract/data.live/error" } }
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
`withQuery` (not `with`) because the resolved props carry the slot's
|
|
1261
|
+
`id` and `input` from state — APP-FORMAT §5.3's restart rule then keys
|
|
1262
|
+
the instance by the resolved props by value. The handler is
|
|
1263
|
+
`createContractSubscription(client)` (`@jarenjs/contract/app`), a plain
|
|
1264
|
+
`(props, dispatch) => cleanup` — no task-effect factory is needed. It
|
|
1265
|
+
calls `client.subscribe`, applies each emission host-side, and
|
|
1266
|
+
dispatches the named actions with `{ id, … }` payloads; an `onError`
|
|
1267
|
+
outcome lands in the error action as-is, and a server `end` lands there
|
|
1268
|
+
as a `network`-kind outcome with the channel-closed code (`JC2074`) —
|
|
1269
|
+
the stream is gone and the slot says so; reconnection is a fresh
|
|
1270
|
+
`start`, never automatic. The slice schema pins the subscribe slot like
|
|
1271
|
+
the task slots (`status` to its three states, `input` to the
|
|
1272
|
+
operation's input schema or `null`, `seq` to a non-negative integer).
|
|
1273
|
+
|
|
1274
|
+
## §12 Projections
|
|
1275
|
+
|
|
1276
|
+
Everything a consumer wants **beside** the runtime is a projection of the
|
|
1277
|
+
same compiled contract (`@jarenjs/contract/project` — the one subpath of
|
|
1278
|
+
this package that imports `@jarenjs/emit`, so a bundle that never
|
|
1279
|
+
projects never carries it): the public projection every other artifact
|
|
1280
|
+
is built on (§12.1), OpenAPI 3.1 (§12.2), TypeScript declarations
|
|
1281
|
+
(§12.3), Markdown reference documentation (§12.4) and AI tool
|
|
1282
|
+
definitions (§12.5), with the `jaren-contract` CLI (§12.6) writing and
|
|
1283
|
+
`--check`ing them in CI. A projection takes a **compiled** contract —
|
|
1284
|
+
never a raw document — so it renders resolved bindings and materialized
|
|
1285
|
+
policies, and every projection is **deterministic**: the same contract
|
|
1286
|
+
renders byte-identical artifacts.
|
|
1287
|
+
|
|
1288
|
+
### §12.1 The public projection — normative, the revision hashes it
|
|
1289
|
+
|
|
1290
|
+
`publicProjection(contract, { ops? })` is a JSON document that is itself
|
|
1291
|
+
a **valid `$contract` 0.1** (it compiles, and projecting the compile
|
|
1292
|
+
yields the same bytes): the browser-safe subset a client needs and no
|
|
1293
|
+
more, and the exact bytes the contract revision will hash. Because the
|
|
1294
|
+
hash is a compatibility claim, the member order is **normative**:
|
|
1295
|
+
|
|
1296
|
+
- root: `$contract`, `id`, `version`, `compat`, `$defs`, `operations` —
|
|
1297
|
+
each present only when the contract carries it (`$defs` only when a
|
|
1298
|
+
retained schema reaches one);
|
|
1299
|
+
- operation (document order): `kind`, `input`, `output`, `errors`,
|
|
1300
|
+
`policy`, `http`, `doc` — `input`, `errors` and `doc` only when
|
|
1301
|
+
declared;
|
|
1302
|
+
- error declaration: `status` (always, resolved), `schema` (when
|
|
1303
|
+
declared);
|
|
1304
|
+
- policy: `task`, `idempotency`, `revision` (when declared), `cache`,
|
|
1305
|
+
`retry` (when declared), `audience` — every default materialized;
|
|
1306
|
+
**`limits` and `errors.details` are server-side knobs and never
|
|
1307
|
+
appear**;
|
|
1308
|
+
- http: `method`, `path` (canonical `{name}` template), `in` (every
|
|
1309
|
+
member's location), `body` (when a whole-body member is declared),
|
|
1310
|
+
`status`, `media` — the canonical binding is written out like any
|
|
1311
|
+
declared one;
|
|
1312
|
+
- `$defs`: exactly the entries reachable from the retained operations'
|
|
1313
|
+
`input`/`output`/error schemas by same-document `$ref` (transitively;
|
|
1314
|
+
an anchor or a pointer into an entry counts as the entry), in
|
|
1315
|
+
**first-reference order** — walking operations in document order,
|
|
1316
|
+
each operation's `input`, `output`, then its errors in declaration
|
|
1317
|
+
order.
|
|
1318
|
+
|
|
1319
|
+
Retained = the operations whose `policy.audience` is not `"server"`,
|
|
1320
|
+
narrowed further by `ops` when given (`JC1008` for an id the contract
|
|
1321
|
+
does not declare; a listed `server` operation is still not retained).
|
|
1322
|
+
Schema subtrees are the compiled document's own (frozen); the
|
|
1323
|
+
composition is fresh.
|
|
1324
|
+
|
|
1325
|
+
### §12.2 OpenAPI 3.1
|
|
1326
|
+
|
|
1327
|
+
`toOpenApi(contract, { info?, servers?, lenient? })` →
|
|
1328
|
+
`{ document, dropped }`: a valid OpenAPI **3.1.0** document rendered by
|
|
1329
|
+
a JSLT stylesheet shipped as JSON (`src/project/openapi.jslt.json`,
|
|
1330
|
+
compiled once at module scope), built on the public projection. The
|
|
1331
|
+
test suite validates the rendered document against the vendored
|
|
1332
|
+
official OpenAPI 3.1 meta-schema with `JarenValidator`
|
|
1333
|
+
(`test/contract/fixtures/openapi-3.1.schema.json`, Apache-2.0 — the
|
|
1334
|
+
suite validating its own projection with its own validator is the
|
|
1335
|
+
point). The mapping:
|
|
1336
|
+
|
|
1337
|
+
| contract | OpenAPI |
|
|
1338
|
+
|---|---|
|
|
1339
|
+
| — | `openapi: "3.1.0"`, `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"`, `info` (title/version defaulting to the contract id/version), `servers` when given |
|
|
1340
|
+
| operation | `paths[<canonical path>][<lowercased method>]`, paths sorted by path then method; `operationId` = the id; `summary` = `doc`'s first line (`description` = the whole `doc` when it has more); `tags` = the id's first dotted segment |
|
|
1341
|
+
| `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
|
|
1342
|
+
| body members | `requestBody`: the object of the body-located members (their `required` intersection, the input's `additionalProperties`); the whole input schema when every member is body-located; the member's own schema under `http.body`; `content[<http.media>]` |
|
|
1343
|
+
| `output`, `http.status` | `responses[<status>]` with the output schema; no content on `204`; opaque → `content[<media>]: { type: "string", format: "binary" }` |
|
|
1344
|
+
| declared `errors` | one response per status: the D7 wire-error schema (`code` **enum-pinned** to the codes of that status, `details` the declared schema when present) |
|
|
1345
|
+
| the binding's own statuses | shared `components.responses` (`BadRequest` 400, `NotFound` 404, `IdempotencyConflict` 409 on idempotent operations, `PayloadTooLarge` 413, `UnsupportedMediaType` 415 on body-carrying operations, `InternalError` 500), `$ref`'d per operation unless a declared error already answers that status |
|
|
1346
|
+
| `$defs` | `components.schemas`, every `#/$defs/X…` rewritten `#/components/schemas/X…` |
|
|
1347
|
+
| policy | the `x-jaren-policy` extension: `{ task, idempotency, cache, revision?, retry? }` verbatim (an `x-` extension is OpenAPI's sanctioned place) |
|
|
1348
|
+
|
|
1349
|
+
An opaque operation never meets a `requestBody` here: the compiler
|
|
1350
|
+
refuses a body-located member on one (`JC0017`, §4.5), so the mapping
|
|
1351
|
+
needs no rule for it.
|
|
1352
|
+
|
|
1353
|
+
**The keyword policy — map-or-reject, nothing silent.** Every schema the
|
|
1354
|
+
projection carries is walked once:
|
|
1355
|
+
|
|
1356
|
+
| keyword | treatment |
|
|
1357
|
+
|---|---|
|
|
1358
|
+
| `nullable: true` | mapped: `"null"` added to `type` (dropped + reported when there is no `type` to widen) |
|
|
1359
|
+
| `nullable: false` | dropped + reported (asserts nothing in 3.1) |
|
|
1360
|
+
| boolean `required` | **rejected** `JC0060` at the keyword; under `lenient` dropped + reported (the array form is JSON Schema's own and passes) |
|
|
1361
|
+
| `$query`, `$data`, `errorMessage`, `x-form` and every `x-*` | dropped + reported (Jaren-side; the wire never enforced them for a peer) |
|
|
1362
|
+
| `components` inside a schema | **rejected** `JC0060`, `lenient` or not (a document member has no schema reading) |
|
|
1363
|
+
| a same-document `$ref` that lands outside the projection's `$defs` (an anchor, `#`, a pointer into an operation) | **rejected** `JC0060`; under `lenient` dropped + reported (the schema is honestly wider) |
|
|
1364
|
+
| `const`, `enum`, `default`, `examples`, `example` | data — copied verbatim, never walked |
|
|
1365
|
+
| everything else | copied, subschemas walked |
|
|
1366
|
+
|
|
1367
|
+
`dropped` is the audit trail: `[{ docPath, keyword, reason }]` with the
|
|
1368
|
+
`docPath` of the keyword **in the contract document**, in document
|
|
1369
|
+
order. The projection's own code, in the `JC0050–JC0069` range beside
|
|
1370
|
+
§6's table:
|
|
1371
|
+
|
|
1372
|
+
| code | condition |
|
|
1373
|
+
|---|---|
|
|
1374
|
+
| JC0060 | the OpenAPI projection met a schema keyword it cannot map honestly: a boolean `required` or a same-document `$ref` that lands outside `$defs` (both dropped and reported under `lenient`), or a `components` member inside a schema (`ContractCompileError`, `docPath` into the contract document) |
|
|
1375
|
+
|
|
1376
|
+
### §12.3 TypeScript
|
|
1377
|
+
|
|
1378
|
+
`toTypeScript(contract, { banner? })` renders one `.d.ts` on
|
|
1379
|
+
`@jarenjs/emit`'s type model (`compileEmitModel` + `renderTypeScript`,
|
|
1380
|
+
the suite's one declaration renderer): per public operation
|
|
1381
|
+
`<PascalOp>Input` / `<PascalOp>Output` / `<PascalOp><PascalCode>Details`
|
|
1382
|
+
(collisions uniqued with a numeric suffix; a `$defs` entry keeps its own
|
|
1383
|
+
name), the reachable `$defs` once, then — fixed text in a JTLT
|
|
1384
|
+
stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
|
|
1385
|
+
typed operation map: kind, input, output, the declared error codes as a
|
|
1386
|
+
literal union), `UrlOperations` (opaque operations included, for
|
|
1387
|
+
`Client.url`), and `Meta`, `WireError`, `Outcome<T>`, `InvokeContext`,
|
|
1388
|
+
`Client`, `Failure`, `HandlerContext`, `Handlers`. `Meta` and
|
|
1389
|
+
`WireError` spell **exactly** the fixed D6 shapes (§10.1) —
|
|
1390
|
+
`OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
|
|
1391
|
+
a test holds the text to them; `details` is `unknown` and `status`
|
|
1392
|
+
`number | null`, never optional members. An input-less operation's
|
|
1393
|
+
`input` is `null`; an opaque operation appears only in `UrlOperations`.
|
|
1394
|
+
|
|
1395
|
+
### §12.4 Markdown
|
|
1396
|
+
|
|
1397
|
+
`toMarkdown(contract, { title? })` renders one reference document: the
|
|
1398
|
+
title and version, an operations table (id, method, path, kind, task,
|
|
1399
|
+
idempotency), one section per public operation (its `doc`, the policy
|
|
1400
|
+
line, parameters, body, responses, declared errors) and a Types part
|
|
1401
|
+
rendered by emit's Markdown target over the **same** type model as
|
|
1402
|
+
§12.3 — so every type name a section links to is a heading that exists.
|
|
1403
|
+
|
|
1404
|
+
### §12.5 AI tools
|
|
1405
|
+
|
|
1406
|
+
`contractTools(contract, client, { ops?, name? })` → an array of
|
|
1407
|
+
`{ name, description, inputSchema, execute }` — the `ToolDef` shape
|
|
1408
|
+
`@jarenjs/ai`'s `createToolbox().add` takes and WebMCP's `registerTool`
|
|
1409
|
+
reads, **without importing that package** (the generated-document rule:
|
|
1410
|
+
the shape is a plain object; the test suite registers them into a real
|
|
1411
|
+
toolbox). Per public, invokable operation (opaque and subscribe
|
|
1412
|
+
operations are skipped by the default set and refused when `ops` names
|
|
1413
|
+
one — a tool carries one invoke, not bytes and not a stream), in
|
|
1414
|
+
document order:
|
|
1415
|
+
|
|
1416
|
+
- `name`: the id with `.` → `_` (injective — an id carries no `_`), or
|
|
1417
|
+
`options.name(id)`; every name MUST match OpenAI's
|
|
1418
|
+
`^[a-zA-Z0-9_-]{1,64}$` and two operations mapping to one name is
|
|
1419
|
+
`JC1008`;
|
|
1420
|
+
- `description`: the `doc`, or a derived `<kind> operation <id>
|
|
1421
|
+
(<METHOD> <path>)`;
|
|
1422
|
+
- `inputSchema`: the operation's input schema made **self-contained** —
|
|
1423
|
+
the `$defs` it reaches inlined under the schema's own `$defs`
|
|
1424
|
+
(`bundleSameDocument`, the suite's one same-document bundler) — or a
|
|
1425
|
+
closed empty object schema for an input-less operation;
|
|
1426
|
+
- `execute`: `(args) => client.invoke(op, args)` (`null` for an
|
|
1427
|
+
input-less operation), resolving the outcome JSON — a model sees the
|
|
1428
|
+
same `{ ok, value | error, meta }` an app does, and a failed outcome
|
|
1429
|
+
is a resolved value, never a rejection.
|
|
1430
|
+
|
|
1431
|
+
Opaque operations are skipped by default and refused (`JC1008`) when
|
|
1432
|
+
`ops` names one — a tool carries JSON; an opaque operation is reached
|
|
1433
|
+
through `client.url`.
|
|
1434
|
+
|
|
1435
|
+
### §12.6 The CLI
|
|
1436
|
+
|
|
1437
|
+
`jaren-contract <describe|public|openapi|types|docs> --contract <file>
|
|
1438
|
+
[--out <dir|file>] [--check] [--info-title T] [--info-version V]
|
|
1439
|
+
[--lenient]` (the package `bin`). `describe` prints `describe()`;
|
|
1440
|
+
`public`/`openapi` print or write JSON (two-space indent, trailing
|
|
1441
|
+
newline); `types`/`docs` the text artifacts; `--out` names a file, or a
|
|
1442
|
+
directory that gets `<contract id><extension>`
|
|
1443
|
+
(`.describe.json`/`.public.json`/`.openapi.json`/`.d.ts`/`.md`).
|
|
1444
|
+
`--check` writes nothing and exits **1** when the file differs from
|
|
1445
|
+
what the document projects today — the CI drift gate; exit **0** when
|
|
1446
|
+
current or written; exit **2** on a usage error, an unreadable document,
|
|
1447
|
+
or a compile refusal, printed as `code docPath reason`. `openapi`
|
|
1448
|
+
reports every dropped keyword on stderr; without `--lenient` a
|
|
1449
|
+
rejectable keyword is exit 2 with `JC0060` and its `docPath`.
|
|
1450
|
+
|
|
1451
|
+
## §13 The breaking-change diff
|
|
1452
|
+
|
|
1453
|
+
`diffContracts(a, b)` (`@jarenjs/contract/diff`) classifies every change
|
|
1454
|
+
from contract `a` (what consumers hold today) to contract `b` (what they
|
|
1455
|
+
would meet) into `{ breaking, additive, neutral, unknown }` by the rule
|
|
1456
|
+
table below. It takes compiled contracts or raw documents (a document is
|
|
1457
|
+
compiled first, so a malformed one refuses with its own `JC00xx` before
|
|
1458
|
+
any comparison). Each entry is a
|
|
1459
|
+
`Change = { kind, op, docPath, from?, to?, rule, note? }` where `kind` is
|
|
1460
|
+
a stable slug, `rule` names the row, and `docPath` points into the
|
|
1461
|
+
document that carries the change — into `a` for a removal, into `b`
|
|
1462
|
+
otherwise — composed over the *resolved* structure, so a constraint
|
|
1463
|
+
reached through a bare `{ "$ref": "#/$defs/X" }` hop reports the path a
|
|
1464
|
+
validator error would name.
|
|
1465
|
+
|
|
1466
|
+
| # | Change | Class |
|
|
1467
|
+
|---|---|---|
|
|
1468
|
+
| R1 | operation removed | breaking |
|
|
1469
|
+
| R2 | operation added | additive |
|
|
1470
|
+
| R3 | `kind`, `http.method`, `http.path` (shape — variable *names* are not shape), `http.status`, `http.media` (opaqueness included), a member's `http.in` location, or `http.body` changed | breaking |
|
|
1471
|
+
| R4 | input: a member added to `required` (or a new required member) | breaking |
|
|
1472
|
+
| R5 | input: a member removed while the new input schema is `additionalProperties: false` (or the input removed entirely) | breaking; otherwise `neutral` with a note (the member is now ignored, not validated) |
|
|
1473
|
+
| R6 | input: a member's schema narrowed (type set shrinks, `enum`/`const` shrinks, `maximum` lowers, `minimum` rises, `maxLength` lowers, `minLength` rises, `pattern` added) | breaking |
|
|
1474
|
+
| R7 | input: a member's schema widened (the inverse of R6), an optional member added, or a member dropped from `required` | additive |
|
|
1475
|
+
| R8 | output: a member removed, made optional (dropped from `required`), or narrowed | breaking |
|
|
1476
|
+
| R9 | output: a new member (optional or required), a member added to `required`, or widened | additive |
|
|
1477
|
+
| R10 | error code removed, or its `status` changed | breaking |
|
|
1478
|
+
| R11 | error code added | additive |
|
|
1479
|
+
| R12 | `policy.idempotency` `none/optional → required` (a client must now send a key) | breaking; `required → optional/none` and `none ↔ optional` additive |
|
|
1480
|
+
| R13 | `policy.task`, `policy.retry`, `policy.cache`, `policy.revision` or `doc` changed | neutral |
|
|
1481
|
+
| R14 | `policy.audience` `public → server` | breaking; the reverse additive |
|
|
1482
|
+
| R15 | a schema construct the checker does not model differs between the two (`anyOf`/`oneOf`/`allOf`/`if`/`not`/`$dynamicRef`, a `format`, a *changed* `pattern`, an external or sibling-carrying `$ref`, a changed error `details` schema, …) | **unknown** — reported, never silently classed |
|
|
1483
|
+
|
|
1484
|
+
Riders the rows carry:
|
|
1485
|
+
|
|
1486
|
+
- **R3 covers the whole wire shape.** The order's five members plus a
|
|
1487
|
+
member's `in` location and the whole-body `body` member — a member
|
|
1488
|
+
that moves from `query` to `header` rewrites the request exactly like
|
|
1489
|
+
a moved path, so it classifies with the binding row. Renaming a path
|
|
1490
|
+
*variable* alone is not an R3 change (the shape compares with
|
|
1491
|
+
variables blanked); the renamed input member surfaces through
|
|
1492
|
+
R4/R5/R7 instead.
|
|
1493
|
+
- **R12 and `retry`.** The `required → optional` additive row holds for
|
|
1494
|
+
operations without `policy.retry`: an `optional`-idempotency command
|
|
1495
|
+
with `retry` cannot compile (`JC0014`, §3.1), so `diffContracts`
|
|
1496
|
+
never meets that pair.
|
|
1497
|
+
- **The schema walk models exactly R6 plus structure.** Object members
|
|
1498
|
+
(`properties`, `required`, `additionalProperties`) and `items`
|
|
1499
|
+
recurse; the R6 keyword set compares as constraints; pure annotations
|
|
1500
|
+
(`title`, `description`, `examples`, `$comment`, `deprecated`) never
|
|
1501
|
+
move a wire byte and are ignored; **everything else that differs is
|
|
1502
|
+
R15**. Nested member changes classify by narrowing/widening (adding a
|
|
1503
|
+
constrained optional member to an open nested object narrows it;
|
|
1504
|
+
removing one from an open object widens it); the *top-level* input
|
|
1505
|
+
members classify by R4/R5/R7, and output members by R8/R9 at every
|
|
1506
|
+
depth.
|
|
1507
|
+
- **Audience is the compatibility surface.** Operations whose
|
|
1508
|
+
`policy.audience` is `server` on **both** sides are skipped entirely;
|
|
1509
|
+
an audience flip is R14 and subsumes the operation's other changes.
|
|
1510
|
+
`policy.limits` and `policy.errors.details` are server-side knobs
|
|
1511
|
+
outside the public projection and are never reported.
|
|
1512
|
+
|
|
1513
|
+
`isCompatible(clientContract, serverContract)` (exported from
|
|
1514
|
+
`@jarenjs/contract/diff`, one implementation shared with the client's
|
|
1515
|
+
`negotiate()`) is the complementary *declared* answer: `true` when the
|
|
1516
|
+
two ends state the same `version`, or when either end's `compat` names
|
|
1517
|
+
the other's `version` — `compatReason` returns which rule held
|
|
1518
|
+
(`'same-version' | 'server-accepts' | 'client-accepts' | null`).
|
|
1519
|
+
`diffContracts` computes what changed; `isCompatible` reads what the
|
|
1520
|
+
authors claim. Never derive one from the other: a `version` is bumped
|
|
1521
|
+
by a person, a revision (§14) moves by itself.
|
|
1522
|
+
|
|
1523
|
+
The CLI's compatibility gate: `jaren-contract diff --from a.json --to
|
|
1524
|
+
b.json [--fail-on breaking[,unknown,…]]` prints the classified diff as
|
|
1525
|
+
JSON and exits **1** when any `--fail-on` class is non-empty (exit 0
|
|
1526
|
+
otherwise; exit 2 on a usage error, an unreadable file or a compile
|
|
1527
|
+
refusal).
|
|
1528
|
+
|
|
1529
|
+
## §14 The revision
|
|
1530
|
+
|
|
1531
|
+
`contract.revision() → Promise<string>` is the lowercase hex SHA-256
|
|
1532
|
+
over the RFC 8785 canonical bytes of the **public projection** (§12.1)
|
|
1533
|
+
— computed with the platform's `crypto.subtle`, memoized per compiled
|
|
1534
|
+
contract, so `compileContract` stays synchronous and the digest is paid
|
|
1535
|
+
at most once per process (measured here: ~4–5 ms for a 123-operation,
|
|
1536
|
+
160 KB document). Because it hashes the projection and the projection
|
|
1537
|
+
materializes defaults in a normative member order:
|
|
1538
|
+
|
|
1539
|
+
- two compiles of equal documents agree, across processes and platforms;
|
|
1540
|
+
- a change to any client-observable member — a public operation's
|
|
1541
|
+
schemas, binding, declared errors, client-facing policy — moves it;
|
|
1542
|
+
- a change a client cannot observe — a `server`-audience operation, a
|
|
1543
|
+
`policy.limits` value, an `errors.details` level, a `doc`-only edit
|
|
1544
|
+
*does* move it (`doc` is projected) but a declared binding rewritten
|
|
1545
|
+
to its own defaults does **not** (the projection materializes
|
|
1546
|
+
defaults, so two documents that behave alike hash alike).
|
|
1547
|
+
|
|
1548
|
+
`describe()` carries `revision: <hex | null>` — `null` until someone
|
|
1549
|
+
awaited `revision()`; `describe()` stays synchronous and never computes
|
|
1550
|
+
it. The HTTP server computes it **lazily on the first well-known
|
|
1551
|
+
request** (§7.6): `GET /.well-known/jaren-contract` awaits the digest
|
|
1552
|
+
once and answers the description with `revision` filled; the client's
|
|
1553
|
+
`negotiate()` reads it and carries it in `meta.revision` of every
|
|
1554
|
+
subsequent outcome — correlation data, never the compatibility decision
|
|
1555
|
+
(that is `version`/`compat`, §13). The revision is not a `version`:
|
|
1556
|
+
never bump `version` merely because the revision moved, and never
|
|
1557
|
+
compare revisions to decide compatibility.
|
|
1558
|
+
|
|
1559
|
+
A public projection that cannot be canonicalized has no revision:
|
|
1560
|
+
|
|
1561
|
+
| code | condition |
|
|
1562
|
+
|---|---|
|
|
1563
|
+
| JC0061 | the public projection is not canonicalizable, so no revision exists — a string member carrying an unpaired surrogate, say (`ContractCompileError` rejected from `revision()`; its `docPath` points at the offending value *inside the projection*) |
|
|
1564
|
+
|
|
1565
|
+
(The compiler's document snapshot already refuses non-finite numbers,
|
|
1566
|
+
functions and cycles at `JC0001`, so an unpaired surrogate in a string
|
|
1567
|
+
is the reachable case.) The well-known responder reports the refusal to
|
|
1568
|
+
`onError` once and honestly answers `revision: null`. The revision and
|
|
1569
|
+
the idempotency request hash (§8) are the same computation —
|
|
1570
|
+
`canonicalSha256` in `@jarenjs/json` — never a 32-bit content
|
|
1571
|
+
fingerprint, which collides.
|
|
1572
|
+
|
|
1573
|
+
## §15 The local binding
|
|
1574
|
+
|
|
1575
|
+
`openLocalClient(contract, handlers, options) → Client` — the same
|
|
1576
|
+
operation pipeline as every server binding, with no wire: the test
|
|
1577
|
+
seam, SSR, a CLI calling its own operations. The client and the server
|
|
1578
|
+
are one object; `serveLocal` is the same factory under the serve name,
|
|
1579
|
+
for symmetry with the driver pairs of the other bindings. The `Client`
|
|
1580
|
+
is the binding-agnostic shape of §10 minus what cannot exist here:
|
|
1581
|
+
`invoke`, `capabilities`, `contract`, `describe`, `close` — no `url`,
|
|
1582
|
+
no `negotiate`, no `pending`.
|
|
1583
|
+
|
|
1584
|
+
`invoke(op, input, ctx)` with `ctx = { signal?, attempt? }`:
|
|
1585
|
+
|
|
1586
|
+
1. an unknown operation, or an opaque one, throws `JC1005` — non-JSON
|
|
1587
|
+
media does not exist in-process (`capabilities.media: false`); so
|
|
1588
|
+
does a subscribe operation — this binding carries no streams
|
|
1589
|
+
(`capabilities.stream: false`), and needs no handler for one;
|
|
1590
|
+
2. the input is validated with the operation's compiled validator; a
|
|
1591
|
+
refusal is the pre-send `JC2050` outcome (kind `contract`, details
|
|
1592
|
+
by `policy.errors.details`) and nothing ran — the same refusal every
|
|
1593
|
+
client binding shares;
|
|
1594
|
+
3. the handler runs through the neutral pipeline with the frozen
|
|
1595
|
+
context `{ op, trace, signal, params: null, headers: {}, fail,
|
|
1596
|
+
idempotency: null }` — `trace` from the `trace` option (default
|
|
1597
|
+
`crypto.randomUUID`), `signal` the caller's composed with the
|
|
1598
|
+
client's closer. There is no `ctx.etag`, `ctx.status` or `ctx.body`:
|
|
1599
|
+
statuses, entity tags and bytes do not exist here, and a handler
|
|
1600
|
+
that reaches for them fails honestly (`JC2070`) instead of
|
|
1601
|
+
pretending;
|
|
1602
|
+
4. the outcome (§10.1 shapes, assembled by the same assembler):
|
|
1603
|
+
|
|
1604
|
+
| settlement | outcome |
|
|
1605
|
+
|---|---|
|
|
1606
|
+
| the output, valid | `{ ok: true, value, meta }` — `meta.trace` the generated trace |
|
|
1607
|
+
| a declared failure (`ctx.fail`, or a thrown `ContractRuntimeError` whose code the operation declares) | kind `failure` with `{ code, message, status: null, details, retryable }` — `status` is `null` and PRESENT (D6: a binding that cannot carry a member carries `null`, never omits it); the message is `contract/error/<code>` from the host catalog or the generic `contract/handler-error` |
|
|
1608
|
+
| 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 |
|
|
1609
|
+
| `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
|
|
1610
|
+
|
|
1611
|
+
Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
|
|
1612
|
+
reported in `capabilities.validatedOutput`; the output is validated
|
|
1613
|
+
ONCE, in the pipeline — the assembler does not re-validate what never
|
|
1614
|
+
crossed a wire), `catalog`, `onError`. Capabilities:
|
|
1615
|
+
|
|
1616
|
+
```jsonc
|
|
1617
|
+
{ "name": "local", "status": false, "headers": false, "media": false,
|
|
1618
|
+
"etag": false, "idempotency": false, "validatedOutput": true,
|
|
1619
|
+
"stream": false, "cancel": "signal" }
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
Two readings that keep one handler table serving http AND locally:
|
|
1623
|
+
|
|
1624
|
+
- **a declared `policy.idempotency` is allowed and inert.** The same
|
|
1625
|
+
contract must serve over http (where the ledger enforces it) and
|
|
1626
|
+
locally (where "the wire retried" cannot happen — a re-run is the
|
|
1627
|
+
caller's own hand). The binding does not refuse it (`JC1003` is for a
|
|
1628
|
+
feature a binding was asked to carry and cannot, like http without a
|
|
1629
|
+
ledger); it carries `capabilities.idempotency: false` so the
|
|
1630
|
+
downgrade is declared, never silent.
|
|
1631
|
+
- **an opaque or subscribe operation needs no handler and may still
|
|
1632
|
+
have one.** `serveLocal` exempts both from the `JC1002` handler
|
|
1633
|
+
requirement (neither can be invoked here), and accepts a handler
|
|
1634
|
+
table that carries them — so an http server's table is reusable
|
|
1635
|
+
verbatim; those handlers are simply never called.
|
|
1636
|
+
|
|
1637
|
+
The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
|
|
1638
|
+
`@jarenjs/contract/local` and `/port` is this table as data):
|
|
1639
|
+
|
|
1640
|
+
| code | kind | msgid | retryable | when |
|
|
1641
|
+
|---|---|---|---|---|
|
|
1642
|
+
| `JC2070` | contract | `contract/local-handler-failed` | no | the serving host's handler failed in any class — a throw, an undeclared code, a broken output or error-details schema; `onError` sees the cause |
|
|
1643
|
+
|
|
1644
|
+
## §16 The port binding
|
|
1645
|
+
|
|
1646
|
+
`servePort(contract, handlers, { channel, trace?, validateOutput?,
|
|
1647
|
+
catalog?, onError? })` and `openPortClient(contract, { channel,
|
|
1648
|
+
timeoutMs = 15000, catalog? })` — request/response over anything with
|
|
1649
|
+
`postMessage` and a message-listener surface: a `MessagePort` (started
|
|
1650
|
+
automatically), a `Worker`, a `BroadcastChannel`, a worker's own
|
|
1651
|
+
`self`, or a plain object of that shape. The server prepares the same
|
|
1652
|
+
pipeline routes as §7 and refuses the same host mistakes (`JC1001`,
|
|
1653
|
+
`JC1002` — opaque operations exempt exactly as in §15; a subscribe
|
|
1654
|
+
operation NEEDS its handler, it streams here — §18.2); the client is
|
|
1655
|
+
the §10 shape minus `url`/`negotiate`/`pending`, plus `subscribe`
|
|
1656
|
+
(§19). A request frame naming a subscribe operation is answered
|
|
1657
|
+
`JC2071` like an opaque one — a stream is never a request/response.
|
|
1658
|
+
Exactly ONE server should serve a shared channel — two would both
|
|
1659
|
+
answer every request.
|
|
1660
|
+
|
|
1661
|
+
### §16.1 Frames
|
|
1662
|
+
|
|
1663
|
+
The grammar is `schemas/jaren-contract-port.schema.json` (draft-07 twin
|
|
1664
|
+
beside it), and every frame the binding emits validates against it.
|
|
1665
|
+
Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
|
|
1666
|
+
|
|
1667
|
+
```jsonc
|
|
1668
|
+
{ "jaren": "contract/0.1", "id": "<clientId>:<seq>", "op": "data.rows",
|
|
1669
|
+
"input": { "collection": "notes" } } // request; attempt?/key? reserved
|
|
1670
|
+
{ "jaren": "contract/0.1", "id": "<clientId>:<seq>", "ok": true,
|
|
1671
|
+
"value": [ /* … */ ], "trace": "3f2c…" } // success
|
|
1672
|
+
{ "jaren": "contract/0.1", "id": "<clientId>:<seq>", "ok": false,
|
|
1673
|
+
"error": { "code": "conflict", "message": "…", "details": { }, "retryable": false },
|
|
1674
|
+
"trace": "3f2c…" } // error
|
|
1675
|
+
{ "jaren": "contract/0.1", "cancel": "<clientId>:<seq>" } // cancel; never answered
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
**Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
|
|
1679
|
+
`clientId` a UUID per client instance, `seq` a per-client counter — so
|
|
1680
|
+
two clients on one shared channel can never collide, and a client
|
|
1681
|
+
ignores every frame whose id does not start with its own `clientId +
|
|
1682
|
+
":"` (one cheap prefix test before any map lookup). A late response
|
|
1683
|
+
for a cancelled or timed-out id finds no pending entry and is dropped
|
|
1684
|
+
silently. The request members `attempt` and `key` are reserved by the
|
|
1685
|
+
grammar and ignored by servers of this version: the attempt id stays
|
|
1686
|
+
caller-side in `meta` (D6 — the identities live in state, never in the
|
|
1687
|
+
transport) and idempotency is not carried on this binding.
|
|
1688
|
+
|
|
1689
|
+
**What the wire cannot carry, it does not pretend to.** No statuses,
|
|
1690
|
+
no headers, no entity tags, no non-JSON media (an opaque operation is
|
|
1691
|
+
`JC1005` at `invoke` and answered `JC2071` if some other client asks);
|
|
1692
|
+
a declared failure's `status` is `null` in the outcome, member present
|
|
1693
|
+
(D6). `input` is the whole input object (`null` for an input-less
|
|
1694
|
+
operation); an `undefined` handler value crosses as `null` (frames are
|
|
1695
|
+
JSON). Cancellation is `cancel: "message"`: an abort posts the cancel
|
|
1696
|
+
frame — an optimization that stops wasted work; the id scoping is the
|
|
1697
|
+
guarantee.
|
|
1698
|
+
|
|
1699
|
+
### §16.2 The server, per request frame
|
|
1700
|
+
|
|
1701
|
+
A frame without the marker is IGNORED, never answered — other traffic
|
|
1702
|
+
may share the channel (an owner-discovery ping, a live push). So is a
|
|
1703
|
+
frame carrying `ok` (another server's response on a shared channel)
|
|
1704
|
+
and a marked frame without a usable string `id` (nothing to address).
|
|
1705
|
+
Then: an unknown or opaque `op` is answered `JC2071` **without echoing
|
|
1706
|
+
what was asked** (a request value never enters a message, §7.3's
|
|
1707
|
+
rule); an input failing its validator is `JC2006` with details by
|
|
1708
|
+
policy; otherwise the request runs through the pipeline under one
|
|
1709
|
+
`AbortController` per id — a `cancel` frame aborts it — and the
|
|
1710
|
+
settlement is posted back with the server `trace`: the §7.3
|
|
1711
|
+
classification with `JC2070` in place of §7's 500s (a declared failure
|
|
1712
|
+
keeps its code, message, details and retryability; every host fault is
|
|
1713
|
+
`JC2070` with the cause to `onError`). A response for an id whose
|
|
1714
|
+
controller was aborted is not posted. `close()` detaches the listener
|
|
1715
|
+
and aborts every in-flight request.
|
|
1716
|
+
|
|
1717
|
+
### §16.3 The client, per outcome
|
|
1718
|
+
|
|
1719
|
+
Pre-send exactly as §15 steps 1–2 (`JC1005` thrown, `JC2050`
|
|
1720
|
+
pre-send). Then one request frame; the outcome:
|
|
1721
|
+
|
|
1722
|
+
| the channel answered | outcome |
|
|
1723
|
+
|---|---|
|
|
1724
|
+
| `ok: true` with `value` | validated against the output schema → `{ ok: true, value, meta }` (`JC2053` kind `contract` on a mismatch, as §10); `meta.trace` from the frame |
|
|
1725
|
+
| `ok: false` with a declared or §7 taxonomy `code` | kind `failure`, `status: null`, the frame's message/details/retryable (a peer's `JC2006` means the two ends validated differently — visible, not hidden) |
|
|
1726
|
+
| `ok: false` with `JC2070`/`JC2071` | kind `contract`, the code kept — a served-host fault or a contract the two ends disagree about is never dressed as a declared failure |
|
|
1727
|
+
| `ok: false` with any other code | kind `contract` `JC2055` (§10.3 — an undeclared response) |
|
|
1728
|
+
| a frame addressed to this client that does not match the grammar | kind `contract` `JC2073` |
|
|
1729
|
+
| nothing within `timeoutMs` | kind `network` `JC2072` (retryable); `0` disables the timer |
|
|
1730
|
+
| `postMessage` threw (closed, detached) | kind `network` `JC2074` |
|
|
1731
|
+
| `ctx.signal` aborted, or `close()` | kind `cancelled` `JC2052`; the cancel frame is posted when the channel still accepts one |
|
|
1732
|
+
|
|
1733
|
+
The port codes (with `JC2070` of §15; `PORT_LOCAL_ERRORS` is the table
|
|
1734
|
+
as data):
|
|
1735
|
+
|
|
1736
|
+
| code | kind | msgid | retryable | when |
|
|
1737
|
+
|---|---|---|---|---|
|
|
1738
|
+
| `JC2071` | contract | `contract/unknown-operation` | no | the server answered "no operation of that name is served on this channel" — unknown, or opaque |
|
|
1739
|
+
| `JC2072` | network | `contract/port-timeout` | yes | no answer within `timeoutMs` |
|
|
1740
|
+
| `JC2073` | contract | `contract/malformed-frame` | no | a response frame addressed to this client fails the frame grammar |
|
|
1741
|
+
| `JC2074` | network | `contract/channel-closed` | no | the channel refused the request frame |
|
|
1742
|
+
|
|
1743
|
+
## §17 Subscribe operations
|
|
1744
|
+
|
|
1745
|
+
A **subscribe** operation declares a live query: its `output` is the
|
|
1746
|
+
**snapshot** schema — the document a subscriber holds — and after the
|
|
1747
|
+
snapshot the server streams LIVE-FORMAT emissions `{ patch, seq }`
|
|
1748
|
+
(RFC 6902 `add`/`remove`/`replace` only) that the consumer applies to
|
|
1749
|
+
it. `input` travels as for a read (path variables → `path`, the rest →
|
|
1750
|
+
`query` by default); `errors` are declared as usual and end the stream
|
|
1751
|
+
as an `error` event (§18). The compiler enforces the shape the wire
|
|
1752
|
+
requires: `policy.task` MUST be `switch` (`JC0018` — a subscription
|
|
1753
|
+
slot is replaced, never queued), the binding MUST be `GET` (`JC0019`;
|
|
1754
|
+
the canonical binding of a subscribe without `http` is `GET /<op-id>`
|
|
1755
|
+
with every member in the query), `policy.idempotency` MUST be `none`
|
|
1756
|
+
(`JC0020`), and `http.media` is **forced** to `text/event-stream` — a
|
|
1757
|
+
declared conflicting media is `JC0012`, and `describe()` shows the
|
|
1758
|
+
forced value. A subscribe operation is never opaque: its events are
|
|
1759
|
+
JSON the contract decodes and validates. `policy.stream` (§3.1) is its
|
|
1760
|
+
knob set — `resume`, `heartbeatMs`, `maxPatchBytes` — and is refused
|
|
1761
|
+
on any other kind.
|
|
1762
|
+
|
|
1763
|
+
### §17.1 The handler — a duck-typed LIVE subscription
|
|
1764
|
+
|
|
1765
|
+
`handlers[op] = (input, ctx) => Subscription | Promise<Subscription>`
|
|
1766
|
+
where
|
|
1767
|
+
|
|
1768
|
+
```
|
|
1769
|
+
Subscription = {
|
|
1770
|
+
result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
|
|
1771
|
+
subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
|
|
1772
|
+
close(), // release the registration
|
|
1773
|
+
replay?(seq), // optional: the emissions after seq, or null/undefined when it cannot
|
|
1774
|
+
mode?, // ignored by the binding
|
|
1775
|
+
}
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
— a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
|
|
1779
|
+
+ `subscribe` + `close`; it has no `replay`), so a handler is one line:
|
|
1780
|
+
`(input) => store.collection('x').live(doc, { externals: input })`. No
|
|
1781
|
+
import of `@jarenjs/db` exists anywhere in the package; the shape is
|
|
1782
|
+
duck-typed. The handler runs through the same settlement boundary as
|
|
1783
|
+
every operation: a `ContractFailure` (or a thrown `ContractRuntimeError`
|
|
1784
|
+
with a declared code) is a declared failure, any other throw or hostile
|
|
1785
|
+
value is a host fault, and a settled value that does not carry
|
|
1786
|
+
`subscribe` + `close` + (`result` or `snapshot()`) is a host fault too.
|
|
1787
|
+
On HTTP these pre-stream failures answer as ordinary §7.3 responses
|
|
1788
|
+
(declared status, or 500); on `port` they answer as `error` push frames
|
|
1789
|
+
(§18). Once the stream is live, the binding reads the snapshot
|
|
1790
|
+
(`snapshot()` when present, else `result`), validates it against
|
|
1791
|
+
`output` (a failure ends the stream with an `error` event `JC2091` —
|
|
1792
|
+
the server broke the contract; the cause goes to `onError`, never the
|
|
1793
|
+
wire), forwards each emission verbatim (the binding never mutates a
|
|
1794
|
+
patch), and calls `stop()` then `close()` **exactly once** — on peer
|
|
1795
|
+
disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
|
|
1796
|
+
close, and after an `error` emission ends the stream.
|
|
1797
|
+
|
|
1798
|
+
## §18 The stream wire
|
|
1799
|
+
|
|
1800
|
+
### §18.1 HTTP: Server-Sent Events
|
|
1801
|
+
|
|
1802
|
+
Request: `GET <path>` with `accept: text/event-stream`. **A client
|
|
1803
|
+
without that accept header gets the snapshot as plain JSON** — the same
|
|
1804
|
+
operation serves a one-shot read through the ordinary §7 pipeline
|
|
1805
|
+
(handler → subscription → snapshot validated → `stop()`/`close()` →
|
|
1806
|
+
JSON response), which is also what `invoke` on a subscribe operation
|
|
1807
|
+
does; `capabilities.stream: true` says the streaming half exists.
|
|
1808
|
+
|
|
1809
|
+
Response: `200`, `content-type: text/event-stream`, `cache-control:
|
|
1810
|
+
no-store`, `x-jaren-trace`. Events, in order:
|
|
1811
|
+
|
|
1812
|
+
- `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
|
|
1813
|
+
that names none), data `{ "value": <snapshot>, "resumed": false }`.
|
|
1814
|
+
The envelope exists because a resume verdict cannot ride *inside* the
|
|
1815
|
+
snapshot value without breaking a closed output schema; `resumed:
|
|
1816
|
+
false` states this snapshot is a fresh document (a refused resume —
|
|
1817
|
+
`JC2095` — looks exactly like this, which is how the client learns).
|
|
1818
|
+
- `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
|
|
1819
|
+
LIVE-FORMAT emission verbatim.
|
|
1820
|
+
- heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
|
|
1821
|
+
- `error` — data the §7.3 wire error (no `status` member matters here);
|
|
1822
|
+
the stream ends with it.
|
|
1823
|
+
- `end` — data `{ "reason": "closed" | "server-shutdown" }`.
|
|
1824
|
+
|
|
1825
|
+
`seq` is strictly increasing per stream; a violation is the client's
|
|
1826
|
+
`JC2092`. An emission whose serialized patch exceeds
|
|
1827
|
+
`policy.stream.maxPatchBytes` is replaced by a fresh `snapshot` event
|
|
1828
|
+
at that emission's seq — the consumer swaps its document instead of
|
|
1829
|
+
patching it; nothing is dropped.
|
|
1830
|
+
|
|
1831
|
+
**Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
|
|
1832
|
+
resume. Under `resume: "replay"` the binding asks
|
|
1833
|
+
`subscription.replay?.(seq)`; when the handler answers an array of
|
|
1834
|
+
emissions, the stream starts with the `patch` events after that seq
|
|
1835
|
+
(no snapshot) and continues live. Otherwise — `resume: "snapshot"`, no
|
|
1836
|
+
`replay`, or a `replay` that answers `null` — the stream starts with a
|
|
1837
|
+
fresh `snapshot` whose data carries `resumed: false` (`JC2095`,
|
|
1838
|
+
informational, never an outcome).
|
|
1839
|
+
|
|
1840
|
+
`toNodeHandler` writes SSE with `flushHeaders()` + `res.write` and ends
|
|
1841
|
+
on close; `toFetchHandler` answers a `ReadableStream` body; both abort
|
|
1842
|
+
`ctx.signal` when the peer goes away (`request.signal`, `req` close),
|
|
1843
|
+
which runs the exactly-once `stop()`/`close()`. A dispatcher's
|
|
1844
|
+
`close()` ends every live SSE stream with `end` (`server-shutdown`)
|
|
1845
|
+
before releasing it.
|
|
1846
|
+
|
|
1847
|
+
### §18.2 Port: push frames
|
|
1848
|
+
|
|
1849
|
+
The §16 frame family gains three shapes (the grammar artifact carries
|
|
1850
|
+
them):
|
|
1851
|
+
|
|
1852
|
+
```jsonc
|
|
1853
|
+
{ "jaren": "contract/0.1", "subscribe": "<clientId>:<seq>", "op": "data.live",
|
|
1854
|
+
"input": { "collection": "notes" }, "lastSeq": 41 } // client → server; lastSeq? resumes
|
|
1855
|
+
{ "jaren": "contract/0.1", "unsubscribe": "<clientId>:<seq>" } // client → server; never answered
|
|
1856
|
+
{ "jaren": "contract/0.1", "id": "<clientId>:<seq>", "event": "snapshot",
|
|
1857
|
+
"seq": 0, "data": { "value": { "rows": [] }, "resumed": false } } // server → client push
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
A push frame's `event` is `snapshot | patch | error | end` with the
|
|
1861
|
+
same data shapes as §18.1 (`error` data is the wire error; `end` data
|
|
1862
|
+
the reason record); `seq` mirrors the event's seq (`error`/`end` carry
|
|
1863
|
+
the last delivered seq). Ids are scoped exactly as §16's request ids,
|
|
1864
|
+
so two clients on one channel hold independent subscriptions and an
|
|
1865
|
+
`unsubscribe` from one cannot touch the other's. Pre-stream failures —
|
|
1866
|
+
unknown or non-subscribe `op` (`JC2071`), invalid input (`JC2006`),
|
|
1867
|
+
handler faults (`JC2070`), an invalid snapshot (`JC2091`) — arrive as
|
|
1868
|
+
`error` push frames. There is no heartbeat on a port (delivery is
|
|
1869
|
+
in-process); `server.close()` pushes `end` (`server-shutdown`) to every
|
|
1870
|
+
live subscription before stopping it.
|
|
1871
|
+
|
|
1872
|
+
### §18.3 The stream codes
|
|
1873
|
+
|
|
1874
|
+
`STREAM_ERRORS` (`@jarenjs/contract/stream`) is the table as data;
|
|
1875
|
+
these rows join `CONTRACT_CODES` and the English catalog like every
|
|
1876
|
+
other range:
|
|
1877
|
+
|
|
1878
|
+
| code | kind | msgid | retryable | when |
|
|
1879
|
+
|---|---|---|---|---|
|
|
1880
|
+
| `JC2090` | contract | `contract/not-a-stream` | no | the server answered a subscribe request with a non-stream response (client-side) |
|
|
1881
|
+
| `JC2091` | contract | `contract/invalid-snapshot` | no | a snapshot fails the output validator — the server broke the contract; sent as the `error` event that ends the stream |
|
|
1882
|
+
| `JC2092` | contract | `contract/seq-regression` | no | an event's seq is not strictly greater than the last delivered (client-side) |
|
|
1883
|
+
| `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 |
|
|
1884
|
+
| `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
|
|
1885
|
+
| `JC2095` | — | — | — | a requested resume was refused; informational, carried as `resumed: false` in the fresh snapshot's event data, never an outcome |
|
|
1886
|
+
|
|
1887
|
+
`JC2096–JC2109` are reserved for later stream codes. `JC1009` (an SSE
|
|
1888
|
+
data string the frame cannot carry) and `JC1010` (`subscribe` of a
|
|
1889
|
+
non-subscribe operation) are the stream's host programming errors
|
|
1890
|
+
(§7.3's host table).
|
|
1891
|
+
|
|
1892
|
+
## §19 The client: `subscribe`
|
|
1893
|
+
|
|
1894
|
+
`client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
|
|
1895
|
+
signal, lastSeq }) → { stop() }` — on the `http` and `port` clients
|
|
1896
|
+
alike (`capabilities.stream: true`); `serveLocal` keeps
|
|
1897
|
+
`capabilities.stream: false` and needs no handler for a subscribe
|
|
1898
|
+
operation (`invoke` of one throws `JC1005` there). A non-subscribe
|
|
1899
|
+
operation is `JC1010`, thrown — the host named the wrong operation.
|
|
1900
|
+
|
|
1901
|
+
- `onSnapshot(value, { seq, resumed })` — a fresh, validated snapshot;
|
|
1902
|
+
the consumer replaces its document. `resumed` is `false` exactly as
|
|
1903
|
+
§18.1 defines it.
|
|
1904
|
+
- `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
|
|
1905
|
+
client forwards patches; the app binding (§11.4) and the consumer
|
|
1906
|
+
apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
|
|
1907
|
+
the stream ends with `JC2092`.
|
|
1908
|
+
- `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
|
|
1909
|
+
declared error event; `network` for a transport failure or a missed
|
|
1910
|
+
heartbeat; `contract` for `JC2090`/`JC2092`/`JC2093`, an invalid
|
|
1911
|
+
snapshot value, or a pre-send input refusal `JC2050`). Its `error`
|
|
1912
|
+
and `meta` carry every member (`status: null` where the wire has
|
|
1913
|
+
none). After `onError` the stream is finished and cleaned up.
|
|
1914
|
+
- `onEnd({ reason })` — the server's `end` event; a stream that ends
|
|
1915
|
+
without one is reported as `reason: "closed"`.
|
|
1916
|
+
|
|
1917
|
+
Every callback is optional and total for the client: a callback that
|
|
1918
|
+
throws does not break the stream machinery. `signal` aborts the
|
|
1919
|
+
subscription silently (the caller asked); `stop()` does the same and,
|
|
1920
|
+
on `port`, posts the `unsubscribe` frame. `lastSeq` is what a
|
|
1921
|
+
reconnect passes (§18's resumption). **Reconnection is not automatic**:
|
|
1922
|
+
the host decides — a `subscribe` that ends with a `network` outcome is
|
|
1923
|
+
re-entered by calling `subscribe` again with the last delivered seq.
|