@jarenjs/flow 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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @jarenjs/flow
2
2
 
3
+ Complete ingestion and durable domain runs reuse the existing workflow engine and injected persistence/job fences. See the [combined adoption recipe](../../docs/ADOPTION-EVIDENCE.md) for restart and no-op proofs; real provider read-back and operator reconciliation remain host responsibilities.
4
+
3
5
  Executable JSON workflows: flat FSMs, statecharts, DAGs, and a composition
4
6
  compiler that combines long-lived control with concurrent work regions.
5
7
  The **jaren-fsm 0.1 format**
@@ -345,3 +347,10 @@ Every subpath a consumer can import, derived from the manifest by
345
347
  Unit tests live in `test/flow/` at the repository root
346
348
  (`npm run test:flow`). See the repository [README](../../README.md) for
347
349
  the full suite documentation.
350
+
351
+ Bounded provider ingestion composes the workflow engine with injected page and
352
+ transaction capabilities. `createIngestion` resumes source/version/partition
353
+ checkpoints and publishes only complete coverage; see the
354
+ [ingestion contract](docs/WORKFLOW-FORMAT.md#complete-provider-ingestion).
355
+ Run-scoped `resources` reach tasks separately from JSON checkpoints and drain
356
+ before release.
@@ -47,6 +47,7 @@ export type CompiledDag = {
47
47
  onNode?: (record: DagNodeRecord) => void;
48
48
  runId?: string;
49
49
  drainOnAbort?: boolean;
50
+ resources?: unknown;
50
51
  }) => Promise<any>;
51
52
  };
52
53
  /**
@@ -56,8 +57,8 @@ export type CompiledDag = {
56
57
  * registry resolution — `run` only executes closures.
57
58
  *
58
59
  * @param {any} doc - the jaren-dag document
59
- * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
60
- * | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
60
+ * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any)
61
+ * | { run: (props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any, version?: string,
61
62
  * taskVersions?: Record<string, string> }>,
62
63
  * checkpoint?: DagCheckpointStore, revision?: string }} [options]
63
64
  * @returns {CompiledDag}
@@ -70,11 +71,11 @@ export declare function compileDag(doc: any, options?: {
70
71
  tasks?: Record<string, ((props: {
71
72
  with: any;
72
73
  input: any;
73
- }, signal: AbortSignal) => any) | {
74
+ }, signal: AbortSignal, resources?: any) => any) | {
74
75
  run: (props: {
75
76
  with: any;
76
77
  input: any;
77
- }, signal: AbortSignal) => any;
78
+ }, signal: AbortSignal, resources?: any) => any;
78
79
  version?: string;
79
80
  taskVersions?: Record<string, string>;
80
81
  }>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The provider executor is single-attempt for this composition. Its dispatch
3
+ * hook persists intent after scheduling/authority checks and before transport.
4
+ * Every ambiguous result stays unresolved; read-back/operator reconciliation is
5
+ * explicit. A new worker may inspect the record but cannot blindly resend it.
6
+ * @param {{ store: any, executor: any, authorize: Function, classify: Function }} options
7
+ */
8
+ export declare function createExternalEffects(options: {
9
+ store: any;
10
+ executor: any;
11
+ authorize: Function;
12
+ classify: Function;
13
+ }): Readonly<{
14
+ run: (id: string, resources: {
15
+ lease: any;
16
+ signal?: AbortSignal;
17
+ }) => Promise<any>;
18
+ /** Existing queue worker binding. Unresolved/refused operations pause in the
19
+ * queue's cancelled state, which can be explicitly requeued for reconciliation.
20
+ * They must not become completed jobs whose identity cannot be claimed again.
21
+ * @param {{ operationId: string }} payload @param {any} context */
22
+ handler(payload: {
23
+ operationId: string;
24
+ }, context: any): Promise<any>;
25
+ }>;
@@ -9,6 +9,7 @@ export { fsmToApp, fsmStateSchema } from './app.js';
9
9
  export { compileDag } from './dag.js';
10
10
  export { compileStatechart, createStatechartSession } from './statechart.js';
11
11
  export { lowerWorkflow, compileWorkflow } from './workflow.js';
12
+ export { createIngestion } from './ingest.js';
12
13
  export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
13
14
  export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
14
15
  export type StatechartState = import('./statechart.js').StatechartState;
@@ -27,3 +28,5 @@ export type CompiledWorkflow = import('./workflow.js').CompiledWorkflow;
27
28
  /** @typedef {import('./workflow.js').WorkflowStore} WorkflowStore */
28
29
  /** @typedef {import('./workflow.js').WorkflowResult} WorkflowResult */
29
30
  /** @typedef {import('./workflow.js').CompiledWorkflow} CompiledWorkflow */
31
+ export { createExternalEffects } from './effects.js';
32
+ export { createDomainRun } from './runs.js';
@@ -0,0 +1,48 @@
1
+ export type IngestionPlan = {
2
+ source: string;
3
+ version: string;
4
+ generation: string;
5
+ partitions: string[];
6
+ input: any;
7
+ policyRevision: string;
8
+ consistency: 'snapshot' | 'revision';
9
+ };
10
+ /**
11
+ * @typedef {{ source: string, version: string, generation: string,
12
+ * partitions: string[], input: any, policyRevision: string,
13
+ * consistency: 'snapshot' | 'revision' }} IngestionPlan
14
+ */
15
+ /**
16
+ * Compose the existing workflow engine with bounded provider pages and atomic
17
+ * staging. source(plan, phase) proves either a stable source snapshot or a
18
+ * monotonic source revision. Local generation numbers are never that proof.
19
+ * @param {{ provider: { pages: (input: any, context: any) => AsyncIterable<any> },
20
+ * store: { begin: Function, stage: Function, invalidate: Function, publish: Function },
21
+ * source: (plan: IngestionPlan, phase: string) => any,
22
+ * maxPartitions?: number, maxPages?: number, maxRows?: number, maxBytes?: number }} options
23
+ */
24
+ export declare function createIngestion(options: {
25
+ provider: {
26
+ pages: (input: any, context: any) => AsyncIterable<any>;
27
+ };
28
+ store: {
29
+ begin: Function;
30
+ stage: Function;
31
+ invalidate: Function;
32
+ publish: Function;
33
+ };
34
+ source: (plan: IngestionPlan, phase: string) => any;
35
+ maxPartitions?: number;
36
+ maxPages?: number;
37
+ maxRows?: number;
38
+ maxBytes?: number;
39
+ }): Readonly<{
40
+ /** @param {IngestionPlan} plan
41
+ * @param {{ executor: any, authority?: any, signal?: AbortSignal, deadline?: number }} resources */
42
+ run(plan: IngestionPlan, resources: {
43
+ executor: any;
44
+ authority?: any;
45
+ signal?: AbortSignal;
46
+ deadline?: number;
47
+ }): Promise<any>;
48
+ }>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bind one compiled workflow to mapped run/checkpoint persistence. Leases are
3
+ * the existing queue's capabilities; resources stay private and release only
4
+ * after tasks drain and final cancellation/failure observation is persisted.
5
+ * @param {any} document @param {{ store: any, schemaVersion: string, tasks?: any }} options
6
+ */
7
+ export declare function createDomainRun(document: any, options: {
8
+ store: any;
9
+ schemaVersion: string;
10
+ tasks?: any;
11
+ }): Readonly<{
12
+ /** @param {string} id @param {any} input @param {{ lease: any, signal?: AbortSignal, release?: Function, event?: any, [key: string]: any }} resources */
13
+ run(id: string, input: any, resources: {
14
+ lease: any;
15
+ signal?: AbortSignal;
16
+ release?: Function;
17
+ event?: any;
18
+ [key: string]: any;
19
+ }): Promise<import("./workflow.js").WorkflowResult>;
20
+ /** Cancellation authority is checked by the host's command. Navigation must
21
+ * detach observation, not call this method. Remote workers see durable intent
22
+ * at the next checkpoint; local workers abort and drain before this resolves.
23
+ * @param {string} id @param {number} revision @param {{ actor: string, reason: string }} evidence */
24
+ cancel(id: string, revision: number, evidence: {
25
+ actor: string;
26
+ reason: string;
27
+ }): Promise<any>;
28
+ }>;
@@ -73,6 +73,7 @@ export type WorkflowRunOptions = {
73
73
  snapshot?: any;
74
74
  expectedGeneration?: number;
75
75
  signal?: AbortSignal;
76
+ resources?: unknown;
76
77
  now?: number;
77
78
  event?: {
78
79
  type: string;
@@ -83,7 +84,7 @@ export type WorkflowRunOptions = {
83
84
  /** @typedef {{ load: (runId: string) => WorkflowSnapshot | null | Promise<WorkflowSnapshot | null>,
84
85
  * save: (runId: string, snapshot: WorkflowSnapshot, expectedGeneration: number) => boolean | Promise<boolean> }} WorkflowStore */
85
86
  /** @typedef {{ runId: string, snapshot?: any, expectedGeneration?: number,
86
- * signal?: AbortSignal, now?: number, event?: {type: string, payload?: any},
87
+ * signal?: AbortSignal, resources?: unknown, now?: number, event?: {type: string, payload?: any},
87
88
  * onTrace?: (record: any) => void }} WorkflowRunOptions */
88
89
  /** Compile once; each run owns its control and checkpoint records.
89
90
  * Store save MUST atomically compare expectedGeneration (0 means absent).
@@ -258,3 +258,95 @@ review/retry, plus general task/choice/loop/nesting/wait, failure/resume,
258
258
  checkpoint reuse, generation races, changed identities, cancellation and
259
259
  schema/compiler agreement. Streaming input, graphical editing, action-document
260
260
  awaiting and domain orchestration projections are separate contracts.
261
+
262
+ ## Complete provider ingestion
263
+
264
+ `createIngestion({provider,store,source,maxPartitions?,maxPages?,maxRows?,maxBytes?})`
265
+ compiles one workflow over injected capabilities. It imports no provider or
266
+ persistence package. `provider.pages(input,context)` supplies bounded pages;
267
+ `store.begin/stage/invalidate/publish` supplies atomic persistence. The public
268
+ implementations are `compileProvider` from `@jarenjs/contract/provider` and
269
+ `createDbIngestionStore` from `@jarenjs/linq/db`.
270
+
271
+ ```js
272
+ import { createIngestion } from '@jarenjs/flow';
273
+
274
+ const ingestion = createIngestion({ provider, store,
275
+ source: async (plan, phase) => readSourceEvidence(plan, phase),
276
+ maxPartitions: 8, maxPages: 32, maxRows: 4096, maxBytes: 1048576,
277
+ });
278
+ const result = await ingestion.run({
279
+ source: 'inventory/test', version: 'source-snapshot-1', generation: 'pull-1',
280
+ partitions: ['north', 'south'], input: {}, policyRevision: 'policy-1',
281
+ consistency: 'snapshot',
282
+ }, { executor: providerRun, authority: providerRun, signal: providerRun.signal });
283
+ ```
284
+
285
+ A plan has a source identity, source version, local generation, unique requested
286
+ partitions, JSON input and reconciliation policy revision. `consistency` is
287
+ `snapshot` (an upstream stable snapshot) or `revision` (an upstream monotonic
288
+ revision which changes with every relevant source mutation). The injected source
289
+ check returns `{version,consistency}` at start, after each page and before
290
+ publication. It must report actual upstream evidence, not echo local generation
291
+ numbers. Each page's version must match. A changed source invalidates staging;
292
+ a host may start a fresh generation after obtaining fresh source evidence.
293
+
294
+ The iterator resumes each unfinished partition from its committed continuation.
295
+ It waits for a page to commit before requesting another. No network request
296
+ holds a database transaction. Default aggregate limits are 64 partitions, 64
297
+ pages, 65536 rows and 16777216 bytes; counts include prior committed pages on
298
+ resume. The descriptor's page limits additionally bound an individual iterator.
299
+ An interrupted pull retains staging; failed/partial partitions never publish a
300
+ complete pointer. Pages reporting partial errors retain their exact raw wire
301
+ text and cannot complete a partition. Source-invalid pages within limits remain
302
+ inspectable but cannot certify completion.
303
+
304
+ Publication checks source and optional current authority again. For private
305
+ runs pass the same `withProviderRun` capability as executor and authority;
306
+ its publication gate suppresses callbacks after cancellation or revocation.
307
+ Returning `unchanged` requires matching source version, input, requested
308
+ partitions, consistency and policy revision, and produces zero fact writes or
309
+ revisions without requesting another page. A new source or policy revision
310
+ stages a new generation. The store owns transactional recovery; application
311
+ policy owns reconciliation, retention and downstream cutover.
312
+
313
+ DAG and workflow run options accept `resources` separately from JSON input.
314
+ Task handlers receive `(props, signal, resources)`; resources never participate
315
+ in checkpoint serialization, replay identity or trace output. A resource-bearing
316
+ run drains tasks on abort before settling so the caller can release its lease.
317
+ Tasks must return JSON results and keep resource handles out of their output.
318
+
319
+ ## Domain run and external-effect adoption
320
+
321
+ `createExternalEffects({ store, executor, authorize, classify })` composes the
322
+ existing workflow engine with a mapped effect store and the public provider
323
+ executor. `run(operationId, { lease, signal? })` reads the reviewed plan,
324
+ reauthorizes resume and dispatch, and persists sending intent from the executor's
325
+ `beforeDispatch` hook. This runs after scheduler admission, immediately before
326
+ transport. A private one-attempt budget prevents executor retries from escaping
327
+ the durable per-leg budget. Only validated successful provider responses passed
328
+ through the host's `classify(response, plannedLeg)` become confirmed/rejected
329
+ public evidence. Timeout, abort, disconnect, invalid response or lost settlement
330
+ remain unresolved. Restart recovers stale sending intent without resending it.
331
+ Read-back and operator decisions use the store's explicit `reconcile` method.
332
+ Register `effects.handler` with the existing queue worker to pause unresolved or
333
+ refused operations in the queue's cancelled state. Explicit requeue/claim then
334
+ provides a fresh reconciliation fence; confirmed operations complete normally.
335
+
336
+ `createDomainRun(document, { store, schemaVersion, tasks })` compiles the existing
337
+ workflow once and binds its checkpoint CAS to application run records.
338
+ `run(id, input, { lease, signal?, event?, release?, ...resources })` preserves the
339
+ application run ID. `lease` may be a capability or a function returning the
340
+ worker's current renewed lease. Checkpoint provenance includes the canonical
341
+ workflow/schema identity and the engine's input/task-version identity.
342
+
343
+ `cancel(id, observedRevision, { actor, reason })` records explicit cancellation
344
+ intent, stops local task admission, signals and drains local workers, and waits
345
+ for their final observation and resource release. Remote workers see the durable
346
+ cancellation at the next checkpoint/admission boundary; a remote cancel request
347
+ may therefore return pending intent rather than completed cancellation. The
348
+ requesting contract must authorize the actor. A signal alone also requires
349
+ workers to drain; failed/cancelled observations use fixed public statuses without
350
+ raw exception messages. Attaching an observer is independent of starting/cancelling
351
+ a run. Reset is a separate, explicitly guarded store operation and cannot erase
352
+ business receipts or unresolved effects.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/flow",
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",
@@ -49,7 +49,7 @@
49
49
  "prepack": "npm run build:types"
50
50
  },
51
51
  "dependencies": {
52
- "@jarenjs/core": "^0.73.0",
53
- "@jarenjs/json": "^0.73.0"
52
+ "@jarenjs/core": "^0.83.2",
53
+ "@jarenjs/json": "^0.83.2"
54
54
  }
55
55
  }
package/src/dag.js CHANGED
@@ -71,7 +71,7 @@ function compileEmbedded(compile, embedded, docPath) {
71
71
  * declared task identity this workflow depends on, keyed by node id and
72
72
  * SORTED (§7.8); a nested workflow's map composes under its node's
73
73
  * path. Empty when no node declares a version.
74
- * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string, drainOnAbort?: boolean }) => Promise<any>} run -
74
+ * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string, drainOnAbort?: boolean, resources?: unknown }) => Promise<any>} run -
75
75
  * Execute the graph for one input (`undefined` reads as `null`).
76
76
  */
77
77
 
@@ -121,8 +121,8 @@ function normalizeTaskEntry(entry, name) {
121
121
  * registry resolution — `run` only executes closures.
122
122
  *
123
123
  * @param {any} doc - the jaren-dag document
124
- * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
125
- * | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
124
+ * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any)
125
+ * | { run: (props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any, version?: string,
126
126
  * taskVersions?: Record<string, string> }>,
127
127
  * checkpoint?: DagCheckpointStore, revision?: string }} [options]
128
128
  * @returns {CompiledDag}
@@ -413,7 +413,7 @@ export function compileDag(doc, options) {
413
413
  throw new TypeError(
414
414
  'run: a checkpointed dag needs a non-empty string "runId" to persist under');
415
415
  }
416
- return execute(input === undefined ? null : input, signal, onNode, runId, opts?.drainOnAbort === true);
416
+ return execute(input === undefined ? null : input, signal, onNode, runId, opts?.drainOnAbort === true || opts?.resources !== undefined, opts?.resources);
417
417
  }
418
418
 
419
419
  /**
@@ -422,8 +422,9 @@ export function compileDag(doc, options) {
422
422
  * @param {((record: DagNodeRecord) => void)|undefined} onNode
423
423
  * @param {string|undefined} runId
424
424
  * @param {boolean} drainOnAbort
425
+ * @param {unknown} resources - host-private, never part of input/checkpoint identity
425
426
  */
426
- async function execute(runInput, signal, onNode, runId, drainOnAbort) {
427
+ async function execute(runInput, signal, onNode, runId, drainOnAbort, resources) {
427
428
  const controller = new AbortController();
428
429
  /** @type {FlowRuntimeError|null} */
429
430
  let failure = null;
@@ -566,7 +567,7 @@ export function compileDag(doc, options) {
566
567
  with: node.with === null ? null : node.with(scope) ?? null,
567
568
  input: scope,
568
569
  };
569
- value = (await node.handler(props, controller.signal)) ?? null;
570
+ value = (await node.handler(props, controller.signal, resources)) ?? null;
570
571
  break;
571
572
  }
572
573
  default: value = null; break;
package/src/effects.js ADDED
@@ -0,0 +1,77 @@
1
+ //@ts-check
2
+ /** External effects orchestrate existing workflows, provider admission and fenced storage. */
3
+ import { deepFreeze } from '@jarenjs/core/object';
4
+ import { compileWorkflow } from './workflow.js';
5
+
6
+ /**
7
+ * The provider executor is single-attempt for this composition. Its dispatch
8
+ * hook persists intent after scheduling/authority checks and before transport.
9
+ * Every ambiguous result stays unresolved; read-back/operator reconciliation is
10
+ * explicit. A new worker may inspect the record but cannot blindly resend it.
11
+ * @param {{ store: any, executor: any, authorize: Function, classify: Function }} options
12
+ */
13
+ export function createExternalEffects(options) {
14
+ if (!options || typeof options.executor?.execute !== 'function' || typeof options.authorize !== 'function'
15
+ || typeof options.classify !== 'function' || ['get', 'begin', 'settle', 'recover'].some((name) => typeof options.store?.[name] !== 'function'))
16
+ throw new TypeError('external effects need a fenced store, provider executor, authorization and evidence classifier');
17
+ const { store, executor, authorize, classify } = options;
18
+ const workflow = compileWorkflow({ $workflow: '0.2', revision: 'external-effects/1', initial: 'send', states: {
19
+ send: { work: { task: 'send', version: '1' }, then: 'done' }, done: { final: true },
20
+ } }, { tasks: { send: { version: '1', run: async ({ input }, signal, resources) => {
21
+ const lease = () => typeof resources.lease === 'function' ? resources.lease() : resources.lease;
22
+ let record = await store.get(input.id);
23
+ if (await authorize(deepFreeze(record.plan), 'resume', resources) !== true) return { state: 'refused', reason: 'unauthorized' };
24
+ record = (await store.recover(record.id, record.revision, lease())).record;
25
+ deepFreeze(record.plan);
26
+ for (const planned of record.plan.legs) {
27
+ const leg = record.legs.find((value) => value.id === planned.id);
28
+ if (leg.state === 'confirmed' || leg.state === 'rejected') continue;
29
+ if (signal.aborted) return { state: 'cancelled', revision: record.revision };
30
+ if (leg.state === 'unresolved') return { state: 'unresolved', revision: record.revision, leg: leg.id };
31
+ let started = false, used = 0;
32
+ const budget = { safety: planned.request.safety, get used() { return used; }, get remaining() { return 1 - used; }, take: () => used++ === 0 };
33
+ let response;
34
+ try {
35
+ response = await executor.execute(planned.request, { signal, budget, beforeDispatch: async () => {
36
+ if (started || await authorize(deepFreeze(record.plan), 'dispatch', resources) !== true) return false;
37
+ const intent = await store.begin(record.id, planned.id, record.revision, lease());
38
+ if (intent.state !== 'sending' || intent.writes !== 1) return false;
39
+ record = intent.record;
40
+ started = true;
41
+ return true;
42
+ } });
43
+ }
44
+ catch { response = { state: 'unresolved' }; }
45
+ if (!started) return { state: 'refused', reason: 'not-admitted', revision: record.revision };
46
+ let outcome = { state: 'unresolved', evidence: { reason: 'unconfirmed' } };
47
+ if (!signal.aborted && response.state === 'ok') {
48
+ try {
49
+ const observed = await classify(response, planned);
50
+ if (observed && ['confirmed', 'rejected'].includes(observed.state) && Object.hasOwn(observed, 'evidence')) outcome = observed;
51
+ }
52
+ catch { /* malformed or unvalidated remote evidence remains unresolved */ }
53
+ }
54
+ record = (await store.settle(record.id, planned.id, record.revision, lease(), outcome)).record;
55
+ if (outcome.state === 'unresolved') return { state: 'unresolved', revision: record.revision, leg: planned.id };
56
+ }
57
+ return { state: 'complete', revision: record.revision, legs: record.legs.map(({ id, state }) => ({ id, state })) };
58
+ } } } });
59
+ /** @param {string} id @param {{ lease: any, signal?: AbortSignal }} resources */
60
+ async function run(id, resources) {
61
+ const result = await workflow.run({ id }, { runId: id, resources, signal: resources.signal });
62
+ return result.result;
63
+ }
64
+ return Object.freeze({
65
+ run,
66
+ /** Existing queue worker binding. Unresolved/refused operations pause in the
67
+ * queue's cancelled state, which can be explicitly requeued for reconciliation.
68
+ * They must not become completed jobs whose identity cannot be claimed again.
69
+ * @param {{ operationId: string }} payload @param {any} context */
70
+ async handler(payload, context) {
71
+ if (typeof context?.pause !== 'function') throw new TypeError('external handler needs a job worker pause capability');
72
+ const result = await run(payload.operationId, context);
73
+ if (result.state !== 'complete') await context.pause();
74
+ return result;
75
+ },
76
+ });
77
+ }
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ export { fsmToApp, fsmStateSchema } from './app.js';
11
11
  export { compileDag } from './dag.js';
12
12
  export { compileStatechart, createStatechartSession } from './statechart.js';
13
13
  export { lowerWorkflow, compileWorkflow } from './workflow.js';
14
+ export { createIngestion } from './ingest.js';
14
15
  export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
15
16
  export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
16
17
 
@@ -22,3 +23,5 @@ export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
22
23
  /** @typedef {import('./workflow.js').WorkflowStore} WorkflowStore */
23
24
  /** @typedef {import('./workflow.js').WorkflowResult} WorkflowResult */
24
25
  /** @typedef {import('./workflow.js').CompiledWorkflow} CompiledWorkflow */
26
+ export { createExternalEffects } from './effects.js';
27
+ export { createDomainRun } from './runs.js';
package/src/ingest.js ADDED
@@ -0,0 +1,104 @@
1
+ //@ts-check
2
+ /** Resumable complete ingestion over injected provider and transaction stores. */
3
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { compileWorkflow } from './workflow.js';
5
+
6
+ /**
7
+ * @typedef {{ source: string, version: string, generation: string,
8
+ * partitions: string[], input: any, policyRevision: string,
9
+ * consistency: 'snapshot' | 'revision' }} IngestionPlan
10
+ */
11
+
12
+ /**
13
+ * Compose the existing workflow engine with bounded provider pages and atomic
14
+ * staging. source(plan, phase) proves either a stable source snapshot or a
15
+ * monotonic source revision. Local generation numbers are never that proof.
16
+ * @param {{ provider: { pages: (input: any, context: any) => AsyncIterable<any> },
17
+ * store: { begin: Function, stage: Function, invalidate: Function, publish: Function },
18
+ * source: (plan: IngestionPlan, phase: string) => any,
19
+ * maxPartitions?: number, maxPages?: number, maxRows?: number, maxBytes?: number }} options
20
+ */
21
+ export function createIngestion(options) {
22
+ if (!options || typeof options.provider?.pages !== 'function' || typeof options.source !== 'function'
23
+ || ['begin', 'stage', 'invalidate', 'publish'].some((name) => typeof options.store?.[name] !== 'function'))
24
+ throw new TypeError('ingestion needs provider pages, source evidence and an atomic staging store');
25
+ const { provider, store, source, maxPartitions = 64, maxPages = 64, maxRows = 65536, maxBytes = 16777216 } = options;
26
+ for (const value of [maxPartitions, maxPages, maxRows, maxBytes]) if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('ingestion limits must be positive finite integers');
27
+ const workflow = compileWorkflow({ $workflow: '0.2', revision: 'ingestion/1', initial: 'pull', states: {
28
+ pull: { work: { task: 'ingest', version: '1' }, then: 'done' }, done: { final: true },
29
+ } }, { tasks: { ingest: { version: '1', run: async ({ input: plan }, signal, resources) => {
30
+ const incomplete = (reason) => ({ state: 'incomplete', reason, changes: 0, writes: 0, revisions: 0 });
31
+ const proof = async (phase) => {
32
+ const evidence = await source(plan, phase);
33
+ return !signal.aborted && evidence?.version === plan.version && evidence.consistency === plan.consistency
34
+ ? { version: evidence.version, consistency: evidence.consistency } : null;
35
+ };
36
+ let evidence = await proof('start');
37
+ if (!evidence) return incomplete('source-changed');
38
+ if (resources.authority && !await resources.authority.check()) return incomplete('authority-changed');
39
+ const begun = await store.begin(plan);
40
+ if (begun.state !== 'staging') return begun;
41
+ let checkpoint = begun.checkpoint;
42
+ for (const partition of plan.partitions) {
43
+ const saved = checkpoint.partitions.find((part) => part.id === partition);
44
+ if (saved.complete) continue;
45
+ let complete = false;
46
+ const pages = provider.pages(plan.input, { executor: resources.executor, signal, partition,
47
+ sourceVersion: plan.version, cursor: saved.cursor, deadline: resources.deadline });
48
+ try {
49
+ for await (const page of pages) {
50
+ if (signal.aborted) return incomplete('cancelled');
51
+ if (page.state !== 'page') {
52
+ if (page.state !== 'complete') return incomplete(page.reason);
53
+ complete = true;
54
+ continue;
55
+ }
56
+ // Network has settled before entering stage's transaction. The exact
57
+ // raw wire text remains beside compiled observations in that commit.
58
+ evidence = await proof('page');
59
+ if (!evidence || page.version !== plan.version) {
60
+ if (checkpoint.pages + 1 <= maxPages && checkpoint.rows + page.rows.length <= maxRows && checkpoint.bytes + page.bytes <= maxBytes)
61
+ await store.stage(plan, partition, { ...page, complete: false, reason: 'source-changed' });
62
+ await store.invalidate(plan, 'source-changed');
63
+ return incomplete('source-changed');
64
+ }
65
+ if (checkpoint.pages + 1 > maxPages || checkpoint.rows + page.rows.length > maxRows || checkpoint.bytes + page.bytes > maxBytes)
66
+ return incomplete('ingestion-limit');
67
+ if (resources.authority && !await resources.authority.check()) return incomplete('authority-changed');
68
+ const staged = await store.stage(plan, partition, page);
69
+ if (staged.state !== 'staged') return incomplete(staged.reason);
70
+ checkpoint = staged.checkpoint;
71
+ if (page.reason !== null) return incomplete(page.reason);
72
+ }
73
+ }
74
+ catch {
75
+ if (signal.aborted) return incomplete('cancelled');
76
+ return incomplete('interrupted');
77
+ }
78
+ if (!complete) return incomplete('missing-completion');
79
+ }
80
+ evidence = await proof('publish');
81
+ if (!evidence) { await store.invalidate(plan, 'source-changed'); return incomplete('source-changed'); }
82
+ if (signal.aborted) return incomplete('cancelled');
83
+ return resources.authority
84
+ ? resources.authority.publish(evidence, (proof) => store.publish(plan, proof, { signal }))
85
+ : store.publish(plan, evidence, { signal });
86
+ } } } });
87
+ return Object.freeze({
88
+ /** @param {IngestionPlan} plan
89
+ * @param {{ executor: any, authority?: any, signal?: AbortSignal, deadline?: number }} resources */
90
+ async run(plan, resources) {
91
+ if (!plan || ['source', 'version', 'generation', 'policyRevision'].some((name) => typeof plan[name] !== 'string' || !plan[name])
92
+ || !['snapshot', 'revision'].includes(plan.consistency) || !Array.isArray(plan.partitions)
93
+ || !plan.partitions.length || plan.partitions.length > maxPartitions
94
+ || plan.partitions.some((partition) => typeof partition !== 'string' || !partition)
95
+ || new Set(plan.partitions).size !== plan.partitions.length || !Object.hasOwn(plan, 'input')
96
+ || Object.keys(plan).some((name) => !['source', 'version', 'generation', 'partitions', 'input', 'policyRevision', 'consistency'].includes(name)))
97
+ throw new TypeError('ingestion plan needs source/version/generation, unique bounded partitions, input, policyRevision and consistency');
98
+ const input = JSON.parse(canonicalizeJson(plan));
99
+ const result = await workflow.run(input, { runId: canonicalizeJson([plan.source, plan.generation]), resources,
100
+ signal: resources.signal });
101
+ return result.result;
102
+ },
103
+ });
104
+ }
package/src/runs.js ADDED
@@ -0,0 +1,68 @@
1
+ //@ts-check
2
+ /** Adopt application run records without adding an orchestration engine. */
3
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { compileWorkflow } from './workflow.js';
5
+
6
+ /**
7
+ * Bind one compiled workflow to mapped run/checkpoint persistence. Leases are
8
+ * the existing queue's capabilities; resources stay private and release only
9
+ * after tasks drain and final cancellation/failure observation is persisted.
10
+ * @param {any} document @param {{ store: any, schemaVersion: string, tasks?: any }} options
11
+ */
12
+ export function createDomainRun(document, options) {
13
+ if (!options?.store || typeof options.schemaVersion !== 'string' || !options.schemaVersion
14
+ || ['attach', 'load', 'save', 'get', 'finish', 'requestCancel'].some((name) => typeof options.store[name] !== 'function'))
15
+ throw new TypeError('domain run needs a mapped run store and schemaVersion');
16
+ const { store, schemaVersion } = options;
17
+ const workflowIdentity = canonicalizeJson(document);
18
+ const active = new Map();
19
+ const bound = (id) => {
20
+ const attempt = active.get(id);
21
+ if (!attempt) throw new TypeError('run has no active attempt');
22
+ return attempt;
23
+ };
24
+ const workflow = compileWorkflow(document, { tasks: options.tasks, store: {
25
+ load: (id) => { const attempt = bound(id); return store.load(id, attempt.identity, attempt.lease()); },
26
+ save: (id, snapshot, expected) => store.save(id, snapshot, expected, bound(id).lease()),
27
+ } });
28
+ return Object.freeze({
29
+ /** @param {string} id @param {any} input @param {{ lease: any, signal?: AbortSignal, release?: Function, event?: any, [key: string]: any }} resources */
30
+ async run(id, input, resources) {
31
+ if (active.has(id)) throw new TypeError('domain run is already active in this runner');
32
+ const lease = () => typeof resources.lease === 'function' ? resources.lease() : resources.lease;
33
+ const identity = { id, jobId: lease()?.jobId, workflow: workflowIdentity, schemaVersion };
34
+ const controller = new AbortController();
35
+ const signal = resources.signal ? AbortSignal.any([controller.signal, resources.signal]) : controller.signal;
36
+ const drained = Promise.withResolvers();
37
+ const attempt = { identity, lease, controller, drained: drained.promise };
38
+ active.set(id, attempt);
39
+ let attached = false;
40
+ try {
41
+ await store.attach(identity, lease()); attached = true;
42
+ return await workflow.run(input, { runId: id, signal, resources, event: resources.event });
43
+ }
44
+ catch (error) {
45
+ if (attached) {
46
+ const record = await store.get(id);
47
+ await store.finish(id, record.cancelRequested || signal.aborted ? 'cancelled' : 'failed', lease());
48
+ }
49
+ throw error;
50
+ }
51
+ finally {
52
+ active.delete(id);
53
+ try { await resources.release?.(); }
54
+ finally { drained.resolve(undefined); }
55
+ }
56
+ },
57
+ /** Cancellation authority is checked by the host's command. Navigation must
58
+ * detach observation, not call this method. Remote workers see durable intent
59
+ * at the next checkpoint; local workers abort and drain before this resolves.
60
+ * @param {string} id @param {number} revision @param {{ actor: string, reason: string }} evidence */
61
+ async cancel(id, revision, evidence) {
62
+ await store.requestCancel(id, revision, evidence);
63
+ const attempt = active.get(id);
64
+ if (attempt) { attempt.controller.abort(); await attempt.drained; }
65
+ return store.get(id);
66
+ },
67
+ });
68
+ }
package/src/workflow.js CHANGED
@@ -173,7 +173,7 @@ export function lowerWorkflow(document) {
173
173
  /** @typedef {{ load: (runId: string) => WorkflowSnapshot | null | Promise<WorkflowSnapshot | null>,
174
174
  * save: (runId: string, snapshot: WorkflowSnapshot, expectedGeneration: number) => boolean | Promise<boolean> }} WorkflowStore */
175
175
  /** @typedef {{ runId: string, snapshot?: any, expectedGeneration?: number,
176
- * signal?: AbortSignal, now?: number, event?: {type: string, payload?: any},
176
+ * signal?: AbortSignal, resources?: unknown, now?: number, event?: {type: string, payload?: any},
177
177
  * onTrace?: (record: any) => void }} WorkflowRunOptions */
178
178
  /** Compile once; each run owns its control and checkpoint records.
179
179
  * Store save MUST atomically compare expectedGeneration (0 means absent).
@@ -407,9 +407,14 @@ export function compileWorkflow(document, options = {}) {
407
407
  let result;
408
408
  let error = null;
409
409
  try {
410
- result = Object.hasOwn(pending, 'result') ? pending.result
411
- : await wait(dags.get(id).run(clone(pending.input), { runId: key, signal: controller.signal,
412
- onNode: (record) => observe({ type: 'node', state: id, activation: pending.visit, ...record }) }));
410
+ if (Object.hasOwn(pending, 'result')) result = pending.result;
411
+ else {
412
+ const task = dags.get(id).run(clone(pending.input), { runId: key, signal: controller.signal,
413
+ resources: opts.resources,
414
+ onNode: (record) => observe({ type: 'node', state: id, activation: pending.visit, ...record }) });
415
+ // A private resource lease outlives every worker that received it.
416
+ result = opts.resources === undefined ? await wait(task) : await task;
417
+ }
413
418
  }
414
419
  catch (err) {
415
420
  if (spec.catch === undefined || err?.code !== 'JF2006') throw err;