@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.
- package/README.md +41 -0
- package/dist/types/app/binding.d.ts +10 -0
- package/dist/types/app/index.d.ts +1 -1
- package/dist/types/app/subscription.d.ts +14 -1
- package/dist/types/client/http.d.ts +1 -1
- package/dist/types/command.d.ts +27 -0
- package/dist/types/compile.d.ts +5 -0
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/http/dispatch.d.ts +2 -2
- package/dist/types/http/wire.d.ts +6 -5
- 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 +107 -30
- package/docs/DURABLE.md +66 -0
- package/docs/PROVIDER-FORMAT.md +157 -0
- package/package.json +14 -6
- package/src/app/binding.js +23 -2
- package/src/app/index.js +1 -1
- package/src/app/subscription.js +32 -3
- package/src/client/http.js +10 -52
- package/src/command.js +70 -0
- package/src/compile.js +17 -12
- package/src/errors.js +4 -0
- package/src/http/dispatch.js +2 -2
- package/src/http/serve.js +1 -7
- package/src/http/wire.js +15 -10
- package/src/project/openapi.js +3 -1
- 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/docs/CONTRACT-FORMAT.md
CHANGED
|
@@ -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
|
-
|
|
265
|
-
normalizer compiled
|
|
266
|
-
(`@jarenjs/validate/normalize`)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
`
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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 (
|
|
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` (
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
|
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.
|
package/docs/DURABLE.md
ADDED
|
@@ -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.
|
|
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.
|
|
106
|
-
"@jarenjs/json": "^0.
|
|
107
|
-
"@jarenjs/validate": "^0.
|
|
108
|
-
"@jarenjs/emit": "^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
|
}
|
package/src/app/binding.js
CHANGED
|
@@ -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
|
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';
|
|
@@ -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
|
+
}
|