@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,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
|
+
}
|