@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,306 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The server half of the stream binding, carrier-neutral
4
+ * (docs/CONTRACT-FORMAT.md §17–§18): take the subscription a handler
5
+ * settled (the duck-typed LIVE shape — `result`/`snapshot()`,
6
+ * `subscribe(cb) → stop`, `close()`, optional `replay(seq)`), decide
7
+ * resumption, read and validate the snapshot, forward each emission,
8
+ * and guarantee `stop()` then `close()` run **exactly once** however
9
+ * the stream ends — peer disconnect, unsubscribe, server shutdown, an
10
+ * error emission, an invalid snapshot. The HTTP dispatcher renders the
11
+ * intents this module raises as SSE events; the port server renders
12
+ * them as push frames — the sequencing is decided here once so the two
13
+ * carriers can never disagree.
14
+ *
15
+ * Total for everything a handler's subscription can do: a throwing
16
+ * `snapshot()`/`result` accessor, a `subscribe` that throws, a hostile
17
+ * emission, a throwing `stop`/`close` — every one settles into the
18
+ * `fault` intent (the cause for the binding's `onError`, never the
19
+ * wire) or is swallowed at close, and the stream still terminates.
20
+ */
21
+
22
+ import { toPromise, isThenable } from '@jarenjs/core/function';
23
+
24
+ import { verdict } from '../http/wire.js';
25
+
26
+ /**
27
+ * @typedef {import('../pipeline.js').PipelineRoute} PipelineRoute
28
+ */
29
+
30
+ /**
31
+ * The duck-typed subscription of docs/CONTRACT-FORMAT.md §17.1.
32
+ * @typedef {{ result?: unknown, snapshot?: () => unknown,
33
+ * subscribe: (cb: (emission: any) => void) => (() => void),
34
+ * close: () => void, replay?: (seq: number) => unknown, mode?: unknown }} SubscriptionLike
35
+ */
36
+
37
+ /**
38
+ * What the carrier renders. Every hook is called at most once per
39
+ * event, in wire order; after `error` or `end` no further hook fires.
40
+ * `error` carries the intent (`'invalid-snapshot'` — send `JC2091`;
41
+ * `'source'` — the subscription emitted `{ error }`, send the host
42
+ * fault code) and the cause for the observer. `done` fires exactly once
43
+ * after the stream terminated for any reason — the carrier releases
44
+ * its resources (timers, registries) there.
45
+ * @typedef {Object} StreamHooks
46
+ * @property {(seq: number, value: unknown, resumed: boolean) => void} snapshot
47
+ * @property {(seq: number, emission: { patch: unknown[], seq: number }) => void} patch
48
+ * @property {(intent: 'invalid-snapshot' | 'source', cause: unknown, lastSeq: number) => void} error
49
+ * @property {(reason: string, lastSeq: number) => void} end
50
+ * @property {() => void} done
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} StreamOptions
55
+ * @property {number | null} lastSeq - the peer's `Last-Event-ID` / `lastSeq`, or `null`
56
+ * @property {boolean} validate - whether snapshots run the output validator
57
+ */
58
+
59
+ /**
60
+ * Whether a settled handler value is a usable subscription.
61
+ * Reads guardedly; a hostile value classifies as "not a subscription".
62
+ * @param {unknown} value
63
+ * @returns {value is SubscriptionLike}
64
+ */
65
+ export function isSubscriptionLike(value) {
66
+ try {
67
+ if (value === null || typeof value !== 'object') return false;
68
+ const s = /** @type {any} */ (value);
69
+ return typeof s.subscribe === 'function' && typeof s.close === 'function'
70
+ && (typeof s.snapshot === 'function' || s.result !== undefined);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A guarded read of the current snapshot document.
79
+ * @param {SubscriptionLike} sub
80
+ * @returns {{ ok: true, value: unknown } | { ok: false, cause: unknown }}
81
+ */
82
+ function readSnapshot(sub) {
83
+ try {
84
+ return { ok: true, value: typeof sub.snapshot === 'function' ? sub.snapshot() : sub.result };
85
+ }
86
+ catch (err) {
87
+ return { ok: false, cause: err };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Run one subscription over a carrier. Emissions are forwarded
93
+ * verbatim (a patch is never mutated); an emission whose serialized
94
+ * patch exceeds `policy.stream.maxPatchBytes` is replaced by a fresh
95
+ * snapshot at that emission's seq (§18.1); an `{ error }` emission —
96
+ * and a hostile one — raises the `error` intent and ends the stream.
97
+ *
98
+ * Returns the stopper: `stop(reason)` with a string emits the `end`
99
+ * event with that reason first (a server shutdown); `stop(null)` is
100
+ * silent (the peer is gone, or asked). Idempotent; the subscription's
101
+ * own `stop()` and `close()` run exactly once either way.
102
+ *
103
+ * @param {PipelineRoute} route
104
+ * @param {SubscriptionLike} sub
105
+ * @param {StreamHooks} hooks
106
+ * @param {StreamOptions} options
107
+ * @returns {{ stop: (reason: string | null) => void }}
108
+ */
109
+ export function runSubscription(route, sub, hooks, options) {
110
+ const policy = /** @type {NonNullable<import('../compile.js').CompiledPolicy['stream']>} */ (
111
+ route.op.policy.stream ?? { resume: 'snapshot', heartbeatMs: 15000, maxPatchBytes: null });
112
+ const maxPatchBytes = policy.maxPatchBytes;
113
+ let finished = false;
114
+ let ready = false;
115
+ /** @type {any[]} */
116
+ const buffered = [];
117
+ /** @type {(() => void) | null} */
118
+ let stopSub = null;
119
+ let lastSeq = 0;
120
+
121
+ /**
122
+ * The one stopper every path returns: on a finished stream it is a
123
+ * no-op, so an early-failed stream hands back the same shape.
124
+ * @type {{ stop: (reason: string | null) => void }}
125
+ */
126
+ const stopper = {
127
+ stop: (reason) => {
128
+ if (finished) return;
129
+ if (reason !== null) {
130
+ try {
131
+ hooks.end(reason, lastSeq);
132
+ }
133
+ catch {
134
+ // the end event is best-effort on a dying carrier
135
+ }
136
+ }
137
+ release();
138
+ },
139
+ };
140
+
141
+ /** Release the subscription exactly once; a throwing stop/close is swallowed. */
142
+ function release() {
143
+ if (finished) return;
144
+ finished = true;
145
+ if (stopSub !== null) {
146
+ try {
147
+ stopSub();
148
+ }
149
+ catch {
150
+ // a throwing stop never blocks the close
151
+ }
152
+ stopSub = null;
153
+ }
154
+ try {
155
+ sub.close();
156
+ }
157
+ catch {
158
+ // a throwing close never blocks termination
159
+ }
160
+ try {
161
+ hooks.done();
162
+ }
163
+ catch {
164
+ // the carrier's cleanup must not break termination
165
+ }
166
+ }
167
+
168
+ /**
169
+ * End with an error intent: the carrier renders the event, then the
170
+ * subscription is released.
171
+ * @param {'invalid-snapshot' | 'source'} intent
172
+ * @param {unknown} cause
173
+ */
174
+ function fail(intent, cause) {
175
+ if (finished) return;
176
+ try {
177
+ hooks.error(intent, cause, lastSeq);
178
+ }
179
+ catch {
180
+ // a carrier that cannot render still releases
181
+ }
182
+ release();
183
+ }
184
+
185
+ /**
186
+ * Read, validate and emit a fresh snapshot at `seq`.
187
+ * @param {number} seq
188
+ * @param {boolean} resumed
189
+ * @returns {boolean} false when the stream ended instead
190
+ */
191
+ function emitSnapshot(seq, resumed) {
192
+ const snap = readSnapshot(sub);
193
+ if (!snap.ok) {
194
+ fail('source', snap.cause);
195
+ return false;
196
+ }
197
+ if (options.validate) {
198
+ const v = verdict(route.validateOutput, snap.value);
199
+ if (!v.valid) {
200
+ fail('invalid-snapshot', v.thrown !== undefined ? v.thrown : v.errors);
201
+ return false;
202
+ }
203
+ }
204
+ lastSeq = seq;
205
+ hooks.snapshot(seq, snap.value, resumed);
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * One emission from the subscription, delivered or buffered.
211
+ * @param {any} emission
212
+ */
213
+ function deliver(emission) {
214
+ if (finished) return;
215
+ if (!ready) {
216
+ buffered.push(emission);
217
+ return;
218
+ }
219
+ let patch;
220
+ let seq;
221
+ let sourceError;
222
+ try {
223
+ sourceError = emission === null || typeof emission !== 'object' ? new TypeError('the subscription emitted a non-object') : undefined;
224
+ if (sourceError === undefined && emission.error !== undefined) sourceError = emission.error;
225
+ if (sourceError === undefined) {
226
+ patch = emission.patch;
227
+ seq = emission.seq;
228
+ }
229
+ }
230
+ catch (err) {
231
+ sourceError = err;
232
+ }
233
+ if (sourceError !== undefined) {
234
+ fail('source', sourceError);
235
+ return;
236
+ }
237
+ if (!Array.isArray(patch) || !Number.isFinite(seq)) {
238
+ fail('source', new TypeError('the subscription emitted a value that is not { patch, seq }'));
239
+ return;
240
+ }
241
+ if (seq <= lastSeq && lastSeq !== 0) return; // an already-delivered record (a replay overlap)
242
+ if (maxPatchBytes !== null && JSON.stringify(patch).length > maxPatchBytes) {
243
+ // the consumer swaps its document instead of patching it (§18.1)
244
+ emitSnapshot(seq, false);
245
+ return;
246
+ }
247
+ lastSeq = seq;
248
+ hooks.patch(seq, { patch, seq });
249
+ }
250
+
251
+ // subscribe FIRST so nothing between the subscription and the initial
252
+ // events is lost; emissions buffer until the initial events are out.
253
+ // In the synchronous snapshot path nothing can land in between — the
254
+ // buffer exists for the (possibly asynchronous) replay path.
255
+ try {
256
+ stopSub = sub.subscribe(deliver);
257
+ }
258
+ catch (err) {
259
+ fail('source', err);
260
+ return stopper;
261
+ }
262
+ if (typeof stopSub !== 'function') stopSub = null;
263
+
264
+ /** Flush what buffered while the initial events were decided. */
265
+ function flush() {
266
+ ready = true;
267
+ while (buffered.length > 0 && !finished) deliver(buffered.shift());
268
+ }
269
+
270
+ const wantsReplay = options.lastSeq !== null && policy.resume === 'replay' && typeof sub.replay === 'function';
271
+ if (wantsReplay) {
272
+ let replayed;
273
+ try {
274
+ replayed = /** @type {NonNullable<SubscriptionLike['replay']>} */ (sub.replay)(/** @type {number} */ (options.lastSeq));
275
+ }
276
+ catch (err) {
277
+ fail('source', err);
278
+ return stopper;
279
+ }
280
+ const settle = (/** @type {unknown} */ entries) => {
281
+ if (finished) return;
282
+ if (Array.isArray(entries)) {
283
+ lastSeq = /** @type {number} */ (options.lastSeq);
284
+ ready = true;
285
+ for (let i = 0; i < entries.length && !finished; i++) deliver(entries[i]);
286
+ flush();
287
+ }
288
+ else {
289
+ // the handler cannot replay: a fresh snapshot, resume refused (JC2095)
290
+ if (emitSnapshot(0, false)) flush();
291
+ }
292
+ };
293
+ if (isThenable(replayed)) {
294
+ toPromise(replayed).then(settle, (/** @type {unknown} */ err) => fail('source', err));
295
+ }
296
+ else settle(replayed);
297
+ }
298
+ else {
299
+ // a fresh stream, or a resume the policy answers with a snapshot
300
+ if (emitSnapshot(0, false)) flush();
301
+ }
302
+
303
+ return stopper;
304
+ }
305
+
306
+ export { STREAM_ERRORS, STREAM_EVENTS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent } from './sse.js';
@@ -0,0 +1,67 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The stream wire's shared shapes (docs/CONTRACT-FORMAT.md §18):
4
+ * the stream code table as data (`STREAM_ERRORS`, the same
5
+ * table-as-data shape as `HTTP_ERRORS` and `PORT_LOCAL_ERRORS`), the
6
+ * event names both carriers speak, and the SSE text framing on top of
7
+ * `@jarenjs/core/text/sse` — one JSON value per event, a heartbeat
8
+ * comment line, and the `JC1009` host refusal for text the frame
9
+ * cannot carry. The port carrier uses the same tables and data shapes
10
+ * with no SSE text at all (§18.2).
11
+ */
12
+
13
+ import { encodeSseEvent } from '@jarenjs/core/text/sse';
14
+
15
+ import { ContractHostError } from '../errors.js';
16
+
17
+ /**
18
+ * The stream request-time codes — code → `{ kind, msgid, retryable }`.
19
+ * The normative table is docs/CONTRACT-FORMAT.md §18.3; a test holds
20
+ * them equal. `JC2095` (a refused resume) is deliberately absent: it is
21
+ * informational, carried as `resumed: false` in a snapshot's event
22
+ * data, never an outcome.
23
+ */
24
+ export const STREAM_ERRORS = Object.freeze({
25
+ JC2090: Object.freeze({ kind: 'contract', msgid: 'contract/not-a-stream', retryable: false }),
26
+ JC2091: Object.freeze({ kind: 'contract', msgid: 'contract/invalid-snapshot', retryable: false }),
27
+ JC2092: Object.freeze({ kind: 'contract', msgid: 'contract/seq-regression', retryable: false }),
28
+ JC2093: Object.freeze({ kind: 'contract', msgid: 'contract/stream-error', retryable: false }),
29
+ JC2094: Object.freeze({ kind: 'network', msgid: 'contract/heartbeat-missed', retryable: true }),
30
+ });
31
+
32
+ /** The event names of the stream wire, on both carriers. */
33
+ export const STREAM_EVENTS = Object.freeze(['snapshot', 'patch', 'error', 'end']);
34
+
35
+ /** The media type of the SSE carrier. */
36
+ export const STREAM_MEDIA = 'text/event-stream';
37
+
38
+ /** The heartbeat comment line the SSE carrier writes every `heartbeatMs`. */
39
+ export const HEARTBEAT_LINE = ':\n\n';
40
+
41
+ /**
42
+ * One stream event as SSE text: the event name, the seq as the SSE id,
43
+ * the JSON data. JSON output never carries a raw CR, so the encoder's
44
+ * refusal is reachable only through a host handing this function
45
+ * non-JSON text — `JC1009`, thrown.
46
+ * @param {string} event - `snapshot | patch | error | end`
47
+ * @param {number | null} seq - the event's seq; `null` writes no id line
48
+ * @param {unknown} data - the event's JSON data
49
+ * @returns {string}
50
+ * @throws {ContractHostError} `JC1009` when the text cannot ride an SSE frame
51
+ */
52
+ export function encodeStreamEvent(event, seq, data) {
53
+ let text;
54
+ try {
55
+ text = JSON.stringify(data);
56
+ }
57
+ catch (err) {
58
+ throw new ContractHostError('JC1009', `the ${event} event's data cannot be serialized as JSON (${err instanceof Error ? err.message : 'not JSON'})`);
59
+ }
60
+ if (text === undefined) text = 'null';
61
+ try {
62
+ return encodeSseEvent({ event, id: seq === null ? null : String(seq), data: text });
63
+ }
64
+ catch (err) {
65
+ throw new ContractHostError('JC1009', err instanceof Error ? err.message : 'the event cannot ride an SSE frame');
66
+ }
67
+ }