@jarenjs/contract 0.75.0 → 0.83.2
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 +9 -0
- package/dist/types/app/index.d.ts +1 -1
- package/dist/types/app/subscription.d.ts +10 -0
- package/dist/types/command.d.ts +27 -0
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/provider/compile.d.ts +73 -0
- package/dist/types/provider/execute.d.ts +104 -0
- package/dist/types/provider/index.d.ts +4 -0
- package/dist/types/provider/run.d.ts +33 -0
- package/docs/CONTRACT-FORMAT.md +41 -0
- package/docs/DURABLE.md +66 -0
- package/docs/PROVIDER-FORMAT.md +157 -0
- package/package.json +14 -6
- package/src/app/index.js +1 -1
- package/src/app/subscription.js +21 -0
- package/src/client/http.js +3 -40
- package/src/command.js +70 -0
- package/src/errors.js +4 -0
- package/src/provider/compile.js +200 -0
- package/src/provider/execute.js +191 -0
- package/src/provider/index.js +5 -0
- package/src/provider/run.js +138 -0
package/src/app/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
export { contractAppBinding } from './binding.js';
|
|
14
14
|
export { createContractEffect } from './effect.js';
|
|
15
|
-
export { createContractSubscription } from './subscription.js';
|
|
15
|
+
export { createContractSubscription, createRunPageHandler } from './subscription.js';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* @typedef {import('./binding.js').ContractAppBinding} ContractAppBinding
|
package/src/app/subscription.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* slot must say so, so a view can offer a reconnect (a new `start`).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
25
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
24
26
|
import { compileJSONPatch } from '@jarenjs/json/patch';
|
|
25
27
|
|
|
26
28
|
import { ContractHostError } from '../errors.js';
|
|
@@ -136,3 +138,22 @@ export function createContractSubscription(client, options = {}) {
|
|
|
136
138
|
// re-exported so a host settling its own outcomes beside the handler
|
|
137
139
|
// needs no second import path
|
|
138
140
|
export { CLIENT_ERRORS };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Authorize each bounded run-page read before touching durable history. Register
|
|
144
|
+
* as an ordinary read handler; callers may poll or resume from their last cursor.
|
|
145
|
+
* @param {{ page: (id: string, options: any) => any, authorize: (input: any, context: any) => any, maxPage?: number }} options
|
|
146
|
+
*/
|
|
147
|
+
export function createRunPageHandler(options) {
|
|
148
|
+
const maxPage = options?.maxPage ?? 128;
|
|
149
|
+
if (typeof options?.page !== 'function' || typeof options.authorize !== 'function' || !Number.isSafeInteger(maxPage) || maxPage < 1)
|
|
150
|
+
throw new ContractHostError('JC1008', 'run pages need page/authorize capabilities and a finite limit');
|
|
151
|
+
return async (input, context) => {
|
|
152
|
+
input = deepFreeze(JSON.parse(canonicalizeJson(input)));
|
|
153
|
+
if (await options.authorize(input, context) !== true) throw new ContractHostError('JC1008', 'run observation refused');
|
|
154
|
+
if (typeof input?.id !== 'string' || !input.id || !Number.isSafeInteger(input.after) || input.after < 0
|
|
155
|
+
|| !Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > maxPage)
|
|
156
|
+
throw new ContractHostError('JC1008', 'run observation needs id, revision cursor and bounded limit');
|
|
157
|
+
return options.page(input.id, { after: input.after, limit: input.limit });
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/client/http.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* Everything per operation is decided once at `open`.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { backoffDelay as sharedBackoff, sleep as defaultSleep } from '@jarenjs/core/retry';
|
|
29
30
|
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
30
31
|
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
31
32
|
import { canonicalSha256 } from '@jarenjs/json/canonical';
|
|
@@ -199,9 +200,6 @@ const storageUpdates = new WeakMap();
|
|
|
199
200
|
/** The backoff ceiling of a retry, in ms. */
|
|
200
201
|
const BACKOFF_MAX = 8000;
|
|
201
202
|
|
|
202
|
-
/** The jitter added to a backoff, in ms (upper bound, exclusive). */
|
|
203
|
-
const BACKOFF_JITTER = 250;
|
|
204
|
-
|
|
205
203
|
/**
|
|
206
204
|
* @param {string} code
|
|
207
205
|
* @param {string} reason
|
|
@@ -211,42 +209,6 @@ function host(code, reason) {
|
|
|
211
209
|
return new ContractHostError(code, `openHttpClient: ${reason}`);
|
|
212
210
|
}
|
|
213
211
|
|
|
214
|
-
/**
|
|
215
|
-
* An `AbortError`-named error, the platform's when a signal carries one.
|
|
216
|
-
* @param {AbortSignal | null} signal
|
|
217
|
-
* @returns {unknown}
|
|
218
|
-
*/
|
|
219
|
-
function abortReason(signal) {
|
|
220
|
-
if (signal !== null && signal.reason !== undefined) return signal.reason;
|
|
221
|
-
const err = new Error('The operation was aborted.');
|
|
222
|
-
err.name = 'AbortError';
|
|
223
|
-
return err;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Abortable delay; rejects with the abort reason.
|
|
228
|
-
* @param {number} ms
|
|
229
|
-
* @param {AbortSignal} [signal]
|
|
230
|
-
* @returns {Promise<void>}
|
|
231
|
-
*/
|
|
232
|
-
function defaultSleep(ms, signal) {
|
|
233
|
-
return new Promise((resolve, reject) => {
|
|
234
|
-
if (signal !== undefined && signal.aborted) {
|
|
235
|
-
reject(abortReason(signal));
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const onAbort = () => {
|
|
239
|
-
clearTimeout(timer);
|
|
240
|
-
reject(abortReason(signal ?? null));
|
|
241
|
-
};
|
|
242
|
-
const timer = setTimeout(() => {
|
|
243
|
-
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
244
|
-
resolve();
|
|
245
|
-
}, ms);
|
|
246
|
-
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true });
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
212
|
/**
|
|
251
213
|
* A rejection value's `name`, read guardedly; `null` when it has none.
|
|
252
214
|
* @param {unknown} err
|
|
@@ -444,7 +406,8 @@ export function openHttpClient(contract, options = {}) {
|
|
|
444
406
|
* @returns {number}
|
|
445
407
|
*/
|
|
446
408
|
function backoffDelay(n) {
|
|
447
|
-
return
|
|
409
|
+
return sharedBackoff({ policy: 'contract-compat', baseMs: 1000, maxMs: BACKOFF_MAX,
|
|
410
|
+
random: () => hostFact('random', runtime.random) }, n + 1);
|
|
448
411
|
}
|
|
449
412
|
const now = options.now === undefined ? runtime.now : options.now;
|
|
450
413
|
/** @type {Catalog | null} */
|
package/src/command.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Opt-in business settlement shared by handlers on every invocation carrier. */
|
|
3
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { ContractFailure, ContractHostError, ContractRuntimeError } from './errors.js';
|
|
6
|
+
import { runOperation, validateOperationInput } from './pipeline.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compile once around the existing neutral operation pipeline. Identity and
|
|
10
|
+
* current authorization are application policy, never inferred from a receipt.
|
|
11
|
+
* Handlers receive the repository's transaction as ctx.host; current-state reads
|
|
12
|
+
* belong to a separate read operation. Transport TTL policy remains independent.
|
|
13
|
+
* @param {import('./compile.js').CompiledOperation} operation
|
|
14
|
+
* @param {{ repository: { execute: Function }, identity: (input: any, context: any) => any,
|
|
15
|
+
* authorize: (input: any, context: any) => any, handler: (input: any, context: any) => any,
|
|
16
|
+
* commitFailures?: string[], references?: (result: any, context: any) => any[],
|
|
17
|
+
* refuse?: (reason: string, context: any) => any }} options
|
|
18
|
+
*/
|
|
19
|
+
export function createCommand(operation, options) {
|
|
20
|
+
if (!operation || operation.kind !== 'command' || operation.http.opaque || operation.policy.idempotency !== 'none' || typeof options?.repository?.execute !== 'function'
|
|
21
|
+
|| ['identity', 'authorize', 'handler'].some((name) => typeof options[name] !== 'function'))
|
|
22
|
+
throw new ContractHostError('JC1013', 'command settlement needs a JSON command, receipt repository, identity, authorization and handler');
|
|
23
|
+
const failures = new Set(options.commitFailures ?? []);
|
|
24
|
+
if ([...failures].some((code) => !Object.hasOwn(operation.errors, code))) throw new ContractHostError('JC1013', 'committed failure codes must be declared');
|
|
25
|
+
const route = { op: operation, handler: options.handler, raw: false, validateInput: operation.input?.validate ?? null,
|
|
26
|
+
validateOutput: operation.output.validate, details: operation.policy.errors.details, errors: operation.errors,
|
|
27
|
+
retryOn: new Set(operation.policy.retry?.on ?? []) };
|
|
28
|
+
const refused = (reason) => ({ state: 'refused', reason, historic: false });
|
|
29
|
+
/** @param {any} input @param {any} [context] */
|
|
30
|
+
async function execute(input, context = {}) {
|
|
31
|
+
// Freeze before asynchronous authority checks so callers cannot change the
|
|
32
|
+
// authorized request or the request whose hash the application computes.
|
|
33
|
+
const value = deepFreeze(JSON.parse(canonicalizeJson(input ?? null)));
|
|
34
|
+
if (await options.authorize(value, context) !== true) return refused('unauthorized');
|
|
35
|
+
if (validateOperationInput(route, value) !== null) throw new ContractRuntimeError('JC2110', 'command input failed validation', { msgid: 'contract/command-failed' });
|
|
36
|
+
const identity = options.identity(value, context);
|
|
37
|
+
if (identity?.op !== operation.id) throw new ContractHostError('JC1013', 'identity operation must match the compiled command');
|
|
38
|
+
try {
|
|
39
|
+
return await options.repository.execute(identity, async (tx) => {
|
|
40
|
+
if (context.signal?.aborted) throw refused('cancelled');
|
|
41
|
+
const hostContext = Object.freeze({ ...context, host: tx, fail: ContractFailure });
|
|
42
|
+
const result = await runOperation(route, value, hostContext);
|
|
43
|
+
if (result.kind === 'contract') throw new ContractRuntimeError('JC2110', 'command validation or handler failed', { msgid: 'contract/command-failed', cause: result.cause });
|
|
44
|
+
if (result.kind === 'failure' && !failures.has(result.code)) throw { state: 'uncommitted', historic: false, outcome: result };
|
|
45
|
+
if (context.signal?.aborted) throw refused('cancelled');
|
|
46
|
+
// Canonicalization rejects non-JSON values before any transaction commits.
|
|
47
|
+
const outcome = JSON.parse(canonicalizeJson(result.kind === 'failure' ? { ...result, details: result.details ?? null } : result));
|
|
48
|
+
return { outcome, references: options.references?.(outcome, hostContext) ?? [] };
|
|
49
|
+
}, context.lease === undefined ? {} : { lease: context.lease });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error?.state === 'uncommitted' || error?.state === 'refused') return error;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
execute,
|
|
58
|
+
/** A deliberate carrier wrapper around the one settlement implementation.
|
|
59
|
+
* @param {any} input @param {any} context */
|
|
60
|
+
async handler(input, context) {
|
|
61
|
+
const settled = await execute(input, context);
|
|
62
|
+
if (settled.state === 'refused') {
|
|
63
|
+
if (options.refuse) return options.refuse(settled.reason, context);
|
|
64
|
+
throw new ContractRuntimeError('JC2110', 'command refused', { msgid: 'contract/command-failed' });
|
|
65
|
+
}
|
|
66
|
+
const outcome = settled.receipt?.outcome ?? settled.outcome;
|
|
67
|
+
return outcome.kind === 'failure' ? ContractFailure(outcome.code, outcome.params, outcome.details, { retryable: outcome.retryable }) : outcome.value;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -52,6 +52,7 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
52
52
|
JC0018: 'a subscribe operation declares a policy.task other than switch — a subscription slot is replaced, never queued',
|
|
53
53
|
JC0019: 'a subscribe operation is bound to a method other than GET — a stream is fetched, not sent',
|
|
54
54
|
JC0020: 'a subscribe operation declares a policy.idempotency other than none — a subscription registers, it does not commit',
|
|
55
|
+
JC0021: 'a provider protocol descriptor is malformed or names an undeclared transform capability',
|
|
55
56
|
// ——— projection compile (ContractCompileError, docPath into the contract document) ———
|
|
56
57
|
JC0060: 'the OpenAPI projection met a schema keyword it cannot map honestly: a boolean required (draft-04 style) or a same-document $ref that lands outside $defs (both dropped and reported under lenient), or a components member inside a schema',
|
|
57
58
|
JC0061: 'the public projection is not canonicalizable, so no revision exists — a string with an unpaired surrogate, say; docPath points at the offending value inside the projection',
|
|
@@ -67,6 +68,8 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
67
68
|
JC1009: 'encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id',
|
|
68
69
|
JC1010: 'client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)',
|
|
69
70
|
JC1011: 'a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out',
|
|
71
|
+
JC1012: 'a provider executor, descriptor host or run capability is malformed',
|
|
72
|
+
JC1013: 'a durable command settlement capability is malformed',
|
|
70
73
|
// ——— http request-time (ContractRuntimeError, mapped onto the wire) ———
|
|
71
74
|
JC2001: 'no operation matches the request method and path (404)',
|
|
72
75
|
JC2002: 'the path shape is served under other methods (405, Allow lists them)',
|
|
@@ -108,6 +111,7 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
108
111
|
JC2095: 'a requested resume was refused — informational, carried as resumed:false in the fresh snapshot\'s event data, never an outcome',
|
|
109
112
|
JC2096: 'the stream\'s bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)',
|
|
110
113
|
JC2097: 'the client\'s reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)',
|
|
114
|
+
JC2110: 'a durable command was refused or failed validation',
|
|
111
115
|
});
|
|
112
116
|
|
|
113
117
|
/**
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Compile JSON protocol declarations to bounded pull-based page execution. */
|
|
3
|
+
import { deepFreeze, isJsonObject } from '@jarenjs/core/object';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
6
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
7
|
+
import { ContractCompileError } from '../errors.js';
|
|
8
|
+
import { providerHostError } from './execute.js';
|
|
9
|
+
|
|
10
|
+
const clone = (value) => JSON.parse(canonicalizeJson(value));
|
|
11
|
+
const fail = (reason, path = '') => { throw new ContractCompileError('JC0021', reason, path); };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compile selectors/transforms once using the existing Query/JSLT engines.
|
|
15
|
+
* Named callbacks receive cloned protocol data only, never the run host.
|
|
16
|
+
* These are trusted host functions, not a sandbox for arbitrary JavaScript.
|
|
17
|
+
* @param {any} document
|
|
18
|
+
* @param {{ callbacks?: Record<string, (value: any) => any>, compileSchema?: (schema: any) => (value: any) => boolean }} [options]
|
|
19
|
+
*/
|
|
20
|
+
export function compileProvider(document, options = {}) {
|
|
21
|
+
let doc;
|
|
22
|
+
try { doc = clone(document); }
|
|
23
|
+
catch { fail('provider descriptor must be JSON'); }
|
|
24
|
+
if (!isJsonObject(doc) || doc.$provider !== '0.1') fail('provider descriptor requires $provider:0.1');
|
|
25
|
+
const known = ['id', '$provider', 'apiVersion', 'endpoint', 'protocol', 'method', 'safety', 'headers', 'query', 'body', 'graphql', 'response', 'pagination', 'limits', 'capability', 'inputSchema'];
|
|
26
|
+
for (const key of Object.keys(doc)) if (!known.includes(key)) fail('unknown provider member', `/${key}`);
|
|
27
|
+
for (const name of ['id', 'apiVersion', 'endpoint']) if (typeof doc[name] !== 'string' || !doc[name]) fail(`${name} must be nonempty`, `/${name}`);
|
|
28
|
+
let endpoint;
|
|
29
|
+
try { endpoint = new URL(doc.endpoint); }
|
|
30
|
+
catch { fail('endpoint must be an absolute HTTP(S) URL', '/endpoint'); }
|
|
31
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
32
|
+
fail('endpoint must be HTTP(S), without embedded credentials or a fragment', '/endpoint');
|
|
33
|
+
if (!['rest', 'graphql'].includes(doc.protocol) || !['safe-read', 'provider-idempotent', 'single-send'].includes(doc.safety))
|
|
34
|
+
fail('protocol and replay safety must be declared');
|
|
35
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(doc.method)) fail('unsupported HTTP method', '/method');
|
|
36
|
+
if (doc.headers !== undefined && (!isJsonObject(doc.headers) || Object.entries(doc.headers).some(([name, value]) =>
|
|
37
|
+
typeof value !== 'string' || /authorization|cookie|token|secret|api[-_]key/i.test(name) || /[\r\n]/.test(name + value))))
|
|
38
|
+
fail('headers must be public static strings; authentication belongs to host transport', '/headers');
|
|
39
|
+
if (doc.capability !== undefined && !['json', 'upload', 'media', 'bulk', 'binary'].includes(doc.capability)) fail('unknown capability', '/capability');
|
|
40
|
+
const limits = doc.limits ?? {};
|
|
41
|
+
if (!isJsonObject(limits)) fail('limits must be an object', '/limits');
|
|
42
|
+
for (const [name, fallback] of Object.entries({ pages: 3, rows: 256, bytes: 262144 })) {
|
|
43
|
+
if (limits[name] === undefined) limits[name] = fallback;
|
|
44
|
+
if (!Number.isSafeInteger(limits[name]) || limits[name] < 1) fail('limits must be positive finite integers', `/limits/${name}`);
|
|
45
|
+
}
|
|
46
|
+
if (Object.keys(limits).some((name) => !['pages', 'rows', 'bytes'].includes(name))) fail('unknown limit', '/limits');
|
|
47
|
+
const pagination = doc.pagination ?? {};
|
|
48
|
+
if (!isJsonObject(pagination) || Object.keys(pagination).some((name) => !['cursorParam', 'cursorVariable', 'empty'].includes(name))
|
|
49
|
+
|| (pagination.empty !== undefined && !['complete', 'incomplete'].includes(pagination.empty))) fail('invalid pagination', '/pagination');
|
|
50
|
+
for (const name of ['cursorParam', 'cursorVariable']) if (pagination[name] !== undefined && (typeof pagination[name] !== 'string' || !pagination[name])) fail('cursor target must be nonempty', `/pagination/${name}`);
|
|
51
|
+
if (!isJsonObject(doc.response) || !Object.hasOwn(doc.response, 'rows') || !Object.hasOwn(doc.response, 'id')) fail('response needs rows and id selectors', '/response');
|
|
52
|
+
for (const key of Object.keys(doc.response)) if (!['rows', 'id', 'cursor', 'hasMore', 'errors', 'cost', 'version', 'transform', 'schema'].includes(key)) fail('unknown response member', `/response/${key}`);
|
|
53
|
+
const schema = (value, path) => {
|
|
54
|
+
if (value === undefined) return () => true;
|
|
55
|
+
if (typeof options.compileSchema !== 'function') fail('schema validation requires a declared compiler capability', path);
|
|
56
|
+
try {
|
|
57
|
+
const validate = options.compileSchema(value);
|
|
58
|
+
if (typeof validate !== 'function') fail('schema compiler must return a validator', path);
|
|
59
|
+
return validate;
|
|
60
|
+
}
|
|
61
|
+
catch { fail('schema could not be compiled', path); }
|
|
62
|
+
};
|
|
63
|
+
const validInput = schema(doc.inputSchema, '/inputSchema');
|
|
64
|
+
const validResponse = schema(doc.response.schema, '/response/schema');
|
|
65
|
+
|
|
66
|
+
const selector = (spec, path, fallback) => {
|
|
67
|
+
if (spec === undefined) return () => fallback;
|
|
68
|
+
try {
|
|
69
|
+
if (isJsonObject(spec) && Object.hasOwn(spec, 'callback')) {
|
|
70
|
+
if (Object.keys(spec).length !== 1 || typeof spec.callback !== 'string'
|
|
71
|
+
|| !Object.hasOwn(options.callbacks ?? {}, spec.callback) || typeof options.callbacks[spec.callback] !== 'function') fail('undeclared callback', path);
|
|
72
|
+
const callback = options.callbacks[spec.callback];
|
|
73
|
+
return (data) => clone(callback(clone(data)));
|
|
74
|
+
}
|
|
75
|
+
return compileJsonQuery(spec);
|
|
76
|
+
}
|
|
77
|
+
catch (error) { if (error instanceof ContractCompileError) throw error; fail('invalid selector', path); }
|
|
78
|
+
};
|
|
79
|
+
const rowsOf = selector(doc.response.rows, '/response/rows', []);
|
|
80
|
+
const idOf = selector(doc.response.id, '/response/id', null);
|
|
81
|
+
const cursorOf = selector(doc.response.cursor, '/response/cursor', null);
|
|
82
|
+
const moreOf = selector(doc.response.hasMore, '/response/hasMore', undefined);
|
|
83
|
+
const errorsOf = selector(doc.response.errors ?? (doc.protocol === 'graphql' ? '$.errors' : undefined), '/response/errors', null);
|
|
84
|
+
const costOf = selector(doc.response.cost, '/response/cost', null);
|
|
85
|
+
const versionOf = selector(doc.response.version, '/response/version', null);
|
|
86
|
+
const queryOf = selector(doc.query, '/query', {});
|
|
87
|
+
const bodyOf = selector(doc.body, '/body', undefined);
|
|
88
|
+
let transform = (row) => row;
|
|
89
|
+
if (doc.response.transform !== undefined) {
|
|
90
|
+
const spec = doc.response.transform;
|
|
91
|
+
if (spec?.kind === 'jslt') {
|
|
92
|
+
try { transform = compileJsltStylesheet(spec.expression); }
|
|
93
|
+
catch { fail('invalid JSLT transform', '/response/transform'); }
|
|
94
|
+
}
|
|
95
|
+
else if (spec?.kind === 'query') transform = selector(spec.expression, '/response/transform', null);
|
|
96
|
+
else if (spec?.callback) transform = selector(spec, '/response/transform', null);
|
|
97
|
+
else fail('transform must name query, jslt or a declared callback', '/response/transform');
|
|
98
|
+
}
|
|
99
|
+
let variablesOf;
|
|
100
|
+
if (doc.protocol === 'graphql') {
|
|
101
|
+
if (doc.method !== 'POST' || !isJsonObject(doc.graphql) || typeof doc.graphql.query !== 'string' || !doc.graphql.query.trim()
|
|
102
|
+
|| Object.keys(doc.graphql).some((name) => !['query', 'variables'].includes(name))) fail('GraphQL requires POST and a query with optional variables', '/graphql');
|
|
103
|
+
variablesOf = selector(doc.graphql.variables, '/graphql/variables', {});
|
|
104
|
+
}
|
|
105
|
+
else if (doc.graphql !== undefined) fail('graphql belongs to the GraphQL dialect', '/graphql');
|
|
106
|
+
if (['GET', 'HEAD'].includes(doc.method) && doc.body !== undefined) fail('GET/HEAD cannot carry a body', '/body');
|
|
107
|
+
deepFreeze(doc);
|
|
108
|
+
|
|
109
|
+
/** @param {any} input @param {any} context */
|
|
110
|
+
async function* pages(input, context) {
|
|
111
|
+
if (!context || typeof context.executor?.execute !== 'function') throw providerHostError('provider pages require an executor');
|
|
112
|
+
let pageCount = 0, rowCount = 0, byteCount = 0, attempts = 0;
|
|
113
|
+
let cursor = context.cursor ?? null;
|
|
114
|
+
const cursors = new Set(cursor === null ? [] : [canonicalizeJson(cursor)]);
|
|
115
|
+
const ids = new Set();
|
|
116
|
+
const end = (state, reason) => ({ state, reason, pages: pageCount, rows: rowCount, bytes: byteCount, attempts, cursor });
|
|
117
|
+
if (doc.capability && doc.capability !== 'json') { yield end('refused', 'unsupported-capability'); return; }
|
|
118
|
+
if (!validInput(input)) { yield end('refused', 'input-schema'); return; }
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (context.signal?.aborted) { yield end('incomplete', 'cancelled'); return; }
|
|
121
|
+
if (pageCount >= limits.pages) { yield end('incomplete', 'page-limit'); return; }
|
|
122
|
+
const data = { input: clone(input), cursor, apiVersion: doc.apiVersion, partition: context.partition ?? null, sourceVersion: context.sourceVersion ?? null };
|
|
123
|
+
let request;
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(doc.endpoint);
|
|
126
|
+
const query = queryOf(data);
|
|
127
|
+
if (!isJsonObject(query)) throw new Error('query must return an object');
|
|
128
|
+
for (const [name, value] of Object.entries(query)) {
|
|
129
|
+
if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) throw new Error('query values must be scalar');
|
|
130
|
+
if (value !== null) url.searchParams.set(name, String(value));
|
|
131
|
+
}
|
|
132
|
+
if (cursor !== null && pagination.cursorParam) url.searchParams.set(pagination.cursorParam, String(cursor));
|
|
133
|
+
let body = bodyOf(data);
|
|
134
|
+
if (doc.protocol === 'graphql') {
|
|
135
|
+
const variables = clone(variablesOf(data));
|
|
136
|
+
if (!isJsonObject(variables)) throw new Error('variables must be an object');
|
|
137
|
+
if (pagination.cursorVariable) Object.defineProperty(variables, pagination.cursorVariable, { value: cursor, enumerable: true, configurable: true, writable: true });
|
|
138
|
+
body = { query: doc.graphql.query, variables };
|
|
139
|
+
}
|
|
140
|
+
request = { url: url.href, method: doc.method, headers: { ...doc.headers, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
141
|
+
...(body === undefined ? {} : { body: canonicalizeJson(body) }), safety: doc.safety,
|
|
142
|
+
account: context.account, idempotencyKey: context.idempotencyKey };
|
|
143
|
+
}
|
|
144
|
+
catch { yield end('refused', 'request-transform'); return; }
|
|
145
|
+
const response = await context.executor.execute(request, { ...context, maxBytes: limits.bytes - byteCount });
|
|
146
|
+
attempts += response.attempts;
|
|
147
|
+
byteCount += response.bytes;
|
|
148
|
+
if (response.state !== 'ok') { yield { ...end(response.state === 'refused' && response.reason !== 'byte-limit' ? 'refused' : 'incomplete', response.reason), response }; return; }
|
|
149
|
+
if (byteCount > limits.bytes) { yield end('incomplete', 'byte-limit'); return; }
|
|
150
|
+
let raw, rows, rowIds, next, hasMore, errors, cost, version;
|
|
151
|
+
try {
|
|
152
|
+
raw = JSON.parse(response.text);
|
|
153
|
+
if (!validResponse(raw)) throw new Error('response schema failed');
|
|
154
|
+
rows = rowsOf(raw);
|
|
155
|
+
if (!Array.isArray(rows)) throw new Error('rows must be an array');
|
|
156
|
+
rowIds = rows.map((row) => idOf(row));
|
|
157
|
+
if (rowIds.some((id) => (typeof id !== 'string' || !id) && (typeof id !== 'number' || !Number.isSafeInteger(id)))) throw new Error('ids must be nonempty strings or safe integers');
|
|
158
|
+
rows = rows.map((row) => clone(transform(clone(row))));
|
|
159
|
+
next = cursorOf(raw) ?? null;
|
|
160
|
+
if (next !== null && !['string', 'number'].includes(typeof next)) throw new Error('cursor must be a scalar');
|
|
161
|
+
hasMore = moreOf(raw);
|
|
162
|
+
if (hasMore !== undefined && typeof hasMore !== 'boolean') throw new Error('hasMore must be boolean');
|
|
163
|
+
hasMore ??= next !== null;
|
|
164
|
+
errors = errorsOf(raw) ?? null;
|
|
165
|
+
cost = costOf(raw) ?? null;
|
|
166
|
+
version = versionOf(raw) ?? null;
|
|
167
|
+
}
|
|
168
|
+
catch { yield { ...end('incomplete', 'response-transform'), text: response.text }; return; }
|
|
169
|
+
pageCount++;
|
|
170
|
+
rowCount += rows.length;
|
|
171
|
+
let reason = null;
|
|
172
|
+
if (errors !== null && (!Array.isArray(errors) || errors.length)) reason = 'partial-errors';
|
|
173
|
+
else if (rowCount > limits.rows) reason = 'row-limit';
|
|
174
|
+
else if (!rows.length && (hasMore || pagination.empty !== 'complete')) reason = 'empty-page';
|
|
175
|
+
else if (rowIds.some((id) => ids.has(canonicalizeJson(id))) || new Set(rowIds.map((id) => canonicalizeJson(id))).size !== rowIds.length) reason = 'no-progress';
|
|
176
|
+
else if (hasMore && (next === null || cursors.has(canonicalizeJson(next)))) reason = 'no-progress';
|
|
177
|
+
const complete = reason === null && !hasMore;
|
|
178
|
+
yield { state: 'page', raw, text: response.text, rows, ids: rowIds, cursor, continuation: hasMore ? next : null,
|
|
179
|
+
complete, errors, cost, version, reason, bytes: response.bytes, attempts: response.attempts };
|
|
180
|
+
if (reason !== null) { yield end('incomplete', reason); return; }
|
|
181
|
+
if (complete) { yield end('complete', 'complete'); return; }
|
|
182
|
+
for (const id of rowIds) ids.add(canonicalizeJson(id));
|
|
183
|
+
cursors.add(canonicalizeJson(next));
|
|
184
|
+
cursor = next;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
document: doc,
|
|
189
|
+
pages,
|
|
190
|
+
/** Collect a bounded pull; streaming consumers should iterate pages instead.
|
|
191
|
+
* @param {any} input @param {any} context @returns {Promise<any>} */
|
|
192
|
+
async pull(input, context) {
|
|
193
|
+
const observations = [];
|
|
194
|
+
for await (const page of pages(input, context)) {
|
|
195
|
+
if (page.state === 'page') observations.push(page);
|
|
196
|
+
else return { ...page, observations };
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** One bounded, single-attempt transport seam with explicit replay safety. */
|
|
3
|
+
import { createScheduler } from '@jarenjs/core/schedule';
|
|
4
|
+
import { backoffDelay, createAttemptBudget, parseRetryAfter, sleep as defaultSleep } from '@jarenjs/core/retry';
|
|
5
|
+
import { ContractHostError } from '../errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {string} reason @returns {ContractHostError} */
|
|
8
|
+
export const providerHostError = (reason) => new ContractHostError('JC1012', reason);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} ProviderRequest
|
|
12
|
+
* @property {string} url
|
|
13
|
+
* @property {string} [method]
|
|
14
|
+
* @property {Record<string, string>} [headers] - public headers only; credentials belong to host transport
|
|
15
|
+
* @property {string} [body]
|
|
16
|
+
* @property {'safe-read' | 'provider-idempotent' | 'single-send'} safety
|
|
17
|
+
* @property {string} [idempotencyKey] - required evidence for provider-idempotent
|
|
18
|
+
* @property {string} [account] - opaque scheduler scope, never credentials
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} ProviderExecutorOptions
|
|
23
|
+
* @property {(request: ProviderRequest, context: { signal: AbortSignal, attempt: number, maxAttempts: 1 }) => Promise<Response>} [transport]
|
|
24
|
+
* @property {number} [attempts]
|
|
25
|
+
* @property {number} [overallMs]
|
|
26
|
+
* @property {number} [attemptMs]
|
|
27
|
+
* @property {number} [maxBytes] - response bytes across all attempts
|
|
28
|
+
* @property {number} [maxRequestBytes]
|
|
29
|
+
* @property {number} [baseMs]
|
|
30
|
+
* @property {number} [maxMs]
|
|
31
|
+
* @property {'http' | 'milliseconds' | 'none'} [retryAfter]
|
|
32
|
+
* @property {string} [retryAfterHeader]
|
|
33
|
+
* @property {number} [concurrency]
|
|
34
|
+
* @property {number} [maxQueue]
|
|
35
|
+
* @property {number} [maxScopes]
|
|
36
|
+
* @property {number} [spacingMs]
|
|
37
|
+
* @property {() => number} [now]
|
|
38
|
+
* @property {() => number} [random]
|
|
39
|
+
* @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Injected transports must issue exactly one request and honor the supplied
|
|
44
|
+
* signal; SDK retry loops must be disabled. Shutdown awaits transport and body
|
|
45
|
+
* settlement even when a transport ignores its signal. JSON outcomes never
|
|
46
|
+
* contain exceptions, request headers, controllers or live response handles.
|
|
47
|
+
* @param {ProviderExecutorOptions} [options]
|
|
48
|
+
*/
|
|
49
|
+
export function createProviderExecutor(options = {}) {
|
|
50
|
+
const { attempts = 3, overallMs = 30000, attemptMs = 10000, maxBytes = 262144,
|
|
51
|
+
maxRequestBytes = 262144, baseMs = 500, maxMs = 8000, now = Date.now,
|
|
52
|
+
random = Math.random, sleep = defaultSleep, retryAfter = 'http', retryAfterHeader = 'retry-after' } = options;
|
|
53
|
+
for (const [key, value] of Object.entries({ attempts, overallMs, attemptMs, maxBytes, maxRequestBytes })) {
|
|
54
|
+
if (!Number.isSafeInteger(value) || value < 1) throw providerHostError(`${key} must be a positive finite integer`);
|
|
55
|
+
}
|
|
56
|
+
if (![baseMs, maxMs].every((value) => Number.isFinite(value) && value >= 0)
|
|
57
|
+
|| typeof now !== 'function' || typeof random !== 'function' || typeof sleep !== 'function'
|
|
58
|
+
|| !['http', 'milliseconds', 'none'].includes(retryAfter) || typeof retryAfterHeader !== 'string')
|
|
59
|
+
throw providerHostError('invalid retry policy');
|
|
60
|
+
const transport = options.transport ?? ((request, context) => fetch(request.url, {
|
|
61
|
+
method: request.method, headers: request.headers, body: request.body, signal: context.signal, redirect: 'manual',
|
|
62
|
+
}));
|
|
63
|
+
if (typeof transport !== 'function') throw providerHostError('transport must be a single-attempt function');
|
|
64
|
+
const scheduler = createScheduler({ ...options, now, sleep });
|
|
65
|
+
const closer = new AbortController();
|
|
66
|
+
/** @type {Set<Promise<any>>} */
|
|
67
|
+
const running = new Set();
|
|
68
|
+
const encoder = new TextEncoder();
|
|
69
|
+
|
|
70
|
+
/** @param {ProviderRequest} request @param {any} context */
|
|
71
|
+
async function execute(request, context) {
|
|
72
|
+
if (!request || !['safe-read', 'provider-idempotent', 'single-send'].includes(request.safety))
|
|
73
|
+
throw providerHostError('request must declare replay safety');
|
|
74
|
+
if (request.safety === 'provider-idempotent' && (typeof request.idempotencyKey !== 'string' || !request.idempotencyKey))
|
|
75
|
+
throw providerHostError('provider-idempotent requires the provider key');
|
|
76
|
+
let url;
|
|
77
|
+
try { url = new URL(request.url); }
|
|
78
|
+
catch { throw providerHostError('request URL must be absolute HTTP(S)'); }
|
|
79
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
|
|
80
|
+
throw providerHostError('request URL must be absolute HTTP(S) without credentials');
|
|
81
|
+
if (request.body !== undefined && typeof request.body !== 'string') throw providerHostError('only bounded text requests are supported');
|
|
82
|
+
const budget = context.budget ?? createAttemptBudget(attempts, request.safety);
|
|
83
|
+
if (budget.safety !== request.safety || typeof budget.take !== 'function') throw providerHostError('attempt budget safety must match the request');
|
|
84
|
+
const signal = AbortSignal.any([closer.signal, ...(context.signal ? [context.signal] : [])]);
|
|
85
|
+
if (context.deadline !== undefined && (typeof context.deadline !== 'number' || Number.isNaN(context.deadline)))
|
|
86
|
+
throw providerHostError('deadline must be a numeric instant');
|
|
87
|
+
const deadline = Math.min(now() + overallMs, context.deadline ?? Infinity);
|
|
88
|
+
const byteLimit = Math.min(maxBytes, context.maxBytes ?? maxBytes);
|
|
89
|
+
if (!Number.isSafeInteger(byteLimit) || byteLimit < 0) throw providerHostError('per-request byte credit must be a nonnegative finite integer');
|
|
90
|
+
const scope = JSON.stringify([url.origin, request.account ?? '']);
|
|
91
|
+
let count = 0;
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
/** @param {string} state @param {string} reason @param {any} [extra] */
|
|
94
|
+
const outcome = (state, reason, extra = {}) => ({ state, reason, attempts: count, bytes, ...extra });
|
|
95
|
+
if (request.safety === 'provider-idempotent' && options.transport === undefined)
|
|
96
|
+
return outcome('refused', 'idempotency-transport-required');
|
|
97
|
+
if (encoder.encode(request.body ?? '').byteLength > maxRequestBytes) return outcome('refused', 'request-byte-limit');
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (signal.aborted) return outcome('cancelled', 'cancelled');
|
|
100
|
+
if (now() >= deadline) return outcome('refused', 'deadline');
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await scheduler.run(async () => {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const attemptSignal = AbortSignal.any([signal, controller.signal]);
|
|
106
|
+
const timerStop = new AbortController();
|
|
107
|
+
const until = Math.min(deadline, now() + attemptMs);
|
|
108
|
+
const timer = sleep(Math.max(0, until - now()), timerStop.signal).then(() => controller.abort(), () => {});
|
|
109
|
+
try {
|
|
110
|
+
if (context.beforeDispatch && await context.beforeDispatch(request) !== true) return outcome('refused', 'authority-changed');
|
|
111
|
+
if (attemptSignal.aborted || now() >= until) return outcome(signal.aborted ? 'cancelled' : 'refused', signal.aborted ? 'cancelled' : 'deadline');
|
|
112
|
+
if (count >= attempts || !budget.take()) return outcome('refused', 'attempt-budget');
|
|
113
|
+
count++;
|
|
114
|
+
let response;
|
|
115
|
+
try { response = await transport(request, { signal: attemptSignal, attempt: budget.used, maxAttempts: 1 }); }
|
|
116
|
+
catch { return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'transport', { retryable: true }); }
|
|
117
|
+
if (!response || typeof response.status !== 'number' || typeof response.headers?.get !== 'function')
|
|
118
|
+
return outcome('failed', 'transport-shape');
|
|
119
|
+
const rate = parseRetryAfter(response.headers.get(retryAfterHeader), { dialect: retryAfter, now: now() });
|
|
120
|
+
if (rate !== undefined) scheduler.observe(scope, rate);
|
|
121
|
+
const reader = response.body?.getReader();
|
|
122
|
+
let text = '';
|
|
123
|
+
let readFailure = false;
|
|
124
|
+
if (reader) {
|
|
125
|
+
const cancel = () => { reader.cancel().catch(() => {}); };
|
|
126
|
+
attemptSignal.addEventListener('abort', cancel, { once: true });
|
|
127
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
128
|
+
try {
|
|
129
|
+
for (;;) {
|
|
130
|
+
if (attemptSignal.aborted) { await reader.cancel(); break; }
|
|
131
|
+
const part = await reader.read();
|
|
132
|
+
if (part.done) { text += decoder.decode(); break; }
|
|
133
|
+
bytes += part.value.byteLength;
|
|
134
|
+
if (bytes > byteLimit) { await reader.cancel(); return outcome('refused', 'byte-limit'); }
|
|
135
|
+
text += decoder.decode(part.value, { stream: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { readFailure = true; await reader.cancel().catch(() => {}); }
|
|
139
|
+
finally { attemptSignal.removeEventListener('abort', cancel); reader.releaseLock(); }
|
|
140
|
+
}
|
|
141
|
+
if (signal.aborted) return outcome('cancelled', 'cancelled');
|
|
142
|
+
if (attemptSignal.aborted || now() >= until)
|
|
143
|
+
return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'deadline', { retryable: true });
|
|
144
|
+
if (readFailure) return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'body', { retryable: true });
|
|
145
|
+
const status = response.status;
|
|
146
|
+
const ok = status >= 200 && status < 300;
|
|
147
|
+
return outcome(ok ? 'ok' : 'failed', ok ? 'response' : 'http', {
|
|
148
|
+
status, text, retryAfterMs: rate ?? null,
|
|
149
|
+
retryable: status === 408 || status === 429 || status >= 500,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
finally { timerStop.abort(); await timer; }
|
|
153
|
+
}, { scope, deadline, signal });
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : '';
|
|
157
|
+
const reason = ['closed', 'cancelled', 'deadline', 'queue-full', 'scope-limit'].includes(message) ? message : 'host-fault';
|
|
158
|
+
return outcome(signal.aborted ? 'cancelled' : 'refused', signal.aborted ? 'cancelled' : reason);
|
|
159
|
+
}
|
|
160
|
+
if (signal.aborted) return outcome('cancelled', 'cancelled');
|
|
161
|
+
if (!result.retryable || request.safety === 'single-send' || !budget.remaining || count >= attempts) return result;
|
|
162
|
+
const delay = backoffDelay({ baseMs, maxMs, random }, count, result.retryAfterMs ?? undefined);
|
|
163
|
+
if (delay >= deadline - now()) return outcome('refused', 'deadline', { retryAfterMs: result.retryAfterMs ?? null });
|
|
164
|
+
try { await sleep(delay, signal); }
|
|
165
|
+
catch { return outcome('cancelled', 'cancelled'); }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
/** @param {ProviderRequest} request
|
|
171
|
+
* @param {{ signal?: AbortSignal, deadline?: number, maxBytes?: number, budget?: ReturnType<typeof createAttemptBudget>, beforeDispatch?: (request: ProviderRequest) => boolean | Promise<boolean> }} [context]
|
|
172
|
+
* @returns {Promise<any>} */
|
|
173
|
+
execute(request, context = {}) {
|
|
174
|
+
// Admission and dispatch use one immutable request, even if its caller
|
|
175
|
+
// changes the original object while queued or refreshing authority.
|
|
176
|
+
const captured = request && Object.freeze({ ...request,
|
|
177
|
+
...(request.headers === undefined ? {} : { headers: Object.freeze({ ...request.headers }) }) });
|
|
178
|
+
const promise = execute(captured, context);
|
|
179
|
+
running.add(promise);
|
|
180
|
+
promise.then(() => running.delete(promise), () => running.delete(promise));
|
|
181
|
+
return promise;
|
|
182
|
+
},
|
|
183
|
+
/** Stop new work and drain requests, body readers and retry waits. */
|
|
184
|
+
async close() {
|
|
185
|
+
closer.abort();
|
|
186
|
+
await scheduler.close();
|
|
187
|
+
await Promise.allSettled([...running]);
|
|
188
|
+
},
|
|
189
|
+
stats: scheduler.stats,
|
|
190
|
+
});
|
|
191
|
+
}
|