@jarenjs/contract 0.73.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.
@@ -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
@@ -289,7 +251,7 @@ function transportString(v) {
289
251
  * @property {string} method
290
252
  * @property {readonly import('../path.js').PathSegment[]} segments
291
253
  * @property {readonly string[]} queryMembers
292
- * @property {ReadonlySet<string>} queryRepeated
254
+ * @property {ReadonlySet<string>} queryJson
293
255
  * @property {readonly string[]} headerMembers
294
256
  * @property {readonly string[]} headerNames
295
257
  * @property {readonly string[]} bodyMembers - body-located members when the body is their object
@@ -315,8 +277,6 @@ function prepare(op) {
315
277
  const http = op.http;
316
278
  /** @type {string[]} */
317
279
  const queryMembers = [];
318
- /** @type {Set<string>} */
319
- const queryRepeated = new Set();
320
280
  /** @type {string[]} */
321
281
  const headerMembers = [];
322
282
  /** @type {string[]} */
@@ -324,14 +284,12 @@ function prepare(op) {
324
284
  /** @type {string[]} */
325
285
  const bodyMembers = [];
326
286
  const transport = op.input === null ? null : op.input.transport;
327
- const repeated = new Set(transport === null ? [] : transport.members.repeated);
328
287
  const members = Object.keys(http.in);
329
288
  for (let i = 0; i < members.length; i++) {
330
289
  const m = members[i];
331
290
  const loc = http.in[m];
332
291
  if (loc === 'query') {
333
292
  queryMembers.push(m);
334
- if (repeated.has(m)) queryRepeated.add(m);
335
293
  }
336
294
  else if (loc === 'header') {
337
295
  headerMembers.push(m);
@@ -345,7 +303,7 @@ function prepare(op) {
345
303
  method: http.method,
346
304
  segments: http.template.segments,
347
305
  queryMembers,
348
- queryRepeated,
306
+ queryJson: new Set(transport === null ? [] : transport.queryJson),
349
307
  headerMembers,
350
308
  headerNames,
351
309
  bodyMembers,
@@ -448,7 +406,8 @@ export function openHttpClient(contract, options = {}) {
448
406
  * @returns {number}
449
407
  */
450
408
  function backoffDelay(n) {
451
- return Math.min(1000 * 2 ** n, BACKOFF_MAX) + Math.floor(hostFact('random', runtime.random) * BACKOFF_JITTER);
409
+ return sharedBackoff({ policy: 'contract-compat', baseMs: 1000, maxMs: BACKOFF_MAX,
410
+ random: () => hostFact('random', runtime.random) }, n + 1);
452
411
  }
453
412
  const now = options.now === undefined ? runtime.now : options.now;
454
413
  /** @type {Catalog | null} */
@@ -535,13 +494,12 @@ export function openHttpClient(contract, options = {}) {
535
494
  for (let i = 0; i < route.queryMembers.length; i++) {
536
495
  const m = route.queryMembers[i];
537
496
  const v = value[m];
538
- if (v === undefined || v === null) continue;
539
- if (route.queryRepeated.has(m) && Array.isArray(v)) {
540
- for (let j = 0; j < v.length; j++) {
541
- if (v[j] !== undefined && v[j] !== null) params.append(m, transportString(v[j]));
542
- }
497
+ if (route.queryJson.has(m)) {
498
+ if (v !== undefined) params.append(m, JSON.stringify(v));
499
+ continue;
543
500
  }
544
- else params.append(m, transportString(v));
501
+ if (v === undefined || v === null) continue;
502
+ params.append(m, transportString(v));
545
503
  }
546
504
  const query = params.toString();
547
505
  return query.length === 0 ? path : path + '?' + query;
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/compile.js CHANGED
@@ -370,20 +370,18 @@ function checkRefs(node, docPath, scope, isRoot) {
370
370
  */
371
371
 
372
372
  /**
373
- * The transport half of an operation's input: the members that travel
374
- * as strings (path, query, header) and the normalizer that decodes them
375
- * with `coerceTypes` scoped to exactly those members. `repeated` lists
376
- * the query and header members whose effective schema type is `array`
377
- * a decoder collects repeats of those into an array (a repeated query
378
- * key; a repeated header line or a comma-separated header list) before
379
- * normalizing; every other query member is last-wins and every other
380
- * header member is a single line. Body members are never here.
381
- * `schemas` holds each transport member's declared schema (what the
382
- * normalizer was compiled over) and `required` the transport members the
383
- * input schema requires — what a URL builder validates without the body.
373
+ * The transport half of an operation's input. Scalar path/query/header
374
+ * members use a compiled coercing normalizer; queryJson members carry
375
+ * one JSON value and bypass coercion. `repeated` marks array types (the
376
+ * header decoder collects their repeated lines or comma-separated list;
377
+ * JSON query encoding takes precedence for query members). Body members
378
+ * are never here. `schemas` and `required` describe every transport
379
+ * member for URL validation, including those excluded from normalization.
384
380
  * @typedef {Object} InputTransport
385
381
  * @property {(value: any) => any} normalize
386
382
  * @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
383
+ * @property {readonly string[]} queryJson - query members with a declared
384
+ * object/array type, encoded as one JSON value (including nullable unions)
387
385
  * @property {Readonly<Record<string, any>>} schemas
388
386
  * @property {readonly string[]} required
389
387
  */
@@ -1074,6 +1072,7 @@ export function compileContract(doc, options = {}) {
1074
1072
  const queryMembers = [];
1075
1073
  const headerMembers = [];
1076
1074
  const repeated = [];
1075
+ const queryJson = [];
1077
1076
  /** @type {Record<string, any>} */
1078
1077
  const pick = {};
1079
1078
  for (let i = 0; i < p.members.length; i++) {
@@ -1089,18 +1088,24 @@ export function compileContract(doc, options = {}) {
1089
1088
  const eff = effectiveSchema(schema, scope);
1090
1089
  const type = isJsonObject(eff) ? eff.type : undefined;
1091
1090
  if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
1091
+ if (loc === 'query' && (type === 'object' || type === 'array'
1092
+ || (Array.isArray(type) && (type.includes('object') || type.includes('array'))))) queryJson.push(m);
1092
1093
  }
1093
1094
  }
1094
1095
  if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
1095
1096
  // the sub-schema is rooted on the document itself, so every
1096
1097
  // same-document `$ref` a member schema carries resolves exactly as
1097
1098
  // it does for the validator
1098
- const sub = { ...src, type: 'object', properties: pick };
1099
+ // JSON query values have the same typing discipline as a JSON
1100
+ // body: validate them verbatim, never coerce their descendants.
1101
+ const scalarPick = Object.fromEntries(Object.entries(pick).filter(([name]) => !queryJson.includes(name)));
1102
+ const sub = { ...src, type: 'object', properties: scalarPick };
1099
1103
  const normalize = compileNormalizer(sub, { coerceTypes: true });
1100
1104
  const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
1101
1105
  transport = {
1102
1106
  normalize,
1103
1107
  members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
1108
+ queryJson,
1104
1109
  schemas: pick,
1105
1110
  required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
1106
1111
  };
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
  /**
@@ -133,7 +133,7 @@ import {
133
133
  * @property {ReadonlySet<string>} nonBody - path/query/header member names, never taken from the body
134
134
  * @property {readonly string[]} pathMembers
135
135
  * @property {ReadonlySet<string>} queryMembers
136
- * @property {ReadonlySet<string>} repeated - array-typed query members
136
+ * @property {ReadonlySet<string>} queryJson - JSON-encoded query members
137
137
  * @property {readonly string[]} headerMembers - member names
138
138
  * @property {readonly string[]} headerNames - the lowercase header of each
139
139
  * @property {readonly boolean[]} headerArray - array-typed, per header member
@@ -597,7 +597,7 @@ function afterIdentity(server, request, route, trace, hit, isHead, method, path,
597
597
  const m = route.pathMembers[i];
598
598
  setObjectMember(input, m, params[m]);
599
599
  }
600
- if (!decodeQuery(query, route.queryMembers, route.repeated, input)) {
600
+ if (!decodeQuery(query, route.queryMembers, input, route.queryJson)) {
601
601
  return refuse(server, 'JC2012', trace, {}, undefined, null, null);
602
602
  }
603
603
  /** @type {Record<string, string>} */
package/src/http/serve.js CHANGED
@@ -158,8 +158,6 @@ function prepare(op, handler, tag) {
158
158
  const pathMembers = [];
159
159
  /** @type {Set<string>} */
160
160
  const queryMembers = new Set();
161
- /** @type {Set<string>} */
162
- const repeated = new Set();
163
161
  /** @type {string[]} */
164
162
  const headerMembers = [];
165
163
  /** @type {string[]} */
@@ -178,10 +176,6 @@ function prepare(op, handler, tag) {
178
176
  headerNames.push(headerNameOf(m));
179
177
  headerArray.push(transport.members.repeated.includes(m));
180
178
  }
181
- for (let i = 0; i < transport.members.repeated.length; i++) {
182
- const m = transport.members.repeated[i];
183
- if (queryMembers.has(m)) repeated.add(m);
184
- }
185
179
  }
186
180
  const members = Object.keys(http.in);
187
181
  for (let i = 0; i < members.length; i++) {
@@ -203,7 +197,7 @@ function prepare(op, handler, tag) {
203
197
  nonBody,
204
198
  pathMembers,
205
199
  queryMembers,
206
- repeated,
200
+ queryJson: new Set(transport === null ? [] : transport.queryJson),
207
201
  headerMembers,
208
202
  headerNames,
209
203
  headerArray,
package/src/http/wire.js CHANGED
@@ -340,18 +340,19 @@ export function formatEntityTag(tag, strong) {
340
340
  /**
341
341
  * Decode a query string into the declared members of an input object:
342
342
  * only declared names are set (an undeclared key is never merged, so no
343
- * request can smuggle a member); a `repeated` member collects every
344
- * occurrence into an array, every other member is last-wins; a `+` is a
343
+ * request can smuggle a member); JSON members decode exactly one value,
344
+ * scalar members are last-wins; a `+` is a
345
345
  * space and escapes decode as `application/x-www-form-urlencoded`
346
346
  * (`URLSearchParams`). Returns `false` when the query is not decodable
347
- * (a malformed percent-escape or invalid UTF-8) the `JC2012` case.
347
+ * (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
348
+ * — the `JC2012` case.
348
349
  * @param {string} query - the part after `?`, possibly empty
349
350
  * @param {ReadonlySet<string>} declared - the query member names
350
- * @param {ReadonlySet<string>} repeated - the array-typed ones
351
351
  * @param {Record<string, unknown>} out - the input object under assembly
352
+ * @param {ReadonlySet<string>} json - schema-directed JSON members
352
353
  * @returns {boolean} false when not decodable
353
354
  */
354
- export function decodeQuery(query, declared, repeated, out) {
355
+ export function decodeQuery(query, declared, out, json) {
355
356
  if (query.length === 0) return true;
356
357
  // URLSearchParams never throws: it keeps a malformed escape as its
357
358
  // literal text and replaces invalid UTF-8; both are "not decodable"
@@ -368,12 +369,16 @@ export function decodeQuery(query, declared, repeated, out) {
368
369
  const params = new URLSearchParams(query);
369
370
  for (const [name, value] of params) {
370
371
  if (!declared.has(name)) continue;
371
- if (repeated.has(name)) {
372
- const list = out[name];
373
- if (Array.isArray(list)) list.push(value);
374
- else setObjectMember(out, name, [value]);
372
+ if (json.has(name)) {
373
+ // One JSON value per declared structured member. Scalar strings
374
+ // are untouched; repeated JSON members are ambiguous and refused.
375
+ const values = params.getAll(name);
376
+ if (values.length !== 1) return false;
377
+ try { setObjectMember(out, name, JSON.parse(value)); }
378
+ catch { return false; }
379
+ continue;
375
380
  }
376
- else setObjectMember(out, name, value);
381
+ setObjectMember(out, name, value);
377
382
  }
378
383
  return true;
379
384
  }
@@ -324,7 +324,7 @@ function docLines(doc) {
324
324
  function operationView(op, projected, ctx) {
325
325
  const base = at('/operations', op.id);
326
326
  const http = op.http;
327
- /** @type {{ name: string, in: string, required: boolean, schema: any }[]} */
327
+ /** @type {any[]} */
328
328
  const parameters = [];
329
329
  /** @type {Record<string, any>} */
330
330
  const bodyMembers = {};
@@ -351,6 +351,8 @@ function operationView(op, projected, ctx) {
351
351
  bodyCount++;
352
352
  if (name === http.body) bodyMemberRequired = required;
353
353
  }
354
+ else if (loc === 'query' && op.input?.transport?.queryJson.includes(name))
355
+ parameters.push({ name, in: loc, required, content: { 'application/json': { schema: mapped } } });
354
356
  else parameters.push({ name, in: loc, required, schema: mapped });
355
357
  }
356
358
  if (op.policy.idempotency !== 'none') {
@@ -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
+ }