@jarenjs/contract 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
|
@@ -0,0 +1,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.
|