@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.
@@ -261,25 +261,44 @@ top-level `input.properties` member gets exactly one location:
261
261
  does not declare);
262
262
  4. otherwise the default: `query` for a `read`, `body` for a `command`.
263
263
 
264
- Path, query and header members arrive as strings and are decoded by a
265
- normalizer compiled **over those members only** with `coerceTypes`
266
- (`@jarenjs/validate/normalize`); **a body member is never coerced**. The
267
- compiled operation carries this as
268
- `input.transport = { normalize, members: { path, query, header, repeated }, schemas, required }`
269
- (`null` when nothing travels as a string) beside `input.effective`
270
- the object schema the declared `input` resolves to (itself, or the end
271
- of its `$ref` chain), whose `properties` are the operation's members;
272
- `schemas` holds each
273
- transport member's declared schema and `required` the transport members
274
- the input requires (what a URL builder validates without the body), and
275
- `repeated` lists the
276
- query and header members whose effective schema type is `array` a
277
- decoder collects repeats of those into an array (a repeated query key; a
278
- repeated header line or a comma-separated header list, RFC 9110 §5.3)
279
- before normalizing; every other query member is last-wins and every
280
- other header member is one line (§7.4). The server validates the
281
- reassembled input object with the operation's compiled validator; the
282
- client validates the same object before it splits it.
264
+ Scalar path, query and header members arrive as strings and are decoded
265
+ by a normalizer compiled over those members with `coerceTypes`
266
+ (`@jarenjs/validate/normalize`). **JSON query members and body members are
267
+ never coerced**, including nested values.
268
+
269
+ A query member whose effective declared `type` is `object` or `array`
270
+ (or a type array including either) uses **one JSON value in one query
271
+ parameter**. The client applies `JSON.stringify`, then `URLSearchParams`;
272
+ the server percent-decodes and applies `JSON.parse` before validation.
273
+ This includes nullable and scalar/structured unions: their values always
274
+ use JSON encoding. Empty arrays, nulls, arrays of objects, numeric object
275
+ keys and strings inside those unions round-trip without guessing from
276
+ text. An absent member is omitted. Plain `string` members remain literal,
277
+ even when their text looks like JSON. `$ref` chains are resolved at
278
+ compilation; unconstrained schemas and unions expressed only with
279
+ `anyOf`/`oneOf` do not imply a codec declare a top-level `type` to choose
280
+ one. Malformed JSON or multiple occurrences of a JSON member are `JC2012`;
281
+ a well-formed value of the wrong type is `JC2006`. Undeclared query keys
282
+ are ignored. Scalar query members remain last-wins.
283
+
284
+ The compiled operation carries
285
+ `input.transport = { normalize, members: { path, query, header, repeated }, queryJson, schemas, required }`
286
+ (`null` when nothing travels as a string), beside `input.effective`, the
287
+ resolved input object schema. `schemas` and `required` describe all
288
+ transport members for URL validation; `queryJson` lists the JSON members
289
+ excluded from scalar normalization. `repeated` identifies array-typed
290
+ query/header members; JSON query encoding takes precedence. Array headers
291
+ still collect repeated lines or comma-separated values (RFC 9110 §5.3)
292
+ before normalization; other headers require one line (§7.4). The server
293
+ validates the reassembled input; the client validates before splitting it.
294
+
295
+ **Wire migration:** array query members use `tag=["a","b"]` (URL-encoded),
296
+ replacing `tag=a&tag=b`. Update handwritten callers and deploy matching
297
+ client/server versions together; published contracts should change their
298
+ `version` so revision negotiation detects a mismatched deployment. The
299
+ OpenAPI projection declares these parameters with
300
+ `content: { "application/json": { schema: ... } }`, rather than an
301
+ exploded array schema.
283
302
 
284
303
  An operation bound to `GET` or `HEAD` MUST NOT carry a body-located
285
304
  member (`JC0016`) — including a `command` whose members default to the
@@ -413,6 +432,7 @@ carries the same codes and a test holds them equal.
413
432
  | JC0018 | a subscribe operation declares a `policy.task` other than `switch` — a subscription slot is replaced, never queued (§17) |
414
433
  | JC0019 | a subscribe operation is bound to a method other than `GET` — a stream is fetched, not sent (§17) |
415
434
  | JC0020 | a subscribe operation declares a `policy.idempotency` other than `none` — a subscription registers, it does not commit (§17) |
435
+ | JC0021 | a provider descriptor is malformed or names an undeclared transform capability |
416
436
 
417
437
  `JC0021–JC0049` are reserved for further document-level rules and are
418
438
  appended to this table when they land; `JC0050–JC0069` are the binding-
@@ -601,12 +621,12 @@ is `JC2008`.
601
621
  stripped by the decoder); then `JSON.parse` (a failure is `JC2005`).
602
622
  7. **Assemble** the input object through a prototype-safe setter only, in
603
623
  this order: path members (raw decoded strings), query members
604
- (`URLSearchParams` semantics — `+` is a space; a member listed in
605
- `transport.members.repeated` collects every occurrence into an array,
606
- every other member is last-wins; an **undeclared query key is
607
- ignored, never merged**; an undecodable query is `JC2012`), declared
608
- header members (by their lowercased name; §7.4), then the transport
609
- normalizer over exactly those members (`coerceTypes`), then the body,
624
+ (`URLSearchParams` semantics — `+` is a space; `queryJson` members
625
+ decode one JSON value, scalar members are last-wins; an **undeclared
626
+ query key is ignored, never merged**; malformed encoding/JSON or a
627
+ repeated JSON member is `JC2012`), declared header members (by their
628
+ lowercased name; §7.4), then the scalar transport normalizer (JSON
629
+ members excluded), then the body,
610
630
  **never coerced**: `http.body` names a member → the parsed value is
611
631
  that member; otherwise the parsed value must be an object (`JC2006`
612
632
  with `path: ""` otherwise) and each of its own members is set unless
@@ -747,7 +767,7 @@ below; `HTTP_ERRORS` (`@jarenjs/contract/http`) is the same table as data,
747
767
  | `JC2009` | 409 | `contract/idempotency-conflict` | see §8 | the ledger says `in-progress` (retryable, `retry-after: 1`) or `mismatch` (not retryable, `details: [{ "kind": "mismatch" }]`) |
748
768
  | `JC2010` | 500 | `contract/invalid-output` | no | the handler value fails the output validator, cannot be serialized, a raw response is malformed, or a declared error's details fail their schema — the server broke the contract |
749
769
  | `JC2011` | 400 | `contract/malformed-path` | no | the path carries a malformed percent-escape |
750
- | `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (a malformed escape, invalid UTF-8) |
770
+ | `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (malformed escape/UTF-8/JSON, or a repeated JSON member) |
751
771
  | `JC2013` | 501 | `contract/not-implemented` | no | a `partial` server has no handler for the operation |
752
772
  | `JC2014` | 412 | `contract/precondition-failed` | no | `If-Match` does not match the armed tag (strong comparison), or `If-None-Match` matches on a non-GET/HEAD |
753
773
  | `JC2015` | 400 | `contract/invalid-header` | no | a declared scalar header member arrived repeated, or a header value is not a string |
@@ -773,6 +793,9 @@ wire response:
773
793
  | `JC1009` | the stream wire's SSE encoder was handed text the frame cannot carry: a bare carriage return inside `data`, a line terminator inside `event` or `id` (§18) |
774
794
  | `JC1010` | `client.subscribe` was asked for an operation that is not a subscribe operation (§19) |
775
795
  | `JC1011` | a ledger `commit`/`fail` named a ref that settles no started record — expired, reclaimed under a newer generation, or settled already (§8); refused by the ledger, reported to `onError` by the binding |
796
+ | `JC1013` | a durable command settlement capability is malformed |
797
+ | `JC2110` | a durable command was refused or failed validation |
798
+ | `JC1012` | a provider executor, descriptor host or run capability is malformed (PROVIDER-FORMAT.md) |
776
799
 
777
800
  ### §7.4 Headers
778
801
 
@@ -1341,9 +1364,9 @@ is true exactly for a 304.
1341
1364
  by `policy.errors.details`; **nothing was sent**.
1342
1365
  3. **Split by location** (`http.in`): path variables → `encodeURIComponent`
1343
1366
  per segment into the canonical template; query members →
1344
- `URLSearchParams` (an array-typed member repeats the key per element;
1345
- `null`/`undefined` members are omitted; a scalar is its string, a
1346
- non-scalar its JSON); header members → the header named by the
1367
+ `URLSearchParams` (`queryJson` members use one JSON value, including
1368
+ null and empty arrays; undefined is omitted; scalar transport members
1369
+ omit null/undefined and otherwise use their string); header members → the header named by the
1347
1370
  member lowercased (an array as a `, `-joined list); the body: `http.body`
1348
1371
  names a member → `JSON.stringify` of that member's value; otherwise
1349
1372
  the object of the body-located members, stringified. `method` from
@@ -1712,6 +1735,23 @@ The generated actions:
1712
1735
  - `<ns><op>/reset` — as for tasks: `status "idle"`, `kind`/`error`
1713
1736
  cleared, everything else kept.
1714
1737
 
1738
+ Reconnect is an opt-in **binding choice**, not a new stream policy:
1739
+ `contractAppBinding(contract, { subs: { "data.live": { reconnect: { max: 2 } } } })`
1740
+ embeds the per-operation option in its generated `withQuery` props and
1741
+ `createContractSubscription` forwards it to `client.subscribe`. During
1742
+ retry/backoff the slot stays `live`, retaining its id, value and sequence;
1743
+ replay patches continue against the same cached document. Exhaustion
1744
+ surfaces `JC2097` in the slot. Explicit server end and non-network errors
1745
+ are terminal. Stop/reset/destroy cancel the active stream and prevent
1746
+ further retries. Without the option a lost stream surfaces immediately.
1747
+ Unknown/unselected/non-subscribe operations and malformed reconnect
1748
+ options are `JC1007`. Reconnection is supplied by the HTTP client; port
1749
+ and local clients retain their existing terminal channel lifecycle.
1750
+
1751
+ The subscription handler rejects stale sequence numbers **before**
1752
+ applying a patch to its cached document, and ignores callbacks after
1753
+ cleanup; the generated action guards provide the state-side check too.
1754
+
1715
1755
  The binding additionally returns `subs` — one entry per subscribe
1716
1756
  operation — and names its handler in `subscription`:
1717
1757
 
@@ -1807,7 +1847,7 @@ point). The mapping:
1807
1847
  |---|---|
1808
1848
  | — | `openapi: "3.1.0"`, `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"`, `info` (title/version defaulting to the contract id/version), `servers` when given |
1809
1849
  | operation | `paths[<canonical path>][<lowercased method>]`, paths sorted by path then method; `operationId` = the id; `summary` = `doc`'s first line (`description` = the whole `doc` when it has more); `tags` = the id's first dotted segment |
1810
- | `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
1850
+ | `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`, or `content.application/json.schema` for JSON query members; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
1811
1851
  | body members | `requestBody`: the object of the body-located members (their `required` intersection, the input's `additionalProperties`); the whole input schema when every member is body-located; the member's own schema under `http.body`; `content[<http.media>]` |
1812
1852
  | `output`, `http.status` | `responses[<status>]` with the output schema; no content on `204`; opaque → `content[<media>]: { type: "string", format: "binary" }` |
1813
1853
  | declared `errors` | one response per status: the D7 wire-error schema (`code` **enum-pinned** to the codes of that status, `details` the declared schema when present) |
@@ -2561,3 +2601,40 @@ a body that ends without an `end` event is then `onEnd({ reason:
2561
2601
  as the HTTP client does and then does nothing with it: a channel has no
2562
2602
  network loss to reconnect from (a closed channel is `JC2074`, final),
2563
2603
  and one options object serves both clients.
2604
+
2605
+ ## Durable business commands
2606
+
2607
+ `createCommand(operation, options)` from `@jarenjs/contract/command` composes
2608
+ business settlement with the existing neutral input/output/error pipeline.
2609
+ It accepts a JSON command with `policy.idempotency: "none"`, an injected
2610
+ `repository.execute(identity, work, context)`, `identity(input, context)`,
2611
+ `authorize(input, context)` and `handler(input, context)`. The identity declares
2612
+ tenant, environment, aggregate, operation, command key, hash version and payload
2613
+ hash. The host is responsible for a collision-resistant hash over every field
2614
+ that affects the command; canonical JSON itself is a lossless alternative.
2615
+
2616
+ The handler receives the repository transaction as `ctx.host`. Expected entity
2617
+ revisions, domain writes, receipt writes and `ctx.host.jobs.enqueue` belong to
2618
+ that transaction. The neutral pipeline validates the handler's result before
2619
+ settlement. Any invalid output/error, failed receipt write or failed commit
2620
+ rolls back the transaction. Only failure codes explicitly listed in
2621
+ `commitFailures` may commit observations; ordinary declared failures roll back.
2622
+ The optional `references(outcome, context)` preserves application audit IDs.
2623
+
2624
+ Use the same `command.handler` in HTTP/local/port handler tables and
2625
+ `command.execute(input, context)` in jobs. Direct execution reports `committed`,
2626
+ `replay`, `uncommitted` or `refused`; committed/replayed outcomes live in
2627
+ `receipt.outcome`. `historic: true` means the stored effect, never current entity
2628
+ state. A separate read operation observes current state. Every invocation checks
2629
+ current authorization before accessing a receipt. Refused execution exposes no
2630
+ historic result. Bindings render a refusal as a generic host fault unless the
2631
+ host supplies `refuse(reason, context)` returning a declared operation failure.
2632
+
2633
+ Transport response caching is deliberately disallowed on a durable command:
2634
+ an HTTP ledger replay could bypass the command's current authorization hook.
2635
+ Existing HTTP TTL policies and local `capabilities.idempotency: false` retain
2636
+ their meanings. Business replay is supplied by the mapped receipt repository,
2637
+ not by the transport capability. Cancellation cannot undo an already committed
2638
+ receipt or establish that a remote effect did not happen.
2639
+
2640
+ See [durable composition](DURABLE.md) for the crash matrix and public recipe.
@@ -0,0 +1,66 @@
1
+ # Durable business operations
2
+
3
+ Commands, mapped receipts, existing jobs and workflow checkpoints compose through
4
+ injected capabilities. Application models own business history, field mapping,
5
+ authorization, revision policy and arithmetic. No second authoritative ledger or
6
+ scheduler is created. These are local atomicity and recovery guarantees; external
7
+ exactly-once delivery is not claimed.
8
+
9
+ | Boundary | Durable observation | Automatic send/replay |
10
+ |---|---|---|
11
+ | Same command identity/hash on HTTP, local or job | Original validated outcome | Business effect replays without another mutation |
12
+ | Different payload/hash version | Identity mismatch | Refused before mutation |
13
+ | Current actor unauthorized | No historic disclosure | Refused before receipt read |
14
+ | Output/error/receipt/commit failure | Entire local transaction rolls back | No business receipt exists |
15
+ | Declared committed failure | Validated observation and stable references | Historic failure replays |
16
+ | Lease/HTTP TTL expires | Business receipt remains | Receipt wins before work admission |
17
+ | Before external sending intent | Prepared leg | A valid job fence may admit its first send |
18
+ | Sending intent, lost response or lost settlement | Unresolved leg | No automatic resend |
19
+ | One confirmed leg, one unresolved leg | Both per-leg observations retained | Confirmed leg skipped; unresolved leg awaits evidence |
20
+ | Provider idempotency guarantee and explicit retry decision | Original key and finite attempt budget | Only the approved next attempt is admitted |
21
+ | Probe absent without guarantee | Uncertainty retained | Refused as retry proof |
22
+ | Lease takeover | New job fence | Stale begin/settlement refused |
23
+ | Observer navigation | Run persists; observation detaches | Resume by durable cursor |
24
+ | Explicit cancellation | Intent, worker drain, final observation | Resources release after final persistence |
25
+ | Incompatible checkpoint | Stored history retained | Refused before reuse |
26
+ | Reset/compaction | Receipt identity and audit references retained | Cannot authorize unresolved replay |
27
+
28
+ Use installed public exports:
29
+
30
+ ```js
31
+ import { createCommand } from '@jarenjs/contract/command';
32
+ import { createDbReceipts, createDbEffectStore, createDbRunStore } from '@jarenjs/linq/db';
33
+ import { createExternalEffects, createDomainRun } from '@jarenjs/flow';
34
+ import { createProviderExecutor } from '@jarenjs/contract/provider';
35
+ import { createRunPageHandler } from '@jarenjs/contract/app';
36
+ import { createRunObservation } from '@jarenjs/app';
37
+
38
+ // Application declarations: client, compiledContract, identity, authorize, adjust.
39
+ const receipts = createDbReceipts(client, { receipts: 'history', leases: 'claims' });
40
+ const command = createCommand(compiledContract.operations['item.adjust'], {
41
+ repository: receipts, identity, authorize, handler: adjust,
42
+ });
43
+ // HTTP/local use { 'item.adjust': command.handler }; jobs call command.execute.
44
+ ```
45
+
46
+ `test/consumer/durable.js` exercises the installed composition with synthetic data.
47
+ The receipt, external-effect and run tests include cross-process races, interrupted
48
+ multi-leg execution, second-run no-op checks and current authorization. The measured
49
+ cost probe is `npm run benchmark:durable`; its results below report overhead as
50
+ well as resource bounds. Real provider guarantees, downstream cutover, native
51
+ executables, PostgreSQL behavior and operator reconciliation acceptance require
52
+ separate host/application qualification. Synthetic Node/Bun results cannot prove
53
+ those claims. The retained reference fixtures remain unchanged.
54
+
55
+ <!--fact:durable.measurements-->
56
+
57
+ Measured on v24.19.0, linux/x64, AMD Ryzen 9 5900HX with Radeon Graphics.
58
+
59
+ | Commands | Domain/outbox ms | Durable command ms | Receipt replay ms | Added cost ratio | Sends / unresolved resends | Second writes / revisions | Events | Heap / RSS MiB | Teardown ms / resources |
60
+ |---:|---:|---:|---:|---:|---|---|---:|---|---|
61
+ | 32 | 7.09 | 15.88 | 5.42 | 2.24x | 2 / 0 | 0 / 0 | 8 | 23.69 / 112.07 | 0.28 / 0 |
62
+ | 128 | 14.73 | 33.22 | 13.35 | 2.26x | 2 / 0 | 0 / 0 | 8 | 41.05 / 123.25 | 0.13 / 0 |
63
+
64
+ Synthetic local SQLite commands and interrupted multi-leg effects. Baseline executes the same domain/outbox work without a receipt; durable execution adds validation, authorization and immutable replay. Limits are fixed acceptance ceilings, not performance claims. Real providers, downstream acceptance, native executables, PostgreSQL and operator reconciliation are pending.
65
+
66
+ <!--/fact-->
@@ -0,0 +1,157 @@
1
+ # Provider descriptors, authority and complete ingestion
2
+
3
+ `@jarenjs/contract/provider` exports `createProviderExecutor`, `compileProvider`
4
+ and `withProviderRun`. The executor composes core scheduling with one injected
5
+ HTTP attempt. The compiler describes JSON REST and explicitly declared GraphQL
6
+ dialects. Credentials, destination rules and provider schemas remain host inputs.
7
+
8
+ ## Executor
9
+
10
+ ```js
11
+ import { createProviderExecutor, compileProvider } from '@jarenjs/contract/provider';
12
+
13
+ const executor = createProviderExecutor({ attempts: 3, overallMs: 30000,
14
+ attemptMs: 10000, maxBytes: 262144, concurrency: 4, maxQueue: 64 });
15
+ const provider = compileProvider({
16
+ $provider: '0.1', id: 'inventory', apiVersion: '2026-01',
17
+ endpoint: 'https://inventory.example/items', protocol: 'rest',
18
+ method: 'GET', safety: 'safe-read',
19
+ query: { environment: '$.input.environment' },
20
+ response: { rows: '$.items', id: '$.id', cursor: '$.next', version: '$.version' },
21
+ pagination: { cursorParam: 'cursor', empty: 'complete' },
22
+ limits: { pages: 3, rows: 256, bytes: 262144 },
23
+ });
24
+ try {
25
+ const result = await provider.pull({ environment: 'test' }, { executor });
26
+ // Inspect result.state and the preserved result.observations before using it.
27
+ } finally { await executor.close(); }
28
+ ```
29
+
30
+ `execute(request, context)` requires an absolute HTTP(S) URL without embedded
31
+ credentials and explicit safety: `safe-read`, `provider-idempotent`, or
32
+ `single-send`. Provider-idempotent also requires `idempotencyKey`; the injected
33
+ transport must bind that key using the provider's documented mechanism. The
34
+ default transport refuses provider-idempotent requests with
35
+ `idempotency-transport-required` because it has no dialect-specific key binding. An
36
+ idempotency key does not qualify business reconciliation or durable receipts.
37
+ Single-send transport/body uncertainty returns `unresolved` and never resends
38
+ inside the executor. Supply the same core attempt budget in `context.budget`
39
+ when an outer job or workflow can invoke the same request again.
40
+
41
+ The optional injected `transport(request, {signal,attempt,maxAttempts})` returns
42
+ a Fetch `Response`. It must make exactly one request (`maxAttempts:1`), disable
43
+ SDK retries, and honor abort. The default uses `fetch` with redirects disabled.
44
+ Custom redirect handling and idempotency binding remain transport obligations.
45
+ JSON results preserve status, exact response text, consumed byte count, attempts
46
+ and Retry-After, but never raw exceptions, credentials or response handles.
47
+ `state` is `ok`, `failed`, `refused`, `unresolved` or `cancelled`.
48
+
49
+ Defaults are three total attempts, an overall deadline of 30 seconds, ten seconds
50
+ per attempt, 262144 response bytes across attempts and 262144 request-body bytes.
51
+ `baseMs` defaults to 500 and `maxMs` to 8000. `now`, `random` and `sleep` are
52
+ injectable. Core concurrency, queue, scope and spacing options are forwarded.
53
+ The `account` request field combines with URL origin for fair rate scopes.
54
+ `context.deadline` and `context.maxBytes` can tighten the executor's limits.
55
+ Stream readers stop on byte exhaustion; late replies cannot become successes
56
+ after abort/deadline. Close drains transport, body readers and retry waits.
57
+
58
+ Retryable transport failures and HTTP 408/429/5xx consume the same total budget.
59
+ `retryAfter` selects `http` (seconds/date), `milliseconds`, or `none`;
60
+ `retryAfterHeader` names the header. A server delay which cannot fit the remaining
61
+ deadline refuses with `deadline`. It is never shortened to the backoff cap.
62
+ AI's declared clamp and the contract client's jitter remain their explicit
63
+ [compatibility policies](../../core/docs/SCHEDULING.md).
64
+
65
+ ## Versioned descriptor
66
+
67
+ `$provider:'0.1'`, `id`, `apiVersion`, `endpoint`, `protocol`, `method`, `safety`
68
+ and `response` are required. API version is public data available to request
69
+ selectors; the descriptor explicitly binds it in the endpoint, headers or query.
70
+ Unknown fields are refused at compile time (`JC0021`). Malformed executor/run
71
+ host options use `JC1012`.
72
+
73
+ | Declaration | Contract |
74
+ |---|---|
75
+ | `headers` | Static public string headers. Authentication/cookie/key headers are refused; host transport owns credentials. |
76
+ | `query`, `body` | Existing Query documents over `{input,cursor,apiVersion,partition,sourceVersion}`; query returns scalar parameters, body becomes canonical JSON. GET/HEAD bodies are refused. |
77
+ | `graphql` | POST with `{query,variables?}`; variables is a Query document. `pagination.cursorVariable` receives the continuation. |
78
+ | `response.rows`, `id` | Query selectors for an array and each source identity. String identities remain strings; numeric IDs must be safe integers. |
79
+ | `response.cursor`, `hasMore` | Scalar continuation and optional explicit boolean. GraphQL terminal cursors may remain present when hasMore is false. |
80
+ | `response.errors`, `cost`, `version` | Extracted protocol evidence. GraphQL errors default to `$.errors`; any partial errors make the pull incomplete. |
81
+ | `response.transform` | `{kind:'query',expression}`, `{kind:'jslt',expression}`, or `{callback:name}`. Source raw/text/IDs remain beside transformed rows. |
82
+ | `inputSchema`, `response.schema` | Optional schemas compiled by injected `compileSchema`; absence of that capability refuses the descriptor. |
83
+ | `pagination` | `cursorParam`, `cursorVariable`, and explicit `empty:'complete'` or `'incomplete'`. Empty pages with continuation are always incomplete. |
84
+ | `limits` | Positive finite `pages`, `rows`, `bytes`; defaults match the example. Budgets apply across one iterator, including failed request bytes. |
85
+ | `capability` | `json` is supported. `upload`, `media`, `bulk`, `binary` refuse before dispatch. |
86
+
87
+ Named selector callbacks are supplied in `compileProvider(doc,{callbacks})`.
88
+ They receive cloned protocol data only. They are trusted host functions, not a
89
+ JavaScript sandbox; the framework never supplies run resources to them.
90
+ Schema, Query and JSLT each retain their existing compiler and semantics.
91
+
92
+ `pages(input,context)` is an async iterator. Each `page` contains exact `text`,
93
+ parsed `raw`, transformed `rows`, source `ids`, input `cursor`, `continuation`,
94
+ `complete`, `version`, `errors`, `cost`, `reason`, `bytes` and `attempts`.
95
+ The consumer's next pull admits the next request. A terminal `complete`,
96
+ `incomplete` or `refused` record reports totals; `pull` collects that bounded
97
+ sequence into `observations`. Repeated cursors, duplicate IDs, empty intermediate
98
+ pages, partial errors, broken transforms and exhausted credits remain incomplete.
99
+ Accepted partial pages remain inspectable. Bytes refused at a stream limit are
100
+ discarded, and malformed response text is returned without claiming parsed rows.
101
+ Raw text retains wire distinctions that JSON numbers alone cannot represent.
102
+
103
+ ## Private run authority
104
+
105
+ `withProviderRun(evidence,host,work,options)` composes the existing
106
+ identify/acquire/release hooks. Evidence is a closed JSON record of nonempty
107
+ opaque strings: `runId`, `actor`, `environment`, `destination`, `revision`, `lease`.
108
+ The host implements `identify(meta)`, `acquire(input,identity,enter)`,
109
+ `current(evidence,privateHost,operation)` and `transport(request,context)`.
110
+ `current` re-reads live authority and returns matching evidence or null.
111
+ The framework compares every field before dispatch and publication, including
112
+ after asynchronous refresh. All allowlists and membership rules stay in current.
113
+
114
+ The callback receives a run with enumerable `evidence` only. Its private
115
+ `execute`, `check`, `publish` and `signal` members cannot enter ordinary JSON
116
+ state. Transport alone receives `context.host`; privileged clients are unique
117
+ to concurrent runs. Host acquisition must serialize account switching where
118
+ required. Shutdown stops admission and drains workers and callbacks before
119
+ acquired and identity resources release, in that order. Released run callbacks
120
+ cannot dispatch or publish again. The library suppresses raw host errors;
121
+ applications must also keep secrets out of explicitly returned business data.
122
+
123
+ Flow DAG/workflow `run(...,{resources})` passes resources as the task handler's
124
+ third argument, separate from JSON input, checkpoint identity and trace data.
125
+ Tasks with resources drain on abort before the workflow rejects. Compile the
126
+ workflow once, then provide the particular run on each invocation. Authority
127
+ is reacquired on resume; persisted references never restore live credentials.
128
+
129
+ ## Complete snapshots and qualification
130
+
131
+ [`createIngestion`](../../flow/docs/WORKFLOW-FORMAT.md#complete-provider-ingestion)
132
+ composes bounded pages with the existing workflow engine and an injected store.
133
+ [`createDbIngestionStore`](../../linq/docs/DB-CLIENT.md#complete-ingestion-store)
134
+ co-commits pages/checkpoints and publishes only complete requested partitions.
135
+ Source snapshot or monotonic revision evidence is mandatory, including a version
136
+ on every page. Identical published input is a no-op; application reconciliation
137
+ owns manual provenance. No network operation holds a page transaction.
138
+
139
+ Offline REST, partial GraphQL and archive-link transcripts and two independent
140
+ bounded consumers are executable through `npm run test:packed` on Node and Bun.
141
+ Node's abrupt-process recovery, rollback and zero-write tests run in the unit
142
+ suite. Real provider authority/read-back, native external SDK behavior and
143
+ downstream cutover remain separate qualifications. External writes await durable
144
+ receipt/reconciliation capability; streaming DAG inputs are not required here.
145
+
146
+ <!--fact:providers.measurements-->
147
+
148
+ Measured on v24.19.0, linux/x64, AMD Ryzen 9 5900HX with Radeon Graphics.
149
+
150
+ | Consumer | Rows | Requests / budget | Response bytes / budget | Retained reader ms | Native ingestion ms | Second writes / revisions | Teardown ms / remaining resources | Sampled heap / RSS MiB |
151
+ |---|---:|---|---|---:|---:|---|---|---|
152
+ | catalog | 256 | 3 / 3 | 66426 / 262144 | 0.96 | 64.45 | 0 / 0 | 0.27 / 0 | 35.69 / 116.45 |
153
+ | archive-stock | 512 | 6 / 6 | 135886 / 524288 | 1.31 | 79.46 | 0 / 0 | 0.11 / 0 | 42.55 / 173.56 |
154
+
155
+ Offline synthetic Node SQLite ingestion. The retained reader only extracts recorded transcripts; native timings include opening storage, descriptor execution, private authority checks, page/checkpoint commits, publication and zero-write replay. Their timings describe different work. No real provider latency, credentials, external write reconciliation or production cutover is qualified.
156
+
157
+ <!--/fact-->
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/contract",
3
3
  "private": false,
4
- "version": "0.73.0",
4
+ "version": "0.83.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -59,7 +59,15 @@
59
59
  "default": "./src/project/index.js"
60
60
  },
61
61
  "./schemas/*": "./schemas/*",
62
- "./package.json": "./package.json"
62
+ "./package.json": "./package.json",
63
+ "./provider": {
64
+ "types": "./dist/types/provider/index.d.ts",
65
+ "default": "./src/provider/index.js"
66
+ },
67
+ "./command": {
68
+ "types": "./dist/types/command.d.ts",
69
+ "default": "./src/command.js"
70
+ }
63
71
  },
64
72
  "files": [
65
73
  "dist/types/",
@@ -102,9 +110,9 @@
102
110
  "prepack": "npm run build:types"
103
111
  },
104
112
  "dependencies": {
105
- "@jarenjs/core": "^0.73.0",
106
- "@jarenjs/json": "^0.73.0",
107
- "@jarenjs/validate": "^0.73.0",
108
- "@jarenjs/emit": "^0.73.0"
113
+ "@jarenjs/core": "^0.83.2",
114
+ "@jarenjs/json": "^0.83.2",
115
+ "@jarenjs/validate": "^0.83.2",
116
+ "@jarenjs/emit": "^0.83.2"
109
117
  }
110
118
  }
@@ -34,6 +34,9 @@ import { ContractHostError } from '../errors.js';
34
34
  * @property {string} [statePath] - where the slice lives in app state, as a
35
35
  * chain of identifier-safe segments; default `/contract`
36
36
  * @property {readonly string[]} [ops] - the operations the app uses; default every operation
37
+ * @property {Record<string, { reconnect: { max: number } }>} [subs] - per
38
+ * subscribe-operation options. Reconnect is opt-in; the slot stays live
39
+ * while the HTTP client resumes, and reports error after exhaustion.
37
40
  */
38
41
 
39
42
  /**
@@ -145,8 +148,9 @@ function replace(path, value) {
145
148
  * @param {Record<string, any>} actions
146
149
  * @param {any[]} subs
147
150
  * @param {Record<string, any>} properties
151
+ * @param {{ max: number } | undefined} reconnect
148
152
  */
149
- function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties) {
153
+ function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties, reconnect) {
150
154
  const idGuard = { $eq: ['$payload.id', idQuery] };
151
155
 
152
156
  setObjectMember(actions, `${namespace}${id}/start`, {
@@ -224,6 +228,7 @@ function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuer
224
228
  snapshot: `${namespace}${id}/snapshot`,
225
229
  patch: `${namespace}${id}/patch`,
226
230
  error: `${namespace}${id}/error`,
231
+ ...(reconnect === undefined ? {} : { reconnect: { $const: { max: reconnect.max } } }),
227
232
  },
228
233
  });
229
234
 
@@ -302,6 +307,21 @@ export function contractAppBinding(contract, options = {}) {
302
307
  }
303
308
  const ops = options.ops === undefined ? contract.ids : options.ops;
304
309
  if (!Array.isArray(ops)) throw host('ops must be an array of operation ids');
310
+ const streamOptions = options.subs === undefined ? {} : options.subs;
311
+ if (streamOptions === null || typeof streamOptions !== 'object' || Array.isArray(streamOptions))
312
+ throw host('subs must be an operation-options object');
313
+ for (const id of Object.keys(streamOptions)) {
314
+ if (!ops.includes(id) || !Object.hasOwn(contract.operations, id) || contract.operations[id].kind !== 'subscribe')
315
+ throw host(`subs names '${id}', which is not a selected subscribe operation`);
316
+ const entry = streamOptions[id];
317
+ const reconnect = entry?.reconnect;
318
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
319
+ || Object.keys(entry).some((key) => key !== 'reconnect')
320
+ || reconnect === null || typeof reconnect !== 'object' || Array.isArray(reconnect)
321
+ || Object.keys(reconnect).some((key) => key !== 'max')
322
+ || !Number.isSafeInteger(reconnect.max) || reconnect.max < 0)
323
+ throw host(`subs['${id}'] must be { reconnect: { max } } with a non-negative safe integer max`);
324
+ }
305
325
  const queryRoot = '$' + statePath.replaceAll('/', '.');
306
326
 
307
327
  /** @type {Record<string, TaskSlot | StreamSlot>} */
@@ -328,7 +348,8 @@ export function contractAppBinding(contract, options = {}) {
328
348
  const done = `${namespace}${id}/done`;
329
349
 
330
350
  if (op.kind === 'subscribe') {
331
- appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties);
351
+ appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties,
352
+ Object.hasOwn(streamOptions, id) ? streamOptions[id].reconnect : undefined);
332
353
  setObjectMember(slice, id, { id: 0, status: 'idle', kind: null, input: null, value: null, error: null, meta: null, seq: 0 });
333
354
  required.push(id);
334
355
  continue;
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
@@ -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';
@@ -48,7 +50,7 @@ import { PORT_LOCAL_ERRORS } from '../pipeline.js';
48
50
  /**
49
51
  * The props of one generated subs entry: the operation, the slot id and
50
52
  * input resolved from state, and the action names to dispatch.
51
- * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
53
+ * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string, reconnect?: { max: number } }} StreamProps
52
54
  */
53
55
 
54
56
  /**
@@ -86,12 +88,18 @@ export function createContractSubscription(client, options = {}) {
86
88
  const { op, id } = p;
87
89
  /** @type {unknown} */
88
90
  let doc = null;
91
+ let seq = -1;
92
+ let stopped = false;
89
93
  const sub = client.subscribe(op, p.input === undefined ? null : p.input, {
94
+ ...(p.reconnect === undefined ? {} : { reconnect: p.reconnect }),
90
95
  onSnapshot: (/** @type {unknown} */ value, /** @type {{ seq: number }} */ info) => {
96
+ if (stopped) return;
91
97
  doc = value;
98
+ seq = info.seq;
92
99
  dispatch(p.snapshot, { id, value, seq: info.seq });
93
100
  },
94
101
  onPatch: (/** @type {{ patch: any[], seq: number }} */ emission) => {
102
+ if (stopped || emission.seq <= seq) return;
95
103
  try {
96
104
  doc = compileJSONPatch(/** @type {any} */ (emission.patch))(doc);
97
105
  }
@@ -107,10 +115,12 @@ export function createContractSubscription(client, options = {}) {
107
115
  });
108
116
  return;
109
117
  }
118
+ seq = emission.seq;
110
119
  dispatch(p.patch, { id, value: doc, seq: emission.seq });
111
120
  },
112
- onError: (/** @type {Outcome} */ outcome) => dispatch(p.error, { id, outcome }),
121
+ onError: (/** @type {Outcome} */ outcome) => { if (!stopped) dispatch(p.error, { id, outcome }); },
113
122
  onEnd: () => {
123
+ if (stopped) return;
114
124
  // the server ended the stream: the slot must say the channel is
115
125
  // gone, so a view can offer a reconnect (a fresh start)
116
126
  dispatch(p.error, {
@@ -121,10 +131,29 @@ export function createContractSubscription(client, options = {}) {
121
131
  });
122
132
  },
123
133
  });
124
- return () => sub.stop();
134
+ return () => { stopped = true; sub.stop(); };
125
135
  };
126
136
  }
127
137
 
128
138
  // re-exported so a host settling its own outcomes beside the handler
129
139
  // needs no second import path
130
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
+ }