@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 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, calls the handler
158
- through one promise boundary, validates the output (500 — the server
159
- broke the contract), applies `If-Match`/`If-None-Match`, serializes.
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 — a catch-all route, the raw body handed to dispatch
193
+ // Fastify — hijack before parsing; the node adapter carries body limits, SSE and abort
176
194
  const app = fastify();
177
- app.removeAllContentTypeParsers();
178
- app.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => done(null, body));
179
- app.all('/*', async (req, reply) => {
180
- const r = await server.dispatch({ method: req.method, url: req.url, headers: req.headers, body: req.body ?? null });
181
- reply.code(r.status).headers(r.headers);
182
- return r.body === null ? reply.send() : reply.send(r.body);
183
- });
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, call the handler
5
- * through one uniform promise boundary, validate the output, apply the
6
- * entity-tag conditionals, serialize, commit. Every failure a request
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`; a consumer that
9
- * never imports it never loads it.
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';
@@ -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 10; done.
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. **The handler**, through **one uniform promise boundary** a
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
- 11. **Output validation** (`validateOutput: "always"`, the default): the
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
- 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
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
- `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.
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"`; `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.
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`, `JC2014`) **releases**
761
- the key as retryable with no response. `now` on a claim is the binding's
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`, so a bundle that never
1279
- projects never carries it): the public projection every other artifact
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.1",
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.1",
106
- "@jarenjs/json": "^0.43.1",
107
- "@jarenjs/validate": "^0.43.1",
108
- "@jarenjs/emit": "^0.43.1"
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
- case 'types': rendered = toTypeScript(contract); break;
243
- case 'docs': rendered = toMarkdown(contract); break;
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();
@@ -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, call the handler
6
- * through one uniform promise boundary, validate the output, apply the
7
- * entity-tag conditionals, serialize, commit. Every failure a request
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 which is what the ledger
299
- * needs to commit, record or release the claim.
300
- * @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean }} Armed
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 (ifNoneMatch !== undefined && entityTagMatches(ifNoneMatch, armed.etag, armed.strong, false)) {
823
- if (ctx.method === 'GET' || ctx.method === 'HEAD') {
824
- headers.etag = etag;
825
- return { status: 304, headers, body: null };
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
- return boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false)
960
- .then((response) => settleClaim(server, ledger, ref, response, ctx, armed));
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`/`JC2014`) releases the key as retryable. TOTAL: a
996
- * ledger that throws or rejects is reported, and the response still
997
- * goes out.
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;
@@ -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`; a consumer that
10
- * never imports it never loads it.
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';