@jarenjs/contract 0.43.1 → 0.43.3
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 +80 -12
- package/dist/types/http/dispatch.d.ts +18 -3
- package/dist/types/http/serve.d.ts +9 -0
- package/dist/types/project/index.d.ts +3 -2
- package/docs/CONTRACT-FORMAT.md +187 -28
- package/package.json +5 -5
- package/src/cli.js +5 -6
- package/src/http/dispatch.js +143 -24
- package/src/http/serve.js +34 -2
- package/src/project/index.js +3 -2
package/README.md
CHANGED
|
@@ -154,9 +154,10 @@ The pipeline routes (404/405 with `Allow`), enforces the body limit
|
|
|
154
154
|
(413) before reading, checks the media (415), parses (400), assembles the
|
|
155
155
|
input from path, query and headers through a prototype-safe setter,
|
|
156
156
|
normalizes the transport strings, validates (400 with `details` by
|
|
157
|
-
`policy.errors.details`), claims the idempotency key,
|
|
158
|
-
through one promise boundary, validates
|
|
159
|
-
broke the contract), applies
|
|
157
|
+
`policy.errors.details`), claims the idempotency key, decides a declared
|
|
158
|
+
precondition, calls the handler through one promise boundary, validates
|
|
159
|
+
the output (500 — the server broke the contract), applies
|
|
160
|
+
`If-Match`/`If-None-Match`, serializes.
|
|
160
161
|
Every non-2xx body is `{ code, message, requestId, details?, retryable }`
|
|
161
162
|
with `x-jaren-trace` on the response; a handler's thrown error never
|
|
162
163
|
reaches the wire (`onError` sees it). `server.capabilities` says what
|
|
@@ -166,23 +167,54 @@ is refused at construction. `GET /.well-known/jaren-contract` answers
|
|
|
166
167
|
`describe()`. The normative pipeline, taxonomy and ledger interface are
|
|
167
168
|
[CONTRACT-FORMAT.md §7–§9](docs/CONTRACT-FORMAT.md#7-the-http-server-binding).
|
|
168
169
|
|
|
170
|
+
Without a declared resolver, `If-Match`/`If-None-Match` are applied
|
|
171
|
+
**after** the handler and only when it armed a tag — a cache device,
|
|
172
|
+
**never a write guard**. The `preconditions` option is the write guard:
|
|
173
|
+
a per-operation resolver of the CURRENT entity tag, decided before the
|
|
174
|
+
handler, so a stale `If-Match` refuses 412 with zero handler runs and a
|
|
175
|
+
matching `If-None-Match` read answers 304 without computing the
|
|
176
|
+
representation.
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
const server = serveHttp(contract, handlers, {
|
|
180
|
+
ledger: createMemoryLedger(),
|
|
181
|
+
preconditions: {
|
|
182
|
+
'product.save': (input) => `r${revisionOf(input.id)}`, // a bare string is a STRONG tag; null = no representation
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
169
187
|
### Recipes: Fastify, Hono, Express
|
|
170
188
|
|
|
171
189
|
None of these is a dependency; each recipe is executed by a test that
|
|
172
190
|
imports the framework from the benchmark workspace.
|
|
173
191
|
|
|
174
192
|
```js
|
|
175
|
-
// Fastify —
|
|
193
|
+
// Fastify — hijack before parsing; the node adapter carries body limits, SSE and abort
|
|
176
194
|
const app = fastify();
|
|
177
|
-
|
|
178
|
-
app.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
reply.code(r.status).headers(r.headers);
|
|
182
|
-
return r.body === null ? reply.send() : reply.send(r.body);
|
|
183
|
-
});
|
|
195
|
+
const handler = toNodeHandler(server);
|
|
196
|
+
app.all('/*', {
|
|
197
|
+
onRequest: (req, reply, done) => { reply.hijack(); handler(req.raw, reply.raw); done(); },
|
|
198
|
+
}, () => {});
|
|
184
199
|
```
|
|
185
200
|
|
|
201
|
+
`reply.hijack()` hands the untouched socket to the node adapter before
|
|
202
|
+
any parser runs, so the recipe coexists with an existing app: routes
|
|
203
|
+
registered beside it keep their parsers and parsed bodies, static paths
|
|
204
|
+
beat the wildcard, and Fastify's own `bodyLimit` never answers — the
|
|
205
|
+
operation's `policy.limits.maxBodyBytes` is the single body ceiling,
|
|
206
|
+
refusing as the contract's coded `JC2003` instead of Fastify's
|
|
207
|
+
`FST_ERR_CTP_BODY_TOO_LARGE`. Subscribe operations stream (the adapter
|
|
208
|
+
calls `response.stream`) and a dropped peer reaches the handler as
|
|
209
|
+
`ctx.signal` — the earlier buffer-parser recipe carried neither. To
|
|
210
|
+
confine the contract, register the same route in an encapsulated plugin
|
|
211
|
+
with `{ prefix }`; the prefix must then prefix the contract's declared
|
|
212
|
+
paths (canonical bindings and the well-known path included). One
|
|
213
|
+
shutdown note: a hijacked request never completes in Fastify's own
|
|
214
|
+
bookkeeping, so its keep-alive socket never counts as idle — close the
|
|
215
|
+
dispatcher first, then `app.server.closeAllConnections()` before
|
|
216
|
+
`app.close()`.
|
|
217
|
+
|
|
186
218
|
```js
|
|
187
219
|
// Hono — the fetch handler is the whole app (Bun.serve, Deno, workers alike)
|
|
188
220
|
const app = new Hono();
|
|
@@ -195,6 +227,39 @@ const app = express();
|
|
|
195
227
|
app.use(toNodeHandler(server));
|
|
196
228
|
```
|
|
197
229
|
|
|
230
|
+
### Recipe: large outputs — validate on rebuild, serve by revision
|
|
231
|
+
|
|
232
|
+
`validateOutput: "always"` proves every response against the contract
|
|
233
|
+
and is the right default; on a multi-megabyte cached representation it
|
|
234
|
+
is also the measured heavy share of the hot row (the benchmark's fourth
|
|
235
|
+
column keeps it visible — docs/ROADMAP.md). The honest downgrade is not
|
|
236
|
+
"skip validation" but "validate once per REVISION instead of once per
|
|
237
|
+
request": prove the snapshot when it is rebuilt, arm its revision as the
|
|
238
|
+
tag, and let `preconditions` answer 304 before the handler even runs.
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
const validateCatalog = contract.operations['catalog.load'].output.validate;
|
|
242
|
+
const cache = { revision: 0, value: null };
|
|
243
|
+
function rebuild(next) { // on every write to the source
|
|
244
|
+
const v = validateCatalog(next); // the JC2010 caught at build time, once
|
|
245
|
+
if (!(v === true || v?.valid === true)) throw new Error('the snapshot breaks the contract');
|
|
246
|
+
cache.revision += 1;
|
|
247
|
+
cache.value = next;
|
|
248
|
+
}
|
|
249
|
+
const server = serveHttp(contract, {
|
|
250
|
+
...handlers,
|
|
251
|
+
'catalog.load': (input, ctx) => { ctx.etag(`r${cache.revision}`, { strong: true }); return cache.value; },
|
|
252
|
+
}, {
|
|
253
|
+
validateOutput: 'never', // declared: capabilities.validatedOutput === false
|
|
254
|
+
preconditions: { 'catalog.load': () => `r${cache.revision}` }, // 304 BEFORE the handler
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The tradeoff is declared, never silent: `validateOutput` is server-wide,
|
|
259
|
+
so `capabilities.validatedOutput === false` tells every consumer the
|
|
260
|
+
per-request guarantee moved to the rebuild path — keep that path the
|
|
261
|
+
only writer of the cache, or the guarantee is gone.
|
|
262
|
+
|
|
198
263
|
## Call it from the other end
|
|
199
264
|
|
|
200
265
|
```js
|
|
@@ -478,7 +543,10 @@ deliberate decision, not a gap:
|
|
|
478
543
|
- **No replication or durability.** The ledger and the command
|
|
479
544
|
lifecycle ship as JSON documents (`$model`, `$fsm`) a host may open
|
|
480
545
|
with `@jarenjs/db`; the in-memory ledger is for tests and
|
|
481
|
-
single-process hosts. Durability is the host's
|
|
546
|
+
single-process hosts. Durability is the host's — and needs no
|
|
547
|
+
dependency: [CONTRACT-FORMAT.md §8.1](docs/CONTRACT-FORMAT.md#81-a-durable-ledger-over-nodesqlite--an-example-not-an-export)
|
|
548
|
+
is a complete, tested ~60-line ledger over `node:sqlite` (built into
|
|
549
|
+
Node ≥ 24), deliberately an example rather than an export.
|
|
482
550
|
- **No automatic reconnect.** A stream that ends with a `network`
|
|
483
551
|
outcome is re-entered by the host calling `subscribe` again with the
|
|
484
552
|
last delivered seq (`lastSeq` is the hook); the backoff/resume/give-up
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file The request pipeline of the HTTP server binding: one plain
|
|
3
3
|
* request object in, one plain response object out — route, decode,
|
|
4
|
-
* assemble, normalize, validate, claim idempotency,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* assemble, normalize, validate, claim idempotency, decide a declared
|
|
5
|
+
* precondition (the `preconditions` option: `If-Match`/`If-None-Match`
|
|
6
|
+
* against the resolver's CURRENT tag, before the handler), call the
|
|
7
|
+
* handler through one uniform promise boundary, validate the output,
|
|
8
|
+
* apply the post-handler entity-tag conditionals, serialize, commit. Every failure a request
|
|
7
9
|
* can cause is a coded response (docs/CONTRACT-FORMAT.md §7); the
|
|
8
10
|
* function rejects only for a malformed request OBJECT (`JC1004`, an
|
|
9
11
|
* adapter author's mistake) — never for request content and never for
|
|
@@ -52,12 +54,24 @@ export type RawResponse = {
|
|
|
52
54
|
headers?: Record<string, string>;
|
|
53
55
|
body?: string | Uint8Array | null;
|
|
54
56
|
};
|
|
57
|
+
export type TagResolver = (input: any, ctx: RequestContext) => string | {
|
|
58
|
+
tag: string;
|
|
59
|
+
strong?: boolean;
|
|
60
|
+
} | null | Promise<string | {
|
|
61
|
+
tag: string;
|
|
62
|
+
strong?: boolean;
|
|
63
|
+
} | null>;
|
|
55
64
|
export type Route = {
|
|
56
65
|
op: CompiledOperation;
|
|
57
66
|
/**
|
|
58
67
|
* - `null` on a partial server
|
|
59
68
|
*/
|
|
60
69
|
handler: Handler | null;
|
|
70
|
+
/**
|
|
71
|
+
* - the pre-handler resolver of the
|
|
72
|
+
* `preconditions` option; `null` = conditionals stay post-handler
|
|
73
|
+
*/
|
|
74
|
+
tag: TagResolver | null;
|
|
61
75
|
/**
|
|
62
76
|
* - opaque: the handler is raw
|
|
63
77
|
*/
|
|
@@ -145,4 +159,5 @@ export type Armed = {
|
|
|
145
159
|
status: number;
|
|
146
160
|
outcome: number;
|
|
147
161
|
retryable: boolean;
|
|
162
|
+
decided: boolean;
|
|
148
163
|
};
|
|
@@ -55,6 +55,15 @@ export type ServeHttpOptions = {
|
|
|
55
55
|
* - `'never'` is a declared downgrade, reported in `capabilities.validatedOutput`
|
|
56
56
|
*/
|
|
57
57
|
validateOutput?: 'always' | 'never';
|
|
58
|
+
/**
|
|
59
|
+
* - operation id → the CURRENT entity-tag resolver, making `If-Match`/
|
|
60
|
+
* `If-None-Match` a PRE-handler decision for that operation: a stale
|
|
61
|
+
* precondition refuses `JC2014` with zero handler invocations, a
|
|
62
|
+
* matching `If-None-Match` read answers 304 without computing the
|
|
63
|
+
* representation (docs/CONTRACT-FORMAT.md §7.5); refused on a
|
|
64
|
+
* `subscribe` or opaque operation
|
|
65
|
+
*/
|
|
66
|
+
preconditions?: Record<string, import('./dispatch.js').TagResolver>;
|
|
58
67
|
/**
|
|
59
68
|
* - the path answering `describe()`; default `/.well-known/jaren-contract`; `false` disables
|
|
60
69
|
*/
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* through a JSLT stylesheet), `toTypeScript` and `toMarkdown` (on
|
|
6
6
|
* `@jarenjs/emit`'s type model), `contractTools` (`@jarenjs/ai` tool
|
|
7
7
|
* definitions, no import edge) and the same-document bundler they share.
|
|
8
|
-
* This is the one subpath that imports `@jarenjs/emit
|
|
9
|
-
*
|
|
8
|
+
* This is the one subpath that imports `@jarenjs/emit` — the CLI loads
|
|
9
|
+
* it lazily, only for `types` and `docs`; a consumer that never imports
|
|
10
|
+
* either never loads it.
|
|
10
11
|
*/
|
|
11
12
|
export { publicProjection } from '../public.js';
|
|
12
13
|
export { toOpenApi } from './openapi.js';
|
package/docs/CONTRACT-FORMAT.md
CHANGED
|
@@ -184,7 +184,7 @@ the resolved value and marks it inferred.
|
|
|
184
184
|
|---|---|---|---|
|
|
185
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
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. |
|
|
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. Distinct from the **contract revision** (`contract.revision()`, `describe().revision`, the client's `meta.revision` — the hash of the public projection, §14): `policy.revision` names the *resource* revision a command asserts, and no binding reads it at runtime — a `preconditions` resolver (§7.5) or the handler's own domain comparison is how a server enforces it. |
|
|
188
188
|
| `cache` | `none` \| `revision` | `none` | Whether a read's result may be cached by revision. |
|
|
189
189
|
| `limits.maxBodyBytes` | positive integer | `1048576` | The request-body ceiling a server binding enforces. |
|
|
190
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. |
|
|
@@ -535,7 +535,7 @@ is `JC2008`.
|
|
|
535
535
|
every matched operation, opaque and body-less included.
|
|
536
536
|
4. **Opaque** → the transport input validated as in step 8 when no
|
|
537
537
|
member is body-located (`JC2006`), then the raw handler through the
|
|
538
|
-
same boundary as step
|
|
538
|
+
same boundary as step 11; done.
|
|
539
539
|
5. **Media.** A body-carrying operation (a body-located member or a
|
|
540
540
|
whole-body member) with a non-empty body requires a `content-type`
|
|
541
541
|
whose `type/subtype` is the operation's `http.media` (parameters
|
|
@@ -571,26 +571,42 @@ is `JC2008`.
|
|
|
571
571
|
9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
|
|
572
572
|
`Idempotency-Key` is `JC2007` under `required` and runs plainly under
|
|
573
573
|
`optional`; otherwise the input is hashed and the ledger claimed.
|
|
574
|
-
10. **
|
|
574
|
+
10. **Preconditions, opt-in** (§7.5): when the operation has a
|
|
575
|
+
`preconditions` resolver, the CURRENT tag is resolved BEFORE the
|
|
576
|
+
handler — a command consults it only under a conditional header, a
|
|
577
|
+
safe method always. `If-Match` first (strong comparison): a
|
|
578
|
+
mismatch, or a `null` resolution (`*` included), is `JC2014` with
|
|
579
|
+
**zero handler invocations** — on a claimed command the key is
|
|
580
|
+
released retryable, nothing ran; then `If-None-Match` (weak): a
|
|
581
|
+
match answers `304` before the handler on GET/HEAD and `JC2014` on
|
|
582
|
+
other methods, while a `null` resolution passes it — the
|
|
583
|
+
create-guard. On a pass a safe method arms the resolved tag (the
|
|
584
|
+
handler may re-arm), a command arms nothing, and step 13's
|
|
585
|
+
comparisons stand down. The claim (step 9) comes FIRST: a committed
|
|
586
|
+
key replays before the resolver runs.
|
|
587
|
+
11. **The handler**, through **one uniform promise boundary** — a
|
|
575
588
|
synchronous throw, a non-promise return and a rejection settle
|
|
576
589
|
alike. A `ContractFailure` (from `ctx.fail`) or a thrown
|
|
577
590
|
`ContractRuntimeError` with a declared code → the declared error
|
|
578
591
|
response; anything else → `JC2008`, its cause handed to `onError`.
|
|
579
592
|
A value whose `then` accessor throws is a rejection here — the
|
|
580
593
|
hostile-value case of `JC2008`.
|
|
581
|
-
|
|
594
|
+
12. **Output validation** (`validateOutput: "always"`, the default): the
|
|
582
595
|
value against the operation's output validator → `JC2010` on failure
|
|
583
596
|
or on a throwing accessor; the validator's errors reach `onError`,
|
|
584
597
|
never the wire. `"never"` is a declared downgrade
|
|
585
598
|
(`capabilities.validatedOutput: false`).
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
comparison; match → `304` on
|
|
589
|
-
Because the tag is known only
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
`
|
|
593
|
-
|
|
599
|
+
13. **Entity tags** when the handler armed one and step 10 did not
|
|
600
|
+
already decide (§7.5): `If-Match` first (strong comparison; mismatch
|
|
601
|
+
→ `JC2014`), then `If-None-Match` (weak comparison; match → `304` on
|
|
602
|
+
GET/HEAD, `JC2014` on other methods). Because the tag is known only
|
|
603
|
+
after the handler runs, both are evaluated **after** step 11 and
|
|
604
|
+
only when a tag was armed — **a cache device, never a write guard**:
|
|
605
|
+
a stale `If-Match` here means the handler already ran, and on a
|
|
606
|
+
claimed command the 412 is recorded non-retryable (§8). The
|
|
607
|
+
pre-handler guard is the `preconditions` option (step 10), or the
|
|
608
|
+
handler's own comparison of `ctx.headers["if-match"]`.
|
|
609
|
+
14. **Serialize.** `JSON.stringify(value)`; a value JSON cannot carry
|
|
594
610
|
(a cycle, a BigInt) is `JC2010`; `undefined` answers no body. Status
|
|
595
611
|
is `ctx.status()` or `http.status`; headers `content-type: <media>;
|
|
596
612
|
charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
|
|
@@ -702,13 +718,48 @@ the server's; `x-attempt` or any client attempt id is never read either.
|
|
|
702
718
|
|
|
703
719
|
### §7.5 Entity tags and conditionals
|
|
704
720
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
the `etag` header and no body
|
|
709
|
-
methods; `If-Match` is compared
|
|
710
|
-
or a weak armed tag never
|
|
711
|
-
|
|
721
|
+
**Armed tags — post-handler, a cache device.** `ctx.etag(tag)` arms a
|
|
722
|
+
weak tag (`etag: W/"tag"`), `ctx.etag(tag, { strong: true })` a strong
|
|
723
|
+
one (`etag: "tag"`). `If-None-Match` is compared weakly (`*` matches;
|
|
724
|
+
`W/` indicators are ignored) → `304` with the `etag` header and no body
|
|
725
|
+
on GET/HEAD, `412` (`JC2014`) on other methods; `If-Match` is compared
|
|
726
|
+
strongly (`*` matches; a weak candidate or a weak armed tag never
|
|
727
|
+
matches) → `412` on a mismatch. Both are evaluated after the handler
|
|
728
|
+
ran, and only when it armed a tag — so **this path is never a write
|
|
729
|
+
guard**: a command's handler has already run, and may already have
|
|
730
|
+
mutated, when its stale `If-Match` answers 412, and a handler that arms
|
|
731
|
+
no tag has its conditionals silently pass. On a claimed command such a
|
|
732
|
+
post-handler 412 is recorded non-retryable with its response (§8), so a
|
|
733
|
+
blind retry replays the 412 instead of mutating again.
|
|
734
|
+
|
|
735
|
+
**The `preconditions` option — pre-handler, the write guard.**
|
|
736
|
+
`serveHttp(contract, handlers, { preconditions: { '<op>': (input, ctx)
|
|
737
|
+
=> … } })` declares the CURRENT entity-tag resolver of an operation
|
|
738
|
+
(refused at construction, `JC1001`, on a subscribe or opaque
|
|
739
|
+
operation). The resolver answers a plain string — a **strong** tag (the
|
|
740
|
+
deliberate asymmetry with `ctx.etag`, whose bare form is weak:
|
|
741
|
+
`If-Match` needs strong comparison to mean anything) — or `{ tag,
|
|
742
|
+
strong }`, or `null` for "no current representation", or a promise of
|
|
743
|
+
any of those; a throw, a rejection or another shape is the host's fault
|
|
744
|
+
(`JC2008`, a claimed key released retryable). A command consults its
|
|
745
|
+
resolver only under a conditional header; a safe method always
|
|
746
|
+
resolves, so its response carries the tag. The decision runs BEFORE the
|
|
747
|
+
handler: `If-Match` first (strong; RFC 9110 §13.2.2) — a mismatch or a
|
|
748
|
+
`null` resolution (`*` included) refuses `JC2014` with **zero handler
|
|
749
|
+
invocations**, the current tag riding the 412's `etag` header for
|
|
750
|
+
recovery; then `If-None-Match` (weak) — a match answers `304` before
|
|
751
|
+
the handler on GET/HEAD (the cached-read win: the representation is
|
|
752
|
+
never computed) and `JC2014` on other methods, while a `null`
|
|
753
|
+
resolution passes it (`If-None-Match: *` is the create-guard). On a
|
|
754
|
+
pass, a safe method arms the resolved tag — the handler's own
|
|
755
|
+
`ctx.etag` then re-arms the RESPONSE tag only — and a command arms
|
|
756
|
+
nothing (a mutated representation must not echo its pre-state tag, RFC
|
|
757
|
+
9110 §8.8.3). Idempotency composes claim-first (§8): a committed key
|
|
758
|
+
replays before the resolver runs, and a pre-handler 412 releases the
|
|
759
|
+
key retryable — nothing ran. This is how a `policy.revision` command
|
|
760
|
+
becomes HTTP-enforceable (§3.1): resolve the resource's current
|
|
761
|
+
revision into a tag, and the domain transaction stays the final
|
|
762
|
+
authority.
|
|
712
763
|
|
|
713
764
|
### §7.6 HEAD, the well-known path, options
|
|
714
765
|
|
|
@@ -720,10 +771,12 @@ answers `describe()` — `revision: null` until the revision lands, `compat`
|
|
|
720
771
|
present — for negotiation. `trace` (default `crypto.randomUUID`) generates
|
|
721
772
|
the server trace; `scope(ctx)` derives the idempotency scope (§8);
|
|
722
773
|
`partial` allows missing handlers; `validateOutput` is `"always" |
|
|
723
|
-
"never"`; `
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
774
|
+
"never"`; `preconditions` maps operation ids to pre-handler tag
|
|
775
|
+
resolvers (§7.5); `errorBody(wire, ctx)` and `onError(err, ctx)` are the
|
|
776
|
+
two host hooks (`ctx` is `null` before an operation is matched);
|
|
777
|
+
`catalog` is a message catalog (templates or compiled renderers)
|
|
778
|
+
consulted before the English one; `now` is the clock stamped into ledger
|
|
779
|
+
claims.
|
|
727
780
|
|
|
728
781
|
## §8 Idempotency and the ledger
|
|
729
782
|
|
|
@@ -757,8 +810,12 @@ retryable); `started` and not expired → `in-progress` (409, `retry-after:
|
|
|
757
810
|
key may be retried); `failed` and not retryable → `replay` of the stored
|
|
758
811
|
failure. After the handler: a success **commits** the response; a
|
|
759
812
|
declared failure is recorded as **failed** with its response and its
|
|
760
|
-
`retryable`; a server fault (`JC2008`, `JC2010`,
|
|
761
|
-
|
|
813
|
+
`retryable`; a server fault (`JC2008`, `JC2010`, and a PRE-handler
|
|
814
|
+
`JC2014` from a `preconditions` resolver — nothing ran) **releases** the
|
|
815
|
+
key as retryable with no response; a POST-handler `JC2014` (the handler
|
|
816
|
+
already ran and may have mutated) is recorded as **failed**, not
|
|
817
|
+
retryable, with its 412 — a blind retry under the same key replays the
|
|
818
|
+
412 instead of running the handler again. `now` on a claim is the binding's
|
|
762
819
|
clock (`options.now`), which a ledger may prefer to its own. Opaque
|
|
763
820
|
operations bypass the ledger; reads never carry a key. A ledger that
|
|
764
821
|
throws or rejects is reported to `onError` and the response still goes
|
|
@@ -790,6 +847,104 @@ committed` on `commit`, `started → failed` on `fail`, `failed → started`
|
|
|
790
847
|
on `claim` guarded by `$.context.retryable` — which the memory ledger
|
|
791
848
|
walks exactly.
|
|
792
849
|
|
|
850
|
+
### §8.1 A durable ledger over `node:sqlite` — an example, not an export
|
|
851
|
+
|
|
852
|
+
A host that retries commands needs a ledger that survives a restart, and
|
|
853
|
+
it does NOT need `@jarenjs/db` for that: `node:sqlite` is built into
|
|
854
|
+
Node ≥ 24 — the suite's floor — so the ~60 lines below are as
|
|
855
|
+
dependency-free as the package. Two design points carry the semantics:
|
|
856
|
+
`BEGIN IMMEDIATE` makes each `claim` one writer (two processes cannot
|
|
857
|
+
both claim a key), and the ref is the `AUTOINCREMENT` sequence of one
|
|
858
|
+
specific insert — never reused, where a bare SQLite rowid would be — so
|
|
859
|
+
a stale ref can never settle over a record a later claim re-created
|
|
860
|
+
(the memory ledger's object-identity guard, spelled in SQL). Everything
|
|
861
|
+
else mirrors `createMemoryLedger` exactly: expiry on `claim` and
|
|
862
|
+
`lookup`, `sweep()` for a host timer, mismatch before status, a
|
|
863
|
+
retryable failure handing the key back, a non-retryable one replaying
|
|
864
|
+
its stored response. Remember the boundary (§8): this ledger
|
|
865
|
+
deduplicates DELIVERY — the domain's own durable records stay
|
|
866
|
+
authoritative for business state.
|
|
867
|
+
|
|
868
|
+
```js
|
|
869
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
870
|
+
|
|
871
|
+
/** A durable Ledger over one SQLite file — a host's example, not an export. */
|
|
872
|
+
export function createSqliteLedger(path, { ttlMs = 86_400_000, now: clock = Date.now } = {}) {
|
|
873
|
+
const db = new DatabaseSync(path);
|
|
874
|
+
db.exec(`
|
|
875
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
876
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
877
|
+
id TEXT NOT NULL UNIQUE, op TEXT NOT NULL, scope TEXT NOT NULL, key TEXT NOT NULL,
|
|
878
|
+
hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('started', 'committed', 'failed')),
|
|
879
|
+
response TEXT, retryable INTEGER,
|
|
880
|
+
createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
|
|
881
|
+
CREATE INDEX IF NOT EXISTS ledger_by_expires ON ledger (expiresAt);
|
|
882
|
+
CREATE INDEX IF NOT EXISTS ledger_by_status ON ledger (status);`);
|
|
883
|
+
const one = db.prepare('SELECT * FROM ledger WHERE id = ?');
|
|
884
|
+
const put = db.prepare("INSERT INTO ledger (id, op, scope, key, hash, status, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, 'started', ?, ?, ?)");
|
|
885
|
+
const drop = db.prepare('DELETE FROM ledger WHERE id = ?');
|
|
886
|
+
const settle = db.prepare("UPDATE ledger SET status = ?, response = ?, retryable = ?, updatedAt = ? WHERE seq = ? AND status = 'started'");
|
|
887
|
+
const reap = db.prepare('DELETE FROM ledger WHERE expiresAt <= ?');
|
|
888
|
+
const stored = (row) => (row.response === null ? null : JSON.parse(row.response));
|
|
889
|
+
return {
|
|
890
|
+
claim({ op, scope, key, hash, now }) {
|
|
891
|
+
const at = typeof now === 'number' ? now : clock();
|
|
892
|
+
const id = `${op}|${scope}|${key}`;
|
|
893
|
+
db.exec('BEGIN IMMEDIATE'); // one writer: two processes cannot both claim the key
|
|
894
|
+
try {
|
|
895
|
+
const row = one.get(id);
|
|
896
|
+
if (row !== undefined) {
|
|
897
|
+
if (row.expiresAt <= at) drop.run(id);
|
|
898
|
+
else if (row.hash !== hash) { db.exec('COMMIT'); return { state: 'mismatch' }; }
|
|
899
|
+
else if (row.status === 'started') { db.exec('COMMIT'); return { state: 'in-progress' }; }
|
|
900
|
+
else if (row.status === 'committed') { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
901
|
+
else if (row.retryable !== 1 && row.response !== null) { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
|
|
902
|
+
else drop.run(id); // a retryable failure: the key runs again
|
|
903
|
+
}
|
|
904
|
+
const ref = { seq: put.run(id, op, scope, key, hash, at, at, at + ttlMs).lastInsertRowid };
|
|
905
|
+
db.exec('COMMIT');
|
|
906
|
+
return { state: 'new', ref };
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
db.exec('ROLLBACK');
|
|
910
|
+
throw err;
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
commit(ref, response) {
|
|
914
|
+
settle.run('committed', JSON.stringify(response), null, clock(), ref.seq);
|
|
915
|
+
},
|
|
916
|
+
fail(ref, retryable, response) {
|
|
917
|
+
settle.run('failed', response === undefined ? null : JSON.stringify(response), retryable === true ? 1 : 0, clock(), ref.seq);
|
|
918
|
+
},
|
|
919
|
+
lookup({ op, scope, key }) {
|
|
920
|
+
const row = one.get(`${op}|${scope}|${key}`);
|
|
921
|
+
if (row === undefined) return null;
|
|
922
|
+
if (row.expiresAt <= clock()) {
|
|
923
|
+
drop.run(row.id);
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
const record = { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 };
|
|
927
|
+
delete record.seq; // the record shape is exactly LedgerRecord (§8)
|
|
928
|
+
return record;
|
|
929
|
+
},
|
|
930
|
+
sweep: () => Number(reap.run(clock()).changes),
|
|
931
|
+
close: () => db.close(),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
The executable copy — the same listing, plus the `@ts-check` casts a
|
|
937
|
+
test file carries — lives in `test/contract/ledger-sqlite.test.js`,
|
|
938
|
+
which proves the memory-ledger contract over it (claim/commit/replay,
|
|
939
|
+
mismatch, in-progress, retryable re-run, non-retryable replay), expiry
|
|
940
|
+
and `sweep`, the stale-ref identity, `serveHttp` idempotency
|
|
941
|
+
end-to-end, and durability across a second open of the same file. It is
|
|
942
|
+
deliberately NOT an export of this package: the `Ledger` interface is
|
|
943
|
+
the product, and an exported implementation would make `node:sqlite`'s
|
|
944
|
+
locking part of this package's API surface. Scope keys by installation
|
|
945
|
+
or principal through `options.scope`, give expiry a real TTL, and put
|
|
946
|
+
`sweep()` on a host timer.
|
|
947
|
+
|
|
793
948
|
## §9 Adapters
|
|
794
949
|
|
|
795
950
|
Two dependency-free, structurally typed adapters put a dispatcher behind
|
|
@@ -817,7 +972,10 @@ the platform:
|
|
|
817
972
|
|
|
818
973
|
Fastify, Hono and Express are recipes in the README, each ≤15 lines and
|
|
819
974
|
executed by a test that imports the framework from the benchmark
|
|
820
|
-
workspace only — no framework is a dependency of this package.
|
|
975
|
+
workspace only — no framework is a dependency of this package. Each
|
|
976
|
+
recipe rides one of the two adapters (Fastify hijacks the raw
|
|
977
|
+
request/response pair before any parser runs), so body limits, SSE
|
|
978
|
+
streaming and peer abort behave identically through all three.
|
|
821
979
|
|
|
822
980
|
## §10 The HTTP client binding
|
|
823
981
|
|
|
@@ -1275,8 +1433,9 @@ operation's input schema or `null`, `seq` to a non-negative integer).
|
|
|
1275
1433
|
|
|
1276
1434
|
Everything a consumer wants **beside** the runtime is a projection of the
|
|
1277
1435
|
same compiled contract (`@jarenjs/contract/project` — the one subpath of
|
|
1278
|
-
this package that imports `@jarenjs/emit`,
|
|
1279
|
-
|
|
1436
|
+
this package that imports `@jarenjs/emit`, and the CLI loads it lazily,
|
|
1437
|
+
only for `types` and `docs`, so a bundle that never projects never
|
|
1438
|
+
carries it): the public projection every other artifact
|
|
1280
1439
|
is built on (§12.1), OpenAPI 3.1 (§12.2), TypeScript declarations
|
|
1281
1440
|
(§12.3), Markdown reference documentation (§12.4) and AI tool
|
|
1282
1441
|
definitions (§12.5), with the `jaren-contract` CLI (§12.6) writing and
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/contract",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.43.
|
|
4
|
+
"version": "0.43.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -102,9 +102,9 @@
|
|
|
102
102
|
"prepack": "npm run build:types"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
-
"@jarenjs/core": "^0.43.
|
|
106
|
-
"@jarenjs/json": "^0.43.
|
|
107
|
-
"@jarenjs/validate": "^0.43.
|
|
108
|
-
"@jarenjs/emit": "^0.43.
|
|
105
|
+
"@jarenjs/core": "^0.43.3",
|
|
106
|
+
"@jarenjs/json": "^0.43.3",
|
|
107
|
+
"@jarenjs/validate": "^0.43.3",
|
|
108
|
+
"@jarenjs/emit": "^0.43.3"
|
|
109
109
|
}
|
|
110
110
|
}
|
package/src/cli.js
CHANGED
|
@@ -22,8 +22,6 @@ import { ContractCompileError, ContractHostError } from './errors.js';
|
|
|
22
22
|
import { diffContracts } from './diff.js';
|
|
23
23
|
import { publicProjection } from './public.js';
|
|
24
24
|
import { toOpenApi } from './project/openapi.js';
|
|
25
|
-
import { toTypeScript } from './project/typescript.js';
|
|
26
|
-
import { toMarkdown } from './project/markdown.js';
|
|
27
25
|
|
|
28
26
|
const USAGE = `jaren-contract — projections of a jaren-contract document
|
|
29
27
|
|
|
@@ -196,7 +194,7 @@ function runDiff(options) {
|
|
|
196
194
|
}
|
|
197
195
|
}
|
|
198
196
|
|
|
199
|
-
function main() {
|
|
197
|
+
async function main() {
|
|
200
198
|
let options;
|
|
201
199
|
try {
|
|
202
200
|
options = parseArgs(process.argv);
|
|
@@ -239,8 +237,9 @@ function main() {
|
|
|
239
237
|
rendered = JSON.stringify(document, null, 2) + '\n';
|
|
240
238
|
break;
|
|
241
239
|
}
|
|
242
|
-
|
|
243
|
-
case '
|
|
240
|
+
// the two emit-edged projections load lazily: diff/describe/public/openapi never touch @jarenjs/emit
|
|
241
|
+
case 'types': rendered = (await import('./project/typescript.js')).toTypeScript(contract); break;
|
|
242
|
+
case 'docs': rendered = (await import('./project/markdown.js')).toMarkdown(contract); break;
|
|
244
243
|
}
|
|
245
244
|
if (options.out === null) {
|
|
246
245
|
process.stdout.write(rendered);
|
|
@@ -261,4 +260,4 @@ function main() {
|
|
|
261
260
|
}
|
|
262
261
|
}
|
|
263
262
|
|
|
264
|
-
main();
|
|
263
|
+
await main();
|
package/src/http/dispatch.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @file The request pipeline of the HTTP server binding: one plain
|
|
4
4
|
* request object in, one plain response object out — route, decode,
|
|
5
|
-
* assemble, normalize, validate, claim idempotency,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* assemble, normalize, validate, claim idempotency, decide a declared
|
|
6
|
+
* precondition (the `preconditions` option: `If-Match`/`If-None-Match`
|
|
7
|
+
* against the resolver's CURRENT tag, before the handler), call the
|
|
8
|
+
* handler through one uniform promise boundary, validate the output,
|
|
9
|
+
* apply the post-handler entity-tag conditionals, serialize, commit. Every failure a request
|
|
8
10
|
* can cause is a coded response (docs/CONTRACT-FORMAT.md §7); the
|
|
9
11
|
* function rejects only for a malformed request OBJECT (`JC1004`, an
|
|
10
12
|
* adapter author's mistake) — never for request content and never for
|
|
@@ -86,12 +88,28 @@ import {
|
|
|
86
88
|
* @typedef {{ status: number, headers?: Record<string, string>, body?: string | Uint8Array | null }} RawResponse
|
|
87
89
|
*/
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The CURRENT entity-tag resolver of the `preconditions` option
|
|
93
|
+
* (docs/CONTRACT-FORMAT.md §7.5): called with the operation's validated
|
|
94
|
+
* input and the frozen context BEFORE the handler, it answers the tag of
|
|
95
|
+
* the current representation — a plain string is a STRONG tag (the
|
|
96
|
+
* asymmetry with `ctx.etag`, whose bare form is weak, is deliberate:
|
|
97
|
+
* `If-Match` needs strong comparison to mean anything), `{ tag, strong }`
|
|
98
|
+
* spells it out, `null` means "no current representation" (`If-Match`,
|
|
99
|
+
* `*` included, fails on it; `If-None-Match`, `*` included, passes — the
|
|
100
|
+
* create-guard). A throw, a rejection or any other shape is the host's
|
|
101
|
+
* fault (`JC2008`).
|
|
102
|
+
* @typedef {(input: any, ctx: RequestContext) => string | { tag: string, strong?: boolean } | null | Promise<string | { tag: string, strong?: boolean } | null>} TagResolver
|
|
103
|
+
*/
|
|
104
|
+
|
|
89
105
|
/**
|
|
90
106
|
* One operation as `serveHttp` prepared it: everything the pipeline
|
|
91
107
|
* reads per request, decided once.
|
|
92
108
|
* @typedef {Object} Route
|
|
93
109
|
* @property {CompiledOperation} op
|
|
94
110
|
* @property {Handler | null} handler - `null` on a partial server
|
|
111
|
+
* @property {TagResolver | null} tag - the pre-handler resolver of the
|
|
112
|
+
* `preconditions` option; `null` = conditionals stay post-handler
|
|
95
113
|
* @property {boolean} raw - opaque: the handler is raw
|
|
96
114
|
* @property {boolean} stream - a subscribe operation: the handler answers a subscription
|
|
97
115
|
* @property {number} maxBody
|
|
@@ -295,9 +313,15 @@ function signalOf(request) {
|
|
|
295
313
|
/**
|
|
296
314
|
* Per-request mutable state: what the context's `etag`/`status` armed,
|
|
297
315
|
* and how the settlement classified — `outcome` 0 success, 1 declared
|
|
298
|
-
* failure (with `retryable`), 2 server fault
|
|
299
|
-
*
|
|
300
|
-
*
|
|
316
|
+
* failure (with `retryable`), 2 server fault (a pre-handler `JC2014`
|
|
317
|
+
* among them: nothing ran, the claim is released retryable), 3 a
|
|
318
|
+
* POST-handler precondition failure (the handler already ran and may
|
|
319
|
+
* have mutated: the claim is recorded non-retryable with its 412) —
|
|
320
|
+
* which is what the ledger needs to commit, record or release the
|
|
321
|
+
* claim. `decided` is set by a `preconditions` resolver that already
|
|
322
|
+
* evaluated the conditionals; the post-handler comparison then stands
|
|
323
|
+
* down.
|
|
324
|
+
* @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean, decided: boolean }} Armed
|
|
301
325
|
*/
|
|
302
326
|
|
|
303
327
|
/**
|
|
@@ -411,7 +435,7 @@ function run(server, request) {
|
|
|
411
435
|
const transported = route.normalize === null ? input : route.normalize(input);
|
|
412
436
|
|
|
413
437
|
/** @type {Armed} */
|
|
414
|
-
const armed = { etag: null, strong: false, status: 0, outcome: 0, retryable: false };
|
|
438
|
+
const armed = { etag: null, strong: false, status: 0, outcome: 0, retryable: false, decided: false };
|
|
415
439
|
/** @type {RequestContext} */
|
|
416
440
|
const ctx = {
|
|
417
441
|
op, trace, method, path, params, headers: ctxHeaders,
|
|
@@ -514,6 +538,7 @@ function run(server, request) {
|
|
|
514
538
|
}
|
|
515
539
|
|
|
516
540
|
Object.freeze(ctx);
|
|
541
|
+
if (route.tag !== null) return preconditionedBoundary(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch);
|
|
517
542
|
return boundary(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, false);
|
|
518
543
|
}
|
|
519
544
|
|
|
@@ -733,6 +758,83 @@ function sseResponse(server, route, ctx, sub, trace, headers) {
|
|
|
733
758
|
|
|
734
759
|
//#region the handler boundary
|
|
735
760
|
|
|
761
|
+
/**
|
|
762
|
+
* The pre-handler conditionals of a declared tag resolver
|
|
763
|
+
* (docs/CONTRACT-FORMAT.md §7.5): resolve the CURRENT entity tag, decide
|
|
764
|
+
* `If-Match` (strong comparison, first — RFC 9110 §13.2.2) and
|
|
765
|
+
* `If-None-Match` (weak) BEFORE the handler, and only then run it. A
|
|
766
|
+
* stale precondition refuses `JC2014` with ZERO handler invocations —
|
|
767
|
+
* the write guard the post-handler comparison cannot be; a matching
|
|
768
|
+
* `If-None-Match` on a safe method answers 304 without computing the
|
|
769
|
+
* representation. A command consults its resolver only under a
|
|
770
|
+
* conditional header; a safe method always resolves, so its response
|
|
771
|
+
* carries the tag (the handler's own `ctx.etag` re-arms the RESPONSE
|
|
772
|
+
* tag only — the conditionals are already decided). On pass, an unsafe
|
|
773
|
+
* method arms nothing: a mutated representation must not echo its
|
|
774
|
+
* pre-state tag (RFC 9110 §8.8.3).
|
|
775
|
+
* @param {Server} server
|
|
776
|
+
* @param {Route} route
|
|
777
|
+
* @param {RequestContext} ctx - frozen
|
|
778
|
+
* @param {any} input
|
|
779
|
+
* @param {string} trace
|
|
780
|
+
* @param {Armed} armed
|
|
781
|
+
* @param {boolean} isHead
|
|
782
|
+
* @param {string | undefined} ifMatch
|
|
783
|
+
* @param {string | undefined} ifNoneMatch
|
|
784
|
+
* @returns {HttpResponse | Promise<HttpResponse>}
|
|
785
|
+
*/
|
|
786
|
+
function preconditionedBoundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch) {
|
|
787
|
+
const safe = ctx.method === 'GET' || ctx.method === 'HEAD';
|
|
788
|
+
if (!safe && ifMatch === undefined && ifNoneMatch === undefined) {
|
|
789
|
+
return boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
|
|
790
|
+
}
|
|
791
|
+
/** @param {unknown} err @returns {HttpResponse} */
|
|
792
|
+
const fault = (err) => {
|
|
793
|
+
observe(server, err === undefined ? new TypeError(`the tag resolver of '${route.op.id}' rejected with undefined`) : err, ctx);
|
|
794
|
+
armed.outcome = 2;
|
|
795
|
+
return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
|
|
796
|
+
};
|
|
797
|
+
/** @param {unknown} resolution @returns {HttpResponse | Promise<HttpResponse>} */
|
|
798
|
+
const decide = (resolution) => {
|
|
799
|
+
/** @type {{ tag: string, strong: boolean } | null} */
|
|
800
|
+
let resolved;
|
|
801
|
+
if (resolution === null) resolved = null;
|
|
802
|
+
else if (typeof resolution === 'string') resolved = { tag: resolution, strong: true };
|
|
803
|
+
else if (typeof resolution === 'object' && typeof (/** @type {any} */ (resolution).tag) === 'string') {
|
|
804
|
+
resolved = { tag: /** @type {any} */ (resolution).tag, strong: /** @type {any} */ (resolution).strong !== false };
|
|
805
|
+
}
|
|
806
|
+
else return fault(new TypeError(`the tag resolver of '${route.op.id}' must answer a string, { tag, strong? } or null`));
|
|
807
|
+
if (resolved !== null && (resolved.tag.length === 0 || resolved.tag.indexOf('"') !== -1)) {
|
|
808
|
+
return fault(new TypeError(`the tag resolver of '${route.op.id}' answered a tag that is empty or carries a double quote`));
|
|
809
|
+
}
|
|
810
|
+
if (ifMatch !== undefined && (resolved === null || !entityTagMatches(ifMatch, resolved.tag, resolved.strong, true))) {
|
|
811
|
+
armed.outcome = 2; // nothing ran: on a claimed command the key is released retryable
|
|
812
|
+
const extra = resolved === null ? null : { etag: formatEntityTag(resolved.tag, resolved.strong) };
|
|
813
|
+
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, extra, ctx);
|
|
814
|
+
}
|
|
815
|
+
if (ifNoneMatch !== undefined && resolved !== null && entityTagMatches(ifNoneMatch, resolved.tag, resolved.strong, false)) {
|
|
816
|
+
const etag = formatEntityTag(resolved.tag, resolved.strong);
|
|
817
|
+
if (safe) return { status: 304, headers: { etag, 'x-jaren-trace': trace }, body: null };
|
|
818
|
+
armed.outcome = 2;
|
|
819
|
+
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, { etag }, ctx);
|
|
820
|
+
}
|
|
821
|
+
armed.decided = true;
|
|
822
|
+
if (safe && resolved !== null) {
|
|
823
|
+
armed.etag = resolved.tag;
|
|
824
|
+
armed.strong = resolved.strong;
|
|
825
|
+
}
|
|
826
|
+
return boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
|
|
827
|
+
};
|
|
828
|
+
let resolution;
|
|
829
|
+
try {
|
|
830
|
+
resolution = /** @type {NonNullable<Route['tag']>} */ (route.tag)(input, ctx);
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
return fault(err);
|
|
834
|
+
}
|
|
835
|
+
return isThenable(resolution) ? toPromise(resolution).then(decide, fault) : decide(resolution);
|
|
836
|
+
}
|
|
837
|
+
|
|
736
838
|
/**
|
|
737
839
|
* Call the handler through the pipeline's uniform promise boundary and
|
|
738
840
|
* project the classified result onto the HTTP wire.
|
|
@@ -812,20 +914,28 @@ function finishValue(server, route, ctx, value, trace, armed, isHead, ifMatch, i
|
|
|
812
914
|
/** @type {Record<string, string>} */
|
|
813
915
|
const headers = { 'x-jaren-trace': trace };
|
|
814
916
|
if (armed.etag !== null) {
|
|
815
|
-
// If-Match first (RFC 9110 §13.2.2), strong comparison; then
|
|
816
|
-
// If-None-Match, weak comparison: 304 for GET/HEAD, 412 otherwise
|
|
817
|
-
if (ifMatch !== undefined && !entityTagMatches(ifMatch, armed.etag, armed.strong, true)) {
|
|
818
|
-
armed.outcome = 2;
|
|
819
|
-
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, null, ctx);
|
|
820
|
-
}
|
|
821
917
|
const etag = formatEntityTag(armed.etag, armed.strong);
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
918
|
+
if (!armed.decided) {
|
|
919
|
+
// the POST-handler conditionals — a cache device, never a write
|
|
920
|
+
// guard: the handler has already run when they are compared, so a
|
|
921
|
+
// 412 here does not mean the work did not happen. Outcome 3
|
|
922
|
+
// records that on a claimed command (non-retryable, the 412
|
|
923
|
+
// replays). If-Match first (RFC 9110 §13.2.2), strong comparison;
|
|
924
|
+
// then If-None-Match, weak: 304 for GET/HEAD, 412 otherwise. A
|
|
925
|
+
// `preconditions` resolver (§7.5) decides these BEFORE the
|
|
926
|
+
// handler instead and stands this block down (`armed.decided`).
|
|
927
|
+
if (ifMatch !== undefined && !entityTagMatches(ifMatch, armed.etag, armed.strong, true)) {
|
|
928
|
+
armed.outcome = 3;
|
|
929
|
+
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, null, ctx);
|
|
930
|
+
}
|
|
931
|
+
if (ifNoneMatch !== undefined && entityTagMatches(ifNoneMatch, armed.etag, armed.strong, false)) {
|
|
932
|
+
if (ctx.method === 'GET' || ctx.method === 'HEAD') {
|
|
933
|
+
headers.etag = etag;
|
|
934
|
+
return { status: 304, headers, body: null };
|
|
935
|
+
}
|
|
936
|
+
armed.outcome = 3;
|
|
937
|
+
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, { etag }, ctx);
|
|
826
938
|
}
|
|
827
|
-
armed.outcome = 2;
|
|
828
|
-
return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, { etag }, ctx);
|
|
829
939
|
}
|
|
830
940
|
headers.etag = etag;
|
|
831
941
|
}
|
|
@@ -956,8 +1066,13 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
|
|
|
956
1066
|
return fault(err);
|
|
957
1067
|
}
|
|
958
1068
|
if (state === 'new') {
|
|
959
|
-
|
|
960
|
-
|
|
1069
|
+
// claim first, precondition second: a committed key replays its
|
|
1070
|
+
// stored response before the resolver runs (a retried command
|
|
1071
|
+
// that already succeeded must not answer 412)
|
|
1072
|
+
const ran = route.tag !== null
|
|
1073
|
+
? preconditionedBoundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch)
|
|
1074
|
+
: boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
|
|
1075
|
+
return toPromise(ran).then((response) => settleClaim(server, ledger, ref, response, ctx, armed));
|
|
961
1076
|
}
|
|
962
1077
|
if (state === 'replay') return replay(server, route, stored, trace, ctx);
|
|
963
1078
|
if (state === 'in-progress') {
|
|
@@ -992,9 +1107,12 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
|
|
|
992
1107
|
* Settle a `new` claim with the response the handler produced: a
|
|
993
1108
|
* success commits (replayed verbatim later); a declared failure is
|
|
994
1109
|
* recorded as failed with its response and retryability; a server fault
|
|
995
|
-
* (`JC2008`/`JC2010
|
|
996
|
-
*
|
|
997
|
-
*
|
|
1110
|
+
* (`JC2008`/`JC2010`, and a PRE-handler `JC2014` — nothing ran)
|
|
1111
|
+
* releases the key as retryable; a POST-handler `JC2014` (outcome 3) is
|
|
1112
|
+
* recorded NON-retryable with its 412 — the handler already ran and may
|
|
1113
|
+
* have mutated, so a blind retry with the same key replays the 412
|
|
1114
|
+
* instead of running it again. TOTAL: a ledger that throws or rejects
|
|
1115
|
+
* is reported, and the response still goes out.
|
|
998
1116
|
* @param {Server} server
|
|
999
1117
|
* @param {Ledger} ledger
|
|
1000
1118
|
* @param {unknown} ref
|
|
@@ -1008,6 +1126,7 @@ function settleClaim(server, ledger, ref, response, ctx, armed) {
|
|
|
1008
1126
|
try {
|
|
1009
1127
|
if (armed.outcome === 0) settlement = ledger.commit(ref, response);
|
|
1010
1128
|
else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response);
|
|
1129
|
+
else if (armed.outcome === 3) settlement = ledger.fail(ref, false, response);
|
|
1011
1130
|
else settlement = ledger.fail(ref, true, undefined);
|
|
1012
1131
|
}
|
|
1013
1132
|
catch (err) {
|
package/src/http/serve.js
CHANGED
|
@@ -50,6 +50,13 @@ export { HTTP_ERRORS, WELL_KNOWN_PATH };
|
|
|
50
50
|
* @property {boolean} [partial] - allow missing handlers; a missing one answers 501 `JC2013`
|
|
51
51
|
* @property {boolean} [head] - answer HEAD for GET operations by running the handler and dropping the body; default true
|
|
52
52
|
* @property {'always' | 'never'} [validateOutput] - `'never'` is a declared downgrade, reported in `capabilities.validatedOutput`
|
|
53
|
+
* @property {Record<string, import('./dispatch.js').TagResolver>} [preconditions]
|
|
54
|
+
* - operation id → the CURRENT entity-tag resolver, making `If-Match`/
|
|
55
|
+
* `If-None-Match` a PRE-handler decision for that operation: a stale
|
|
56
|
+
* precondition refuses `JC2014` with zero handler invocations, a
|
|
57
|
+
* matching `If-None-Match` read answers 304 without computing the
|
|
58
|
+
* representation (docs/CONTRACT-FORMAT.md §7.5); refused on a
|
|
59
|
+
* `subscribe` or opaque operation
|
|
53
60
|
* @property {string | false} [wellKnown] - the path answering `describe()`; default `/.well-known/jaren-contract`; `false` disables
|
|
54
61
|
* @property {(wire: WireErrorBody & { status: number }, ctx: RequestContext | null) => unknown} [errorBody]
|
|
55
62
|
* - projects the wire error record into the response body (a legacy
|
|
@@ -111,9 +118,10 @@ function headerNameOf(member) {
|
|
|
111
118
|
* Prepare one operation for the pipeline.
|
|
112
119
|
* @param {CompiledOperation} op
|
|
113
120
|
* @param {Handler | null} handler
|
|
121
|
+
* @param {import('./dispatch.js').TagResolver | null} tag
|
|
114
122
|
* @returns {Route}
|
|
115
123
|
*/
|
|
116
|
-
function prepare(op, handler) {
|
|
124
|
+
function prepare(op, handler, tag) {
|
|
117
125
|
const http = op.http;
|
|
118
126
|
const input = op.input;
|
|
119
127
|
const transport = input === null ? null : input.transport;
|
|
@@ -156,6 +164,7 @@ function prepare(op, handler) {
|
|
|
156
164
|
return Object.freeze({
|
|
157
165
|
op,
|
|
158
166
|
handler,
|
|
167
|
+
tag,
|
|
159
168
|
raw: http.opaque,
|
|
160
169
|
stream: op.kind === 'subscribe',
|
|
161
170
|
maxBody: op.policy.limits.maxBodyBytes,
|
|
@@ -224,6 +233,29 @@ export function serveHttp(contract, handlers, options = {}) {
|
|
|
224
233
|
throw host('JC1001', `the handler of '${id}' must be a function, got ${typeof handlers[id]}`);
|
|
225
234
|
}
|
|
226
235
|
}
|
|
236
|
+
const preconditions = options.preconditions === undefined ? null : options.preconditions;
|
|
237
|
+
if (preconditions !== null && (typeof preconditions !== 'object' || Array.isArray(preconditions))) {
|
|
238
|
+
throw host('JC1001', 'options.preconditions must be an object of operation id → tag resolver');
|
|
239
|
+
}
|
|
240
|
+
if (preconditions !== null) {
|
|
241
|
+
const ids = Object.keys(preconditions);
|
|
242
|
+
for (let i = 0; i < ids.length; i++) {
|
|
243
|
+
const id = ids[i];
|
|
244
|
+
if (!Object.hasOwn(contract.operations, id)) {
|
|
245
|
+
throw host('JC1001', `preconditions names '${id}', which is not an operation of the contract`);
|
|
246
|
+
}
|
|
247
|
+
if (typeof preconditions[id] !== 'function') {
|
|
248
|
+
throw host('JC1001', `the tag resolver of '${id}' must be a function, got ${typeof preconditions[id]}`);
|
|
249
|
+
}
|
|
250
|
+
const op = contract.operations[id];
|
|
251
|
+
if (op.kind === 'subscribe') {
|
|
252
|
+
throw host('JC1001', `operation '${id}' is a subscribe — a stream has no single representation for a precondition to guard`);
|
|
253
|
+
}
|
|
254
|
+
if (op.http.opaque) {
|
|
255
|
+
throw host('JC1001', `operation '${id}' is opaque — its raw handler owns the bytes and the headers; preconditions cannot apply`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
227
259
|
/** @type {Map<string, Route>} */
|
|
228
260
|
const routes = new Map();
|
|
229
261
|
for (let i = 0; i < contract.ids.length; i++) {
|
|
@@ -236,7 +268,7 @@ export function serveHttp(contract, handlers, options = {}) {
|
|
|
236
268
|
if (op.policy.idempotency !== 'none' && ledger === null) {
|
|
237
269
|
throw host('JC1003', `operation '${id}' declares policy.idempotency '${op.policy.idempotency}' and no ledger was given — this binding cannot carry idempotency without one`);
|
|
238
270
|
}
|
|
239
|
-
routes.set(id, prepare(op, handler));
|
|
271
|
+
routes.set(id, prepare(op, handler, preconditions !== null && Object.hasOwn(preconditions, id) ? preconditions[id] : null));
|
|
240
272
|
}
|
|
241
273
|
|
|
242
274
|
const validateOutput = options.validateOutput === undefined ? 'always' : options.validateOutput;
|
package/src/project/index.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* through a JSLT stylesheet), `toTypeScript` and `toMarkdown` (on
|
|
7
7
|
* `@jarenjs/emit`'s type model), `contractTools` (`@jarenjs/ai` tool
|
|
8
8
|
* definitions, no import edge) and the same-document bundler they share.
|
|
9
|
-
* This is the one subpath that imports `@jarenjs/emit
|
|
10
|
-
*
|
|
9
|
+
* This is the one subpath that imports `@jarenjs/emit` — the CLI loads
|
|
10
|
+
* it lazily, only for `types` and `docs`; a consumer that never imports
|
|
11
|
+
* either never loads it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
export { publicProjection } from '../public.js';
|