@voltro/workflow 0.1.0

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.
@@ -0,0 +1,112 @@
1
+ import { ConfigError } from 'effect';
2
+ import { Cron } from 'effect';
3
+ import { Duration } from 'effect';
4
+ import { Layer } from 'effect';
5
+ import { Sharding } from '@effect/cluster';
6
+ import { SqlClient } from '@effect/sql';
7
+ import { SqlError } from '@effect/sql';
8
+ import { WorkflowEngine } from '@effect/workflow';
9
+
10
+ export declare interface ClusterCronSpec {
11
+ /** Stable schedule name — used as the cluster cron's identity. */
12
+ readonly name: string;
13
+ /** Compiled cron (timezone already baked in by `Cron.parse`). */
14
+ readonly cron: Cron.Cron;
15
+ /** The wrapped firing — records the run row + invokes the user
16
+ * handler. Plain async; we lift it into an Effect. Must not throw
17
+ * in a way that breaks the cluster cron loop — the framework's
18
+ * recording wrapper swallows its own errors. */
19
+ readonly execute: () => Promise<void>;
20
+ /** Skip a firing whose scheduled time is older than this — the
21
+ * cluster's built-in backfill guard. Defaults to "1 day". */
22
+ readonly skipIfOlderThan?: Duration.DurationInput;
23
+ }
24
+
25
+ /** Default port for the cluster's single-runner listener. Override per deploy. */
26
+ export declare const DEFAULT_RUNNER_LISTEN_PORT: 34000;
27
+
28
+ /**
29
+ * Build the cluster-cron layer for one schedule. Returns a
30
+ * `Layer<never, never, Sharding>` — the framework provides `Sharding`
31
+ * by merging this into the same runtime as the cluster workflow
32
+ * engine (see `workflowEngineLayer({ extraShardingLayers })`).
33
+ */
34
+ export declare const makeClusterCronLayer: (spec: ClusterCronSpec) => Layer.Layer<never, never, Sharding.Sharding>;
35
+
36
+ /** Resolve the runner host/port the SAME way `workflowEngineLayer` does:
37
+ * explicit option → `POD_IP` → `VOLTRO_WORKFLOW_RUNNER_HOST` → loopback;
38
+ * port from option → `VOLTRO_WORKFLOW_RUNNER_PORT` → default. */
39
+ export declare const resolveRunnerIdentity: (opts: {
40
+ readonly runnerStorage: RunnerStorage;
41
+ readonly runnerListenHost?: string | undefined;
42
+ readonly runnerListenPort?: number | undefined;
43
+ }) => RunnerIdentity;
44
+
45
+ /** Resolved cluster-runner address + the silent-resume-breaker flag.
46
+ * Single source of truth for both the engine layer and the
47
+ * `/_voltro/inspect/cluster` snapshot, so the CLI never reports an
48
+ * address that differs from the one the runner actually advertises. */
49
+ export declare interface RunnerIdentity {
50
+ readonly host: string;
51
+ readonly port: number;
52
+ /** True iff a SQL-backed runner advertises a loopback host — other
53
+ * pods can't reach it, so cross-pod workflow resume silently breaks. */
54
+ readonly localhostRisk: boolean;
55
+ }
56
+
57
+ /**
58
+ * Runner-storage backend for `@effect/cluster.SingleRunner`.
59
+ *
60
+ * - `'sql'` — durable across crashes + multi-instance scale-out.
61
+ * Used for postgres / mysql / mariadb / mssql.
62
+ * - `'memory'` — durable replay within the SAME process; no
63
+ * horizontal scale-out. Used for `sqlite` because
64
+ * `@effect/cluster.SqlRunnerStorage` has no SQLite
65
+ * lock-acquisition branch (`pg_advisory_lock` and
66
+ * `GET_LOCK` aren't available there).
67
+ */
68
+ export declare type RunnerStorage = 'sql' | 'memory';
69
+
70
+ /**
71
+ * Compose the dialect's SqlClient → SingleRunner → ClusterWorkflowEngine
72
+ * into a single layer that user code provides to its program to get
73
+ * durable workflows.
74
+ *
75
+ * `extraShardingLayers` (e.g. `ClusterCron` layers from
76
+ * `cluster`-coordinated schedules) are merged into the same runtime so
77
+ * they share the runner identity + shard map.
78
+ *
79
+ * For dev / tests where no real engine is available, see
80
+ * `inMemoryWorkflowEngineLayer`.
81
+ */
82
+ export declare const workflowEngineLayer: (options: WorkflowEngineLayerOptions) => Layer.Layer<WorkflowEngine.WorkflowEngine>;
83
+
84
+ export declare interface WorkflowEngineLayerOptions {
85
+ /**
86
+ * The dialect's SqlClient layer (postgres / mysql / mariadb / mssql).
87
+ * For `runnerStorage: 'memory'` it's still required so the cluster's
88
+ * Sharding infra has a SqlClient to satisfy — even though no SQL
89
+ * runs through it for sqlite, the layer's resource lifecycle keeps
90
+ * the cluster's accounting clean.
91
+ */
92
+ readonly sqlClientLayer: Layer.Layer<SqlClient.SqlClient, ConfigError.ConfigError | SqlError.SqlError, never>;
93
+ /** `'sql'` for postgres / mysql / mariadb / mssql, `'memory'` for sqlite. */
94
+ readonly runnerStorage: RunnerStorage;
95
+ readonly runnerListenHost?: string;
96
+ readonly runnerListenPort?: number;
97
+ /**
98
+ * Extra layers that need the cluster's `Sharding` service — most
99
+ * prominently `ClusterCron` layers for `cluster`-coordinated
100
+ * schedules (plan 45). They're merged into the SAME runtime as the
101
+ * workflow engine so they share the single `SingleRunner` /
102
+ * `Sharding` instance (one runner identity, one shard map). The
103
+ * framework passes these in; user code never touches `Sharding`
104
+ * directly.
105
+ *
106
+ * Each layer requires the cluster's `Sharding` service; that
107
+ * requirement is satisfied by `clusterLayer` below.
108
+ */
109
+ readonly extraShardingLayers?: ReadonlyArray<Layer.Layer<never, never, Sharding.Sharding>>;
110
+ }
111
+
112
+ export { }
@@ -0,0 +1,2 @@
1
+ import { i as e, n as t, r as n, t as r } from "./cluster-Cl6mB2rW.js";
2
+ export { t as DEFAULT_RUNNER_LISTEN_PORT, r as makeClusterCronLayer, n as resolveRunnerIdentity, e as workflowEngineLayer };
@@ -0,0 +1,84 @@
1
+ import { ConfigError } from 'effect';
2
+ import { Layer } from 'effect';
3
+ import { Sharding } from '@effect/cluster';
4
+ import { SqlClient } from '@effect/sql';
5
+ import { SqlError } from '@effect/sql';
6
+
7
+ /**
8
+ * Everything a dialect supplies to run the cluster-engine suite against
9
+ * its database. The scenarios are otherwise identical across dialects.
10
+ */
11
+ export declare interface ClusterEngineFixture {
12
+ /** Dialect label for the describe block + workflow-name suffix. */
13
+ readonly name: string;
14
+ /** The dialect's `SqlClient` layer (postgres/mysql/mariadb/mssql/sqlite).
15
+ * Typed via the engine's own option so `@voltro/testing` needs no
16
+ * direct `@effect/sql` dependency. */
17
+ readonly sqlClientLayer: WorkflowEngineLayerOptions['sqlClientLayer'];
18
+ /** `'sql'` for server dialects, `'memory'` for sqlite. */
19
+ readonly runnerStorage: 'sql' | 'memory';
20
+ /** Base cluster-runner listen port (distinct per dialect so suites can
21
+ * run side by side). The suite uses `port`, `port+1`, `port+2`. */
22
+ readonly runnerPort: number;
23
+ /** True iff this dialect supports cross-process workflow resume
24
+ * (= `runnerStorage: 'sql'`). Gates the resume + dormancy scenarios. */
25
+ readonly clusterResume: boolean;
26
+ /** Is the database reachable? Soft-skips the whole suite when not (CI
27
+ * without docker, a dialect's container down). SQLite returns true. */
28
+ readonly reachable: () => Promise<boolean>;
29
+ }
30
+
31
+ /**
32
+ * Register the cluster-engine suite for one dialect. Each
33
+ * `@voltro/sql-*` (or the workflow package) calls this from a thin
34
+ * `cluster.<dialect>.integration.test.ts` with its fixture.
35
+ */
36
+ export declare const runClusterEngineSuite: (fixture: ClusterEngineFixture) => void;
37
+
38
+ /**
39
+ * Runner-storage backend for `@effect/cluster.SingleRunner`.
40
+ *
41
+ * - `'sql'` — durable across crashes + multi-instance scale-out.
42
+ * Used for postgres / mysql / mariadb / mssql.
43
+ * - `'memory'` — durable replay within the SAME process; no
44
+ * horizontal scale-out. Used for `sqlite` because
45
+ * `@effect/cluster.SqlRunnerStorage` has no SQLite
46
+ * lock-acquisition branch (`pg_advisory_lock` and
47
+ * `GET_LOCK` aren't available there).
48
+ */
49
+ declare type RunnerStorage = 'sql' | 'memory';
50
+
51
+ /** Lightweight TCP reachability probe for a dialect's database — the
52
+ * soft-skip a fixture's `reachable()` uses (cheaper than a full connect).
53
+ * SQLite fixtures just return `true` directly. */
54
+ export declare const tcpReachable: (host: string, port: number, timeoutMs?: number) => Promise<boolean>;
55
+
56
+ declare interface WorkflowEngineLayerOptions {
57
+ /**
58
+ * The dialect's SqlClient layer (postgres / mysql / mariadb / mssql).
59
+ * For `runnerStorage: 'memory'` it's still required so the cluster's
60
+ * Sharding infra has a SqlClient to satisfy — even though no SQL
61
+ * runs through it for sqlite, the layer's resource lifecycle keeps
62
+ * the cluster's accounting clean.
63
+ */
64
+ readonly sqlClientLayer: Layer.Layer<SqlClient.SqlClient, ConfigError.ConfigError | SqlError.SqlError, never>;
65
+ /** `'sql'` for postgres / mysql / mariadb / mssql, `'memory'` for sqlite. */
66
+ readonly runnerStorage: RunnerStorage;
67
+ readonly runnerListenHost?: string;
68
+ readonly runnerListenPort?: number;
69
+ /**
70
+ * Extra layers that need the cluster's `Sharding` service — most
71
+ * prominently `ClusterCron` layers for `cluster`-coordinated
72
+ * schedules (plan 45). They're merged into the SAME runtime as the
73
+ * workflow engine so they share the single `SingleRunner` /
74
+ * `Sharding` instance (one runner identity, one shard map). The
75
+ * framework passes these in; user code never touches `Sharding`
76
+ * directly.
77
+ *
78
+ * Each layer requires the cluster's `Sharding` service; that
79
+ * requirement is satisfied by `clusterLayer` below.
80
+ */
81
+ readonly extraShardingLayers?: ReadonlyArray<Layer.Layer<never, never, Sharding.Sharding>>;
82
+ }
83
+
84
+ export { }
@@ -0,0 +1,180 @@
1
+ import { C as e, m as t, p as n, v as r, x as i } from "./primitives-CWy1iu5w.js";
2
+ import { i as a } from "./src-CNeLb-4L.js";
3
+ import { i as o, t as s } from "./cluster-Cl6mB2rW.js";
4
+ import { Cron as c, Deferred as l, Effect as u, Fiber as d, Layer as f, Schema as p } from "effect";
5
+ import { beforeAll as m, describe as h, expect as g, test as _ } from "vitest";
6
+ //#region src/clusterTestSuite.ts
7
+ var v = async (e, t, n = 750) => {
8
+ try {
9
+ let r = await import("node:net");
10
+ return await new Promise((i) => {
11
+ let a = new r.Socket(), o = setTimeout(() => {
12
+ a.destroy(), i(!1);
13
+ }, n);
14
+ a.once("connect", () => {
15
+ clearTimeout(o), a.end(), i(!0);
16
+ }), a.once("error", () => {
17
+ clearTimeout(o), i(!1);
18
+ }), a.connect(t, e);
19
+ });
20
+ } catch {
21
+ return !1;
22
+ }
23
+ }, y = (e, t) => u.gen(function* () {
24
+ let n = yield* u.sync(() => Date.now() + t);
25
+ for (; !e();) {
26
+ if (Date.now() > n) return !1;
27
+ yield* u.sleep("250 millis");
28
+ }
29
+ return !0;
30
+ }), b = (v) => {
31
+ let b = `${v.name}-${Date.now()}`, x = (e, t) => o({
32
+ sqlClientLayer: v.sqlClientLayer,
33
+ runnerStorage: v.runnerStorage,
34
+ runnerListenPort: e,
35
+ ...t && t.length > 0 ? { extraShardingLayers: t } : {}
36
+ });
37
+ h(`cluster workflow engine — ${v.name}`, () => {
38
+ let o = !1;
39
+ m(async () => {
40
+ o = await v.reachable(), o || console.warn(`[skip] cluster suite — ${v.name} not reachable`);
41
+ }, 1e4), _("a two-step workflow runs end-to-end", async () => {
42
+ if (!o) return;
43
+ let e = `clusterTwoStep_${b}`, n = r({
44
+ name: e,
45
+ payload: { id: p.String },
46
+ success: p.Number,
47
+ idempotencyKey: ({ id: t }) => `${e}:${t}`
48
+ }), i = n.toLayer(() => u.gen(function* () {
49
+ let e = yield* t({
50
+ name: "first",
51
+ success: p.Number,
52
+ execute: u.succeed(40)
53
+ });
54
+ return yield* t({
55
+ name: "second",
56
+ success: p.Number,
57
+ execute: u.succeed(e + 2)
58
+ });
59
+ }));
60
+ g(await u.runPromise(u.scoped(n.execute({ id: `e2e-${b}` }).pipe(u.provide(i.pipe(f.provideMerge(x(v.runnerPort)))))))).toBe(42);
61
+ }, 3e4), _("idempotency-key journal — same payload returns same result", async () => {
62
+ if (!o) return;
63
+ let e = `clusterIdempotency_${b}`, n = 0, i = r({
64
+ name: e,
65
+ payload: { id: p.String },
66
+ success: p.Number,
67
+ idempotencyKey: ({ id: t }) => `${e}:${t}`
68
+ }), a = i.toLayer(() => t({
69
+ name: "compute",
70
+ success: p.Number,
71
+ execute: u.sync(() => (n++, n * 100))
72
+ })).pipe(f.provideMerge(x(v.runnerPort))), [s, c] = await u.runPromise(u.scoped(u.gen(function* () {
73
+ return [yield* i.execute({ id: `same-${b}` }), yield* i.execute({ id: `same-${b}` })];
74
+ }).pipe(u.provide(a))));
75
+ g(s).toBe(c), g(n).toBe(1);
76
+ }, 3e4), _("ClusterCron fires its execute on the shard owner", async () => {
77
+ if (!o) return;
78
+ let e = await u.runPromise(l.make()), t = 0, n = s({
79
+ name: `clusterCron_${b}`,
80
+ cron: c.unsafeParse("*/1 * * * * *", "UTC"),
81
+ execute: async () => {
82
+ t += 1, await u.runPromise(l.succeed(e, !0)).catch(() => {});
83
+ }
84
+ });
85
+ g(await u.runPromise(u.scoped(u.gen(function* () {
86
+ return yield* f.build(x(v.runnerPort + 1, [n])), yield* l.await(e).pipe(u.timeout("25 seconds"), u.orElseSucceed(() => !1));
87
+ })))).toBe(!0), g(t).toBeGreaterThanOrEqual(1);
88
+ }, 35e3), _.runIf(v.clusterResume)("a sleeping workflow resumes on a fresh runner after the first is torn down", async () => {
89
+ if (!o) return;
90
+ let e = `dormancyResume_${b}`, i = [], a = [], s = () => r({
91
+ name: e,
92
+ payload: { id: p.String },
93
+ success: p.String,
94
+ idempotencyKey: ({ id: t }) => `${e}:${t}`
95
+ }), c = (e, r) => e.toLayer(() => u.gen(function* () {
96
+ return yield* t({
97
+ name: "step1",
98
+ success: p.Void,
99
+ execute: u.sync(() => {
100
+ i.push(r);
101
+ })
102
+ }), yield* n({
103
+ name: "nap",
104
+ duration: "30 seconds"
105
+ }), yield* t({
106
+ name: "step2",
107
+ success: p.Void,
108
+ execute: u.sync(() => {
109
+ a.push(r);
110
+ })
111
+ }), "done";
112
+ })), l = v.runnerPort + 2, m = (e) => {
113
+ let t = s();
114
+ return {
115
+ wf: t,
116
+ layer: c(t, e).pipe(f.provideMerge(x(l)))
117
+ };
118
+ }, h = { id: `resume-${b}` }, _ = await u.runPromise(u.scoped(u.gen(function* () {
119
+ let { wf: e, layer: t } = m("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(h).pipe(u.provide(n), u.ignore));
120
+ return (yield* y(() => i.length > 0, 6e4)) ? (yield* u.sleep("1 second"), r) : yield* u.die(/* @__PURE__ */ Error("runner A never executed step1"));
121
+ })));
122
+ await u.runPromise(d.interrupt(_).pipe(u.ignore)), g(i).toEqual(["A"]), g(a).toEqual([]), g(await u.runPromise(u.scoped(u.gen(function* () {
123
+ let { wf: e, layer: t } = m("B"), n = yield* f.build(t);
124
+ return yield* e.execute(h).pipe(u.provide(n), u.timeout("45 seconds"), u.orElseSucceed(() => "TIMEOUT"));
125
+ })))).toBe("done"), g(i).toEqual(["A"]), g(a).toEqual(["B"]);
126
+ }, 12e4), _.runIf(v.clusterResume)("dormancy loop: a wake signal survives teardown and is cleared on resume", async () => {
127
+ if (!o) return;
128
+ let s = `dormancyLoop_${b}`, c = `wr_${s}`, l = [], m = /* @__PURE__ */ new Map(), h = a({
129
+ store: {
130
+ insert: async (e, t) => t,
131
+ update: async () => null,
132
+ query: async () => []
133
+ },
134
+ wakeups: {
135
+ register: async (e, t) => {
136
+ m.set(e, { wakeAt: t });
137
+ },
138
+ cancel: async (e) => {
139
+ m.delete(e);
140
+ }
141
+ }
142
+ }), _ = () => r({
143
+ name: s,
144
+ payload: { id: p.String },
145
+ success: p.String,
146
+ idempotencyKey: ({ id: e }) => `${s}:${e}`
147
+ }), S = (r, a) => r.toLayer(() => u.gen(function* () {
148
+ return yield* t({
149
+ name: "step1",
150
+ success: p.Void,
151
+ execute: u.void
152
+ }), yield* n({
153
+ name: "nap",
154
+ duration: "30 seconds"
155
+ }), yield* t({
156
+ name: "step2",
157
+ success: p.Void,
158
+ execute: u.sync(() => {
159
+ l.push(a);
160
+ })
161
+ }), "done";
162
+ }).pipe(u.locally(i, c), u.provideService(e, h))), C = v.runnerPort + 2, w = (e) => {
163
+ let t = _();
164
+ return {
165
+ wf: t,
166
+ layer: S(t, e).pipe(f.provideMerge(x(C)))
167
+ };
168
+ }, T = { id: `loop-${b}` }, E = await u.runPromise(u.scoped(u.gen(function* () {
169
+ let { wf: e, layer: t } = w("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(T).pipe(u.provide(n), u.ignore));
170
+ return (yield* y(() => m.has(c), 6e4)) ? (yield* u.sleep("1 second"), r) : yield* u.die(/* @__PURE__ */ Error("workflow never registered a wakeup"));
171
+ })));
172
+ await u.runPromise(d.interrupt(E).pipe(u.ignore)), g(m.has(c)).toBe(!0), g(l).toEqual([]), g(await u.runPromise(u.scoped(u.gen(function* () {
173
+ let { wf: e, layer: t } = w("B"), n = yield* f.build(t);
174
+ return yield* e.execute(T).pipe(u.provide(n), u.timeout("45 seconds"), u.orElseSucceed(() => "TIMEOUT"));
175
+ })))).toBe("done"), g(l).toEqual(["B"]), g(m.has(c)).toBe(!1);
176
+ }, 12e4);
177
+ });
178
+ };
179
+ //#endregion
180
+ export { b as runClusterEngineSuite, v as tcpReachable };