@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,364 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The client-side outcome (docs/CONTRACT-FORMAT.md §10): the JSON
4
+ * value every `invoke` resolves to — `{ ok: true, value, meta }` or
5
+ * `{ ok: false, kind, error, meta }` with `kind` one of `failure`
6
+ * (a declared operation error, or a taxonomy error the server answered),
7
+ * `network` (the transport failed), `contract` (the peer violated the
8
+ * contract: an invalid response, an undeclared code, a malformed frame —
9
+ * or the client refused pre-send) and `cancelled` (a local abort). Never
10
+ * an `Error`, a `Response` or a `Headers`: JSON only, so the value can
11
+ * land in app state unchanged.
12
+ *
13
+ * `assembleOutcome` is binding-neutral: it takes what a wire answered as
14
+ * `{ status | null, headers, text | value | error }` and the operation's
15
+ * prepared route, and classifies. The HTTP client feeds it a status, the
16
+ * response headers and the body text; an in-process or message-port
17
+ * binding (no statuses) feeds `status: null` with a parsed `value` or a
18
+ * parsed error envelope. Nothing here performs I/O.
19
+ *
20
+ * Outcome, error and meta objects are built with a fixed member order so
21
+ * each shape is one hidden class — and the shapes `makeMeta` and
22
+ * `outcomeError` build ARE the D6 shapes of every binding (03A): `error`
23
+ * is always `{ code, message, status, details, retryable }`, `meta` is
24
+ * always `{ op, attempt, trace, revision, etag, notModified }`, and no
25
+ * member is ever `undefined` (`isJsonValue` — the predicate the app's
26
+ * task effect and state honor — rejects it, and the outcome would fall
27
+ * back to a string). A binding that cannot carry a member carries `null`
28
+ * (`status`, `etag`, `trace`) or `false` (`notModified`) and says so in
29
+ * its `capabilities`; it never omits the member. The member lists are
30
+ * exported (`OUTCOME_ERROR_MEMBERS`, `OUTCOME_META_MEMBERS`) so a later
31
+ * binding asserts against them instead of restating them.
32
+ */
33
+
34
+ import { renderMessage, projectValidationDetails, verdict, HTTP_ERRORS, HANDLER_ERROR_MSGID } from '../http/wire.js';
35
+
36
+ /**
37
+ * @typedef {import('../compile.js').CompiledOperation} CompiledOperation
38
+ * @typedef {import('../http/wire.js').Catalog} Catalog
39
+ */
40
+
41
+ /**
42
+ * The correlation members of every outcome. `op` is the operation id;
43
+ * `attempt` is the CALLER's attempt id (`ctx.attempt`, `null` when the
44
+ * caller gave none) and is never read from a response; `trace` is the
45
+ * SERVER's request id (`x-jaren-trace`), `null` when the wire carried
46
+ * none; `revision` is reserved for the contract revision; `etag` is the
47
+ * entity tag a success carried (`null` otherwise); `notModified` is true
48
+ * exactly for a 304.
49
+ * @typedef {Object} OutcomeMeta
50
+ * @property {string} op
51
+ * @property {unknown} attempt
52
+ * @property {string | null} trace
53
+ * @property {string | null} revision
54
+ * @property {string | null} etag
55
+ * @property {boolean} notModified
56
+ */
57
+
58
+ /**
59
+ * The error member of a failed outcome: a stable `code` (a declared
60
+ * error code, a `JC2xxx` taxonomy code, or a `JC205x` client code), a
61
+ * rendered `message`, the HTTP `status` when the binding carries one
62
+ * (`null` otherwise), `details` (`null` when none — an outcome is JSON,
63
+ * so no member is ever `undefined`) and whether the caller may retry.
64
+ * @typedef {Object} OutcomeError
65
+ * @property {string} code
66
+ * @property {string} message
67
+ * @property {number | null} status
68
+ * @property {unknown} details
69
+ * @property {boolean} retryable
70
+ */
71
+
72
+ /**
73
+ * The members of every outcome `error`, in order (D6). Frozen.
74
+ * @type {readonly ['code', 'message', 'status', 'details', 'retryable']}
75
+ */
76
+ export const OUTCOME_ERROR_MEMBERS = Object.freeze(/** @type {const} */ (['code', 'message', 'status', 'details', 'retryable']));
77
+
78
+ /**
79
+ * The members of every outcome `meta`, in order (D6). Frozen.
80
+ * @type {readonly ['op', 'attempt', 'trace', 'revision', 'etag', 'notModified']}
81
+ */
82
+ export const OUTCOME_META_MEMBERS = Object.freeze(/** @type {const} */ (['op', 'attempt', 'trace', 'revision', 'etag', 'notModified']));
83
+
84
+ /**
85
+ * @typedef {{ ok: true, value: unknown, meta: OutcomeMeta }} OkOutcome
86
+ * @typedef {{ ok: false, kind: 'failure' | 'network' | 'contract' | 'cancelled', error: OutcomeError, meta: OutcomeMeta }} FailedOutcome
87
+ * @typedef {OkOutcome | FailedOutcome} Outcome
88
+ */
89
+
90
+ /**
91
+ * The client-side taxonomy as data: code → `{ msgid, retryable }`. The
92
+ * normative table is docs/CONTRACT-FORMAT.md §10; a test holds the two
93
+ * equal, and equal to `CONTRACT_CODES` and the English catalog.
94
+ * `retryable` of `JC2055` is decided per response (5xx and 429 are
95
+ * retryable); the row carries the default.
96
+ */
97
+ export const CLIENT_ERRORS = Object.freeze({
98
+ JC2050: Object.freeze({ msgid: 'contract/client-invalid-input', retryable: false }),
99
+ JC2051: Object.freeze({ msgid: 'contract/network', retryable: true }),
100
+ JC2052: Object.freeze({ msgid: 'contract/cancelled', retryable: false }),
101
+ JC2053: Object.freeze({ msgid: 'contract/invalid-response', retryable: false }),
102
+ JC2054: Object.freeze({ msgid: 'contract/key-storage-failed', retryable: false }),
103
+ JC2055: Object.freeze({ msgid: 'contract/undeclared-response', retryable: false }),
104
+ JC2056: Object.freeze({ msgid: 'contract/not-a-contract', retryable: false }),
105
+ JC2057: Object.freeze({ msgid: 'contract/incompatible', retryable: false }),
106
+ JC2058: Object.freeze({ msgid: 'contract/host-failed', retryable: false }),
107
+ });
108
+
109
+ /**
110
+ * What the outcome assembler reads per operation, decided once.
111
+ * @typedef {Object} OutcomeRoute
112
+ * @property {string} id
113
+ * @property {(value: unknown) => any} validateOutput
114
+ * @property {Readonly<Record<string, import('../compile.js').CompiledErrorDecl>>} errors
115
+ * @property {ReadonlySet<string>} retryOn
116
+ * @property {'none' | 'paths' | 'full'} details
117
+ */
118
+
119
+ /**
120
+ * Prepare an operation for the assembler.
121
+ * @param {CompiledOperation} op
122
+ * @returns {OutcomeRoute}
123
+ */
124
+ export function prepareOutcomeRoute(op) {
125
+ return Object.freeze({
126
+ id: op.id,
127
+ validateOutput: op.output.validate,
128
+ errors: op.errors,
129
+ retryOn: new Set(op.policy.retry === null ? [] : op.policy.retry.on),
130
+ details: op.policy.errors.details,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * A meta object in its fixed member order.
136
+ * @param {string} op
137
+ * @param {unknown} attempt
138
+ * @param {string | null} trace
139
+ * @returns {OutcomeMeta}
140
+ */
141
+ export function makeMeta(op, attempt, trace) {
142
+ return { op, attempt: attempt === undefined ? null : attempt, trace, revision: null, etag: null, notModified: false };
143
+ }
144
+
145
+ /**
146
+ * @param {unknown} value
147
+ * @param {OutcomeMeta} meta
148
+ * @returns {OkOutcome}
149
+ */
150
+ export function okOutcome(value, meta) {
151
+ return { ok: true, value, meta };
152
+ }
153
+
154
+ /**
155
+ * @param {'failure' | 'network' | 'contract' | 'cancelled'} kind
156
+ * @param {OutcomeError} error
157
+ * @param {OutcomeMeta} meta
158
+ * @returns {FailedOutcome}
159
+ */
160
+ export function failedOutcome(kind, error, meta) {
161
+ return { ok: false, kind, error, meta };
162
+ }
163
+
164
+ /**
165
+ * An error object in its fixed member order.
166
+ * @param {string} code
167
+ * @param {string} message
168
+ * @param {number | null} status
169
+ * @param {unknown} details
170
+ * @param {boolean} retryable
171
+ * @returns {OutcomeError}
172
+ */
173
+ export function outcomeError(code, message, status, details, retryable) {
174
+ return { code, message, status, details: details === undefined ? null : details, retryable };
175
+ }
176
+
177
+ /**
178
+ * A client-originated error (`JC205x`): message from the catalog, the
179
+ * row's `retryable` unless overridden.
180
+ * @param {Catalog | null} catalog
181
+ * @param {keyof typeof CLIENT_ERRORS} code
182
+ * @param {Record<string, unknown>} params
183
+ * @param {number | null} status
184
+ * @param {unknown} details
185
+ * @param {boolean} [retryable]
186
+ * @returns {OutcomeError}
187
+ */
188
+ export function clientError(catalog, code, params, status, details, retryable) {
189
+ const row = CLIENT_ERRORS[code];
190
+ return outcomeError(code, renderMessage(catalog, row.msgid, params), status, details,
191
+ retryable === undefined ? row.retryable : retryable);
192
+ }
193
+
194
+ /**
195
+ * Whether `value` is an object carrying every member of `members`, none
196
+ * of them `undefined` (D6: an absent member is `null`, never omitted).
197
+ * @param {any} value
198
+ * @param {readonly string[]} members
199
+ * @returns {boolean}
200
+ */
201
+ function hasMembers(value, members) {
202
+ if (value === null || typeof value !== 'object') return false;
203
+ for (let i = 0; i < members.length; i++) {
204
+ if (value[members[i]] === undefined) return false;
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * True for a value shaped like an outcome: a plain object with a
211
+ * boolean `ok`, a `meta` carrying every D6 meta member, and, when
212
+ * failed, a `kind` and an `error` carrying every D6 error member with a
213
+ * string `code` — no member `undefined`. Reads guardedly, so a hostile
214
+ * value classifies as "not an outcome".
215
+ * @param {unknown} value
216
+ * @returns {value is Outcome}
217
+ */
218
+ export function isOutcome(value) {
219
+ try {
220
+ if (value === null || typeof value !== 'object') return false;
221
+ const v = /** @type {any} */ (value);
222
+ if (v.ok === true) return hasMembers(v.meta, OUTCOME_META_MEMBERS);
223
+ if (v.ok !== false) return false;
224
+ return (v.kind === 'failure' || v.kind === 'network' || v.kind === 'contract' || v.kind === 'cancelled')
225
+ && hasMembers(v.error, OUTCOME_ERROR_MEMBERS) && typeof v.error.code === 'string'
226
+ && hasMembers(v.meta, OUTCOME_META_MEMBERS);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Whether an undeclared status is worth a retry: the server-side class
235
+ * and the rate limit.
236
+ * @param {number} status
237
+ * @returns {boolean}
238
+ */
239
+ function retryableStatus(status) {
240
+ return status >= 500 || status === 429;
241
+ }
242
+
243
+ /**
244
+ * What a wire answered, as the assembler reads it. Exactly one of `text`,
245
+ * `value` or `error` carries the body: `text` is the raw body of an HTTP
246
+ * response (parsed here; `''`/`null` is an empty body), `value` a
247
+ * success value already decoded by a JSON-framed binding, `error` an
248
+ * error envelope (`{ code, message?, details?, retryable? }`) already
249
+ * decoded by such a binding. `status` is the HTTP status or `null` on a
250
+ * binding that carries none; `headers` holds `etag` when the binding
251
+ * carries entity tags.
252
+ * @typedef {Object} WireMessage
253
+ * @property {number | null} status
254
+ * @property {Readonly<Record<string, string>> | null} headers
255
+ * @property {string | null} [text]
256
+ * @property {unknown} [value]
257
+ * @property {unknown} [error]
258
+ */
259
+
260
+ /**
261
+ * Assemble the outcome of one response. TOTAL: every shape the peer can
262
+ * answer classifies; nothing throws.
263
+ *
264
+ * - a 2xx (or `status: null` with a `value`): an empty body is `null`;
265
+ * otherwise the text is parsed (`JC2053` when not JSON) and the value
266
+ * validated against the output schema (`JC2053` with details by
267
+ * `policy.errors.details`) → `{ ok: true, value, meta }` with `meta.etag`
268
+ * from the header;
269
+ * - a 304 → `{ ok: true, value: null, meta }` with `notModified: true`
270
+ * and the `etag`;
271
+ * - any other status: the body is parsed as JSON; a string `code` that
272
+ * the operation declares, or a `JC2xxx` taxonomy code, is `kind:
273
+ * "failure"` with `{ code, message, status, details?, retryable }` —
274
+ * the body's `message` when it is a string, else rendered; `retryable`
275
+ * the body's boolean, else whether `policy.retry.on` names the code;
276
+ * anything else (a non-JSON body, no string code, an unknown code) is
277
+ * `kind: "contract"` `JC2055` with the status kept and `retryable`
278
+ * for 5xx/429.
279
+ *
280
+ * @param {OutcomeRoute} route
281
+ * @param {WireMessage} message
282
+ * @param {OutcomeMeta} meta - mutated: `etag`/`notModified` are set here
283
+ * @param {Catalog | null} catalog
284
+ * @returns {Outcome}
285
+ */
286
+ export function assembleOutcome(route, message, meta, catalog) {
287
+ const status = message.status;
288
+ const headers = message.headers;
289
+ const etag = headers !== null && typeof headers.etag === 'string' ? headers.etag : null;
290
+ if (status === 304) {
291
+ meta.etag = etag;
292
+ meta.notModified = true;
293
+ return okOutcome(null, meta);
294
+ }
295
+ if (status === null ? message.error === undefined : (status >= 200 && status <= 299)) {
296
+ let value;
297
+ if (message.text !== undefined) {
298
+ const text = message.text;
299
+ if (text === null || text.length === 0) value = null;
300
+ else {
301
+ try {
302
+ value = JSON.parse(text);
303
+ }
304
+ catch {
305
+ return failedOutcome('contract', clientError(catalog, 'JC2053', { op: route.id }, status,
306
+ [{ path: '', keyword: 'json' }], false), meta);
307
+ }
308
+ }
309
+ }
310
+ else value = message.value;
311
+ const v = verdict(route.validateOutput, value);
312
+ if (!v.valid) {
313
+ return failedOutcome('contract', clientError(catalog, 'JC2053', { op: route.id }, status,
314
+ projectValidationDetails(route.details, v.errors), false), meta);
315
+ }
316
+ meta.etag = etag;
317
+ return okOutcome(value, meta);
318
+ }
319
+ // an error: the body decides between a declared/taxonomy failure and a
320
+ // contract violation
321
+ let body;
322
+ if (message.text !== undefined) {
323
+ const text = message.text;
324
+ if (text !== null && text.length > 0) {
325
+ try {
326
+ body = JSON.parse(text);
327
+ }
328
+ catch {
329
+ body = undefined;
330
+ }
331
+ }
332
+ }
333
+ else body = message.error;
334
+ const retryByStatus = status !== null && retryableStatus(status);
335
+ if (body === null || typeof body !== 'object' || Array.isArray(body) || typeof body.code !== 'string') {
336
+ return failedOutcome('contract', clientError(catalog, 'JC2055', { op: route.id, status }, status, undefined, retryByStatus), meta);
337
+ }
338
+ const code = body.code;
339
+ const declared = Object.hasOwn(route.errors, code);
340
+ const taxonomy = !declared && Object.hasOwn(HTTP_ERRORS, code);
341
+ if (!declared && !taxonomy) {
342
+ return failedOutcome('contract', clientError(catalog, 'JC2055', { op: route.id, status }, status, undefined, retryByStatus), meta);
343
+ }
344
+ const message_ = typeof body.message === 'string'
345
+ ? body.message
346
+ : renderMessage(catalog, declared ? HANDLER_ERROR_MSGID : HTTP_ERRORS[/** @type {keyof typeof HTTP_ERRORS} */ (code)].msgid,
347
+ { op: route.id, code, status });
348
+ const retryable = typeof body.retryable === 'boolean' ? body.retryable : route.retryOn.has(code);
349
+ return failedOutcome('failure', outcomeError(code, message_, status, body.details, retryable), meta);
350
+ }
351
+
352
+ /**
353
+ * The outcome a handler projects a THROWN host value into when it must
354
+ * settle with an outcome (the app effect): `kind: "contract"` `JC2058`,
355
+ * the message from the catalog, nothing of the thrown value (its text
356
+ * may carry anything).
357
+ * @param {string} op
358
+ * @param {unknown} attempt
359
+ * @param {Catalog | null} catalog
360
+ * @returns {Outcome}
361
+ */
362
+ export function hostFailureOutcome(op, attempt, catalog) {
363
+ return failedOutcome('contract', clientError(catalog, 'JC2058', { op }, null, undefined), makeMeta(op, attempt, null));
364
+ }
package/src/compat.js ADDED
@@ -0,0 +1,62 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Version compatibility — the one implementation of the
4
+ * negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13.1): two ends
5
+ * speak when they declare the same `version`, or when either end's
6
+ * `compat` list names the other's `version`. The client's `negotiate()`
7
+ * and any server that wants to refuse an incompatible peer both call
8
+ * this; `diffContracts` is the complementary question (WHAT changed),
9
+ * this is the declared answer (do the authors CLAIM the ends speak).
10
+ */
11
+
12
+ /**
13
+ * The version identity the rule reads — a compiled contract, a contract
14
+ * document, or a well-known description all carry these two members.
15
+ * @typedef {{ version?: string | null, compat?: readonly string[] | null }} VersionedContract
16
+ */
17
+
18
+ /**
19
+ * @param {VersionedContract} value
20
+ * @returns {{ version: string | null, compat: readonly string[] }}
21
+ */
22
+ function identity(value) {
23
+ const version = typeof value.version === 'string' ? value.version : null;
24
+ const compat = Array.isArray(value.compat)
25
+ ? value.compat.filter((/** @type {unknown} */ v) => typeof v === 'string')
26
+ : [];
27
+ return { version, compat };
28
+ }
29
+
30
+ /**
31
+ * Why two ends are compatible, or `null` when they are not:
32
+ * `'same-version'` (equal `version`s — two unversioned contracts included),
33
+ * `'server-accepts'` (the server's `compat` names the client's version),
34
+ * `'client-accepts'` (the client's `compat` names the server's version).
35
+ * Checked in that order, so the strongest claim wins the reason.
36
+ * @param {VersionedContract} client
37
+ * @param {VersionedContract} server
38
+ * @returns {'same-version' | 'server-accepts' | 'client-accepts' | null}
39
+ */
40
+ export function compatReason(client, server) {
41
+ const c = identity(client);
42
+ const s = identity(server);
43
+ if (s.version === c.version) return 'same-version';
44
+ if (c.version !== null && s.compat.includes(c.version)) return 'server-accepts';
45
+ if (s.version !== null && c.compat.includes(s.version)) return 'client-accepts';
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Whether a client contract and a server contract declare themselves
51
+ * compatible — the negotiation rule as a predicate. Takes compiled
52
+ * contracts, raw documents or well-known descriptions alike (it reads
53
+ * only `version` and `compat`).
54
+ * @param {VersionedContract} clientContract
55
+ * @param {VersionedContract} serverContract
56
+ * @returns {boolean}
57
+ * @example
58
+ * isCompatible({ version: '5', compat: ['4'] }, { version: '4' }); // true — the client accepts 4
59
+ */
60
+ export function isCompatible(clientContract, serverContract) {
61
+ return compatReason(clientContract, serverContract) !== null;
62
+ }