@voltro/workflow 0.1.4 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -39,6 +39,26 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.1.5] — 2026-07-16
43
+
44
+ ### Added
45
+
46
+ - **@voltro/cli** — `voltro build` now precompiles an API app (previously it only handled web apps). It bundles the whole handler closure — every procedure/executor, workflow, subscriber, reaction, aggregate, agent, tool, webhook, cron, startup, `app.config`, and their shared `database`/`lib` deps — into a single `.framework/dist-api/apiEntry.js` (framework/npm kept external), via a two-pass esbuild build (pass 1 discovers the full module closure so shared side-effectful modules like the schema are covered; pass 2 emits the bundle + a module map). `voltro serve` loads that one bundle at boot and resolves every app module from it — no `node --import tsx` runtime transpilation, and a SINGLE instance of each module (so side-effectful modules like the table registry aren't evaluated twice). Without a build — `voltro dev`, or `voltro serve` on an unbuilt app — every module still loads from source exactly as before, and a per-module miss falls back to source too, so a stale/partial bundle degrades safely. Note: an API's boot is dominated by the framework/Effect module-graph evaluation, not app-module transpilation, so the bundle is primarily a correctness/hygiene win (no source transpilation in production) rather than a large cold-start reduction.
47
+ - **@voltro/i18n** — Two message accessors that react-intl parity was missing. `useMessages()` returns the active locale's RAW (unformatted ICU) catalog — `useMessages()['some.id']` gives the template, not the formatted output — for when you need the raw string. `pickCatalog(catalogs, locale, defaultLocale)` resolves a catalog for an ARBITRARY locale OUTSIDE React (for `meta({ locale })` and other non-hook call sites where `useT` can't run); it returns the concrete catalog type, so a known-key lookup is `string` (not `string | undefined`) — the exact shape `PageMeta.title` needs, replacing the hand-rolled `getCatalog(locale)` helper apps kept copying.
48
+ - **@voltro/cli, @voltro/workflow** — `VOLTRO_WORKFLOW_RUNNER_STORAGE` (`memory` | `sql`) forces the workflow-engine runner storage instead of always deriving it from the store dialect. The load-bearing case is `memory` on a real SQL dialect (postgres / mysql / mariadb / mssql): it runs the single-process durable engine — workflow run state stays SQL-backed via `@effect/cluster`'s `SqlMessageStorage` — but SKIPS `SqlRunnerStorage` entirely, so there is no `cluster_runners` / `cluster_locks` table and none of its `GET_LOCK` advisory-lock acquisition. That unblocks a managed MySQL / MariaDB reached through a connection-load-balancing Service or a non-session-pinned pooler, where the advisory-lock connection can't be pinned to one backend and the runner-storage bootstrap wedges before it ever creates its table (the pod stays un-Ready while a shard-lock refresher errors forever). The tradeoff is no cross-pod shard handoff. An invalid value — or `sql` on sqlite / turso — fails boot loudly rather than silently selecting a broken engine. The `voltro cluster status` snapshot and the boot log both report the resolved storage.
49
+ - **@voltro/cli, @voltro/workflow** — Durable-workflow clustering now keeps **cross-pod handoff on Galera / Percona XtraDB (multi-primary) clusters**. `@effect/cluster`'s default shard-ownership coordination uses session advisory locks (`GET_LOCK` / `pg_advisory_lock`), which are node-local and can't coordinate a fleet whose connections span cluster nodes — so on a Galera cluster behind a load-balancing Service, pods split-brain shard ownership and the runner-storage bootstrap can wedge (pod never becomes Ready). The new **`VOLTRO_WORKFLOW_SHARD_LOCK`** (`auto` | `row` | `advisory`, default `auto`) fixes this: `auto` probes the live connection (`@@wsrep_on`) and, on a wsrep cluster, switches `SqlRunnerStorage` to a certified row-lease on the `cluster_locks` table (`INSERT … ON DUPLICATE KEY UPDATE … WHERE acquired_at < expiry`) instead of advisory locks — Galera certifies that write across all nodes, so shard ownership and dead-pod handoff stay correct without a single-writer proxy. A single-primary server keeps the faster advisory path. The resolved mode is reported in the boot log and in `voltro cluster status` (`shard-lock=row`). Non-wsrep multi-primary topologies (e.g. MySQL Group Replication) can force it with `VOLTRO_WORKFLOW_SHARD_LOCK=row`; an unrecognized value fails boot loudly.
50
+
51
+ ### Fixed
52
+
53
+ - **@voltro/cli** — `voltro start` no longer loads every page module at boot, so a large mostly-static site (e.g. a docs site with hundreds of prerendered routes) boots with memory proportional to its ssr/isr routes instead of its total routes — it used to OOM a modest container even though only a handful of routes ever need a runtime module. The SSR bundle (`voltro build`) now emits page/layout modules as lazy `() => import()` loaders plus a build-time `pageMeta` manifest; `voltro start` reads render mode / tenant-awareness / revalidate from the manifest and imports a page's module (and its content chunk) only when that ssr/isr route is actually rendered. A 500+ route docs site that OOMed a 512 MB container now boots at ~190 MB. Bundles without a `pageMeta` manifest (older builds) fall back to the previous load-every-module behaviour, and `voltro dev` (Vite middleware mode) is unchanged.
54
+ - **@voltro/web** — Static prerendering (`voltro build`) no longer crashes with "Router hooks must be used inside <Router>" on pages whose layout reads router/i18n context (e.g. URL-prefix locale). The framework's React contexts are now process-wide singletons pinned on the global symbol registry, so the SSG renderer (loaded via Vite's `ssrLoadModule('@voltro/web/ssr')`) and the app's page modules (with `@voltro/web` externalised to Node) always resolve the SAME context instance even though the prerender loads the package through two module instances. This only bit consumers building against the published npm package; the framework's own workspace build resolves `@voltro/web`'s source and masked it.
55
+
56
+ ### Internal (no consumer-facing effect)
57
+
58
+ - **@voltro/voltro** — Package READMEs no longer carry a relative `[Changelog](./CHANGELOG.md)` link — npm resolved it to a 404 (`npmjs.com/package/@voltro/CHANGELOG.md`) because relative README links don't resolve for scoped packages, and there is no public changelog URL (the repo is private). The `CHANGELOG.md` is still bundled in each package tarball. The README link row is now Documentation · voltro.dev · Voltro Cloud.
59
+
60
+ ---
61
+
42
62
  ## [0.1.1] — 2026-07-15
43
63
 
44
64
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Durable workflows for Voltro — the workflow() descriptor + step() / awaitSignal primitives over @effect/cluster, with a browser-safe define subpath.**
6
6
 
7
- [📖 Documentation](https://docs.voltro.dev/docs/workflows/overview) · [Changelog](./CHANGELOG.md) · [voltro.dev](https://voltro.dev) · [Voltro Cloud](https://voltro.cloud)
7
+ [📖 Documentation](https://docs.voltro.dev/docs/workflows/overview) · [voltro.dev](https://voltro.dev) · [Voltro Cloud](https://voltro.cloud)
8
8
 
9
9
  </div>
10
10
 
@@ -0,0 +1,58 @@
1
+ import { Duration as e, Effect as t, Layer as n, Option as r } from "effect";
2
+ import { ClusterCron as i, ClusterWorkflowEngine as a, RunnerAddress as o, SingleRunner as s } from "@effect/cluster";
3
+ import { SqlClient as c } from "@effect/sql";
4
+ //#region src/clusterLayer.ts
5
+ var l = 34e3, u = (e) => {
6
+ let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
7
+ return {
8
+ host: t,
9
+ port: e.runnerListenPort ?? 34e3,
10
+ localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
11
+ };
12
+ }, d = (e) => {
13
+ let t = e.sqliteFamily ? "memory" : "sql", n = e.override ?? process.env.VOLTRO_WORKFLOW_RUNNER_STORAGE;
14
+ if (n === void 0 || n.trim() === "") return t;
15
+ let r = n.trim().toLowerCase();
16
+ if (r !== "memory" && r !== "sql") throw Error(`VOLTRO_WORKFLOW_RUNNER_STORAGE must be 'memory' or 'sql', got '${n}'.`);
17
+ if (r === "sql" && e.sqliteFamily) throw Error("VOLTRO_WORKFLOW_RUNNER_STORAGE='sql' is unsupported on sqlite / turso — @effect/cluster has no sqlite advisory-lock branch. Use 'memory'.");
18
+ return r;
19
+ }, f = (e = process.env.VOLTRO_WORKFLOW_SHARD_LOCK) => {
20
+ if (e === void 0 || e.trim() === "") return "auto";
21
+ let t = e.trim().toLowerCase();
22
+ if (t === "auto" || t === "row" || t === "advisory") return t;
23
+ throw Error(`VOLTRO_WORKFLOW_SHARD_LOCK must be 'auto', 'row', or 'advisory', got '${e}'.`);
24
+ }, p = t.gen(function* () {
25
+ let e = yield* (yield* c.SqlClient)`SELECT @@wsrep_on AS wsrep`.pipe(t.catchAll(() => t.succeed([])));
26
+ if (e.length === 0) return !1;
27
+ let n = e[0].wsrep;
28
+ return n === 1 || n === "1" || n === !0 || n === "ON" || n === "on";
29
+ }), m = (e) => t.gen(function* () {
30
+ return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* p;
31
+ }), h = (e) => {
32
+ let i = u({
33
+ runnerStorage: e.runnerStorage,
34
+ runnerListenHost: e.runnerListenHost,
35
+ runnerListenPort: e.runnerListenPort
36
+ }), c = i.host, l = i.port;
37
+ i.localhostRisk && process.stderr.write(`[voltro:workflow] runner host is '${c}' with SQL cluster storage — other pods cannot reach this runner, so a workflow cannot resume on another pod after a reschedule. Inject POD_IP via the K8s downward API (fieldRef: status.podIP) or set runnerListenHost / VOLTRO_WORKFLOW_RUNNER_HOST.
38
+ `);
39
+ let d = e.shardLockMode ?? f(), p = n.unwrapEffect(m({
40
+ dialectId: e.dialectId,
41
+ runnerStorage: e.runnerStorage,
42
+ mode: d
43
+ }).pipe(t.map((t) => (e.runnerStorage === "sql" && process.stderr.write(`[voltro:workflow] shard-lock coordination: ${t ? "row-based" : "advisory"} (dialect=${e.dialectId ?? "unknown"}, mode=${d}` + (t && d === "auto" ? ", wsrep/Galera cluster detected" : "") + ")\n"), s.layer({
44
+ runnerStorage: e.runnerStorage,
45
+ shardingConfig: {
46
+ runnerListenAddress: r.some(o.make(c, l)),
47
+ ...t ? { shardLockDisableAdvisory: !0 } : {}
48
+ }
49
+ }))))).pipe(n.provide(e.sqlClientLayer)), h = e.extraShardingLayers ?? [];
50
+ return (h.length === 0 ? a.layer : n.mergeAll(a.layer, ...h)).pipe(n.provide(p));
51
+ }, g = (n) => i.make({
52
+ name: n.name,
53
+ cron: n.cron,
54
+ execute: t.promise(() => n.execute()),
55
+ skipIfOlderThan: n.skipIfOlderThan ?? e.days(1)
56
+ });
57
+ //#endregion
58
+ export { u as a, h as c, f as i, l as n, d as o, p as r, m as s, g as t };
package/dist/cluster.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ConfigError } from 'effect';
2
2
  import { Cron } from 'effect';
3
3
  import { Duration } from 'effect';
4
+ import { Effect } from 'effect';
4
5
  import { Layer } from 'effect';
5
6
  import { Sharding } from '@effect/cluster';
6
7
  import { SqlClient } from '@effect/sql';
@@ -25,6 +26,14 @@ export declare interface ClusterCronSpec {
25
26
  /** Default port for the cluster's single-runner listener. Override per deploy. */
26
27
  export declare const DEFAULT_RUNNER_LISTEN_PORT: 34000;
27
28
 
29
+ /** Probe the live connection for an active wsrep (Galera / Percona XtraDB)
30
+ * cluster. `@@wsrep_on = 1` iff Galera replication is active; the variable is
31
+ * absent on stock MySQL (→ query errors → not a wsrep cluster) and `0` on a
32
+ * non-clustered MariaDB. Any probe failure resolves to `false` (advisory) —
33
+ * the safe default for the common single-primary case; wsrep deployments that
34
+ * somehow can't be probed can still force `VOLTRO_WORKFLOW_SHARD_LOCK=row`. */
35
+ export declare const detectWsrepCluster: Effect.Effect<boolean, never, SqlClient.SqlClient>;
36
+
28
37
  /**
29
38
  * Build the cluster-cron layer for one schedule. Returns a
30
39
  * `Layer<never, never, Sharding>` — the framework provides `Sharding`
@@ -33,6 +42,11 @@ export declare const DEFAULT_RUNNER_LISTEN_PORT: 34000;
33
42
  */
34
43
  export declare const makeClusterCronLayer: (spec: ClusterCronSpec) => Layer.Layer<never, never, Sharding.Sharding>;
35
44
 
45
+ /** Parse `VOLTRO_WORKFLOW_SHARD_LOCK` (default `'auto'`). Throws on an
46
+ * unrecognized value so a typo fails boot loudly instead of silently keeping
47
+ * the advisory path that breaks cross-pod handoff on Galera. */
48
+ export declare const parseShardLockMode: (raw?: string | undefined) => ShardLockMode;
49
+
36
50
  /** Resolve the runner host/port the SAME way `workflowEngineLayer` does:
37
51
  * explicit option → `POD_IP` → `VOLTRO_WORKFLOW_RUNNER_HOST` → loopback;
38
52
  * port from option → `VOLTRO_WORKFLOW_RUNNER_PORT` → default. */
@@ -42,6 +56,51 @@ export declare const resolveRunnerIdentity: (opts: {
42
56
  readonly runnerListenPort?: number | undefined;
43
57
  }) => RunnerIdentity;
44
58
 
59
+ /**
60
+ * Resolve the runner-storage backend for `SingleRunner`.
61
+ *
62
+ * Default by dialect: sqlite / turso → `'memory'` (`@effect/cluster` has no
63
+ * sqlite advisory-lock branch); every other SQL dialect → `'sql'` (durable +
64
+ * multi-runner shard coordination via `cluster_runners` / `cluster_locks`).
65
+ *
66
+ * `VOLTRO_WORKFLOW_RUNNER_STORAGE` (or an explicit `override`) forces the
67
+ * choice. The load-bearing case is `'memory'` on a real SQL dialect: it keeps
68
+ * the single-process durable engine (workflow run state stays SQL-backed via
69
+ * `@effect/cluster`'s `SqlMessageStorage`) but SKIPS `SqlRunnerStorage`
70
+ * entirely — no `cluster_runners` / `cluster_locks` table, and none of its
71
+ * `GET_LOCK` advisory-lock acquisition. That unblocks a managed MySQL /
72
+ * MariaDB reached through a connection-load-balancing Service or a
73
+ * non-session-pinned pooler, where the advisory-lock connection can't be
74
+ * pinned to one backend and the runner-storage bootstrap wedges before it ever
75
+ * creates its table (pod stays un-Ready, shard-lock refresher errors forever).
76
+ * The tradeoff: no cross-pod shard handoff — a workflow started on a now-dead
77
+ * pod won't resume elsewhere.
78
+ *
79
+ * Throws on an unrecognized value, or on `'sql'` for sqlite / turso (no
80
+ * advisory-lock branch) — a misconfiguration fails boot loudly rather than
81
+ * silently selecting a broken engine.
82
+ */
83
+ export declare const resolveRunnerStorage: (opts: {
84
+ readonly sqliteFamily: boolean;
85
+ readonly override?: string | undefined;
86
+ }) => RunnerStorage;
87
+
88
+ /**
89
+ * Resolve whether `SqlRunnerStorage` should disable advisory locks and use the
90
+ * certified row-lease path instead — the load-bearing decision for correct
91
+ * cross-pod workflow handoff on a Galera / Percona XtraDB cluster.
92
+ *
93
+ * `'memory'` runner storage never builds `SqlRunnerStorage`, so the choice is
94
+ * moot there (returns `false`, no probe). For `'sql'` storage it honors the
95
+ * mode: `'row'`/`'advisory'` are explicit; `'auto'` probes the live connection
96
+ * for wsrep on MySQL/MariaDB and stays advisory on every other dialect.
97
+ */
98
+ export declare const resolveShardLockDisableAdvisory: (opts: {
99
+ readonly dialectId: string | undefined;
100
+ readonly runnerStorage: RunnerStorage;
101
+ readonly mode: ShardLockMode;
102
+ }) => Effect.Effect<boolean, never, SqlClient.SqlClient>;
103
+
45
104
  /** Resolved cluster-runner address + the silent-resume-breaker flag.
46
105
  * Single source of truth for both the engine layer and the
47
106
  * `/_voltro/inspect/cluster` snapshot, so the CLI never reports an
@@ -67,6 +126,35 @@ export declare interface RunnerIdentity {
67
126
  */
68
127
  export declare type RunnerStorage = 'sql' | 'memory';
69
128
 
129
+ /**
130
+ * How `@effect/cluster`'s `SqlRunnerStorage` coordinates shard ownership across
131
+ * a fleet of runners:
132
+ *
133
+ * - `'advisory'` — session-scoped DB advisory locks (`GET_LOCK` on
134
+ * MySQL/MariaDB, `pg_advisory_lock` on Postgres). Fast, and
135
+ * self-releasing the instant a connection drops (so a dead
136
+ * pod's shards are reclaimed immediately). Correct ONLY when
137
+ * every runner's connection reaches ONE coherent server —
138
+ * advisory locks are node-local and do NOT span the nodes of
139
+ * a Galera / Percona XtraDB cluster or a load-balanced pool.
140
+ * - `'row'` — a certified conditional upsert on the `cluster_locks`
141
+ * TABLE (`INSERT … ON DUPLICATE KEY UPDATE … WHERE
142
+ * acquired_at < <expiry>`). Works across a multi-primary
143
+ * cluster because the write is globally certified — the same
144
+ * reason Voltro's own cron `advisoryLock` uses a claims row,
145
+ * not a session lock. Failover is expiry-based (a dead pod's
146
+ * shards are reclaimed once the lease ages out), not instant.
147
+ * - `'auto'` — the default: probe the live connection for a wsrep
148
+ * (Galera / PXC) cluster and pick `'row'` there, `'advisory'`
149
+ * everywhere else. Gives correct multi-replica handoff on
150
+ * Galera out of the box while keeping the faster advisory
151
+ * path on a single-primary server.
152
+ *
153
+ * MySQL Group Replication and other multi-primary topologies that aren't wsrep
154
+ * are NOT auto-detected — pin `VOLTRO_WORKFLOW_SHARD_LOCK=row` for those.
155
+ */
156
+ export declare type ShardLockMode = 'auto' | 'row' | 'advisory';
157
+
70
158
  /**
71
159
  * Compose the dialect's SqlClient → SingleRunner → ClusterWorkflowEngine
72
160
  * into a single layer that user code provides to its program to get
@@ -92,6 +180,20 @@ export declare interface WorkflowEngineLayerOptions {
92
180
  readonly sqlClientLayer: Layer.Layer<SqlClient.SqlClient, ConfigError.ConfigError | SqlError.SqlError, never>;
93
181
  /** `'sql'` for postgres / mysql / mariadb / mssql, `'memory'` for sqlite. */
94
182
  readonly runnerStorage: RunnerStorage;
183
+ /**
184
+ * The resolved dialect id (`postgres` / `mysql` / `mariadb` / `mssql` /
185
+ * `sqlite`). Used by the `'auto'` shard-lock mode to decide whether to probe
186
+ * for a wsrep (Galera) cluster. Omitted → the auto probe is skipped and
187
+ * advisory locking is kept.
188
+ */
189
+ readonly dialectId?: string;
190
+ /**
191
+ * Shard-ownership coordination mode (see {@link ShardLockMode}). Defaults to
192
+ * `VOLTRO_WORKFLOW_SHARD_LOCK` (→ `'auto'`). `'auto'` uses the certified
193
+ * row-lease path on a Galera / PXC cluster and advisory locks elsewhere, so
194
+ * cross-pod workflow handoff is correct on Galera without operator config.
195
+ */
196
+ readonly shardLockMode?: ShardLockMode;
95
197
  readonly runnerListenHost?: string;
96
198
  readonly runnerListenPort?: number;
97
199
  /**
package/dist/cluster.js CHANGED
@@ -1,2 +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 };
1
+ import { a as e, c as t, i as n, n as r, o as i, r as a, s as o, t as s } from "./cluster-DuFweKq0.js";
2
+ export { r as DEFAULT_RUNNER_LISTEN_PORT, a as detectWsrepCluster, s as makeClusterCronLayer, n as parseShardLockMode, e as resolveRunnerIdentity, i as resolveRunnerStorage, o as resolveShardLockDisableAdvisory, t as workflowEngineLayer };
@@ -17,6 +17,13 @@ export declare interface ClusterEngineFixture {
17
17
  readonly sqlClientLayer: WorkflowEngineLayerOptions['sqlClientLayer'];
18
18
  /** `'sql'` for server dialects, `'memory'` for sqlite. */
19
19
  readonly runnerStorage: 'sql' | 'memory';
20
+ /** Force the shard-lock coordination mode (see `ShardLockMode`). Omit → the
21
+ * engine default (`auto`). The `mariadb-row` fixture sets `'row'` to exercise
22
+ * the certified `cluster_locks` lease path that backs cross-pod handoff on a
23
+ * Galera / PXC cluster (where node-local `GET_LOCK` can't coordinate). */
24
+ readonly shardLockMode?: WorkflowEngineLayerOptions['shardLockMode'];
25
+ /** Resolved dialect id, for the `auto` shard-lock wsrep probe. */
26
+ readonly dialectId?: string;
20
27
  /** Base cluster-runner listen port (distinct per dialect so suites can
21
28
  * run side by side). The suite uses `port`, `port+1`, `port+2`. */
22
29
  readonly runnerPort: number;
@@ -48,6 +55,35 @@ export declare const runClusterEngineSuite: (fixture: ClusterEngineFixture) => v
48
55
  */
49
56
  declare type RunnerStorage = 'sql' | 'memory';
50
57
 
58
+ /**
59
+ * How `@effect/cluster`'s `SqlRunnerStorage` coordinates shard ownership across
60
+ * a fleet of runners:
61
+ *
62
+ * - `'advisory'` — session-scoped DB advisory locks (`GET_LOCK` on
63
+ * MySQL/MariaDB, `pg_advisory_lock` on Postgres). Fast, and
64
+ * self-releasing the instant a connection drops (so a dead
65
+ * pod's shards are reclaimed immediately). Correct ONLY when
66
+ * every runner's connection reaches ONE coherent server —
67
+ * advisory locks are node-local and do NOT span the nodes of
68
+ * a Galera / Percona XtraDB cluster or a load-balanced pool.
69
+ * - `'row'` — a certified conditional upsert on the `cluster_locks`
70
+ * TABLE (`INSERT … ON DUPLICATE KEY UPDATE … WHERE
71
+ * acquired_at < <expiry>`). Works across a multi-primary
72
+ * cluster because the write is globally certified — the same
73
+ * reason Voltro's own cron `advisoryLock` uses a claims row,
74
+ * not a session lock. Failover is expiry-based (a dead pod's
75
+ * shards are reclaimed once the lease ages out), not instant.
76
+ * - `'auto'` — the default: probe the live connection for a wsrep
77
+ * (Galera / PXC) cluster and pick `'row'` there, `'advisory'`
78
+ * everywhere else. Gives correct multi-replica handoff on
79
+ * Galera out of the box while keeping the faster advisory
80
+ * path on a single-primary server.
81
+ *
82
+ * MySQL Group Replication and other multi-primary topologies that aren't wsrep
83
+ * are NOT auto-detected — pin `VOLTRO_WORKFLOW_SHARD_LOCK=row` for those.
84
+ */
85
+ declare type ShardLockMode = 'auto' | 'row' | 'advisory';
86
+
51
87
  /** Lightweight TCP reachability probe for a dialect's database — the
52
88
  * soft-skip a fixture's `reachable()` uses (cheaper than a full connect).
53
89
  * SQLite fixtures just return `true` directly. */
@@ -64,6 +100,20 @@ declare interface WorkflowEngineLayerOptions {
64
100
  readonly sqlClientLayer: Layer.Layer<SqlClient.SqlClient, ConfigError.ConfigError | SqlError.SqlError, never>;
65
101
  /** `'sql'` for postgres / mysql / mariadb / mssql, `'memory'` for sqlite. */
66
102
  readonly runnerStorage: RunnerStorage;
103
+ /**
104
+ * The resolved dialect id (`postgres` / `mysql` / `mariadb` / `mssql` /
105
+ * `sqlite`). Used by the `'auto'` shard-lock mode to decide whether to probe
106
+ * for a wsrep (Galera) cluster. Omitted → the auto probe is skipped and
107
+ * advisory locking is kept.
108
+ */
109
+ readonly dialectId?: string;
110
+ /**
111
+ * Shard-ownership coordination mode (see {@link ShardLockMode}). Defaults to
112
+ * `VOLTRO_WORKFLOW_SHARD_LOCK` (→ `'auto'`). `'auto'` uses the certified
113
+ * row-lease path on a Galera / PXC cluster and advisory locks elsewhere, so
114
+ * cross-pod workflow handoff is correct on Galera without operator config.
115
+ */
116
+ readonly shardLockMode?: ShardLockMode;
67
117
  readonly runnerListenHost?: string;
68
118
  readonly runnerListenPort?: number;
69
119
  /**
@@ -1,6 +1,6 @@
1
1
  import { C as e, m as t, p as n, v as r, x as i } from "./primitives-CWy1iu5w.js";
2
2
  import { i as a } from "./src-CNeLb-4L.js";
3
- import { i as o, t as s } from "./cluster-Cl6mB2rW.js";
3
+ import { c as o, t as s } from "./cluster-DuFweKq0.js";
4
4
  import { Cron as c, Deferred as l, Effect as u, Fiber as d, Layer as f, Schema as p } from "effect";
5
5
  import { beforeAll as m, describe as h, expect as g, test as _ } from "vitest";
6
6
  //#region src/clusterTestSuite.ts
@@ -32,6 +32,8 @@ var v = async (e, t, n = 750) => {
32
32
  sqlClientLayer: v.sqlClientLayer,
33
33
  runnerStorage: v.runnerStorage,
34
34
  runnerListenPort: e,
35
+ ...v.dialectId ? { dialectId: v.dialectId } : {},
36
+ ...v.shardLockMode ? { shardLockMode: v.shardLockMode } : {},
35
37
  ...t && t.length > 0 ? { extraShardingLayers: t } : {}
36
38
  });
37
39
  h(`cluster workflow engine — ${v.name}`, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/workflow",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Durable workflows for Voltro — the workflow() descriptor + step() / awaitSignal primitives over @effect/cluster, with a browser-safe define subpath.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -50,8 +50,8 @@
50
50
  "@effect/cluster": "^0.59.0",
51
51
  "@effect/sql": "^0.51.1",
52
52
  "@effect/workflow": "^0.18.2",
53
- "@voltro/database": "0.1.4",
54
- "@voltro/protocol": "0.1.4"
53
+ "@voltro/database": "0.1.5",
54
+ "@voltro/protocol": "0.1.5"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "effect": "^3.21.4",
@@ -1,31 +0,0 @@
1
- import { Duration as e, Effect as t, Layer as n, Option as r } from "effect";
2
- import { ClusterCron as i, ClusterWorkflowEngine as a, RunnerAddress as o, SingleRunner as s } from "@effect/cluster";
3
- //#region src/clusterLayer.ts
4
- var c = 34e3, l = (e) => {
5
- let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
6
- return {
7
- host: t,
8
- port: e.runnerListenPort ?? 34e3,
9
- localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
10
- };
11
- }, u = (e) => {
12
- let t = l({
13
- runnerStorage: e.runnerStorage,
14
- runnerListenHost: e.runnerListenHost,
15
- runnerListenPort: e.runnerListenPort
16
- }), i = t.host, c = t.port;
17
- t.localhostRisk && process.stderr.write(`[voltro:workflow] runner host is '${i}' with SQL cluster storage — other pods cannot reach this runner, so a workflow cannot resume on another pod after a reschedule. Inject POD_IP via the K8s downward API (fieldRef: status.podIP) or set runnerListenHost / VOLTRO_WORKFLOW_RUNNER_HOST.
18
- `);
19
- let u = s.layer({
20
- runnerStorage: e.runnerStorage,
21
- shardingConfig: { runnerListenAddress: r.some(o.make(i, c)) }
22
- }).pipe(n.provide(e.sqlClientLayer)), d = e.extraShardingLayers ?? [];
23
- return (d.length === 0 ? a.layer : n.mergeAll(a.layer, ...d)).pipe(n.provide(u));
24
- }, d = (n) => i.make({
25
- name: n.name,
26
- cron: n.cron,
27
- execute: t.promise(() => n.execute()),
28
- skipIfOlderThan: n.skipIfOlderThan ?? e.days(1)
29
- });
30
- //#endregion
31
- export { u as i, c as n, l as r, d as t };