@voltro/workflow 0.80.0 → 0.81.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.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,96 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.81.0] — 2026-09-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **The compose baseline gives each api app its own database** — `@voltro/cli`
47
+
48
+ `voltro baseline sync` now gives every api app in a `project.json` its own database, login and migrate service. The primary api (the app named `api`, else the first api app) keeps the project's database; every other api app connects to `<project>_<app>` with `<PROJECT>_<APP>_DB_PASSWORD`, and each api app gets `VOLTRO_API_<APP>_URL` for its sibling api apps by service name. Migration: sync, move each secondary app's own tables into its new database, and set the new password variables in production.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.81.0/03_compose-baseline-one-database-per-api-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
51
+ - **A crashed process blocks its idempotency key for 30 seconds, not a day** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/voltro`
52
+
53
+ A process that died inside an idempotent mutation left its key `in_flight` for the whole replay window — 24 hours by default — so every retry with that key was refused with `MutationInFlight` (REST: `409`), including the retry of a workflow step whose callee had simply restarted. A running claim now holds on a 30-second lease that the server renews while the handler runs, over the socket and on REST alike; a dead process stops renewing, and the next send with the key takes over and runs the mutation once — the dead call's transaction never committed. A completed response still replays for the whole window. A takeover is conditional on the claim still being stale, so a claim renewed a moment earlier is never taken from a live handler. `ctx.apis` calls treat `MutationInFlight` as no answer yet: the same key is re-sent when the callee says, until it answers or the call's deadline passes (then `ApiUnavailable`). BREAKING only for an app that implements `IdempotencyStore` itself: `claim` takes the lease, and `renew(scope, key, now)` is required.
54
+
55
+ **`voltro update` carries you across this** — codemod `0.81.0/05_idempotency-store-renews-its-lease`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
56
+ - **Each api app owns its database schema; a second app on it refuses to boot** — `@voltro/database`, `@voltro/cli`
57
+
58
+ The first boot that migrates a database records which app owns it (`_voltro_schema_owner`). A different app booting against the same database and schema now refuses — naming both apps — before it plans anything, under `voltro dev`, `voltro serve` and `VOLTRO_AUTO_MIGRATE=0` alike; `voltro db apply` refuses the same way and claims an unclaimed database — as its first write, after every other check has passed, so an apply refused for drift or a blocked operation leaves the database as it found it. `voltro data` export and import treat the owner row as the deployment's own: it never travels to another database. Two apps on one schema each diffed it against their own declarations and planned to drop the other's tables. Migration: give every api app its own database (`DB_URL`) or schema (`DB_SCHEMA`) and reach the other app's data through `apis:` and `service:` procedures; `voltro doctor` lists api apps of a project that resolve the same target. `voltro db claim --yes` hands a schema to a RENAMED app.
59
+
60
+ **`voltro update` carries you across this** — codemod `0.81.0/01_one-database-per-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
61
+ - **ReactionDefinition is the union of the table form and the query form** — `@voltro/runtime`, `@voltro/voltro`
62
+
63
+ With reactions that watch a live query's rows, `ReactionDefinition` is now `EdgeReactionDefinition | LevelReactionDefinition`. Code holding a value of that type and reading a member only the table form has — `watch.table`, `watch.on`, `guards.dedupeKey`, `when(event)` — no longer compiles: type it `EdgeReactionDefinition`, or narrow with `isLevelReaction(def)` first. `defineReaction({ … })` is unchanged: an object literal resolves to its own form, and a value typed as the union is still accepted and validated by its `watch`.
64
+
65
+ **`voltro update` carries you across this** — codemod `0.81.0/04_reaction-definition-is-a-union`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
66
+ - **Store calls on a table the app does not declare now fail** — `@voltro/runtime`, `@voltro/cli`
67
+
68
+ Every store verb — in handlers, startups, schedules and workflows — now fails with `UndeclaredTableAccess` on a table that is neither declared by the app (its `table()`s, a plugin's `extendSchema.tables`) nor framework-owned (`_voltro_*`), in every environment. A string table name used to reach another app's table past its guards, audit and tenant rules. `store.raw(sql)` is unchanged. Migration: call the owning app through `apis:` and a `service:` procedure, or declare the table if it is this app's own.
69
+
70
+ **`voltro update` carries you across this** — codemod `0.81.0/02_store-refuses-undeclared-tables`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
71
+
72
+ ### Added
73
+
74
+ - **A plugin can bring app-shaped building blocks with `sources`** — `@voltro/protocol`, `@voltro/cli`
75
+
76
+ `definePlugin({ sources: fileURLToPath(new URL('./app', import.meta.url)) })` points at a directory of the same file conventions an app uses — queries and mutations with their executors, `schema.ts` tables, `*.workflow.tsx`, `*.cron.tsx`, subscribers, reactions — and every app that installs the plugin discovers them exactly as it discovers its own: the same codegen, browser-safety guard, access gate, migrations, and both boot paths. A reusable building block (a ledger, an audit trail, an approval flow) is shared between apps of a project without copying it; each installing app gets its own copy of the block's tables in its own database. `voltro build` keys the block's modules by plugin and path, so the serve bundle finds them wherever the block lives after a deploy.
77
+ - **A reaction can watch the rows of a live query, including another app's** — `@voltro/runtime`, `@voltro/cli`
78
+
79
+ `defineReaction({ watch: { query: 'deployments.pending', api: 'cloud' }, key: (row) => row.id, act: { kind: 'workflow', workflow: 'deploy.run' } })` reacts to every ROW of a live query's current result — the set of open work — instead of to each change of a table. Every delivery is the whole set, so a row that appeared while the reacting process was down, restarting or deploying is acted on when it comes back; nothing is lost. The workflow's `idempotencyKey` makes each row's act exactly-once across every replica, so no claim table is involved and a start that failed is tried again on the next delivery or on `resync` (default 5 minutes). `resync` and `stuckAfter` take milliseconds or a duration from `'30s'` to `'8 days'`. With `api:` the query is another app's `service:` query, subscribed over `ctx.apis` — push without the watched app knowing its watcher; without it, one of this app's own queries, read through the same guards and row filter as its REST projection. A row still in the set long after its workflow started is reported as `stuck` and never restarted. `voltro_reaction_pending`, `voltro_reaction_oldest_seconds` and `voltro_reaction_connected` expose the queue per reaction. The table form (`watch: { table }`) is unchanged; `runReaction` now takes the table form's type, `EdgeReactionDefinition`.
80
+ - **Apps of one project call each other through typed service procedures** — `@voltro/voltro`, `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/cli`, `@voltro/testing`, `@voltro/workflow`
81
+
82
+ An api app can now call another app of the same project, typed, over the socket, without touching its tables. The called app declares `service: { callers: ['worker'] }` on a query, mutation or action and lists the caller under `serviceCallers: { worker: {} }`; codegen emits those procedures into `serviceGroup.generated.ts` and never into the browser's `rpcGroup.generated.ts`. The calling app binds it under `apis: { cloud: { package: '@acme/api' } }` and calls `ctx.apis.cloud.deployments.activate(input)` (Promise) or `.effect(input)` (Effect), with every input, output and typed error inferred from the callee's declarations. A service authenticates with a per-edge credential (`VOLTRO_API_<BINDING>_TOKEN` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee, `_NEXT` for rotation); `voltro dev` derives and mints it into both apps' `.env.local`, production supplies it (`voltro secret generate service`) and a missing one fails boot. A caller name is letters, digits and dashes, at most 56 characters, so its `service:<name>` identity fits an `id()` column. The called app plants an `actors` row for each accepted caller at boot (`service:<name>`, `kind: 'serviceAccount'`), so `audit()`-stamped writes name the calling app instead of failing on the foreign key. A service subject holds no scopes: it reaches exactly the procedures that name it, is refused everywhere else — framework built-ins included — and no user, API key or `admin:full` session reaches a service procedure. A write made inside a workflow step carries an idempotency key derived from the step, so a step retried after a lost reply replays instead of writing twice; an app with `serviceCallers` deduplicates mutations by default. A call without an answer fails with `ApiUnavailable` within a deadline (10 s by default); calls are measured as `voltro_api_call_duration_seconds` and `voltro_api_call_errors_total`. `voltro doctor` reports a caller listed without a procedure, a procedure naming an unlisted caller and a binding the callee does not accept. `@voltro/testing` adds `makeServiceApiMock`, which decodes every input and output through the callee's real schemas.
83
+
84
+ `apiSurface: compatible` — the replaced golden lines in `@voltro/protocol` (and the `@voltro/voltro` aggregate re-exporting it) are `AnyCheckSpec` and `DeclaredAccess` gaining the member `ServiceAccessSpec`. Every existing member is unchanged and an app keeps writing the guards it wrote; the framework is the only producer of the new member, which it normalises from `service:`. Only a switch exhaustive over guard specs needs an arm.
85
+ - **startTestApps boots several apps of one project, each service edge wired** — `@voltro/testing`
86
+
87
+ `startTestApps({ apps, edges })` from `@voltro/testing` boots the apps of one project as real `voltro` processes (`dev` by default, `serve` with `mode: 'serve'`), each on a free port — every callee before its callers, because a caller imports the contract its callee's codegen writes — and resolves once every app answers `/internal/readiness`; a cycle of edges is refused before anything starts. Every listed edge gets a freshly minted credential written to both ends, under the variables the apps read (`VOLTRO_API_<BINDING>_TOKEN` / `_URL` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee); `apps.token(caller, callee)` returns it so a test can call the callee as the caller. An app that exits or is not ready in time stops the others and the call rejects with its log. Processes rather than one process, because apps with their own tables cannot share one process's table registry.
88
+ - **makeTestApp requests carry the caller's address** — `@voltro/testing`
89
+
90
+ A route that reads `ctx.remoteAddr` (a per-client rate limit, an audit field) could not be tested per client: every `makeTestApp` request arrived without an address. A request's options now take `remoteAddr`, the address the connection comes from, defaulting to `127.0.0.1`. The route sees it as `ctx.remoteAddr`, resolved by the function the server's listener uses, and a `publicApi` procedure's plugin interceptors see the same address, as on the server. `makeTestApp({ security: { trustedProxies, clientAddressHeader } })` takes the api's own settings, so a forwarding header is believed in a test exactly when it would be in production.
91
+
92
+ `markRestInvocation` takes the request's address as an optional third argument, and `restInvocationOf` returns it beside the spec; both only gain an optional member, so existing calls and reads are unchanged.
93
+
94
+ ### Fixed
95
+
96
+ - **Codegen removes an rpc group the app no longer has procedures for** — `@voltro/cli`
97
+
98
+ An app whose last client-reachable procedure went away — deleted, or marked `internal: true` — kept its `rpcGroup.generated.ts`: codegen reported "no rpc primitives found — nothing to write" and left the old file, a typed contract for procedures the server no longer served. The next `voltro dev` or `voltro codegen` now removes it, as it already removed an empty `serviceGroup.generated.ts` and `apis.generated.ts`. A hand-written file of that name is left alone.
99
+ - **The auto-launched dashboard binds `VOLTRO_DASHBOARD_PORT`** — `@voltro/cli`
100
+
101
+ `VOLTRO_DASHBOARD_PORT` was read by `voltro dashboard` only. The dashboard that `voltro dev` and `voltro start` launch on their own — api and web app alike — always came up on `:5179`, and the launcher wrote `5179` into the child's `VOLTRO_DASHBOARD_PORT`, overriding the value the developer had set. The launcher and `voltro dashboard` now resolve the port through one reader: the variable, else `5179`. A value that is not a port (`1..65535`) is refused with a warning naming it instead of silently becoming `5179`.
102
+ - **The db pool boot line counts a connection outside the pool once** — `@voltro/cli`
103
+
104
+ With change data capture on, the line read `max=5 … + 1 = 6` and then, separately, `PLUS 1 outside the pool (CDC LISTEN consumer)`, which reads as 7 when the process holds 6. The outside connection is now named once, inside the sum it belongs to: `max=5 per replica (DB_MAX_CONNECTIONS) + 1 outside the pool (CDC LISTEN consumer) = 6 per process`.
105
+ - **A declared `utf8mb4` store reaches the workflow engine's tables** — `@voltro/database`, `@voltro/workflow`, `@voltro/cli`
106
+
107
+ `store: { charset: 'utf8mb4' }` converted every table the app declares and left the tables the framework creates without declaring them in the database default: the durable workflow engine's `cluster_*` storage, which holds every workflow call's input and result as JSON text, and the deploy runner's `_voltro_deploys` / `_voltro_migration_ops`. On a `utf8mb3` database a workflow whose input carried an emoji was refused there. Every table the framework creates is now created in the declared charset: the runner's ledgers directly, and the workflow engine's tables by the process that creates them on its first boot, while they are still empty. A workflow table a booting process finds is never rewritten at boot; tables from before the declaration are converted by `voltro db apply` (also when the plan has nothing to do, and with `--plan`) once the deploy has closed and by the `voltro dev` auto-migrate, keeping type, nullability and column CHECKs, and `voltro db plan` lists them beside the plan (under `--json` too, on the log, never in the plan file). The database's own default is left alone. The step only ever widens, and a deploy that fails and is undone never runs it.
108
+ - **The MySQL/MariaDB pool keeps its connections instead of reconnecting** — `@voltro/sql-mysql`
109
+
110
+ The driver pool was created with `maxIdle: 0`, so every connection released to it was closed within a second. A process reconnected after each second of quiet: a TCP handshake per burst, plus a TLS one where configured. Measured on MySQL and MariaDB: a query after two quiet seconds ran on a new connection. The pool now keeps the connections it opened, up to the limit.
111
+ - **Workflows started by a reaction or an incoming webhook start again** — `@voltro/cli`
112
+
113
+ A workflow started by a `*.reaction.ts` act or an incoming `*.webhook.tsx` was refused with `Workflow placement requires explicit caller identity`, under `voltro dev` and `voltro serve`: neither path named a caller, and placement refuses a start without one. Both now start as the framework itself, placed in the changed row's tenant when it has one.
114
+ - **The first boot on an empty database no longer warns about replica observations** — `@voltro/cli`
115
+
116
+ `voltro dev` started publishing this replica's observations before its auto-migrate, so the first write on an empty database hit a table that did not exist yet and printed `could not record this replica's observation — fleet views will report this replica as stale` on the first boot of every new project. The publisher now writes its first observation once the schema is applied — at once on `voltro serve`, whose fingerprint check runs before it — and never when the boot stops earlier.
117
+ - **A 405 on a shared REST path names every method the path serves** — `@voltro/protocol`
118
+
119
+ A `DELETE` to a path served by `GET` and `POST` answered `405` with `Allow: POST`: each route refused with its own method, and the last refusal won. `Allow` and the error body's `allow` now list every method declared on the path, `GET, POST`, as the REST documentation already promised.
120
+ - **A large REST surface no longer holds the boot for seconds registering routes** — `@voltro/runtime`, `@voltro/protocol`
121
+
122
+ Every REST route used to be mounted for all 34 HTTP methods the router knows, at its path and at its sub-path, and each insert compares itself against every entry already held. A public REST surface of about 1000 procedures therefore held about 34 000 router entries, and building them kept the thread busy for over a second after the port was bound — long enough for a liveness probe to time out. A path is now mounted for exactly the methods its routes declare: the same 1000 procedures hold 4 000 entries. Behaviour is unchanged. A method the path does not declare still reaches its routes and gets the precise `405` with `Allow`, a path nobody declared is still a `404`, and `HEAD` still answers from `GET`. For this, a REST route now declares its real `method` instead of `'*'` and sets the new `PluginHttpRoute.answersOtherMethods: true`. A plugin route that declares one method without that flag never sees another method, as before. A server of your own that mounts `restRoutesToHttpRoutes` output by method must hand the other methods to routes with `answersOtherMethods`, or they become `404` instead of `405`.
123
+ - **The testing guide's tsconfig example no longer sets baseUrl** — `@voltro/cli`
124
+
125
+ The `paths` alias example in the testing guide set `"baseUrl": "."`, an option TypeScript 7 removed. It now maps `paths` alone, which resolves against the tsconfig's own directory. `voltro test` still reads a `baseUrl` an older config sets.
126
+ - **voltro update lists a note's code and config lines before prose** — `@voltro/cli`
127
+
128
+ A manual note lists the lines it found in code and config first, under `── found in code or config (N lines)`. Lines in Markdown and text files follow in their own `── mentioned in prose (N lines)` block, and a note that matched only prose says `nothing in code or config matched`. Past the lines shown, the rest are named by file with a count instead of a bare `… and N more`. Before, the lines were sorted by path, and 40 lines of documentation hid a Helm env entry, the one line to change. The guide `voltro agents-md` copies into `agent-docs/` is no longer searched. It names every change of a release, so refreshing it made notes report "found" for changes the project never used.
129
+
130
+ ---
131
+
42
132
  ## [0.80.0] — 2026-09-24
43
133
 
44
134
  ### ⚠ BREAKING
@@ -1,17 +1,17 @@
1
1
  import { a as e, l as t, n, o as r, t as i, u as a } from "./clusterDiagnostics-Cwt-WFec.js";
2
2
  import { Context as o, Duration as s, Effect as c, Layer as l, Option as u, Runtime as d, Schema as f } from "effect";
3
3
  import { SqlClient as p } from "@effect/sql";
4
- import { ClusterCron as m, ClusterSchema as h, ClusterWorkflowEngine as g, EntityAddress as _, EntityId as v, EntityType as ee, MessageStorage as y, RunnerAddress as b, ShardId as te, Sharding as x, ShardingConfig as ne, SingleRunner as re, SqlMessageStorage as ie, SqlRunnerStorage as ae } from "@effect/cluster";
5
- import { fnv1a64 as S, resolveMigrationLockSchema as C, sqlPoolCapacity as oe } from "@voltro/database/sql";
6
- import { createLogger as se } from "@voltro/logger";
4
+ import { ClusterCron as m, ClusterSchema as h, ClusterWorkflowEngine as g, EntityAddress as _, EntityId as ee, EntityType as te, MessageStorage as ne, RunnerAddress as v, ShardId as re, Sharding as y, ShardingConfig as ie, SingleRunner as ae, SqlMessageStorage as oe, SqlRunnerStorage as se } from "@effect/cluster";
5
+ import { applyUndeclaredCharsetWork as b, fnv1a64 as x, readUndeclaredCharsetColumns as ce, resolveMigrationLockSchema as S, sqlPoolCapacity as le, undeclaredCharsetWork as ue } from "@voltro/database/sql";
6
+ import { createLogger as de } from "@voltro/logger";
7
7
  //#region src/workflowJournalReset.ts
8
- var w = f.parseJson(f.Struct({
8
+ var C = f.parseJson(f.Struct({
9
9
  _tag: f.Literal("Success"),
10
10
  value: f.Struct({ _tag: f.Literal("Suspended") })
11
- })), ce = f.parseJson(f.Record({
11
+ })), fe = f.parseJson(f.Record({
12
12
  key: f.String,
13
13
  value: f.String
14
- })), le = (e) => c.gen(function* () {
14
+ })), pe = (e) => c.gen(function* () {
15
15
  if (!/^[1-9]\d*$/.test(e.requestId) || e.workflowName.length === 0 || e.executionId.length === 0) return yield* c.die(/* @__PURE__ */ Error("Suspension observation requires an exact engine delivery owner"));
16
16
  let [t] = yield* (yield* p.SqlClient).withoutTransforms()`
17
17
  SELECT m.entity_type, m.entity_id, m.tag, m.headers, r.id AS reply_id, r.payload
@@ -20,13 +20,13 @@ var w = f.parseJson(f.Struct({
20
20
  WHERE m.id = ${e.requestId} AND m.kind = 0`;
21
21
  if (t === void 0) return;
22
22
  if (t.entity_type !== `Workflow/${e.workflowName}` || t.entity_id !== e.executionId || t.tag !== "run") return yield* c.die(/* @__PURE__ */ Error("Suspension observation belongs to another execution or procedure"));
23
- if (((yield* c.sync(() => f.decodeUnknownSync(ce)(t.headers)))["voltro-workflow-restart"] ?? null) !== e.restartToken || t.reply_id === null || f.decodeUnknownOption(w)(t.payload)._tag === "None") return;
23
+ if (((yield* c.sync(() => f.decodeUnknownSync(fe)(t.headers)))["voltro-workflow-restart"] ?? null) !== e.restartToken || t.reply_id === null || f.decodeUnknownOption(C)(t.payload)._tag === "None") return;
24
24
  let n = String(t.reply_id);
25
25
  return /^[1-9]\d*$/.test(n) ? {
26
26
  ...e,
27
27
  replyId: n
28
28
  } : yield* c.die(/* @__PURE__ */ Error("Suspension reply has an invalid identity"));
29
- }).pipe(c.provideService(p.SafeIntegers, !0)), ue = (t, n) => c.gen(function* () {
29
+ }).pipe(c.provideService(p.SafeIntegers, !0)), me = (t, n) => c.gen(function* () {
30
30
  let { restart: r, suspension: i } = n, a = /* @__PURE__ */ new Map();
31
31
  for (let e of t) {
32
32
  if (!/^[1-9]\d*$/.test(e.requestId) || !/^[1-9]\d*$/.test(e.replyId)) return yield* c.die(/* @__PURE__ */ Error("Journal reset requires positive decimal request and reply identities"));
@@ -101,7 +101,7 @@ var w = f.parseJson(f.Struct({
101
101
  };
102
102
  let r = yield* s`SELECT payload FROM ${u}
103
103
  WHERE id = ${n} AND request_id = ${e} AND kind = 0`;
104
- if (r.length !== 1 || i !== void 0 && f.decodeUnknownOption(w)(r[0]?.payload)._tag === "None") return {
104
+ if (r.length !== 1 || i !== void 0 && f.decodeUnknownOption(C)(r[0]?.payload)._tag === "None") return {
105
105
  kind: "changed",
106
106
  requestId: e
107
107
  };
@@ -118,60 +118,60 @@ var w = f.parseJson(f.Struct({
118
118
  count: o.length
119
119
  };
120
120
  }).pipe(s.withTransaction, c.provideService(p.SafeIntegers, !0));
121
- }), de = (e) => ue([e], { suspension: e }), fe = (e) => c.gen(function* () {
122
- let n = yield* c.runtime(), r = yield* c.promise(() => e.transaction((t) => a(t, e.requestId, (e) => d.runPromise(n)(le(e)))));
121
+ }), he = (e) => me([e], { suspension: e }), ge = (e) => c.gen(function* () {
122
+ let n = yield* c.runtime(), r = yield* c.promise(() => e.transaction((t) => a(t, e.requestId, (e) => d.runPromise(n)(pe(e)))));
123
123
  if (r.kind !== "bound") return r;
124
124
  let i = yield* c.promise(() => e.transaction((n) => t(n, e.requestId)));
125
125
  if (i.kind !== "dispatch") return i;
126
126
  let { request: o } = i;
127
127
  if (o.operation !== "resume" || o.resumeJournal === null) return yield* c.die(/* @__PURE__ */ Error("Resume dispatch requires its committed suspension binding"));
128
- let s = yield* de({
128
+ let s = yield* he({
129
129
  workflowName: o.workflowName,
130
130
  executionId: o.executionId,
131
131
  ...o.resumeJournal
132
132
  });
133
- return yield* (yield* x.Sharding).pollStorage, {
133
+ return yield* (yield* y.Sharding).pollStorage, {
134
134
  kind: "attempted",
135
135
  request: o,
136
136
  journal: s
137
137
  };
138
- }), T = class extends o.Tag("@voltro/workflow/WorkflowResumeDispatcher")() {}, pe = c.gen(function* () {
139
- let e = yield* p.SqlClient, t = yield* x.Sharding;
140
- return T.of({ dispatch: (n) => fe(n).pipe(c.provideService(p.SqlClient, e), c.provideService(x.Sharding, t)) });
141
- }), E = 6322741009312601n, D = () => {
142
- let e = C();
143
- return e === void 0 ? E : S(`voltro_cluster_migration_lock:${e}`) & 9223372036854775807n;
144
- }, O = () => {
145
- let e = `voltro_cluster_migration_${E.toString(36)}`, t = C();
138
+ }), w = class extends o.Tag("@voltro/workflow/WorkflowResumeDispatcher")() {}, _e = c.gen(function* () {
139
+ let e = yield* p.SqlClient, t = yield* y.Sharding;
140
+ return w.of({ dispatch: (n) => ge(n).pipe(c.provideService(p.SqlClient, e), c.provideService(y.Sharding, t)) });
141
+ }), T = 6322741009312601n, E = () => {
142
+ let e = S();
143
+ return e === void 0 ? T : x(`voltro_cluster_migration_lock:${e}`) & 9223372036854775807n;
144
+ }, D = () => {
145
+ let e = `voltro_cluster_migration_${T.toString(36)}`, t = S();
146
146
  if (t === void 0) return e;
147
147
  let n = `${e}_${t}`;
148
- return /^[A-Za-z0-9_]+$/.test(t) && n.length <= 64 ? n : `${e}_h${S(`voltro_cluster_migration_lock:${t}`).toString(16)}`;
149
- }, k = () => {
148
+ return /^[A-Za-z0-9_]+$/.test(t) && n.length <= 64 ? n : `${e}_h${x(`voltro_cluster_migration_lock:${t}`).toString(16)}`;
149
+ }, O = () => {
150
150
  let e = Number(process.env.VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS);
151
151
  return Number.isFinite(e) && e > 0 ? e : 6e4;
152
- }, A = 250, j = (e) => e[0]?.[0], M = (e, t) => e.onDialectOrElse({
152
+ }, k = 250, A = (e) => e[0]?.[0], j = (e, t) => e.onDialectOrElse({
153
153
  sqlite: () => c.void,
154
- mysql: () => t.executeValues(`SELECT GET_LOCK('${O()}', ${Math.ceil(k() / 1e3)})`, []).pipe(c.flatMap((e) => j(e) === 1 || j(e) === "1" ? c.void : c.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${O()}' (GET_LOCK timed out)`)))),
155
- mssql: () => t.executeValues(`DECLARE @res int; EXEC @res = sp_getapplock @Resource = '${O()}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ${k()}; SELECT @res`, []).pipe(c.flatMap((e) => Number(j(e) ?? -999) >= 0 ? c.void : c.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${O()}' (sp_getapplock ${String(j(e))})`)))),
154
+ mysql: () => t.executeValues(`SELECT GET_LOCK('${D()}', ${Math.ceil(O() / 1e3)})`, []).pipe(c.flatMap((e) => A(e) === 1 || A(e) === "1" ? c.void : c.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${D()}' (GET_LOCK timed out)`)))),
155
+ mssql: () => t.executeValues(`DECLARE @res int; EXEC @res = sp_getapplock @Resource = '${D()}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ${O()}; SELECT @res`, []).pipe(c.flatMap((e) => Number(A(e) ?? -999) >= 0 ? c.void : c.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${D()}' (sp_getapplock ${String(A(e))})`)))),
156
156
  orElse: () => {
157
- let e = D().toString(), n = Date.now(), r = t.executeValues(`SELECT pg_try_advisory_lock(${e}::bigint)`, []).pipe(c.flatMap((e) => {
158
- let t = j(e);
159
- return t === !0 || t === "t" || t === 1 ? c.void : Date.now() - n >= k() ? c.die(/* @__PURE__ */ Error(`could not acquire the cluster migration advisory lock within ${Math.round(k() / 1e3)}s. Another runner is migrating the cluster schema, or a prior boot wedged while holding it. (Raise VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS, or inspect pg_locks for the holder.)`)) : c.sleep(`${A} millis`).pipe(c.zipRight(r));
157
+ let e = E().toString(), n = Date.now(), r = t.executeValues(`SELECT pg_try_advisory_lock(${e}::bigint)`, []).pipe(c.flatMap((e) => {
158
+ let t = A(e);
159
+ return t === !0 || t === "t" || t === 1 ? c.void : Date.now() - n >= O() ? c.die(/* @__PURE__ */ Error(`could not acquire the cluster migration advisory lock within ${Math.round(O() / 1e3)}s. Another runner is migrating the cluster schema, or a prior boot wedged while holding it. (Raise VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS, or inspect pg_locks for the holder.)`)) : c.sleep(`${k} millis`).pipe(c.zipRight(r));
160
160
  }));
161
161
  return r;
162
162
  }
163
- }), N = (e, t) => e.onDialectOrElse({
163
+ }), M = (e, t) => e.onDialectOrElse({
164
164
  sqlite: () => c.void,
165
- mysql: () => c.asVoid(t.executeValues(`SELECT RELEASE_LOCK('${O()}')`, [])),
166
- mssql: () => c.asVoid(t.executeValues(`EXEC sp_releaseapplock @Resource = '${O()}', @LockOwner = 'Session'`, [])),
167
- orElse: () => c.asVoid(t.executeValues(`SELECT pg_advisory_unlock(${D().toString()}::bigint)`, []))
168
- }), P = (e, t) => e.onDialectOrElse({
165
+ mysql: () => c.asVoid(t.executeValues(`SELECT RELEASE_LOCK('${D()}')`, [])),
166
+ mssql: () => c.asVoid(t.executeValues(`EXEC sp_releaseapplock @Resource = '${D()}', @LockOwner = 'Session'`, [])),
167
+ orElse: () => c.asVoid(t.executeValues(`SELECT pg_advisory_unlock(${E().toString()}::bigint)`, []))
168
+ }), N = (e, t) => e.onDialectOrElse({
169
169
  sqlite: () => !0,
170
170
  orElse: () => !1
171
171
  }) ? t : c.scoped(c.gen(function* () {
172
172
  let n = yield* e.reserve;
173
- return yield* c.acquireRelease(M(e, n), () => N(e, n).pipe(c.orDie)), yield* t;
174
- })), F = (e) => e === "sql" ? ["cluster_messages", "cluster_runners"] : ["cluster_messages"], I = (e, t) => e.onDialectOrElse({
173
+ return yield* c.acquireRelease(j(e, n), () => M(e, n).pipe(c.orDie)), yield* t;
174
+ })), P = (e) => e === "sql" ? ["cluster_messages", "cluster_runners"] : ["cluster_messages"], F = (e, t) => e.onDialectOrElse({
175
175
  pg: () => e`SELECT (to_regclass(${t}) IS NOT NULL) AS present`,
176
176
  mysql: () => e`SELECT (COUNT(*) > 0) AS present FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ${t}`,
177
177
  mssql: () => e`SELECT CASE WHEN OBJECT_ID(${t}, 'U') IS NULL THEN 0 ELSE 1 END AS present`,
@@ -179,9 +179,17 @@ var w = f.parseJson(f.Struct({
179
179
  }).pipe(c.map((e) => {
180
180
  let t = e[0]?.present;
181
181
  return t === !0 || t === 1 || t === "1" || t === "t" || typeof t == "bigint" && t === 1n;
182
- }), c.catchAll(() => c.succeed(!1))), L = (e, t) => c.forEach(F(t), (t) => I(e, t), { concurrency: 1 }).pipe(c.map((e) => e.every(Boolean))), R = (e, t) => c.scoped(c.asVoid(l.build(t.pipe(l.provide(ne.layerDefaults), l.provide(l.succeed(p.SqlClient, e)))))), z = (e, t) => R(e, ie.layer).pipe(c.zipRight(t === "sql" ? R(e, ae.layer) : c.void)), B = (e) => e === "sql" ? 3 : 2, me = (e) => l.effect(p.SqlClient, c.gen(function* () {
183
- let t = yield* p.SqlClient, n = B(e.runnerStorage), r = oe(t);
184
- return r !== void 0 && r < n ? yield* c.dieMessage(`the workflow runtime needs ${n} database connections at once and the pool has ${r}: ` + (e.runnerStorage === "sql" ? "on a first boot it holds its migration lock and its runner lock while the migration runs on a third, and once running it keeps the runner lock and needs one more to work. " : "on a first boot it holds its migration lock while the migration runs on a second. ") + `Set DB_MAX_CONNECTIONS (or PG_MAX_CONNECTIONS) to at least ${n}` + (e.runnerStorage === "sql" ? ", or VOLTRO_WORKFLOW_RUNNER_STORAGE=memory for a single-process runtime that needs 2 (no cross-pod shard handoff)." : ".")) : ((yield* L(t, e.runnerStorage)) || (yield* P(t, z(t, e.runnerStorage))), t);
182
+ }), c.catchAll(() => c.succeed(!1))), I = (e, t) => c.forEach(P(t), (t) => F(e, t), { concurrency: 1 }).pipe(c.map((e) => e.every(Boolean))), L = (e, t) => c.scoped(c.asVoid(l.build(t.pipe(l.provide(ie.layerDefaults), l.provide(l.succeed(p.SqlClient, e)))))), R = (e, t) => L(e, oe.layer).pipe(c.zipRight(t === "sql" ? L(e, se.layer) : c.void)), z = (e) => c.map(e.unsafe("SELECT table_name AS t FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'cluster\\_%'"), (e) => new Set(e.map((e) => String(e.t ?? e.T)))), ve = (e, t, n) => n === void 0 || !e.onDialectOrElse({
183
+ mysql: () => !0,
184
+ orElse: () => !1
185
+ }) ? R(e, t) : c.gen(function* () {
186
+ let r = yield* z(e);
187
+ yield* R(e, t);
188
+ let i = new Set([...yield* z(e)].filter((e) => !r.has(e)));
189
+ i.size !== 0 && (yield* b(e, ue(yield* ce(e), /* @__PURE__ */ new Set(), n, i)));
190
+ }), ye = (e) => e === "sql" ? 3 : 2, B = (e) => l.effect(p.SqlClient, c.gen(function* () {
191
+ let t = yield* p.SqlClient, n = ye(e.runnerStorage), r = le(t);
192
+ return r !== void 0 && r < n ? yield* c.dieMessage(`the workflow runtime needs ${n} database connections at once and the pool has ${r}: ` + (e.runnerStorage === "sql" ? "on a first boot it holds its migration lock and its runner lock while the migration runs on a third, and once running it keeps the runner lock and needs one more to work. " : "on a first boot it holds its migration lock while the migration runs on a second. ") + `Set DB_MAX_CONNECTIONS (or PG_MAX_CONNECTIONS) to at least ${n}` + (e.runnerStorage === "sql" ? ", or VOLTRO_WORKFLOW_RUNNER_STORAGE=memory for a single-process runtime that needs 2 (no cross-pod shard handoff)." : ".")) : ((yield* I(t, e.runnerStorage)) || (yield* N(t, ve(t, e.runnerStorage, e.charset))), t);
185
193
  })), V = (e) => {
186
194
  if (e instanceof Date) return Number.isNaN(e.getTime()) ? null : e;
187
195
  if (typeof e == "string" || typeof e == "number") {
@@ -189,7 +197,7 @@ var w = f.parseJson(f.Struct({
189
197
  return Number.isNaN(n.getTime()) ? null : n;
190
198
  }
191
199
  return null;
192
- }, H = (e) => e === !0 || e === 1 || e === "1" || e instanceof Uint8Array && e[0] === 1, he = (e, t) => {
200
+ }, H = (e) => e === !0 || e === 1 || e === "1" || e instanceof Uint8Array && e[0] === 1, be = (e, t) => {
193
201
  let n = e.lastIndexOf(":");
194
202
  if (n < 0) return;
195
203
  let r = e.slice(0, n), i = Number(e.slice(n + 1)), a = t.indexOf(r);
@@ -257,7 +265,7 @@ var w = f.parseJson(f.Struct({
257
265
  }))), g = (r) => {
258
266
  let i = /* @__PURE__ */ new Map();
259
267
  for (let e of r) {
260
- let t = he(e, n);
268
+ let t = be(e, n);
261
269
  t !== void 0 && i.set(t, e);
262
270
  }
263
271
  return i.size === 0 ? c.succeed([]) : c.gen(function* () {
@@ -315,14 +323,14 @@ var w = f.parseJson(f.Struct({
315
323
  }).pipe(t.withTransaction, c.catchAll(() => c.succeed(0)));
316
324
  }
317
325
  };
318
- }), W = se({ scope: "voltro:workflow" }), ge = 34e3, G = (e) => {
326
+ }), W = de({ scope: "voltro:workflow" }), xe = 34e3, G = (e) => {
319
327
  let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
320
328
  return {
321
329
  host: t,
322
330
  port: e.runnerListenPort ?? 34e3,
323
331
  localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
324
332
  };
325
- }, _e = (e) => {
333
+ }, Se = (e) => {
326
334
  let t = e.sqliteFamily ? "memory" : "sql", n = e.override ?? process.env.VOLTRO_WORKFLOW_RUNNER_STORAGE;
327
335
  if (n === void 0 || n.trim() === "") return t;
328
336
  let r = n.trim().toLowerCase();
@@ -341,12 +349,12 @@ var w = f.parseJson(f.Struct({
341
349
  return t === 1 || t === "1" || t === !0 || t === "ON" || t === "on";
342
350
  }), J = (e) => c.gen(function* () {
343
351
  return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* q;
344
- }), ve = (e) => ({
352
+ }), Ce = (e) => ({
345
353
  coordination: e.disableAdvisory ? "row-based" : "advisory",
346
354
  dialect: e.dialectId ?? "unknown",
347
355
  mode: e.mode,
348
356
  ...e.disableAdvisory && e.mode === "auto" ? { reason: "wsrep/Galera cluster detected" } : {}
349
- }), ye = () => {
357
+ }), we = () => {
350
358
  let e = (e) => {
351
359
  if (e === void 0) return;
352
360
  let t = Number(e);
@@ -357,72 +365,75 @@ var w = f.parseJson(f.Struct({
357
365
  ...n === void 0 ? {} : { failoverHeartbeatSeconds: n },
358
366
  ...r === void 0 ? {} : { messagePollSeconds: r }
359
367
  };
360
- }, be = c.flatMap(x.Sharding, (e) => e.pollStorage), Y = /* @__PURE__ */ new Set(), xe = (e) => {
368
+ }, Te = c.flatMap(y.Sharding, (e) => e.pollStorage), Y = /* @__PURE__ */ new Set(), Ee = (e) => {
361
369
  let t = G({
362
370
  runnerStorage: e.runnerStorage,
363
371
  runnerListenHost: e.runnerListenHost,
364
372
  runnerListenPort: e.runnerListenPort
365
373
  }), i = t.host, a = t.port;
366
374
  t.localhostRisk && !Y.has(i) && (Y.add(i), W.warn("runner host is not routable from other pods with SQL cluster storage — 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.", { runnerHost: i }));
367
- let d = me({ runnerStorage: e.runnerStorage }).pipe(l.provide(e.sqlClientLayer)), f = e.shardLockMode ?? K(), p = l.unwrapEffect(J({
375
+ let d = B({
376
+ runnerStorage: e.runnerStorage,
377
+ charset: e.charset
378
+ }).pipe(l.provide(e.sqlClientLayer)), f = e.shardLockMode ?? K(), p = l.unwrapEffect(J({
368
379
  dialectId: e.dialectId,
369
380
  runnerStorage: e.runnerStorage,
370
381
  mode: f
371
382
  }).pipe(c.map((t) => {
372
- e.runnerStorage === "sql" && W.info("shard-lock coordination", ve({
383
+ e.runnerStorage === "sql" && W.info("shard-lock coordination", Ce({
373
384
  disableAdvisory: t,
374
385
  dialectId: e.dialectId,
375
386
  mode: f
376
387
  }));
377
- let r = l.effect(n, c.flatMap(x.Sharding, (n) => U({
378
- ownAddress: b.make(i, a).toString(),
388
+ let r = l.effect(n, c.flatMap(y.Sharding, (n) => U({
389
+ ownAddress: v.make(i, a).toString(),
379
390
  shardLockMode: e.runnerStorage === "sql" ? t ? "row" : "advisory" : "none",
380
391
  runnerStorage: e.runnerStorage,
381
392
  dialectId: e.dialectId,
382
- hasShard: (e) => n.hasShardId(te.ShardId.fromString(e))
383
- }))), o = re.layer({
393
+ hasShard: (e) => n.hasShardId(re.ShardId.fromString(e))
394
+ }))), o = ae.layer({
384
395
  runnerStorage: e.runnerStorage,
385
396
  shardingConfig: {
386
- runnerAddress: u.some(b.make(i, a)),
387
- runnerListenAddress: u.some(b.make(i, a)),
397
+ runnerAddress: u.some(v.make(i, a)),
398
+ runnerListenAddress: u.some(v.make(i, a)),
388
399
  ...t ? { shardLockDisableAdvisory: !0 } : {},
389
400
  ...e.failoverLeaseSeconds === void 0 ? {} : { shardLockExpiration: s.seconds(e.failoverLeaseSeconds) },
390
401
  ...e.failoverHeartbeatSeconds === void 0 ? {} : { shardLockRefreshInterval: s.seconds(e.failoverHeartbeatSeconds) },
391
402
  ...e.messagePollSeconds === void 0 ? {} : { entityMessagePollInterval: s.seconds(e.messagePollSeconds) }
392
403
  }
393
404
  });
394
- return l.merge(r, l.effect(T, pe)).pipe(l.provideMerge(o));
395
- }))).pipe(l.provide(d)), m = e.extraShardingLayers ?? [], h = m.length === 0 ? g.layer : l.mergeAll(g.layer, ...m), _ = p.pipe(l.map((e) => o.add(e, x.Sharding, r(o.get(e, x.Sharding)))));
405
+ return l.merge(r, l.effect(w, _e)).pipe(l.provideMerge(o));
406
+ }))).pipe(l.provide(d)), m = e.extraShardingLayers ?? [], h = m.length === 0 ? g.layer : l.mergeAll(g.layer, ...m), _ = p.pipe(l.map((e) => o.add(e, y.Sharding, r(o.get(e, y.Sharding)))));
396
407
  return h.pipe(l.provideMerge(_));
397
- }, Se = (e) => m.make({
408
+ }, X = (e) => m.make({
398
409
  name: e.name,
399
410
  cron: e.cron,
400
411
  execute: c.promise(() => e.execute()),
401
412
  skipIfOlderThan: e.skipIfOlderThan ?? s.days(1)
402
- }), X = (e, t) => `${e}/${t}`, Ce = (e, t, n) => {
403
- let r = v.make(t), i = o.get(e.annotations, h.ShardGroup)(r);
413
+ }), Z = (e, t) => `${e}/${t}`, De = (e, t, n) => {
414
+ let r = ee.make(t), i = o.get(e.annotations, h.ShardGroup)(r);
404
415
  return new _.EntityAddress({
405
- entityType: ee.EntityType.make(`Workflow/${e.name}`),
416
+ entityType: te.EntityType.make(`Workflow/${e.name}`),
406
417
  entityId: r,
407
418
  shardId: n.getShardId(r, i)
408
419
  });
409
- }, Z = f.decodeUnknownOption(f.Exit({
420
+ }, Q = f.decodeUnknownOption(f.Exit({
410
421
  success: f.Unknown,
411
422
  failure: f.Unknown,
412
423
  defect: f.Unknown
413
- })), we = (e) => {
414
- let t = (e) => e && typeof e == "object" && "_tag" in e && typeof e._tag == "string" ? e._tag : void 0, n = Z(e);
424
+ })), Oe = (e) => {
425
+ let t = (e) => e && typeof e == "object" && "_tag" in e && typeof e._tag == "string" ? e._tag : void 0, n = Q(e);
415
426
  if (u.isNone(n)) return "unrecognized-reply";
416
427
  if (n.value._tag === "Failure") return "failed";
417
428
  let r = n.value.value, i = t(r);
418
429
  if (i === "Suspended") return "suspended";
419
430
  if (i === "Complete") {
420
- let e = r.exit, t = Z(e);
431
+ let e = r.exit, t = Q(e);
421
432
  return u.isNone(t) ? "unrecognized-reply" : t.value._tag === "Success" ? "succeeded" : "failed";
422
433
  }
423
434
  return "unrecognized-reply";
424
- }, Q = (e) => $(e.workflow, e.executionId, e.failedActivities), Te = (e) => $(e.workflow, e.executionId, e.resetActivities), $ = (e, t, n) => c.gen(function* () {
425
- let r = yield* x.Sharding, i = yield* y.MessageStorage, a = Ce(e, t, r), o = yield* i.requestIdForPrimaryKey({
435
+ }, ke = (e) => $(e.workflow, e.executionId, e.failedActivities), Ae = (e) => $(e.workflow, e.executionId, e.resetActivities), $ = (e, t, n) => c.gen(function* () {
436
+ let r = yield* y.Sharding, i = yield* ne.MessageStorage, a = De(e, t, r), o = yield* i.requestIdForPrimaryKey({
426
437
  address: a,
427
438
  tag: "run",
428
439
  id: ""
@@ -440,7 +451,7 @@ var w = f.parseJson(f.Struct({
440
451
  activitiesReset: 0,
441
452
  reason: "no-reply"
442
453
  };
443
- let l = we(s.exit);
454
+ let l = Oe(s.exit);
444
455
  if (l !== "failed") return {
445
456
  redriven: !1,
446
457
  runReplyCleared: !1,
@@ -452,7 +463,7 @@ var w = f.parseJson(f.Struct({
452
463
  let t = yield* i.requestIdForPrimaryKey({
453
464
  address: a,
454
465
  tag: "activity",
455
- id: X(e.name, e.attempt)
466
+ id: Z(e.name, e.attempt)
456
467
  }).pipe(c.orDie);
457
468
  if (u.isNone(t)) return {
458
469
  redriven: !1,
@@ -486,4 +497,4 @@ var w = f.parseJson(f.Struct({
486
497
  };
487
498
  });
488
499
  //#endregion
489
- export { ge as a, be as c, _e as d, J as f, T as h, Se as i, ye as l, U as m, Q as n, q as o, xe as p, Te as r, K as s, X as t, G as u };
500
+ export { xe as a, Te as c, Se as d, J as f, w as g, B as h, X as i, we as l, U as m, ke as n, q as o, Ee as p, Ae as r, K as s, Z as t, G as u };
package/dist/cluster.d.ts CHANGED
@@ -6,6 +6,7 @@ import { Duration } from 'effect';
6
6
  import { Effect } from 'effect';
7
7
  import { Layer } from 'effect';
8
8
  import { MessageStorage } from '@effect/cluster';
9
+ import { MysqlCharset } from '@voltro/database/sql';
9
10
  import { Sharding } from '@effect/cluster';
10
11
  import { SqlClient } from '@effect/sql';
11
12
  import { SqlError } from '@effect/sql';
@@ -349,6 +350,21 @@ declare interface ClusterMessageState {
349
350
  readonly lastRead: Date | null;
350
351
  }
351
352
 
353
+ /**
354
+ * A pass-through `SqlClient` layer that runs the cluster first-boot migration
355
+ * (serialized behind the reserved-connection migration lock) as a build
356
+ * side-effect, then re-exposes the SAME `SqlClient`. Provided in place of the raw
357
+ * dialect SqlClient layer so the cluster engine consumes THIS one — forcing the
358
+ * migration to complete before `SingleRunner` builds and hits its own (now no-op)
359
+ * DDL. Skipped entirely on the warm path, so a running cluster and single-runner
360
+ * restarts cost nothing.
361
+ */
362
+ export declare const clusterMigrationGateLayer: (opts: {
363
+ readonly runnerStorage: RunnerStorage;
364
+ /** The declared store charset (mysql family) — the tables this gate creates are created in it. */
365
+ readonly charset?: MysqlCharset | undefined;
366
+ }) => Layer.Layer<SqlClient.SqlClient, SqlError.SqlError, SqlClient.SqlClient>;
367
+
352
368
  declare interface ClusterRunner {
353
369
  readonly address: string;
354
370
  readonly lastHeartbeat: Date | null;
@@ -978,6 +994,13 @@ export declare interface WorkflowEngineLayerOptions {
978
994
  readonly shardLockMode?: ShardLockMode;
979
995
  readonly runnerListenHost?: string;
980
996
  readonly runnerListenPort?: number;
997
+ /**
998
+ * The declared store charset (`store: { dialect, charset }`, mysql family).
999
+ * The engine's tables are created by the library with no character set named;
1000
+ * the migration gate converts the ones it creates to this, so a workflow input
1001
+ * with a 4-byte character is not refused on a `utf8mb3` server.
1002
+ */
1003
+ readonly charset?: MysqlCharset | undefined;
981
1004
  /**
982
1005
  * FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
983
1006
  * takes over a CRASHED replica's in-flight workflows. Reclaim is a
package/dist/cluster.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, d as n, f as r, h as i, i as a, l as o, m as s, n as c, o as l, p as u, r as d, s as f, t as p, u as m } from "./cluster-DJittOqj.js";
2
- export { e as DEFAULT_RUNNER_LISTEN_PORT, i as WorkflowResumeDispatcher, p as activityPrimaryKey, l as detectWsrepCluster, a as makeClusterCronLayer, s as makeClusterDiagnostics, f as parseShardLockMode, t as pollWorkflowStorage, c as redriveFailedRun, o as resolveFailoverTuning, m as resolveRunnerIdentity, n as resolveRunnerStorage, r as resolveShardLockDisableAdvisory, d as resumeRunFromStep, u as workflowEngineLayer };
1
+ import { a as e, c as t, d as n, f as r, g as i, h as a, i as o, l as s, m as c, n as l, o as u, p as d, r as f, s as p, t as m, u as h } from "./cluster-D7VuRhM0.js";
2
+ export { e as DEFAULT_RUNNER_LISTEN_PORT, i as WorkflowResumeDispatcher, m as activityPrimaryKey, a as clusterMigrationGateLayer, u as detectWsrepCluster, o as makeClusterCronLayer, c as makeClusterDiagnostics, p as parseShardLockMode, t as pollWorkflowStorage, l as redriveFailedRun, s as resolveFailoverTuning, h as resolveRunnerIdentity, n as resolveRunnerStorage, r as resolveShardLockDisableAdvisory, f as resumeRunFromStep, d as workflowEngineLayer };
@@ -1,5 +1,6 @@
1
1
  import { ConfigError } from 'effect';
2
2
  import { Layer } from 'effect';
3
+ import { MysqlCharset } from '@voltro/database/sql';
3
4
  import { Sharding } from '@effect/cluster';
4
5
  import { SqlClient } from '@effect/sql';
5
6
  import { SqlError } from '@effect/sql';
@@ -145,6 +146,13 @@ declare interface WorkflowEngineLayerOptions {
145
146
  readonly shardLockMode?: ShardLockMode;
146
147
  readonly runnerListenHost?: string;
147
148
  readonly runnerListenPort?: number;
149
+ /**
150
+ * The declared store charset (`store: { dialect, charset }`, mysql family).
151
+ * The engine's tables are created by the library with no character set named;
152
+ * the migration gate converts the ones it creates to this, so a workflow input
153
+ * with a 4-byte character is not refused on a `utf8mb3` server.
154
+ */
155
+ readonly charset?: MysqlCharset | undefined;
148
156
  /**
149
157
  * FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
150
158
  * takes over a CRASHED replica's in-flight workflows. Reclaim is a
@@ -1,6 +1,6 @@
1
- import { F as e, L as t, S as n, g as r, v as i } from "./primitives-bv2JHUfk.js";
2
- import { r as a } from "./src-C8oxXvy6.js";
3
- import { i as o, p as s } from "./cluster-DJittOqj.js";
1
+ import { F as e, R as t, S as n, g as r, v as i } from "./primitives-BJBn8tiH.js";
2
+ import { r as a } from "./src-D_GRCNhv.js";
3
+ import { i as o, p as s } from "./cluster-D7VuRhM0.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 { SqlClient as m } from "@effect/sql";
6
6
  import { beforeAll as h, describe as g, expect as _, test as v } from "vitest";
package/dist/index.d.ts CHANGED
@@ -1368,6 +1368,28 @@ export declare const CurrentWorkflowReplayShape: FiberRef.FiberRef<ReplayShapeTr
1368
1368
  * `Effect.locally(CurrentWorkflowRunId, runId)`. */
1369
1369
  export declare const CurrentWorkflowRunId: FiberRef.FiberRef<string | undefined>;
1370
1370
 
1371
+ /**
1372
+ * FiberRef carrying the name of the `step()` whose body is running — set by
1373
+ * `step()` around its execute, `undefined` everywhere else.
1374
+ *
1375
+ * It exists so work a step does OUTSIDE the process can be deduplicated
1376
+ * without the author spelling a key: a call to another app of the project made
1377
+ * inside a step is re-sent when the step is retried after its reply was lost,
1378
+ * and `(executionId, step, call)` is the identity that makes the second send a
1379
+ * replay. Read through `currentWorkflowStep`, never directly.
1380
+ */
1381
+ export declare const CurrentWorkflowStep: FiberRef.FiberRef<string | undefined>;
1382
+
1383
+ /**
1384
+ * The step this fiber is running inside, with its durable execution — or
1385
+ * `undefined` outside a recorded step. Stable across replays and retries of the
1386
+ * same step, and different for every other step and every other run.
1387
+ */
1388
+ export declare const currentWorkflowStep: Effect.Effect<{
1389
+ readonly executionId: string;
1390
+ readonly step: string;
1391
+ } | undefined>;
1392
+
1371
1393
  /**
1372
1394
  * Stop retrying an intent whose exact payload can never start.
1373
1395
  *