@voltro/workflow 0.69.1 → 0.70.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,86 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.70.0] — 2026-09-11
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/cli** — The DevTools dashboard has a lock of its own: `VOLTRO_DASHBOARD_TOKEN`. A deployed dashboard that holds other apps' inspect tokens in `VOLTRO_DASHBOARD_APPS` refuses to boot in production without it, `/api/dashboard/config` no longer serves token values, and the proxy attaches a configured target's credentials itself.
47
+
48
+ Before this the dashboard had no door: whoever reached its URL got every configured app's inspect read AND write token from `/api/dashboard/config` in one request, and the proxy forwarded whatever bearer that browser then presented. The URL was the credential.
49
+
50
+ Three rules now, on both boot paths: the browser proves the dashboard token once (`POST /api/dashboard/login`) and gets an HttpOnly, SameSite=Strict session cookie derived from it by HMAC (12 h; a rotated lock invalidates every cookie); every other `/api/dashboard/*` route answers 401 without a session; the config route says where the targets are and whether the pod holds credentials (`hasToken`, `hasWriteToken`), never which. A dashboard with nothing to guard (no configured targets, a loopback dev registry) boots open as before — setting the token locks it anyway. `voltro secret generate dashboard` prints the variable paste-ready. `codemod: none`: no user code changes; the operator sets one variable, and the chart's `dashboardToken` renders it.
51
+
52
+ **No codemod** — this break touches no user-authored code.
53
+
54
+ ### Added
55
+
56
+ - **@voltro/protocol, @voltro/plugin-openapi, @voltro/cli, @voltro/testing** — The `publicApi` surface speaks a REST profile, chosen once in the api's `app.config.ts` (`publicApi: { profile }`) and overridable per descriptor (`publicApi.format`): `'rpc'` — the default, today's wire — `'standard'`, `'jsonapi'`, or a `RestFormat` object of the app's own, with building blocks (`dates`, `errors`, `envelope`, `pagination`, `naming`, `etag`) that override the preset. `'standard'` is the Zalando / Google AIP / Microsoft intersection: `timestampMs` as RFC 3339 (accepted inbound too), every failure as RFC 9457 Problem Details — the transport's refusals included — no envelope, a `nextCursor` as a `Link: <…>; rel="next"` header, a weak `ETag` on GET with `If-None-Match` → 304. `'jsonapi'` answers `{ data, links }` as `application/vnd.api+json`. The spec follows the profile (`format: date-time`, the `ProblemDetails` component, `Link`/`ETag` headers, snake-case names). `makeTestApp({ publicApiProfile })` measures the same wire.
57
+
58
+ A path with `{teamId}` or `:teamId` binds that segment to the input field of the same name — always, under every profile — with the remaining fields from the query string (GET) or the body; the brace form used to be no route, the colon form a `400`, so `GET /v1/teams/{teamId}` was unreachable for a projection.
59
+ - **@voltro/testing, @voltro/database** — `makeTestContext({ fieldEncryptionKey })` registers the field cipher for `.encrypted()` columns under test. Setting `VOLTRO_FIELD_ENCRYPTION_KEY` — in `process.env` or the context's `env` — did nothing here, because the harness runs no boot gate and the gate is what turns the variable into a cipher; the only working route was `setFieldCipher(makeFieldCipher(key))` from another package, which nothing pointed at. The refusal now names the option.
60
+ - **@voltro/database, @voltro/runtime** — `insertIgnoreWithOutcome` on the framework store (and on `yield* EffectStore` plus the `tx` view), with the typed helper `insertIgnoreRowOutcome`: `{ row, outcome: 'inserted' | 'ignored' }`.
61
+
62
+ `insertIgnore` returns the existing row on the ignore path, and that row is indistinguishable from one it just wrote — so a loop that plants missing rows reported every row it visited as planted unless it read the destination first, per row. The answer is decided the way `upsertWithOutcome` decides it: the id the store minted before the statement is not the id the ignore path hands back; where the id cannot decide (caller-supplied, or database-assigned) the destination is read first and the answer stays exact. Plain `insertIgnore` never pays for that read.
63
+ - **@voltro/protocol, @voltro/client, @voltro/web** — `defineMutation({ serialize: { key, maxPending? } })` — calls of one mutation to the SAME resource run in invocation order on the client, for every caller of the tag. A row lock serialises transactions, not the order a browser invoked them in: two quick saves of one snapshot could take the lock in reverse order and the older snapshot then overwrote the newer one; server-side idempotency does not see it, and `.version()` detects the conflict and replays the older write. `key` names the resource from the input; calls sharing it run one at a time, FIFO, per subject, the permit spanning connection setup, the request, replay and its acknowledgement — optimistic previews stay immediate, different keys run independently. An unknown outcome (a transport failure after the request started) blocks the key for that subject rather than let a later write pass it; a subject change drops the queue; admission beyond `maxPending` (default 32) rejects with `MutationSerialExecutionError`. `useMutation`'s `pending` now counts every concurrent call, not only the one that settled last.
64
+
65
+ Declared on the descriptor — where `concurrency` lives for workflows — rather than as a hook builder, because a hook-scoped queue orders one component's calls and lets a second component writing the same resource interleave. Not a durable outbox, a distributed lock or a cross-tab merge.
66
+ - **@voltro/cli** — API `fonts:` declarations now package validated native-readable fonts and relocatable Fontconfig artifacts during build. Dev, serve and direct production entries activate them before startups and handlers; missing or corrupt artifacts refuse boot. Native renderers no longer depend on undeclared host fonts or original workspace font paths.
67
+ - **@voltro/plugin-openapi** — Every mounted API version has an OpenAPI document of its own — `/openapi/v2.json` beside `/openapi.json`, or `?version=v2` — the same spec filtered to that version, every operation fact included; the unversioned URL keeps serving everything. With more than one version mounted the docs page shows Swagger UI's "Select a definition" dropdown: one entry per version, newest first and preselected, an expiring version named with its earliest `sunset` (`v1 — sunset 2027-03-01`), the whole document last.
68
+
69
+ Before this `v1` and `v2` shared one document and one viewer: a consumer integrating `v2` scrolled past every `v1` path, and a generator built both into one client.
70
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-openapi** — `onProcedureSurface` — a plugin hook told the app's wire-reachable rpc procedures (every query, mutation, action and stream discovery found, minus `internal`) once discovery has run, on both boot paths; the procedure counterpart of `onRestSurface`. `openapiPlugin({ includeAppProcedures: true })` documents them as `POST /rpc/<name>` operations without the app listing them: opt-in, so the spec stays REST-only unless asked, and a procedure also passed in `procedures` is documented once.
71
+
72
+ Before this `procedures` was the only way in, and filling it meant importing every descriptor module into `app.config.ts` — evaluated before discovery on every load of the config, the pattern the `onRestSurface` docs name as a boot that hung — while `voltro serve` loads no TypeScript at runtime, so a plugin could not read them later either.
73
+ - **@voltro/protocol, @voltro/plugin-openapi** — A `publicApi` projection documents itself completely: `tags` from the rpc tag's prefix (`teams.list` → `teams`; `publicApi.tags` overrides) with a top-level `tags[]` and `tagDescriptions` on the OpenAPI plugin; an `operationId` from the tag (`teamsList`; `publicApi.operationId` overrides); `security` on every operation — required, or optional under `openAccess` (`[{ bearerAuth: [] }, {}]`), `publicApi.security: false` for none — with `securitySchemes.bearerAuth` published once any projection exists; one response per `errorStatus` entry carrying the error's own schema, `422` for the union's other tags, `429` for a mounted rate limiter; `publicApi.example: { request?, response? }`; path parameters `in: path` and query parameters typed from the input.
74
+
75
+ Before this the plugin wrote `tags` only for an explicit `version`, `security` only for a `guards:` route — so after "Authorize" Swagger UI sent the token to those alone and "Try it out" on every other route ran without a key — no `operationId`, no `errorStatus` response, no example, and no query parameter at all for a GET projection.
76
+
77
+ ### Fixed
78
+
79
+ - **@voltro/protocol** — A `publicApi.path` segment that names no input field refuses the boot, naming the descriptor and the segment (`descriptor 'bookings.get' — path '/v1/bookings/:uuid' binds ':uuid', and the input declares no field of that name (fields: bookingId)`). A field the path binds may repeat in the body or query string with the same value — a PATCH carrying the whole resource — but another value answers `400` naming the field.
80
+
81
+ Before this the segment was bound silently: the route mounted, the handler never saw the value, and a body that carried the same field with another value was overwritten by the path without a word.
82
+ - **@voltro/protocol, @voltro/runtime, @voltro/plugin-openapi, @voltro/plugin-prometheus** — A plugin route answers the path it declared, byte for byte: the dispatcher refuses a request whose path is not the route's own — case-folded twins and a bare trailing slash included — with `404`, before the body is read. `PluginHttpRoute.match: 'exact'` narrows a route to its path alone, no sub-path; the default stays `'prefix'`, the contract every route that branches on its tail was written against. A REST descriptor is an exact route; the OpenAPI spec and docs routes and the Prometheus scrape route declare `'exact'`.
83
+
84
+ Before this the router matched without case and ignored a trailing slash, and every route owned its sub-paths: `/OPENAPI.JSON`, `/openapi.json/`, `/Docs` and `/Metrics` all answered, so a path guard at the ingress that named the one documented spelling covered none of the others. The docs of both plugins now say the guard can name the path as written.
85
+ - **@voltro/workflow, @voltro/cli** — A run that ended outside the engine no longer leaves its unanswered requests in the cluster journal. Cancelling a run (`timeouts.finish`, `cancelOn`, the operator's button, the bulk action) discards its unprocessed messages at once, and the staleness tick discards those of every ended run (`succeeded`, `failed`, `cancelled`) that a runner last read more than a minute ago — logged as `workflow.cluster: discarded unprocessed messages of runs that have ended`.
86
+
87
+ Before this, the `activity` request a killed executor was inside stayed unprocessed after the run was cancelled: every runner that acquired the shard re-read it every ten minutes for the rest of time, its handler waited on a latch that never opens, and the `unprocessed messages` warning was printed again on every engine start. An unprocessed `run` request of a cancelled run would have gone further and executed it. The engine's own reset deletes one request; this deletes the ended run's, and `ClusterDiagnostics.discardMessages` is the one write on that service.
88
+ - **@voltro/cli** — `voltro db encrypt-column <table>.<column> .` accepts the app directory every other `db` command takes as its last argument — accepted and not needed, the connection comes from env. It used to parse `.` as a column reference and refuse. A positional that is neither a `<table>.<column>` nor a directory is named in the refusal.
89
+ - **@voltro/runtime, @voltro/cli, @voltro/database** — Every API key has its `actors` row after the first boot on this version. Both boot paths plant, after the schema is applied, one row per `_voltro_api_keys.id` that has none — through the same `ensureActor` a new key goes through (the built-in row, or the app's `apiKeys.ensureActor` handed the issue input rebuilt from the row). Idempotent; two queries when nothing is missing; `api keys: planted actors rows for keys that had none` with the count, and a key whose row cannot be written is named in a warning rather than failing the boot.
90
+
91
+ Two release notes had asked the operator to run a loop once. The first was printed to the wrong group; the second printed `store.select(...)` for a `*.startup.ts`, and `StartupContext.store` is a `DataStore` with no fluent builder — both API pods refused to boot on it. The 0.69.1 note now says what the boot does and which line to look for. The framework tables `@voltro/database` exports (`_voltroApiKeysTable` and the others) are typed `SchemaTable` instead of `TableLike`, so `queryFor(_voltroApiKeysTable)` compiles where a hand-written read is still wanted.
92
+ - **@voltro/runtime** — Idempotent WebSocket mutations acquire and finalize their claims safely across request interruption. A closed socket cannot orphan a claim acquired by a still pending store Promise; synchronous executor failures release fresh claims too. Already-started Promise executors finish their actual write and record the result before interruption completes, preventing a replay from duplicating the write. Effect executors remain interruptible and release only after cleanup. A late interrupt never deletes the completion receipt of a successful write.
93
+
94
+ Codemod: none — no application source or configuration changes. Claim TTL, replay limits, transport guards and process-crash recovery guarantees are unchanged.
95
+ - **@voltro/cli** — Docker deployments retain optional native platform bindings until runtime tracing, and the pruner traces both conditional import and require entries so relocated API and web images can still execute native image processing.
96
+ - **@voltro/workflow, @voltro/cli** — `own` on a shard-lock holder in the stall report and the `workflow.cluster` warning is now the engine's own account (`Sharding.hasShardId`), and a holder that is this runner reads `this runner (pid N)`.
97
+
98
+ The first version compared the holder's pid with `pg_backend_pid()` of the query that asked. The advisory lock lives on the runner's reserved connection, and the pool hands every other query a different backend — so a runner's own 300 locks read as a stranger's, always, and behind a connection pooler nothing in `pg_stat_activity` could have told them apart either. The report's stated lever for a stranger, ending the holder's session, would have ended the runner. The hint now distinguishes the three cases (another runner, a session that outlived its runner, this runner re-reading a request nobody answers), and the docs say to read `own` before `pg_terminate_backend`.
99
+ - **@voltro/cli** — Production HTTP proxies release upstream resources when the browser abandons an unfinished response, including SSE, and propagate truncated upstream responses as transport failures instead of hanging. Complete responses retain payloads and keep-alive; no application configuration change is needed.
100
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — `publicApi.rateLimit` on a descriptor is that endpoint's own rule for calls through the `/v1` projection — ahead of the plugin's `rules` and `default`, one bucket per caller per endpoint, under `enabled`. A REST call now carries `surface: 'rest'` and the descriptor's `publicApi` spec on the interceptor context, so a plugin can tell a projected call from an rpc one.
101
+
102
+ The field said "rides @voltro/plugin-ratelimit when mounted" and nothing read it: three calls at `limit: 1` answered `200 200 200`, no bucket, no `onLimited`. The plugin never learned which calls came through the projection, let alone what it declared. Without the plugin mounted the spec is inert, as the comment always implied.
103
+ - **@voltro/cli** — The retention sweep arms its policies on the `_voltro_workflow_*` tables only where the workflow engine that creates those tables is composed. An api with no `*.workflow.tsx` has no engine and no such tables, and the five policies still armed on them were five failing `DELETE`s per tick, each an `ERROR` line in the database log, for the life of the deployment — not switchable off from the app. The boot line `retention: N policy(ies) armed` now lists exactly what is swept.
104
+ - **@voltro/plugin-mail** — SMTP replies are classified by SMTP rules instead of HTTP rules: 4xx replies remain retryable, permanent 5xx rejections do not trigger automatic retries (RFC 5321 §4.2.1). HTTP providers keep their existing policy. Tests exercise actual loopback connections, envelope, MIME and attachment encoding without sending external mail.
105
+
106
+ Codemod: none — corrected SMTP reply semantics require no application source or configuration migration.
107
+ - **@voltro/workflow** — The workflow engine's boot check for its cluster tables asks whether a table exists with a query that succeeds either way (`to_regclass` on postgres, `information_schema` on mysql, `OBJECT_ID` on mssql, `sqlite_master` on sqlite) instead of selecting from the table and reading the failure as "no" — which the database server logged as an `ERROR` on every first boot of a database. The probes `@effect/cluster` itself makes for its legacy tables are upstream and still fail-as-answer.
108
+ - **@voltro/cli** — `voltro dev` acquires the workflow runtime as a boot step, as `voltro serve` always has — `workflow runtime ready — N entity type(s) registered` on both paths, through one shared helper with a parity test.
109
+
110
+ A `ManagedRuntime` builds its layer on first use, and `voltro dev` never used it at boot. A fleet of dev pods therefore had NO cluster runner after a restart — no runner row, no shard locks, no re-delivery — until something asked the runtime for a service. The first thing that asked, in a deployment, was the once-per-fleet staleness tick, five minutes after the boot, in the one pod that ran it: a run orphaned by the restart waited exactly that long and was then adopted by that pod alone.
111
+ - **@voltro/cli** — `voltro baseline sync` writes its `docker/Caddyfile` with tabs, the way `caddy fmt` writes it. Caddy warned on every proxy start that the file "is not formatted", and a project that checks its generated files for drift could not fix it on its side — the next sync undid it.
112
+ - **@voltro/plugin-ratelimit** — `redisStore({ url })` binds its client on the first `consume` when nobody activated it — from `url` or the env it reads at activation — so a limit holds in the test harness, where `makeTestContext` runs no lifecycle hooks. A store with neither stays fail-open (`failOpenReason: 'unbound'`), as at activation.
113
+
114
+ Before this a suite that "checked the redis store" ran every call through an unbound store: `200 200 200` at `limit: 2`, no key in Redis, no warning — and nothing a test could read told "the limit held" from "the store was never there".
115
+ - **@voltro/cli, @voltro/i18n** — A `src/pages/[locale]/…` route renders in the locale it matched, on both web boot paths: `voltro start` and `voltro dev` hand the matched `[locale]` parameter to the same i18n resolver (also for the ISR key and for an app without catalogs), and `resolveLocale` takes it as `routeLocale` — ahead of the `voltro:locale` cookie, `Accept-Language` and the default, and only on an exact supported spelling. The build's static prerender already did this; the runtime render fell back to cookie or browser language, so `/en` answered with a German head and `html lang="de"`. In the same change `voltro dev`'s buffered SPA and ISR responses merge a page's `meta.title` into the shell the way production does — there was a second `<title>` before.
116
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — A rate-limited call on the `/v1` projection answers `429` with `Retry-After` and the `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers, the tag and fields as the body — on both boot paths and in `makeTestApp`. A `PluginErrorSchema` may declare `rest: { status, headers }`; the projection encodes a plugin's cross-cutting error under that status (`422` when it names none; the descriptor's own `errorStatus` entry wins), and the plugin declares `429` for `RateLimited`.
117
+
118
+ The plugin merged `RateLimited` into every rpc's wire union at boot and the client decoded it typed, but the REST projection encoded through the descriptor's own `error:` union only — the one surface with no typed channel to fall back on answered `500 Internal Server Error` with no `Retry-After`. The only way to a `429` was to import a plugin error into every descriptor file. A `500`'s cause now rides on the response value off the wire, so the `GET /v1/… → 500` log line names the reason on `voltro serve` too.
119
+
120
+ ---
121
+
42
122
  ## [0.69.1] — 2026-09-11
43
123
 
44
124
  ### Added
@@ -1,72 +1,80 @@
1
1
  import { n as e, t } from "./clusterDiagnostics-CNtl5LX9.js";
2
2
  import { Context as n, Duration as r, Effect as i, Layer as a, Option as o } from "effect";
3
- import { ClusterCron as s, ClusterSchema as c, ClusterWorkflowEngine as l, EntityAddress as u, EntityId as d, EntityType as f, MessageStorage as p, RunnerAddress as m, Sharding as h, ShardingConfig as g, SingleRunner as _, SqlMessageStorage as v, SqlRunnerStorage as ee } from "@effect/cluster";
4
- import { SqlClient as y } from "@effect/sql";
5
- import { fnv1a64 as b, resolveMigrationLockSchema as x } from "@voltro/database/sql";
6
- import { createLogger as S } from "@voltro/logger";
3
+ import { ClusterCron as s, ClusterSchema as c, ClusterWorkflowEngine as l, EntityAddress as u, EntityId as d, EntityType as f, MessageStorage as p, RunnerAddress as m, ShardId as h, Sharding as g, ShardingConfig as _, SingleRunner as v, SqlMessageStorage as y, SqlRunnerStorage as b } from "@effect/cluster";
4
+ import { SqlClient as x } from "@effect/sql";
5
+ import { fnv1a64 as S, resolveMigrationLockSchema as C } from "@voltro/database/sql";
6
+ import { createLogger as ee } from "@voltro/logger";
7
7
  //#region src/clusterMigrationGate.ts
8
- var C = 6322741009312601n, w = () => {
9
- let e = x();
10
- return e === void 0 ? C : b(`voltro_cluster_migration_lock:${e}`) & 9223372036854775807n;
11
- }, T = () => {
12
- let e = `voltro_cluster_migration_${C.toString(36)}`, t = x();
8
+ var w = 6322741009312601n, T = () => {
9
+ let e = C();
10
+ return e === void 0 ? w : S(`voltro_cluster_migration_lock:${e}`) & 9223372036854775807n;
11
+ }, E = () => {
12
+ let e = `voltro_cluster_migration_${w.toString(36)}`, t = C();
13
13
  if (t === void 0) return e;
14
14
  let n = `${e}_${t}`;
15
- return /^[A-Za-z0-9_]+$/.test(t) && n.length <= 64 ? n : `${e}_h${b(`voltro_cluster_migration_lock:${t}`).toString(16)}`;
16
- }, E = () => {
15
+ return /^[A-Za-z0-9_]+$/.test(t) && n.length <= 64 ? n : `${e}_h${S(`voltro_cluster_migration_lock:${t}`).toString(16)}`;
16
+ }, D = () => {
17
17
  let e = Number(process.env.VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS);
18
18
  return Number.isFinite(e) && e > 0 ? e : 6e4;
19
- }, D = 250, O = (e) => e[0]?.[0], k = (e, t) => e.onDialectOrElse({
19
+ }, O = 250, k = (e) => e[0]?.[0], A = (e, t) => e.onDialectOrElse({
20
20
  sqlite: () => i.void,
21
- mysql: () => t.executeValues(`SELECT GET_LOCK('${T()}', ${Math.ceil(E() / 1e3)})`, []).pipe(i.flatMap((e) => O(e) === 1 || O(e) === "1" ? i.void : i.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${T()}' (GET_LOCK timed out)`)))),
22
- mssql: () => t.executeValues(`DECLARE @res int; EXEC @res = sp_getapplock @Resource = '${T()}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ${E()}; SELECT @res`, []).pipe(i.flatMap((e) => Number(O(e) ?? -999) >= 0 ? i.void : i.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${T()}' (sp_getapplock ${String(O(e))})`)))),
21
+ mysql: () => t.executeValues(`SELECT GET_LOCK('${E()}', ${Math.ceil(D() / 1e3)})`, []).pipe(i.flatMap((e) => k(e) === 1 || k(e) === "1" ? i.void : i.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${E()}' (GET_LOCK timed out)`)))),
22
+ mssql: () => t.executeValues(`DECLARE @res int; EXEC @res = sp_getapplock @Resource = '${E()}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ${D()}; SELECT @res`, []).pipe(i.flatMap((e) => Number(k(e) ?? -999) >= 0 ? i.void : i.die(/* @__PURE__ */ Error(`could not acquire cluster migration lock '${E()}' (sp_getapplock ${String(k(e))})`)))),
23
23
  orElse: () => {
24
- let e = w().toString(), n = Date.now(), r = t.executeValues(`SELECT pg_try_advisory_lock(${e}::bigint)`, []).pipe(i.flatMap((e) => {
25
- let t = O(e);
26
- return t === !0 || t === "t" || t === 1 ? i.void : Date.now() - n >= E() ? i.die(/* @__PURE__ */ Error(`could not acquire the cluster migration advisory lock within ${Math.round(E() / 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.)`)) : i.sleep(`${D} millis`).pipe(i.zipRight(r));
24
+ let e = T().toString(), n = Date.now(), r = t.executeValues(`SELECT pg_try_advisory_lock(${e}::bigint)`, []).pipe(i.flatMap((e) => {
25
+ let t = k(e);
26
+ return t === !0 || t === "t" || t === 1 ? i.void : Date.now() - n >= D() ? i.die(/* @__PURE__ */ Error(`could not acquire the cluster migration advisory lock within ${Math.round(D() / 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.)`)) : i.sleep(`${O} millis`).pipe(i.zipRight(r));
27
27
  }));
28
28
  return r;
29
29
  }
30
- }), A = (e, t) => e.onDialectOrElse({
31
- sqlite: () => i.void,
32
- mysql: () => i.asVoid(t.executeValues(`SELECT RELEASE_LOCK('${T()}')`, [])),
33
- mssql: () => i.asVoid(t.executeValues(`EXEC sp_releaseapplock @Resource = '${T()}', @LockOwner = 'Session'`, [])),
34
- orElse: () => i.asVoid(t.executeValues(`SELECT pg_advisory_unlock(${w().toString()}::bigint)`, []))
35
30
  }), j = (e, t) => e.onDialectOrElse({
31
+ sqlite: () => i.void,
32
+ mysql: () => i.asVoid(t.executeValues(`SELECT RELEASE_LOCK('${E()}')`, [])),
33
+ mssql: () => i.asVoid(t.executeValues(`EXEC sp_releaseapplock @Resource = '${E()}', @LockOwner = 'Session'`, [])),
34
+ orElse: () => i.asVoid(t.executeValues(`SELECT pg_advisory_unlock(${T().toString()}::bigint)`, []))
35
+ }), M = (e, t) => e.onDialectOrElse({
36
36
  sqlite: () => !0,
37
37
  orElse: () => !1
38
38
  }) ? t : i.scoped(i.gen(function* () {
39
39
  let n = yield* e.reserve;
40
- return yield* i.acquireRelease(k(e, n), () => A(e, n).pipe(i.orDie)), yield* t;
41
- })), M = (e) => e === "sql" ? ["cluster_messages", "cluster_runners"] : ["cluster_messages"], N = (e, t) => i.forEach(M(t), (t) => e`SELECT 1 FROM ${e(t)}`.pipe(i.as(!0), i.catchAll(() => i.succeed(!1))), { concurrency: 1 }).pipe(i.map((e) => e.every(Boolean))), P = (e, t) => i.scoped(i.asVoid(a.build(t.pipe(a.provide(g.layerDefaults), a.provide(a.succeed(y.SqlClient, e)))))), te = (e, t) => P(e, v.layer).pipe(i.zipRight(t === "sql" ? P(e, ee.layer) : i.void)), F = (e) => a.effect(y.SqlClient, i.gen(function* () {
42
- let t = yield* y.SqlClient;
43
- return (yield* N(t, e.runnerStorage)) || (yield* j(t, te(t, e.runnerStorage))), t;
44
- })), I = (e) => {
40
+ return yield* i.acquireRelease(A(e, n), () => j(e, n).pipe(i.orDie)), yield* t;
41
+ })), N = (e) => e === "sql" ? ["cluster_messages", "cluster_runners"] : ["cluster_messages"], P = (e, t) => e.onDialectOrElse({
42
+ pg: () => e`SELECT (to_regclass(${t}) IS NOT NULL) AS present`,
43
+ mysql: () => e`SELECT (COUNT(*) > 0) AS present FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ${t}`,
44
+ mssql: () => e`SELECT CASE WHEN OBJECT_ID(${t}, 'U') IS NULL THEN 0 ELSE 1 END AS present`,
45
+ orElse: () => e`SELECT (COUNT(*) > 0) AS present FROM sqlite_master WHERE type = 'table' AND name = ${t}`
46
+ }).pipe(i.map((e) => {
47
+ let t = e[0]?.present;
48
+ return t === !0 || t === 1 || t === "1" || t === "t" || typeof t == "bigint" && t === 1n;
49
+ }), i.catchAll(() => i.succeed(!1))), te = (e, t) => i.forEach(N(t), (t) => P(e, t), { concurrency: 1 }).pipe(i.map((e) => e.every(Boolean))), F = (e, t) => i.scoped(i.asVoid(a.build(t.pipe(a.provide(_.layerDefaults), a.provide(a.succeed(x.SqlClient, e)))))), ne = (e, t) => F(e, y.layer).pipe(i.zipRight(t === "sql" ? F(e, b.layer) : i.void)), I = (e) => a.effect(x.SqlClient, i.gen(function* () {
50
+ let t = yield* x.SqlClient;
51
+ return (yield* te(t, e.runnerStorage)) || (yield* M(t, ne(t, e.runnerStorage))), t;
52
+ })), L = (e) => {
45
53
  if (e instanceof Date) return Number.isNaN(e.getTime()) ? null : e;
46
54
  if (typeof e == "string" || typeof e == "number") {
47
55
  let t = typeof e == "string" && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(e) ? `${e.replace(" ", "T")}Z` : e, n = new Date(t);
48
56
  return Number.isNaN(n.getTime()) ? null : n;
49
57
  }
50
58
  return null;
51
- }, L = (e) => e === !0 || e === 1 || e === "1" || e instanceof Uint8Array && e[0] === 1, R = (e, t) => {
59
+ }, R = (e) => e === !0 || e === 1 || e === "1" || e instanceof Uint8Array && e[0] === 1, z = (e, t) => {
52
60
  let n = e.lastIndexOf(":");
53
61
  if (n < 0) return;
54
62
  let r = e.slice(0, n), i = Number(e.slice(n + 1)), a = t.indexOf(r);
55
63
  if (!(a < 0 || !Number.isInteger(i))) return (a + 1) * 1e6 + i;
56
- }, z = (e) => i.gen(function* () {
57
- let n = yield* y.SqlClient, r = e.shardGroups ?? ["default"], a = e.dialectId ?? "", o = a.startsWith("postgres") || a === "pg", s = (e) => e.pipe(i.catchAll(() => i.succeed([]))), c = n.onDialectOrElse({
64
+ }, B = (e) => i.gen(function* () {
65
+ let n = yield* x.SqlClient, r = e.shardGroups ?? ["default"], a = e.dialectId ?? "", o = a.startsWith("postgres") || a === "pg", s = (e) => e.pipe(i.catchAll(() => i.succeed([]))), c = n.onDialectOrElse({
58
66
  pg: () => n.literal("NOW()"),
59
67
  mysql: () => n.literal("NOW()"),
60
68
  mssql: () => n.literal("GETDATE()"),
61
69
  orElse: () => n.literal("CURRENT_TIMESTAMP")
62
70
  }), l = (e, t) => {
63
- let n = I(e.last_read), r = I(e.server_now), i = n !== null && r !== null ? Math.max(0, r.getTime() - n.getTime()) : null;
71
+ let n = L(e.last_read), r = L(e.server_now), i = n !== null && r !== null ? Math.max(0, r.getTime() - n.getTime()) : null;
64
72
  return {
65
73
  tag: typeof e.tag == "string" ? e.tag : null,
66
74
  entityType: String(e.entity_type ?? ""),
67
75
  entityId: String(e.entity_id ?? ""),
68
76
  shardId: String(e.shard_id ?? ""),
69
- processed: L(e.processed),
77
+ processed: R(e.processed),
70
78
  lastRead: i === null ? null : new Date(t - i)
71
79
  };
72
80
  }, u = (e) => n.onDialectOrElse({
@@ -105,44 +113,43 @@ var C = 6322741009312601n, w = () => {
105
113
  }, m = (t) => s(n`
106
114
  SELECT shard_id, address, acquired_at FROM cluster_locks WHERE ${n.in("shard_id", t)}
107
115
  `).pipe(i.map((t) => t.map((t) => {
108
- let n = t, r = String(n.address ?? "");
116
+ let n = t, r = String(n.address ?? ""), i = String(n.shard_id ?? "");
109
117
  return {
110
- shardId: String(n.shard_id ?? ""),
118
+ shardId: i,
111
119
  mode: "row",
112
120
  holder: r,
113
- own: r === e.ownAddress,
114
- since: I(n.acquired_at)
121
+ own: r === e.ownAddress || (e.hasShard?.(i) ?? !1),
122
+ since: L(n.acquired_at)
115
123
  };
116
- }))), h = (e) => {
117
- let t = /* @__PURE__ */ new Map();
118
- for (let n of e) {
119
- let e = R(n, r);
120
- e !== void 0 && t.set(e, n);
124
+ }))), h = (t) => {
125
+ let a = /* @__PURE__ */ new Map();
126
+ for (let e of t) {
127
+ let t = z(e, r);
128
+ t !== void 0 && a.set(t, e);
121
129
  }
122
- return t.size === 0 ? i.succeed([]) : i.gen(function* () {
123
- let e = yield* s(n`SELECT pg_backend_pid() AS pid`), r = Number(e[0]?.pid ?? -1);
130
+ return a.size === 0 ? i.succeed([]) : i.gen(function* () {
124
131
  return (yield* s(n`
125
132
  SELECT l.objid, a.pid, a.client_addr, a.application_name, a.state, a.state_change, a.backend_start, a.wait_event_type
126
133
  FROM pg_locks l
127
134
  JOIN pg_stat_activity a ON a.pid = l.pid
128
- WHERE l.locktype = 'advisory' AND l.granted AND l.classid = 0 AND ${n.in("l.objid", [...t.keys()])}
129
- `)).flatMap((e) => {
130
- let n = e, i = t.get(Number(n.objid));
131
- if (i === void 0) return [];
132
- let a = Number(n.pid);
135
+ WHERE l.locktype = 'advisory' AND l.granted AND l.classid = 0 AND ${n.in("l.objid", [...a.keys()])}
136
+ `)).flatMap((t) => {
137
+ let n = t, r = a.get(Number(n.objid));
138
+ if (r === void 0) return [];
139
+ let i = Number(n.pid), o = n.client_addr === null || n.client_addr === void 0 ? "local socket" : String(n.client_addr), s = e.hasShard?.(r) ?? !1;
133
140
  return [{
134
- shardId: i,
141
+ shardId: r,
135
142
  mode: "advisory",
136
- holder: `pid ${a} from ${n.client_addr === null || n.client_addr === void 0 ? "local socket" : String(n.client_addr)}`,
137
- own: a === r,
138
- since: I(n.state_change),
143
+ holder: s ? `this runner (pid ${i})` : `pid ${i} from ${o}`,
144
+ own: s,
145
+ since: L(n.state_change),
139
146
  detail: {
140
- pid: a,
147
+ pid: i,
141
148
  clientAddr: n.client_addr ?? null,
142
149
  applicationName: n.application_name ?? null,
143
150
  state: n.state ?? null,
144
151
  waitEventType: n.wait_event_type ?? null,
145
- backendStart: I(n.backend_start)?.toISOString() ?? null
152
+ backendStart: L(n.backend_start)?.toISOString() ?? null
146
153
  }
147
154
  }];
148
155
  });
@@ -158,43 +165,55 @@ var C = 6322741009312601n, w = () => {
158
165
  let t = e;
159
166
  return {
160
167
  address: String(t.address ?? ""),
161
- lastHeartbeat: I(t.last_heartbeat),
162
- healthy: L(t.healthy)
168
+ lastHeartbeat: L(t.last_heartbeat),
169
+ healthy: R(t.healthy)
163
170
  };
164
- }))) : i.succeed([])
171
+ }))) : i.succeed([]),
172
+ discardMessages: (e) => {
173
+ let t = e.unreadForMs === void 0 ? n.literal("") : n`AND last_read IS NOT NULL AND last_read < ${u(Math.max(1, Math.ceil(e.unreadForMs / 1e3)))}`;
174
+ return i.gen(function* () {
175
+ let r = (yield* n`
176
+ SELECT id FROM cluster_messages
177
+ WHERE entity_type = ${e.entityType} AND entity_id = ${e.entityId}
178
+ AND processed = ${d}
179
+ ${t}
180
+ `).map((e) => e.id).filter((e) => e != null);
181
+ return r.length === 0 ? 0 : (yield* n`DELETE FROM cluster_replies WHERE ${n.in("request_id", r)}`, yield* n`DELETE FROM cluster_messages WHERE ${n.in("id", r)}`, r.length);
182
+ }).pipe(n.withTransaction, i.catchAll(() => i.succeed(0)));
183
+ }
165
184
  };
166
- }), B = S({ scope: "voltro:workflow" }), V = 34e3, H = (e) => {
185
+ }), V = ee({ scope: "voltro:workflow" }), H = 34e3, U = (e) => {
167
186
  let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
168
187
  return {
169
188
  host: t,
170
189
  port: e.runnerListenPort ?? 34e3,
171
190
  localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
172
191
  };
173
- }, U = (e) => {
192
+ }, W = (e) => {
174
193
  let t = e.sqliteFamily ? "memory" : "sql", n = e.override ?? process.env.VOLTRO_WORKFLOW_RUNNER_STORAGE;
175
194
  if (n === void 0 || n.trim() === "") return t;
176
195
  let r = n.trim().toLowerCase();
177
196
  if (r !== "memory" && r !== "sql") throw Error(`VOLTRO_WORKFLOW_RUNNER_STORAGE must be 'memory' or 'sql', got '${n}'.`);
178
197
  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'.");
179
198
  return r;
180
- }, W = (e = process.env.VOLTRO_WORKFLOW_SHARD_LOCK) => {
199
+ }, G = (e = process.env.VOLTRO_WORKFLOW_SHARD_LOCK) => {
181
200
  if (e === void 0 || e.trim() === "") return "auto";
182
201
  let t = e.trim().toLowerCase();
183
202
  if (t === "auto" || t === "row" || t === "advisory") return t;
184
203
  throw Error(`VOLTRO_WORKFLOW_SHARD_LOCK must be 'auto', 'row', or 'advisory', got '${e}'.`);
185
- }, G = i.gen(function* () {
186
- let e = yield* (yield* y.SqlClient)`SELECT @@wsrep_on AS wsrep`.pipe(i.catchAll(() => i.succeed([])));
204
+ }, K = i.gen(function* () {
205
+ let e = yield* (yield* x.SqlClient)`SELECT @@wsrep_on AS wsrep`.pipe(i.catchAll(() => i.succeed([])));
187
206
  if (e.length === 0) return !1;
188
207
  let t = e[0].wsrep;
189
208
  return t === 1 || t === "1" || t === !0 || t === "ON" || t === "on";
190
- }), K = (e) => i.gen(function* () {
191
- return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* G;
192
- }), q = (e) => ({
209
+ }), q = (e) => i.gen(function* () {
210
+ return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* K;
211
+ }), J = (e) => ({
193
212
  coordination: e.disableAdvisory ? "row-based" : "advisory",
194
213
  dialect: e.dialectId ?? "unknown",
195
214
  mode: e.mode,
196
215
  ...e.disableAdvisory && e.mode === "auto" ? { reason: "wsrep/Galera cluster detected" } : {}
197
- }), J = () => {
216
+ }), Y = () => {
198
217
  let e = (e) => {
199
218
  if (e === void 0) return;
200
219
  let t = Number(e);
@@ -205,29 +224,30 @@ var C = 6322741009312601n, w = () => {
205
224
  ...n === void 0 ? {} : { failoverHeartbeatSeconds: n },
206
225
  ...r === void 0 ? {} : { messagePollSeconds: r }
207
226
  };
208
- }, Y = i.flatMap(h.Sharding, (e) => e.pollStorage), X = /* @__PURE__ */ new Set(), Z = (t) => {
209
- let n = H({
227
+ }, X = i.flatMap(g.Sharding, (e) => e.pollStorage), Z = /* @__PURE__ */ new Set(), re = (t) => {
228
+ let n = U({
210
229
  runnerStorage: t.runnerStorage,
211
230
  runnerListenHost: t.runnerListenHost,
212
231
  runnerListenPort: t.runnerListenPort
213
232
  }), s = n.host, c = n.port;
214
- n.localhostRisk && !X.has(s) && (X.add(s), B.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: s }));
215
- let u = F({ runnerStorage: t.runnerStorage }).pipe(a.provide(t.sqlClientLayer)), d = t.shardLockMode ?? W(), f = a.unwrapEffect(K({
233
+ n.localhostRisk && !Z.has(s) && (Z.add(s), V.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: s }));
234
+ let u = I({ runnerStorage: t.runnerStorage }).pipe(a.provide(t.sqlClientLayer)), d = t.shardLockMode ?? G(), f = a.unwrapEffect(q({
216
235
  dialectId: t.dialectId,
217
236
  runnerStorage: t.runnerStorage,
218
237
  mode: d
219
238
  }).pipe(i.map((n) => {
220
- t.runnerStorage === "sql" && B.info("shard-lock coordination", q({
239
+ t.runnerStorage === "sql" && V.info("shard-lock coordination", J({
221
240
  disableAdvisory: n,
222
241
  dialectId: t.dialectId,
223
242
  mode: d
224
243
  }));
225
- let i = a.effect(e, z({
244
+ let l = a.effect(e, i.flatMap(g.Sharding, (e) => B({
226
245
  ownAddress: m.make(s, c).toString(),
227
246
  shardLockMode: t.runnerStorage === "sql" ? n ? "row" : "advisory" : "none",
228
247
  runnerStorage: t.runnerStorage,
229
- dialectId: t.dialectId
230
- })), l = _.layer({
248
+ dialectId: t.dialectId,
249
+ hasShard: (t) => e.hasShardId(h.ShardId.fromString(t))
250
+ }))), u = v.layer({
231
251
  runnerStorage: t.runnerStorage,
232
252
  shardingConfig: {
233
253
  runnerAddress: o.some(m.make(s, c)),
@@ -238,22 +258,22 @@ var C = 6322741009312601n, w = () => {
238
258
  ...t.messagePollSeconds === void 0 ? {} : { entityMessagePollInterval: r.seconds(t.messagePollSeconds) }
239
259
  }
240
260
  });
241
- return a.merge(l, i);
261
+ return l.pipe(a.provideMerge(u));
242
262
  }))).pipe(a.provide(u)), p = t.extraShardingLayers ?? [];
243
263
  return (p.length === 0 ? l.layer : a.mergeAll(l.layer, ...p)).pipe(a.provideMerge(f));
244
- }, ne = (e) => s.make({
264
+ }, ie = (e) => s.make({
245
265
  name: e.name,
246
266
  cron: e.cron,
247
267
  execute: i.promise(() => e.execute()),
248
268
  skipIfOlderThan: e.skipIfOlderThan ?? r.days(1)
249
- }), Q = (e, t) => `${e}/${t}`, re = (e, t, r) => {
269
+ }), Q = (e, t) => `${e}/${t}`, ae = (e, t, r) => {
250
270
  let i = d.make(t), a = n.get(e.annotations, c.ShardGroup)(i);
251
271
  return new u.EntityAddress({
252
272
  entityType: f.EntityType.make(`Workflow/${e.name}`),
253
273
  entityId: i,
254
274
  shardId: r.getShardId(i, a)
255
275
  });
256
- }, ie = (e) => {
276
+ }, oe = (e) => {
257
277
  let t = (e) => e && typeof e == "object" && "_tag" in e ? String(e._tag) : void 0, n = t(e);
258
278
  if (n === "Failure" || n !== "Success") return "failed";
259
279
  let r = e.value, i = t(r);
@@ -263,8 +283,8 @@ var C = 6322741009312601n, w = () => {
263
283
  return t(e) === "Success" ? "succeeded" : "failed";
264
284
  }
265
285
  return "failed";
266
- }, ae = (e) => $(e.workflow, e.executionId, e.failedActivities), oe = (e) => $(e.workflow, e.executionId, e.resetActivities), $ = (e, t, n) => i.gen(function* () {
267
- let r = yield* h.Sharding, a = yield* p.MessageStorage, s = re(e, t, r), c = yield* a.requestIdForPrimaryKey({
286
+ }, se = (e) => $(e.workflow, e.executionId, e.failedActivities), ce = (e) => $(e.workflow, e.executionId, e.resetActivities), $ = (e, t, n) => i.gen(function* () {
287
+ let r = yield* g.Sharding, a = yield* p.MessageStorage, s = ae(e, t, r), c = yield* a.requestIdForPrimaryKey({
268
288
  address: s,
269
289
  tag: "run",
270
290
  id: ""
@@ -282,7 +302,7 @@ var C = 6322741009312601n, w = () => {
282
302
  activitiesReset: 0,
283
303
  reason: "no-reply"
284
304
  };
285
- if (ie(l.exit) === "succeeded") return {
305
+ if (oe(l.exit) === "succeeded") return {
286
306
  redriven: !1,
287
307
  runReplyCleared: !1,
288
308
  activitiesReset: 0,
@@ -306,4 +326,4 @@ var C = 6322741009312601n, w = () => {
306
326
  };
307
327
  });
308
328
  //#endregion
309
- export { V as a, Y as c, U as d, K as f, ne as i, J as l, z as m, ae as n, G as o, Z as p, oe as r, W as s, Q as t, H as u };
329
+ export { H as a, X as c, W as d, q as f, ie as i, Y as l, B as m, se as n, K as o, re as p, ce as r, G as s, Q as t, U as u };
package/dist/cluster.d.ts CHANGED
@@ -54,6 +54,25 @@ declare interface ClusterDiagnosticsOps {
54
54
  readonly shardLockHolders: (shardIds: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<ShardLockHolder>>;
55
55
  /** The runner table — every address that registered, with its heartbeat. */
56
56
  readonly runners: () => Effect.Effect<ReadonlyArray<ClusterRunner>>;
57
+ /**
58
+ * Delete the unprocessed messages addressed to one entity — the ONE write on
59
+ * this service. A run that ended outside the engine (cancelled by a sweep or
60
+ * an operator, parked as failed) leaves its unanswered requests behind: the
61
+ * `activity` a killed executor never replied to, the `run` itself if it was
62
+ * never processed. Every runner that acquires the shard re-reads them for the
63
+ * re-read window, forever, and an `activity` request for a run that does not
64
+ * execute waits on a latch that never opens. Nothing consumes them; deleting
65
+ * is what the engine's own reset does for one request. Returns how many.
66
+ *
67
+ * `unreadForMs`: only messages a runner last read at least this long ago —
68
+ * the guard for the sweep, which must not take a request an operator's
69
+ * redrive cleared a moment ago and a runner has just re-read.
70
+ */
71
+ readonly discardMessages: (input: {
72
+ readonly entityType: string;
73
+ readonly entityId: string;
74
+ readonly unreadForMs?: number;
75
+ }) => Effect.Effect<number>;
57
76
  }
58
77
 
59
78
  export declare interface ClusterDiagnosticsOptions {
@@ -63,6 +82,14 @@ export declare interface ClusterDiagnosticsOptions {
63
82
  readonly dialectId?: string | undefined;
64
83
  /** Shard groups in configured order — the advisory lock base per group. */
65
84
  readonly shardGroups?: ReadonlyArray<string>;
85
+ /**
86
+ * Does THIS runner hold the shard — `Sharding.hasShardId`, the engine's own
87
+ * account. This is what `own` on a holder means; without it every holder is
88
+ * a stranger. Not read from the database on purpose: the lock session's pid
89
+ * is not the pid of any query this service can make, and behind a pooler
90
+ * every session shares one client address and one application name.
91
+ */
92
+ readonly hasShard?: (shardId: string) => boolean;
66
93
  }
67
94
 
68
95
  declare interface ClusterMessageState {
@@ -327,7 +354,14 @@ declare interface ShardLockHolder {
327
354
  /** Who holds it: `pid <n> from <client>` for a postgres advisory lock, the
328
355
  * runner address for a row lease. */
329
356
  readonly holder: string;
330
- /** `true` when the holder is THIS runner's own lock session/address. */
357
+ /**
358
+ * `true` when THIS runner holds the shard — the engine's own answer
359
+ * (`Sharding.hasShardId`), never a guess from a connection. The first
360
+ * version compared the holder's pid with `pg_backend_pid()` of the query
361
+ * that asked, and the pool hands every query a different backend than the
362
+ * lock session, so a runner's own 300 locks read as a stranger's — and the
363
+ * report's lever, ending the holder's session, would have ended the runner.
364
+ */
331
365
  readonly own: boolean;
332
366
  readonly since: Date | null;
333
367
  readonly detail?: Record<string, unknown>;
package/dist/cluster.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, d as n, f as r, i, l as a, m as o, n as s, o as c, p as l, r as u, s as d, t as f, u as p } from "./cluster-DoLWW-cp.js";
1
+ import { a as e, c as t, d as n, f as r, i, l as a, m as o, n as s, o as c, p as l, r as u, s as d, t as f, u as p } from "./cluster-CSycI4sz.js";
2
2
  export { e as DEFAULT_RUNNER_LISTEN_PORT, f as activityPrimaryKey, c as detectWsrepCluster, i as makeClusterCronLayer, o as makeClusterDiagnostics, d as parseShardLockMode, t as pollWorkflowStorage, s as redriveFailedRun, a as resolveFailoverTuning, p as resolveRunnerIdentity, n as resolveRunnerStorage, r as resolveShardLockDisableAdvisory, u as resumeRunFromStep, l as workflowEngineLayer };
@@ -1,6 +1,6 @@
1
1
  import { I as e, P as t, S as n, g as r, v as i } from "./primitives-CqHxfASq.js";
2
2
  import { o as a } from "./src-TRpoU54e.js";
3
- import { i as o, p as s } from "./cluster-DoLWW-cp.js";
3
+ import { i as o, p as s } from "./cluster-CSycI4sz.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
@@ -711,6 +711,25 @@ export declare interface ClusterDiagnosticsOps {
711
711
  readonly shardLockHolders: (shardIds: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<ShardLockHolder>>;
712
712
  /** The runner table — every address that registered, with its heartbeat. */
713
713
  readonly runners: () => Effect.Effect<ReadonlyArray<ClusterRunner>>;
714
+ /**
715
+ * Delete the unprocessed messages addressed to one entity — the ONE write on
716
+ * this service. A run that ended outside the engine (cancelled by a sweep or
717
+ * an operator, parked as failed) leaves its unanswered requests behind: the
718
+ * `activity` a killed executor never replied to, the `run` itself if it was
719
+ * never processed. Every runner that acquires the shard re-reads them for the
720
+ * re-read window, forever, and an `activity` request for a run that does not
721
+ * execute waits on a latch that never opens. Nothing consumes them; deleting
722
+ * is what the engine's own reset does for one request. Returns how many.
723
+ *
724
+ * `unreadForMs`: only messages a runner last read at least this long ago —
725
+ * the guard for the sweep, which must not take a request an operator's
726
+ * redrive cleared a moment ago and a runner has just re-read.
727
+ */
728
+ readonly discardMessages: (input: {
729
+ readonly entityType: string;
730
+ readonly entityId: string;
731
+ readonly unreadForMs?: number;
732
+ }) => Effect.Effect<number>;
714
733
  }
715
734
 
716
735
  export declare interface ClusterMessageState {
@@ -1902,7 +1921,14 @@ export declare interface ShardLockHolder {
1902
1921
  /** Who holds it: `pid <n> from <client>` for a postgres advisory lock, the
1903
1922
  * runner address for a row lease. */
1904
1923
  readonly holder: string;
1905
- /** `true` when the holder is THIS runner's own lock session/address. */
1924
+ /**
1925
+ * `true` when THIS runner holds the shard — the engine's own answer
1926
+ * (`Sharding.hasShardId`), never a guess from a connection. The first
1927
+ * version compared the holder's pid with `pg_backend_pid()` of the query
1928
+ * that asked, and the pool hands every query a different backend than the
1929
+ * lock session, so a runner's own 300 locks read as a stranger's — and the
1930
+ * report's lever, ending the holder's session, would have ended the runner.
1931
+ */
1906
1932
  readonly own: boolean;
1907
1933
  readonly since: Date | null;
1908
1934
  readonly detail?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/workflow",
3
- "version": "0.69.1",
3
+ "version": "0.70.0",
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",
@@ -51,9 +51,9 @@
51
51
  "@effect/cluster": "^0.60.2",
52
52
  "@effect/sql": "^0.52.1",
53
53
  "@effect/workflow": "^0.19.1",
54
- "@voltro/database": "0.69.1",
55
- "@voltro/logger": "0.69.1",
56
- "@voltro/protocol": "0.69.1"
54
+ "@voltro/database": "0.70.0",
55
+ "@voltro/logger": "0.70.0",
56
+ "@voltro/protocol": "0.70.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "effect": "^3.22.1",