@jarenjs/contract 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file The contract revision: the lowercase hex SHA-256 over the RFC
3
+ * 8785 canonical bytes of the public projection (docs/CONTRACT-FORMAT.md
4
+ * §14) — the compatibility identity two independent processes agree on.
5
+ * It hashes the PUBLIC projection, never the source document, so a
6
+ * server-audience operation, a `policy.limits` value or an error-detail
7
+ * level can change without moving the revision, while any member a
8
+ * client can observe moves it. Memoized per compiled contract in a
9
+ * private `WeakMap`, so `compileContract` stays synchronous and the
10
+ * digest is computed at most once per process; `peekRevision` is the
11
+ * synchronous read `describe()` uses (`null` until the promise settled).
12
+ */
13
+ export type Contract = import('./compile.js').Contract;
14
+ /**
15
+ * Compute (once) the revision of a compiled contract: SHA-256 over the
16
+ * canonical bytes of `publicProjection(contract)`, as 64 lowercase hex
17
+ * characters. A projection that is not canonicalizable — a string member
18
+ * carrying an unpaired surrogate, say — rejects with `JC0061`
19
+ * (`ContractCompileError`, its `docPath` the offending value's pointer
20
+ * INTO THE PROJECTION).
21
+ * @param {Contract} contract
22
+ * @returns {Promise<string>}
23
+ * @example
24
+ * const contract = compileContract(doc);
25
+ * await contract.revision(); // 'e3b0c442…' — stable across compiles of equal documents
26
+ */
27
+ export declare function contractRevision(contract: Contract): Promise<string>;
28
+ /**
29
+ * The revision of a compiled contract if it has been computed, else
30
+ * `null` — the synchronous read `describe()` renders, so a description
31
+ * taken before anyone awaited `revision()` honestly says "not computed"
32
+ * rather than blocking.
33
+ * @param {Contract} contract
34
+ * @returns {string | null}
35
+ */
36
+ export declare function peekRevision(contract: Contract): string | null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file The client half of the stream binding, carrier-neutral
3
+ * (docs/CONTRACT-FORMAT.md §19): one consumer state machine that both
4
+ * `client.subscribe` implementations feed — the HTTP client with
5
+ * decoded SSE events, the port client with push frames. It validates
6
+ * every snapshot against the operation's output schema, enforces the
7
+ * strictly-increasing seq (`JC2092`), classifies a server `error` event
8
+ * (a declared code is a `failure` outcome under its own code; anything
9
+ * else is `contract` `JC2093` with the server's record in `details`),
10
+ * and delivers each `on*` callback totally — a callback that throws
11
+ * never breaks the machine. After the first terminal event (`error`,
12
+ * `end`, a local failure) the machine is finished: the carrier's
13
+ * `finish` hook has run and every later event is dropped.
14
+ */
15
+ import { STREAM_ERRORS } from './sse.js';
16
+ export type OutcomeRoute = import('../client/outcome.js').OutcomeRoute;
17
+ export type OutcomeMeta = import('../client/outcome.js').OutcomeMeta;
18
+ export type Outcome = import('../client/outcome.js').Outcome;
19
+ export type Catalog = import('../http/wire.js').Catalog;
20
+ export type StreamCallbacks = {
21
+ onSnapshot?: (value: unknown, info: {
22
+ seq: number;
23
+ resumed: boolean;
24
+ }) => void;
25
+ onPatch?: (emission: {
26
+ patch: unknown[];
27
+ seq: number;
28
+ }) => void;
29
+ onError?: (outcome: Outcome) => void;
30
+ onEnd?: (info: {
31
+ reason: string;
32
+ }) => void;
33
+ };
34
+ export type StreamConsumerOptions = {
35
+ route: OutcomeRoute & {
36
+ details: 'none' | 'paths' | 'full';
37
+ };
38
+ catalog: Catalog | null;
39
+ /**
40
+ * - mutated: `trace` is refreshed from error records
41
+ */
42
+ meta: OutcomeMeta;
43
+ callbacks: StreamCallbacks;
44
+ /**
45
+ * - the carrier's cleanup (remove the entry,
46
+ * cancel readers and timers); called exactly once, before the terminal callback
47
+ */
48
+ finish: () => void;
49
+ /**
50
+ * - the resume seq the caller passed (the
51
+ * regression baseline until a snapshot or patch moves it)
52
+ */
53
+ lastSeq: number | null;
54
+ };
55
+ /**
56
+ * @typedef {import('../client/outcome.js').OutcomeRoute} OutcomeRoute
57
+ * @typedef {import('../client/outcome.js').OutcomeMeta} OutcomeMeta
58
+ * @typedef {import('../client/outcome.js').Outcome} Outcome
59
+ * @typedef {import('../http/wire.js').Catalog} Catalog
60
+ */
61
+ /**
62
+ * The callbacks of one `client.subscribe` call; every one optional.
63
+ * @typedef {Object} StreamCallbacks
64
+ * @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
65
+ * @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
66
+ * @property {(outcome: Outcome) => void} [onError]
67
+ * @property {(info: { reason: string }) => void} [onEnd]
68
+ */
69
+ /**
70
+ * @typedef {Object} StreamConsumerOptions
71
+ * @property {OutcomeRoute & { details: 'none' | 'paths' | 'full' }} route
72
+ * @property {Catalog | null} catalog
73
+ * @property {OutcomeMeta} meta - mutated: `trace` is refreshed from error records
74
+ * @property {StreamCallbacks} callbacks
75
+ * @property {() => void} finish - the carrier's cleanup (remove the entry,
76
+ * cancel readers and timers); called exactly once, before the terminal callback
77
+ * @property {number | null} lastSeq - the resume seq the caller passed (the
78
+ * regression baseline until a snapshot or patch moves it)
79
+ */
80
+ /**
81
+ * The consumer the carriers feed. `snapshot`/`patch` take the seq the
82
+ * wire carried (the SSE id, the frame's `seq`) — `null` falls back to
83
+ * the data's own `seq`; `error`/`end` take the event data; `fail` takes
84
+ * a ready outcome (a transport failure the carrier classified). All are
85
+ * no-ops once finished.
86
+ * @param {StreamConsumerOptions} options
87
+ * @returns {{ snapshot: (seq: number | null, data: unknown) => void,
88
+ * patch: (seq: number | null, data: unknown) => void,
89
+ * error: (data: unknown) => void,
90
+ * end: (data: unknown) => void,
91
+ * fail: (outcome: Outcome) => void,
92
+ * cancel: () => void,
93
+ * finished: () => boolean }}
94
+ */
95
+ export declare function createStreamConsumer(options: StreamConsumerOptions): {
96
+ snapshot: (seq: number | null, data: unknown) => void;
97
+ patch: (seq: number | null, data: unknown) => void;
98
+ error: (data: unknown) => void;
99
+ end: (data: unknown) => void;
100
+ fail: (outcome: Outcome) => void;
101
+ cancel: () => void;
102
+ finished: () => boolean;
103
+ };
104
+ export { STREAM_ERRORS };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file The server half of the stream binding, carrier-neutral
3
+ * (docs/CONTRACT-FORMAT.md §17–§18): take the subscription a handler
4
+ * settled (the duck-typed LIVE shape — `result`/`snapshot()`,
5
+ * `subscribe(cb) → stop`, `close()`, optional `replay(seq)`), decide
6
+ * resumption, read and validate the snapshot, forward each emission,
7
+ * and guarantee `stop()` then `close()` run **exactly once** however
8
+ * the stream ends — peer disconnect, unsubscribe, server shutdown, an
9
+ * error emission, an invalid snapshot. The HTTP dispatcher renders the
10
+ * intents this module raises as SSE events; the port server renders
11
+ * them as push frames — the sequencing is decided here once so the two
12
+ * carriers can never disagree.
13
+ *
14
+ * Total for everything a handler's subscription can do: a throwing
15
+ * `snapshot()`/`result` accessor, a `subscribe` that throws, a hostile
16
+ * emission, a throwing `stop`/`close` — every one settles into the
17
+ * `fault` intent (the cause for the binding's `onError`, never the
18
+ * wire) or is swallowed at close, and the stream still terminates.
19
+ */
20
+ export type PipelineRoute = import('../pipeline.js').PipelineRoute;
21
+ export type SubscriptionLike = {
22
+ result?: unknown;
23
+ snapshot?: () => unknown;
24
+ subscribe: (cb: (emission: any) => void) => (() => void);
25
+ close: () => void;
26
+ replay?: (seq: number) => unknown;
27
+ mode?: unknown;
28
+ };
29
+ export type StreamHooks = {
30
+ snapshot: (seq: number, value: unknown, resumed: boolean) => void;
31
+ patch: (seq: number, emission: {
32
+ patch: unknown[];
33
+ seq: number;
34
+ }) => void;
35
+ error: (intent: 'invalid-snapshot' | 'source', cause: unknown, lastSeq: number) => void;
36
+ end: (reason: string, lastSeq: number) => void;
37
+ done: () => void;
38
+ };
39
+ export type StreamOptions = {
40
+ /**
41
+ * - the peer's `Last-Event-ID` / `lastSeq`, or `null`
42
+ */
43
+ lastSeq: number | null;
44
+ /**
45
+ * - whether snapshots run the output validator
46
+ */
47
+ validate: boolean;
48
+ };
49
+ /**
50
+ * @typedef {import('../pipeline.js').PipelineRoute} PipelineRoute
51
+ */
52
+ /**
53
+ * The duck-typed subscription of docs/CONTRACT-FORMAT.md §17.1.
54
+ * @typedef {{ result?: unknown, snapshot?: () => unknown,
55
+ * subscribe: (cb: (emission: any) => void) => (() => void),
56
+ * close: () => void, replay?: (seq: number) => unknown, mode?: unknown }} SubscriptionLike
57
+ */
58
+ /**
59
+ * What the carrier renders. Every hook is called at most once per
60
+ * event, in wire order; after `error` or `end` no further hook fires.
61
+ * `error` carries the intent (`'invalid-snapshot'` — send `JC2091`;
62
+ * `'source'` — the subscription emitted `{ error }`, send the host
63
+ * fault code) and the cause for the observer. `done` fires exactly once
64
+ * after the stream terminated for any reason — the carrier releases
65
+ * its resources (timers, registries) there.
66
+ * @typedef {Object} StreamHooks
67
+ * @property {(seq: number, value: unknown, resumed: boolean) => void} snapshot
68
+ * @property {(seq: number, emission: { patch: unknown[], seq: number }) => void} patch
69
+ * @property {(intent: 'invalid-snapshot' | 'source', cause: unknown, lastSeq: number) => void} error
70
+ * @property {(reason: string, lastSeq: number) => void} end
71
+ * @property {() => void} done
72
+ */
73
+ /**
74
+ * @typedef {Object} StreamOptions
75
+ * @property {number | null} lastSeq - the peer's `Last-Event-ID` / `lastSeq`, or `null`
76
+ * @property {boolean} validate - whether snapshots run the output validator
77
+ */
78
+ /**
79
+ * Whether a settled handler value is a usable subscription.
80
+ * Reads guardedly; a hostile value classifies as "not a subscription".
81
+ * @param {unknown} value
82
+ * @returns {value is SubscriptionLike}
83
+ */
84
+ export declare function isSubscriptionLike(value: unknown): value is SubscriptionLike;
85
+ /**
86
+ * Run one subscription over a carrier. Emissions are forwarded
87
+ * verbatim (a patch is never mutated); an emission whose serialized
88
+ * patch exceeds `policy.stream.maxPatchBytes` is replaced by a fresh
89
+ * snapshot at that emission's seq (§18.1); an `{ error }` emission —
90
+ * and a hostile one — raises the `error` intent and ends the stream.
91
+ *
92
+ * Returns the stopper: `stop(reason)` with a string emits the `end`
93
+ * event with that reason first (a server shutdown); `stop(null)` is
94
+ * silent (the peer is gone, or asked). Idempotent; the subscription's
95
+ * own `stop()` and `close()` run exactly once either way.
96
+ *
97
+ * @param {PipelineRoute} route
98
+ * @param {SubscriptionLike} sub
99
+ * @param {StreamHooks} hooks
100
+ * @param {StreamOptions} options
101
+ * @returns {{ stop: (reason: string | null) => void }}
102
+ */
103
+ export declare function runSubscription(route: PipelineRoute, sub: SubscriptionLike, hooks: StreamHooks, options: StreamOptions): {
104
+ stop: (reason: string | null) => void;
105
+ };
106
+ export { STREAM_ERRORS, STREAM_EVENTS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent } from './sse.js';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @file The stream wire's shared shapes (docs/CONTRACT-FORMAT.md §18):
3
+ * the stream code table as data (`STREAM_ERRORS`, the same
4
+ * table-as-data shape as `HTTP_ERRORS` and `PORT_LOCAL_ERRORS`), the
5
+ * event names both carriers speak, and the SSE text framing on top of
6
+ * `@jarenjs/core/text/sse` — one JSON value per event, a heartbeat
7
+ * comment line, and the `JC1009` host refusal for text the frame
8
+ * cannot carry. The port carrier uses the same tables and data shapes
9
+ * with no SSE text at all (§18.2).
10
+ */
11
+ /**
12
+ * The stream request-time codes — code → `{ kind, msgid, retryable }`.
13
+ * The normative table is docs/CONTRACT-FORMAT.md §18.3; a test holds
14
+ * them equal. `JC2095` (a refused resume) is deliberately absent: it is
15
+ * informational, carried as `resumed: false` in a snapshot's event
16
+ * data, never an outcome.
17
+ */
18
+ export declare const STREAM_ERRORS: Readonly<{
19
+ JC2090: Readonly<{
20
+ kind: "contract";
21
+ msgid: "contract/not-a-stream";
22
+ retryable: false;
23
+ }>;
24
+ JC2091: Readonly<{
25
+ kind: "contract";
26
+ msgid: "contract/invalid-snapshot";
27
+ retryable: false;
28
+ }>;
29
+ JC2092: Readonly<{
30
+ kind: "contract";
31
+ msgid: "contract/seq-regression";
32
+ retryable: false;
33
+ }>;
34
+ JC2093: Readonly<{
35
+ kind: "contract";
36
+ msgid: "contract/stream-error";
37
+ retryable: false;
38
+ }>;
39
+ JC2094: Readonly<{
40
+ kind: "network";
41
+ msgid: "contract/heartbeat-missed";
42
+ retryable: true;
43
+ }>;
44
+ }>;
45
+ /** The event names of the stream wire, on both carriers. */
46
+ export declare const STREAM_EVENTS: readonly string[];
47
+ /** The media type of the SSE carrier. */
48
+ export declare const STREAM_MEDIA = "text/event-stream";
49
+ /** The heartbeat comment line the SSE carrier writes every `heartbeatMs`. */
50
+ export declare const HEARTBEAT_LINE = ":\n\n";
51
+ /**
52
+ * One stream event as SSE text: the event name, the seq as the SSE id,
53
+ * the JSON data. JSON output never carries a raw CR, so the encoder's
54
+ * refusal is reachable only through a host handing this function
55
+ * non-JSON text — `JC1009`, thrown.
56
+ * @param {string} event - `snapshot | patch | error | end`
57
+ * @param {number | null} seq - the event's seq; `null` writes no id line
58
+ * @param {unknown} data - the event's JSON data
59
+ * @returns {string}
60
+ * @throws {ContractHostError} `JC1009` when the text cannot ride an SSE frame
61
+ */
62
+ export declare function encodeStreamEvent(event: string, seq: number | null, data: unknown): string;
@@ -0,0 +1,301 @@
1
+ # Calling a contract from @jarenjs/app
2
+
3
+ How an application built with `@jarenjs/app` calls the operations of a
4
+ contract with no route strings, no hand-written `fetch` wrappers and
5
+ no per-operation effect handlers. The shape of the convention is one
6
+ sentence: **every operation becomes a task slot in state plus three
7
+ generated action documents, and one registered effect carries them all**
8
+ — plain JSON the app compiles like any hand-written action, with the
9
+ per-operation concurrency mode taken from the contract's `policy.task`
10
+ and neither package importing the other. The test suite executes this
11
+ document's two worked examples verbatim, against a real `serveHttp`
12
+ dispatcher.
13
+
14
+ The convention is `@jarenjs/app`'s own async-task convention
15
+ ([TASKS.md](../../app/docs/TASKS.md)): correctness lives in state (a
16
+ monotonic slot `id`, a completion action that guards on it), cancellation
17
+ lives in the host. The generator writes those documents so a consumer
18
+ never writes the id guard by hand, and the effect settles every
19
+ operation with the contract's **outcome** (CONTRACT-FORMAT §10) instead
20
+ of a string.
21
+
22
+ ## The API
23
+
24
+ ```js
25
+ import { createApp, createTaskEffect } from '@jarenjs/app';
26
+ import { openHttpClient } from '@jarenjs/contract/client';
27
+ import { contractAppBinding, createContractEffect } from '@jarenjs/contract/app';
28
+
29
+ const client = openHttpClient(contract, { baseUrl: 'https://api.example' });
30
+
31
+ const { slice, actions, schema } = contractAppBinding(contract, {
32
+ statePath: '/contract', // where the slice lives in app state (default)
33
+ namespace: 'contract/', // action-name prefix (default)
34
+ ops: contract.ids, // the operations this app uses (default: all)
35
+ });
36
+ // slice → { 'catalog.load': { id: 0, status: 'idle', kind: null, value: null, error: null, meta: null }, … } mount it at statePath
37
+ // actions → { 'contract/catalog.load/start', '…/done', '…/reset': <query docs>, … } spread into the app's actions
38
+ // schema → the slice's JSON Schema compose into validateState
39
+
40
+ createApp(doc, {
41
+ effects: { contract: createContractEffect(client, { createTaskEffect }) }, // ONE effect for every operation
42
+ });
43
+ ```
44
+
45
+ `createContractEffect` takes the `createTaskEffect` factory **from the
46
+ host** — `@jarenjs/contract` imports nothing from `@jarenjs/app`, and
47
+ there is exactly one task-effect implementation in the suite. It builds
48
+ one inner task effect per distinct `policy.task` mode the contract
49
+ uses (`switch` for a read, `exhaust` for a command by default) and
50
+ routes each descriptor by its `op`, so a `switch` read and an `exhaust`
51
+ command live behind the one `run: "contract"` the documents name. The
52
+ mode is never *named* in a generated document; the generator derives the
53
+ document's state-side guards from it instead (an `exhaust` start is a
54
+ no-op in state while its slot is loading), so state and effect tell the
55
+ same story in every mode.
56
+
57
+ ## The worked example
58
+
59
+ A two-operation contract — a read with a query member and a command
60
+ with a path variable, a required idempotency key and a declared
61
+ `conflict` error:
62
+
63
+ ```json
64
+ {
65
+ "$contract": "0.1",
66
+ "id": "shop",
67
+ "version": "1",
68
+ "$defs": {
69
+ "Product": {
70
+ "type": "object",
71
+ "required": ["id", "name", "price"],
72
+ "properties": {
73
+ "id": { "type": "integer" },
74
+ "name": { "type": "string", "minLength": 1 },
75
+ "price": { "type": "number", "minimum": 0 }
76
+ }
77
+ }
78
+ },
79
+ "operations": {
80
+ "catalog.load": {
81
+ "kind": "read",
82
+ "input": { "type": "object", "properties": { "since": { "type": "string", "format": "date-time" } } },
83
+ "output": { "type": "array", "items": { "$ref": "#/$defs/Product" } },
84
+ "http": { "method": "GET", "path": "/api/catalog" }
85
+ },
86
+ "product.save": {
87
+ "kind": "command",
88
+ "input": {
89
+ "type": "object",
90
+ "required": ["id", "product"],
91
+ "properties": { "id": { "type": "integer" }, "product": { "$ref": "#/$defs/Product" } }
92
+ },
93
+ "output": { "$ref": "#/$defs/Product" },
94
+ "errors": { "conflict": { "status": 409, "schema": { "$ref": "#/$defs/Product" } } },
95
+ "policy": { "idempotency": "required" },
96
+ "http": { "method": "PUT", "path": "/api/products/{id}" }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ The application document. `state.contract` is where the generated slice
103
+ is mounted (the host replaces the placeholder with `slice`), the
104
+ generated actions are spread beside the app's own, and the view reads
105
+ the slots like any other state:
106
+
107
+ ```json
108
+ {
109
+ "$app": "0.1",
110
+ "state": {
111
+ "contract": {},
112
+ "draft": { "id": 1, "name": "Kettle", "price": 12 }
113
+ },
114
+ "view": [
115
+ { "match": "$", "body": ["main", {},
116
+ ["p", { "class": "status" }, "$.contract['catalog.load'].status"],
117
+ ["ul", {}, { "$apply": "$.contract['catalog.load'].value[*]" }],
118
+ ["p", { "class": "save" }, "$.contract['product.save'].status"]
119
+ ] },
120
+ { "match": "$.contract['catalog.load'].value[*]", "body": ["li", {}, "$.name"] }
121
+ ],
122
+ "actions": {
123
+ "draft/name": { "patch": [{ "op": "replace", "path": "/draft/name", "value": "$payload" }] }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Composed and mounted:
129
+
130
+ ```js
131
+ const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
132
+ const validate = new JarenValidator().compile({
133
+ type: 'object',
134
+ required: ['contract', 'draft'],
135
+ properties: { contract: schema, draft: { type: 'object' } },
136
+ });
137
+ const app = createApp(
138
+ { ...doc, state: { ...doc.state, contract: slice }, actions: { ...actions, ...doc.actions } },
139
+ {
140
+ effects: { contract: createContractEffect(client, { createTaskEffect }) },
141
+ validateState: (state) => validate(state),
142
+ });
143
+
144
+ app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
145
+ // → state.contract['catalog.load'] = { id: 1, status: 'loading', kind: null, value: null, error: null, meta: null }
146
+ // … the server answers …
147
+ // → { id: 1, status: 'done', kind: null, value: [ {…}, {…} ], error: null, meta: { op, attempt: 1, trace, … } }
148
+
149
+ app.dispatch('contract/product.save/start', { id: 1, product: app.getState().draft });
150
+ // a 409 conflict → { id: 1, status: 'error', kind: 'failure', value: null, error: { code: 'conflict', status: 409, details: {…}, … }, meta }
151
+ // state.draft is untouched; a later success → status 'done', kind null, value the saved product, error null
152
+ app.dispatch('contract/product.save/reset');
153
+ // → status 'idle', kind null, error null — id, value and meta untouched (dismiss the error, or release a slot the host cancelled)
154
+ ```
155
+
156
+ ## What the generated documents guarantee
157
+
158
+ - **Staleness is rejected by construction.** Two quick `start`s take the
159
+ slot id to 2; the effect's `switch` mode aborts request 1, but even
160
+ when request 1's response arrives (it may already have resolved), its
161
+ `done` carries `id: 1`, the guard compares it with the slot's `2`,
162
+ `$if` takes no branch, and the action yields the empty sequence — no
163
+ state change, no render, no subscriber.
164
+ - **A double-click does not double-run a command — and its result
165
+ lands.** `product.save` is a command, `policy.task` defaults to
166
+ `exhaust`: its generated `start` is wrapped in a state-side guard
167
+ (`$if: [{ $ne: [<slot>.status, "loading"] }, …]`), so while the slot
168
+ has an in-flight task a second `start` is a no-op in state *and* in
169
+ the effect — no patch, no effect invocation, no render; the id stays
170
+ at 1, the handler runs once, the single completion carries id 1
171
+ against a slot at 1 and lands as `done` with the saved product. (For
172
+ a command whose every dispatch must run, `policy.task: "concat"`
173
+ queues; the contract is the one place the choice is made.)
174
+ - **`reset` releases a slot.** `contract/<op>/reset` writes `status:
175
+ "idle"`, `kind: null`, `error: null` and leaves `id`, `value` and
176
+ `meta` alone — the ordinary "dismiss the error" action, and the one
177
+ way out of a slot a host `effect.cancel(slot)` left `loading` on an
178
+ `exhaust` operation (the guarded `start` is a no-op while loading).
179
+ The id stays monotonic, so a late completion of the cancelled attempt
180
+ is still rejected.
181
+ - **A failed reload keeps the last good value.** The error branch writes
182
+ `status`, `error` and `meta`; `value` is untouched, so a list stays on
183
+ screen while the error shows beside it.
184
+ - **Cancellation is silent.** A `cancelled` outcome dispatches nothing
185
+ (the task effect's `AbortError` rule); the slot stays `loading` until
186
+ its successor settles or the host dispatches `reset`.
187
+ - **`validateState` fails closed.** The slice schema pins `status` to
188
+ `idle | loading | done | error`, `kind` to `null | failure | network |
189
+ contract`, `value` to the operation's output schema or `null`, and
190
+ `error`/`meta` to the outcome shapes; a rogue hand-written action that
191
+ writes a nonsense status is `JA2005` and the state stands.
192
+ - **Every failure lands as an outcome, and the slot says which kind.** A
193
+ declared error, a network failure, a contract violation — and a thrown
194
+ host value, projected to `JC2058` — arrive in `error` as the same
195
+ `{ code, message, status, details, retryable }`, and the slot's `kind`
196
+ carries the outcome's kind (`failure` | `network` | `contract`) while
197
+ `status` is `error`, so a view tells "you are offline" from "the
198
+ server refused this" without parsing `error.code`.
199
+
200
+ ## Honest divergences from TASKS.md
201
+
202
+ - The completion payload is read through `$coalesce: ["$payload.result",
203
+ "$payload.error"]` rather than TASKS.md's `$exists: "$payload.error"`
204
+ branch: both members carry an outcome here (a resolved one, or a
205
+ projected host throw), and the outcome's own `ok` decides the branch.
206
+ - The effect's `fail` prop is not emitted: one completion action per
207
+ operation is the query-friendliest shape, and the outcome already
208
+ distinguishes success from failure.
209
+ - The state slot carries `kind`, `value` and `meta` beside TASKS.md's
210
+ `{ id, status, error }`: the operation's output has a schema, so it
211
+ has a typed home in the slice rather than a hand-chosen path, and the
212
+ failure class is a member rather than a code to parse.
213
+ - One slot per operation makes `concat` and `parallel` **"latest
214
+ wins"**: every `start` increments the id, the effect runs them all
215
+ (queued, or concurrently), only the completion carrying the current
216
+ id lands, `status` stays `loading` until the newest does, and earlier
217
+ results and errors are dropped from state. TASKS.md's per-slot
218
+ convention says nothing about several results for one slot; a
219
+ consumer that needs every result of a fan-out needs a slot per key.
220
+ - An `exhaust` operation's `start` carries a state-side guard TASKS.md
221
+ does not write by hand: TASKS.md's exhaust mode ignores duplicate
222
+ starts in the *effect*, but a hand-written start still moves the
223
+ slot id, so the one completion would be rejected; the generator
224
+ decides in state first, and the mode is still never named.
225
+
226
+ ## Live data: subscribe operations
227
+
228
+ A subscribe operation reaches the app as a **subscription**, not a
229
+ task: its slot in the slice is `{ id, status: 'idle' | 'live' |
230
+ 'error', kind, input, value, error, meta, seq }`, its generated
231
+ actions are `start`, `stop`, `snapshot`, `patch`, `error` and `reset`,
232
+ and the binding additionally emits one **`subs` entry** per subscribe
233
+ operation (`run: "contract-stream"`) whose `when` watches `status ===
234
+ 'live'` and whose `withQuery` resolves the slot's `id` and `input`
235
+ from state. `start` stores its payload as the slot's `input` and flips
236
+ the slot `live` — it emits **nothing**; the app's own subscription
237
+ reconciliation sees the liveness and runs the handler, exactly as
238
+ APP-FORMAT §5.3 defines it. A second `start` while `live` is a no-op
239
+ in state (the same state-first guard as an exhaust command's start);
240
+ `stop` flips the slot `idle`, which stops the subscription through its
241
+ own cleanup.
242
+
243
+ The worked live contract (verbatim-tested, like the two examples
244
+ above):
245
+
246
+ ```json
247
+ {
248
+ "$contract": "0.1",
249
+ "id": "board",
250
+ "operations": {
251
+ "board.feed": {
252
+ "kind": "subscribe",
253
+ "input": { "type": "object", "required": ["room"], "properties": { "room": { "type": "string" } } },
254
+ "output": { "type": "object", "required": ["rows"], "properties": { "rows": { "type": "array" } } }
255
+ }
256
+ }
257
+ }
258
+ ```
259
+
260
+ Composed and mounted — the subs entries spread into the document, the
261
+ handler registered under the app's `subs` option:
262
+
263
+ ```js
264
+ const { slice, actions, subs, schema } = contractAppBinding(contract);
265
+ const app = createApp(
266
+ { ...doc, state: { ...doc.state, contract: slice }, actions: { ...actions, ...doc.actions }, subs: [...(doc.subs ?? []), ...subs] },
267
+ {
268
+ effects: { contract: createContractEffect(client, { createTaskEffect }) },
269
+ subs: { 'contract-stream': createContractSubscription(client) },
270
+ });
271
+
272
+ app.dispatch('contract/board.feed/start', { room: 'r1' });
273
+ // → { id: 1, status: 'live', kind: null, input: { room: 'r1' }, value: null, error: null, meta: null, seq: 0 }
274
+ // … the snapshot arrives … → value: { rows: [...] }, seq: <seq0>
275
+ // … a write commits … → value patched copy-on-write, seq: <seq>
276
+ app.dispatch('contract/board.feed/stop');
277
+ // → status 'idle'; the subscription's cleanup ran (client stop → wire unsubscribe)
278
+ ```
279
+
280
+ What the generated documents guarantee, in the same spirit as the task
281
+ slots:
282
+
283
+ - **The handler applies the patches; state replaces.** An app action's
284
+ `patch` member is a *literal* op list whose members are query
285
+ expressions — it cannot splice a runtime array of RFC 6902 ops — so
286
+ `createContractSubscription` applies each `{ patch, seq }` emission
287
+ host-side with `@jarenjs/json/patch` (copy-on-write; unaffected rows
288
+ stay reference-identical) and dispatches the `patch` action with the
289
+ patched document; the action `replace`s the slot's `value` and `seq`.
290
+ - **A stale instance cannot write.** Every dispatched payload carries
291
+ the instance's `id`; `snapshot`/`patch`/`error` guard on it against
292
+ the slot id, and `patch` additionally on `seq` strictly greater than
293
+ the slot's — an out-of-order or replayed event is a provable no-op.
294
+ - **A dead stream is visible.** An `onError` outcome lands in the slot
295
+ (`status: 'error'`, the outcome's `kind`/`error`/`meta`); a server
296
+ `end` lands the same way as a `network`-kind outcome with the
297
+ channel-closed code — the stream is gone and the slot says so, so a
298
+ view can offer a reconnect (a fresh `start`; reconnection is never
299
+ automatic).
300
+ - **`reset` releases the slot** exactly as for tasks: `status 'idle'`,
301
+ `kind`/`error` cleared, `id`/`input`/`value`/`meta`/`seq` kept.