@voltro/cli 0.25.0 → 0.26.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/dist/{apiBuild-BqhCSytw.js → apiBuild-BtrTyKnF.js} +2 -2
  3. package/dist/apiBuild-DDJ0It4j.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-7EmYJ9Xg.js → commands-D-9iYF2D.js} +185 -84
  6. package/dist/dbCommand-DrzXimKf.js +2 -0
  7. package/dist/{dbCommand-FUU12FvD.js → dbCommand-uuNCrFAb.js} +238 -238
  8. package/dist/{dev-BvHT7WZa.js → dev-BnWq4jeA.js} +1 -1
  9. package/dist/{dev-MacSQ1Ll.js → dev-Dbm6SWtn.js} +1665 -1463
  10. package/dist/{frameworkTableAssembly-Cw5zJz6n.js → frameworkTableAssembly-BwHU9Euq.js} +10 -6
  11. package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/{inspect-DuLUrZp9.js → inspect-CUCCzw2I.js} +6 -3
  14. package/dist/inspect-gt8bq-Tz.js +2 -0
  15. package/dist/{inspectMetrics-EQwH7BI4.js → inspectMetrics-BU90mvJN.js} +1 -1
  16. package/dist/{manifestBuild-Dneq4_Jx.js → manifestBuild-BnzAxp2O.js} +1 -1
  17. package/dist/manifestBuild-ifczArzr.js +2 -0
  18. package/dist/serveCommand-XBXuwJty.js +1294 -0
  19. package/dist/serveEntry.js +2 -2
  20. package/dist/{start-C-ZWSDpg.js → start-BGXIf6zT.js} +2 -2
  21. package/dist/startEntry.js +2 -2
  22. package/package.json +17 -17
  23. package/templates/agent-docs/data.md +35 -2
  24. package/templates/agent-docs/database/schema.md +47 -0
  25. package/templates/agent-docs/deployment.md +19 -0
  26. package/templates/agent-docs/plugins.md +25 -3
  27. package/templates/agent-docs/whats-new.md +19 -3
  28. package/templates/agent-docs/workflows.md +116 -22
  29. package/templates/apps/api-ai/package.json +7 -7
  30. package/templates/apps/api-auth/package.json +8 -8
  31. package/templates/apps/api-backend/package.json +7 -7
  32. package/templates/apps/api-backend-deactivation/package.json +7 -7
  33. package/templates/apps/api-backend-mail/package.json +8 -8
  34. package/templates/apps/api-backend-mariadb/package.json +9 -9
  35. package/templates/apps/api-backend-storage/package.json +8 -8
  36. package/templates/apps/api-data-advanced/package.json +8 -8
  37. package/templates/apps/api-durable/package.json +8 -8
  38. package/templates/apps/api-feature-flags/package.json +9 -9
  39. package/templates/apps/api-governance/package.json +8 -8
  40. package/templates/apps/api-kv/package.json +8 -8
  41. package/templates/apps/api-moderation/package.json +8 -8
  42. package/templates/apps/api-observability/package.json +8 -8
  43. package/templates/apps/api-ratelimit/package.json +8 -8
  44. package/templates/apps/api-rbac/package.json +8 -8
  45. package/templates/apps/api-rest/package.json +7 -7
  46. package/templates/apps/api-saas/package.json +11 -11
  47. package/templates/apps/api-search/package.json +8 -8
  48. package/templates/apps/api-versioning/package.json +8 -8
  49. package/templates/apps/api-webhooks/package.json +9 -9
  50. package/templates/apps/changelog/package.json +6 -6
  51. package/templates/apps/edge-functions/package.json +2 -2
  52. package/templates/apps/frontend-admin/package.json +8 -8
  53. package/templates/apps/frontend-app/package.json +8 -8
  54. package/templates/apps/frontend-blank/package.json +7 -7
  55. package/templates/apps/frontend-contact/package.json +7 -7
  56. package/templates/apps/frontend-dashboard/package.json +7 -7
  57. package/templates/apps/frontend-docs/package.json +7 -7
  58. package/templates/apps/frontend-i18n/package.json +6 -6
  59. package/templates/apps/frontend-landing/package.json +7 -7
  60. package/templates/apps/frontend-spa/package.json +7 -7
  61. package/templates/apps/frontend-ssr/package.json +7 -7
  62. package/templates/apps/frontend-ssr-api/package.json +8 -8
  63. package/templates/apps/frontend-static-blog/package.json +6 -6
  64. package/dist/apiBuild-N1R4V792.js +0 -2
  65. package/dist/dbCommand-CIrdFLp9.js +0 -2
  66. package/dist/frameworkTableAssembly-BsnCKzQ6.js +0 -2
  67. package/dist/inspect-C9gjHwBk.js +0 -2
  68. package/dist/manifestBuild-BVwS1Z_6.js +0 -2
  69. package/dist/serveCommand-5ZFiNO1R.js +0 -1241
@@ -1,5 +1,5 @@
1
- import { Z as e } from "./inspectMetrics-EQwH7BI4.js";
1
+ import { Z as e } from "./inspectMetrics-BU90mvJN.js";
2
2
  import { c as t } from "./seedRunner-D6eu-u5U.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-5ZFiNO1R.js";
4
+ import { t as r } from "./serveCommand-XBXuwJty.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,5 +1,5 @@
1
- import { $ as e, D as t, F as n, G as r, H as i, J as a, K as o, M as s, T as c, U as l, W as u, Y as d, _ as ee, a as te, b as f, c as p, dt as m, et as h, f as g, ft as _, g as ne, h as v, i as re, lt as y, m as b, mt as x, nt as ie, o as S, ot as C, p as w, pt as ae, r as T, rt as E, s as D, t as O, tt as k, v as A, w as j, x as M, y as oe, z as se } from "./inspectMetrics-EQwH7BI4.js";
2
- import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-DuLUrZp9.js";
1
+ import { $ as e, D as t, F as n, G as r, H as i, J as a, K as o, M as s, T as c, U as l, W as u, Y as d, _ as ee, a as te, b as f, c as p, dt as m, et as h, f as g, ft as _, g as ne, h as v, i as re, lt as y, m as b, mt as x, nt as ie, o as S, ot as C, p as w, pt as ae, r as T, rt as E, s as D, t as O, tt as k, v as A, w as j, x as M, y as oe, z as se } from "./inspectMetrics-BU90mvJN.js";
2
+ import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-CUCCzw2I.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as F, extname as I, join as L, resolve as R } from "node:path";
5
5
  import { fileURLToPath as z, pathToFileURL as B } from "node:url";
@@ -1,3 +1,3 @@
1
- import { Z as e } from "./inspectMetrics-EQwH7BI4.js";
2
- import { t } from "./start-C-ZWSDpg.js";
1
+ import { Z as e } from "./inspectMetrics-BU90mvJN.js";
2
+ import { t } from "./start-BGXIf6zT.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.108.0",
63
63
  "@effect/sql": "^0.52.0",
64
64
  "@effect/workflow": "^0.19.0",
65
- "@voltro/ai": "0.25.0",
66
- "@voltro/cache": "0.25.0",
67
- "@voltro/data-transfer": "0.25.0",
68
- "@voltro/database": "0.25.0",
69
- "@voltro/env": "0.25.0",
70
- "@voltro/kv": "0.25.0",
71
- "@voltro/logger": "0.25.0",
72
- "@voltro/plugin-auth": "0.25.0",
73
- "@voltro/plugin-broadcast": "0.25.0",
74
- "@voltro/plugin-mail": "0.25.0",
75
- "@voltro/plugin-storage": "0.25.0",
76
- "@voltro/plugin-webhooks": "0.25.0",
77
- "@voltro/protocol": "0.25.0",
78
- "@voltro/runtime": "0.25.0",
79
- "@voltro/serverless": "0.25.0",
80
- "@voltro/workflow": "0.25.0",
65
+ "@voltro/ai": "0.26.0",
66
+ "@voltro/cache": "0.26.0",
67
+ "@voltro/data-transfer": "0.26.0",
68
+ "@voltro/database": "0.26.0",
69
+ "@voltro/env": "0.26.0",
70
+ "@voltro/kv": "0.26.0",
71
+ "@voltro/logger": "0.26.0",
72
+ "@voltro/plugin-auth": "0.26.0",
73
+ "@voltro/plugin-broadcast": "0.26.0",
74
+ "@voltro/plugin-mail": "0.26.0",
75
+ "@voltro/plugin-storage": "0.26.0",
76
+ "@voltro/plugin-webhooks": "0.26.0",
77
+ "@voltro/protocol": "0.26.0",
78
+ "@voltro/runtime": "0.26.0",
79
+ "@voltro/serverless": "0.26.0",
80
+ "@voltro/workflow": "0.26.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -1469,6 +1469,38 @@ Tables with the `tenant()` mixin are scoped by the runtime using `ctx.request.su
1469
1469
 
1470
1470
  The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.
1471
1471
 
1472
+ ## Cost — how large may a live query be?
1473
+
1474
+ Every change re-runs the query and diffs the WHOLE result against the previous
1475
+ one, so the cost is linear in the RESULT SIZE, not in the size of the change.
1476
+ Measured on `diffRows`:
1477
+
1478
+ | result rows | one column changed | every row replaced |
1479
+ | --- | --- | --- |
1480
+ | 50 | 45 µs | — |
1481
+ | 500 | 480 µs | — |
1482
+ | 2 000 | 1.23 ms | 1.29 ms |
1483
+ | 5 000 | 3.1 ms | — |
1484
+
1485
+ Two things follow, and the second is the one that surprises people:
1486
+
1487
+ - **The curve is linear, not quadratic.** Per-row cost is flat across a 100×
1488
+ growth (910 ns → 625 ns), so a large result gets slower in proportion and
1489
+ never falls off a cliff.
1490
+ - **A one-column edit costs the same as replacing everything.** 2 000 rows with
1491
+ a single change is 1.23 ms; the same 2 000 rows entirely replaced is 1.29 ms —
1492
+ 5 % more. The cost is the WALK, not the delta. Making your mutation smaller
1493
+ does not make the subscription cheaper.
1494
+
1495
+ So the number to design against is the RESULT SIZE. A few hundred rows is free.
1496
+ A 5 000-row live query costs 3.1 ms of CPU per change, per replica — fine for a
1497
+ dashboard that changes a few times a minute, wrong for one fed by a high-rate
1498
+ writer. Page the query, or narrow it with a predicate, rather than reaching for
1499
+ a bigger machine.
1500
+
1501
+ These numbers are asserted by `rowPatch.perf.test.ts`, so they are current
1502
+ rather than a note somebody wrote down once.
1503
+
1472
1504
  ## See also
1473
1505
 
1474
1506
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -1693,7 +1725,8 @@ Everything you would otherwise hand-roll is gone, and each of these was a real b
1693
1725
 
1694
1726
  - **At-most-once, best-effort, live.** No persistence, no retry, no redelivery. For guarantees use the [outbox](/docs/data/outbox); this is the other axis.
1695
1727
  - **Ordered per publishing instance per key.** *Not* globally per key — two instances publishing the same key have no shared counter, and we do not promise an order we cannot keep.
1696
- - **Guards are re-checked when the subject changes**, not per delivery. Revoke a role and the stream ends.
1728
+ - **Guards are re-checked on EVERY delivery**, exactly as a live query's are. A resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) runs its resolver each time, so un-sharing a resource or ending a membership stops the stream at the next delivery — the client is told, not silently skipped. What this does *not* catch is a ROLE revoked on the subject itself: those scopes were captured when the subscription opened. That half is covered by the credential bound below.
1729
+ - **A subscription cannot outlive the credential that authorized it.** When the session carries an expiry, the stream ends at it — and `useEvent` reconnects immediately, which is a NEW request, so the subject is resolved afresh and the guards run again for real. Still entitled: it continues and your app sees nothing. No longer entitled: the reconnect is refused, loudly. You write no reconnect handling for this; it is the existing retry doing its job. Note the limit precisely — this bounds EXPIRY, not revocation.
1697
1730
  - **Payloads are capped at 7,500 bytes** of encoded envelope, on **every** dialect. An event says something happened, so carry a photo *reference*, not a photo.
1698
1731
 
1699
1732
  ### `missed` is a number, not a feeling
@@ -1742,7 +1775,7 @@ export const orderPaid = defineEvent({
1742
1775
 
1743
1776
  One `publish` now reaches connected clients, every matching workflow trigger, and every subscribed HTTP target. Without this an app that does both declares the thing twice, in two shapes, and the two drift.
1744
1777
 
1745
- The `webhook:` block is namespaced because its settings mean nothing to the other audiences — a top-level `retry` would read as if it applied to client delivery, which is at-most-once by design and has no retry at all. Requires [`@voltro/plugin-webhooks`](/docs/plugins/webhooks); absent, it costs nothing.
1778
+ The `webhook:` block is namespaced because its settings mean nothing to the other audiences — a top-level `rateLimit` would read as if it throttled client delivery, which it does not it is a ceiling on webhook deliveries only. Requires [`@voltro/plugin-webhooks`](/docs/plugins/webhooks); absent, it costs nothing.
1746
1779
 
1747
1780
  ## Across instances
1748
1781
 
@@ -423,6 +423,53 @@ the framework ships richer types for specific use cases:
423
423
  - **[JSON](/docs/database/json)** — `json<T>()` for arbitrary nested
424
424
  data; native JSONB on postgres.
425
425
 
426
+ ## Pointing at a plugin-owned row — `pluginRef`
427
+
428
+ ```ts
429
+ import { pluginRef } from '@voltro/database'
430
+ import { aiFlowsTable } from '@voltro/plugin-ai-flows'
431
+
432
+ export const favourites = table('favourites', {
433
+ id: id(),
434
+ flowId: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' }),
435
+ sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable(),
436
+ })
437
+ ```
438
+
439
+ A plain typed id column with **no foreign key**, plus a declared orphan rule the
440
+ framework runs on the post-commit change channel.
441
+
442
+ **Reach for `reference()` first.** A real foreign key across a plugin boundary
443
+ works and survives the plugin renaming its table, because `reference()` takes
444
+ the table as a VALUE — see
445
+ [plugins/overview](/docs/plugins/overview#pointing-your-table-at-a-plugins-row).
446
+ `pluginRef` is for the case where you have deliberately chosen NOT to have a
447
+ key: it enforces nothing at the database level.
448
+
449
+ What it restores is the piece that goes missing with that choice — not the
450
+ constraint but the RULE, which otherwise becomes a hand-written subscriber per
451
+ app that nobody notices the absence of.
452
+
453
+ | policy | on the target's delete |
454
+ | --- | --- |
455
+ | `'delete'` | delete the referencing row — for one that only exists to point (a favourite, a pin, a share) |
456
+ | `'null'` | clear the column, keep the row. Requires `.nullable()` |
457
+ | `'keep'` (default) | nothing — the explicit "I handle it myself" |
458
+
459
+ Four things worth knowing before you rely on it:
460
+
461
+ - **The tenant boundary fails closed.** A referencing row whose tenant differs
462
+ from the deleted row's — or which has none — is left alone. Deleting across a
463
+ tenant boundary because a scope was missing is worse than leaving an orphan.
464
+ - **Soft deletes are opt-in** (`onSoftDelete: true`). A soft delete is a state
465
+ the target can undo, so cascading on it destroys rows a restore cannot bring
466
+ back — and plugin tables are inconsistent about carrying `deletedAt` at all.
467
+ - **The target is a table VALUE.** A rename carries the rule with it, which is
468
+ the whole reason the FK was refused; a string would put the coupling back.
469
+ - **A missing plugin refuses at boot**, naming both sides. A declared rule
470
+ against a table nothing registers would sit there looking enforced.
471
+
472
+
426
473
  ## Anti-patterns
427
474
 
428
475
  - **`serial`/`bigserial` integer ids.** Leaks row counts via `/users/12345`. Use `id()` (TypeID).
@@ -1150,6 +1150,24 @@ KV_BACKEND=redis
1150
1150
 
1151
1151
  Schedules and aggregates auto-coordinate via an advisory lock on SQL stores — no extra config to keep them from double-firing across replicas.
1152
1152
 
1153
+ ### Workflow failover across replicas
1154
+
1155
+ On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:
1156
+
1157
+ - **Inject `POD_IP`** (K8s downward API, `fieldRef: status.podIP`) or set `VOLTRO_WORKFLOW_RUNNER_HOST`. This is each replica's cluster **identity** — without a distinct value, every replica registers as the *same* runner and they stop distributing shards (and cross-pod resume degrades). The boot logs a warning if it sees `localhost` with SQL storage.
1158
+ - **Make step side effects idempotent.** Failover is *at-least-once at the step boundary*: a crash between a side effect and its journal write re-runs that step. A step's own [`retry:`](/docs/workflows/retries) does not change this — it's about the step you're inside, not the replica handoff.
1159
+
1160
+ **Is polling the bottleneck? No — reclaim is a lease, not a poll.** A crashed replica keeps its shards until its heartbeat goes stale; only then can a survivor claim them. So takeover latency is bounded by the **lease TTL (~35s by default)**, not by any message-poll interval, and a push mechanism (LISTEN/NOTIFY) does **not** move it. Two knobs tune it:
1161
+
1162
+ ```sh
1163
+ VOLTRO_WORKFLOW_FAILOVER_LEASE=15 # seconds a dead replica's work stays locked (default 35)
1164
+ VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT=5 # lease-refresh cadence (default 10; keep ≈ lease/3)
1165
+ ```
1166
+
1167
+ Lower the lease for **faster failover**, at the cost of **false-positive reclaims**: if a *healthy* replica is paused longer than the lease by a GC pause or a DB-latency spike, another replica may briefly also claim its shards. Keep the heartbeat around a third of the lease so one slow refresh doesn't trip a reclaim. For crash detection that doesn't depend on the timeout at all, pair it with a K8s **liveness probe** so a dead pod is removed promptly.
1168
+
1169
+ (A separate concern is *new*-message pickup: a workflow triggered on the replica that owns its shard starts immediately, but one owned by ANOTHER replica is otherwise picked up on that replica's next storage poll — up to 10s. **If you run a broadcast broker (Redis/NATS — which a multi-replica deployment already does for cross-replica reactivity), this is automatic and near-instant**: a trigger pushes a "wake" over the bus and the shard owner re-polls at once, on any SQL dialect. Without a broker, tune `VOLTRO_WORKFLOW_POLL_INTERVAL=2` instead. Unrelated to the failover path above.)
1170
+
1153
1171
  ## Checklist
1154
1172
 
1155
1173
  - [ ] `VOLTRO_SESSION_SECRET` set from `voltro secret generate session`, in a secrets manager
@@ -1163,6 +1181,7 @@ Schedules and aggregates auto-coordinate via an advisory lock on SQL stores —
1163
1181
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
1164
1182
  - [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
1165
1183
  - [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
1184
+ - [ ] For durable workflows on >1 replica: SQL store + `POD_IP` injected; tune `VOLTRO_WORKFLOW_FAILOVER_LEASE` if a 35s takeover is too slow; step side effects idempotent
1166
1185
 
1167
1186
 
1168
1187
 
@@ -166,9 +166,31 @@ the `_voltro_` namespace in 0.22.0, a `reference(() => table)` followed the rena
166
166
  **`fk: false`-style decoupling is still available** — declare a plain `text()`
167
167
  column instead. Choose it when you deliberately want the app schema independent
168
168
  of the plugin's, and accept that nothing then enforces the link. What you should
169
- NOT do is reach for it by default: an unenforced id column plus a hand-written
170
- `defineSubscriber` that cleans up on delete is referential integrity re-implemented
171
- per app, and it is silently wrong the first time somebody forgets it.
169
+ NOT do is reach for it by default.
170
+
171
+ **When you DO want the decoupling, `pluginRef` gives you the rule without the
172
+ key.** It is a plain typed id column — no constraint, no cross-schema
173
+ dependency — plus a declared orphan policy the framework runs on the post-commit
174
+ change channel:
175
+
176
+ ```ts
177
+ import { pluginRef } from '@voltro/database'
178
+
179
+ flowId: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
180
+ sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()
181
+ ```
182
+
183
+ That closes the gap named above: an unenforced id column plus a hand-written
184
+ `defineSubscriber` that cleans up on delete is referential integrity
185
+ re-implemented per app, and it is silently wrong the first time somebody forgets
186
+ it. The declaration is the same one line either way — the difference is that the
187
+ framework performs it.
188
+
189
+ **Prefer `reference()` when you want a real key.** `pluginRef` is for the case
190
+ where you have deliberately chosen not to have one; it does not make the
191
+ database enforce anything. The tenant boundary fails closed, soft deletes are
192
+ opt-in (`onSoftDelete`), and a `pluginRef` at a table no installed plugin
193
+ registers refuses at boot rather than sitting there looking enforced.
172
194
 
173
195
  **`orphanPolicy` is not part of this.** It is migration metadata — how existing
174
196
  orphan rows are cleaned up *before* the FK constraint is added — and has no
@@ -266,6 +266,13 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
266
266
 
267
267
  ### Fixed
268
268
 
269
+ - **@voltro/runtime** — **A change watched by N identical live subscribers cost N reads and N diffs. It now costs one of each.** Fifty screens open on the same list re-ran the same query fifty times per change and recomputed the same delta fifty times — in memory that is wasted CPU; against SQL it is fifty round trips.
270
+
271
+ `diffRows` costs the WALK, not the delta: ~31µs at 50 rows, ~289µs at 500, ~3.1ms at 5000, and one changed row costs what zero does. So on a large list the diff share is worth as much as the read share.
272
+
273
+ Both are keyed by the resolved read descriptor; the diff share additionally keys on the previous rows' OBJECT IDENTITY. That second half is a safety property rather than an optimisation: a patch computed against another subscriber's base silently corrupts its rows, and it is the one failure on this path that neither a test nor a log would catch. Reference identity cannot be wrong about it — two subscribers share only when they hold the literally same array, which is exactly when the read share already served them together. A late joiner holds a different object and gets its own diff.
274
+
275
+ Recorded because the route was not straight, and the wrong turns are the instructive part. The read memo was removed mid-release as dead code on a measurement that was broken: `handleChange` is dispatched with `void`, so the counter was sampled before the reads had landed, and the "1.00 reads" that condemned it was an artefact of the sampling. The diff share was separately shipped once with a test that could not prove it — counting deliveries, which happen either way, stays green with the share disabled — and was removed for that reason before being rebuilt. The proof needs no mocks: a shared diff is the SAME OBJECT in every delivery, so counting distinct patch identities is exact, and disabling reuse turns one shared patch into fifty.
269
276
  - **@voltro/cli** — **The domain-event audit tables are bounded now.** `ctx.events.emit(...)` writes one row to `_voltro_workflow_events` plus **one per matching trigger** to `_voltro_workflow_event_deliveries`, and nothing in the framework ever deleted from either. Both are registered with the boot retention GC on a 30-day default, env-tunable via `VOLTRO_WORKFLOW_EVENTS_TTL_HOURS` and `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS`.
270
277
 
271
278
  Same family as `_voltro_schedule_claims`, whose sweep landed one release ago after a consumer measured 35,128 rows in 14 days. The comment there already named the pattern — *"the one table of this family with no sweep"* — and two more members of the family were sitting next to it. The delivery log is the faster half: three triggers on one event write four rows per emit.
@@ -359,6 +366,13 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
359
366
  It needs no cluster coordination, and that is a consequence of the design rather than a shortcut: the table version had shared rows, so one replica had to evict them or they would fight. Owner-partitioned presence has no shared state, so every replica sweeps its own and there is nothing to coordinate.
360
367
 
361
368
  Removals are ANNOUNCED — a local removal nobody broadcasts is a member every other replica keeps showing. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window rather than up to 2×.
369
+ - **@voltro/cli** — **`voltro build` could delete output it had just written.** The post-build orphan prune compared each file's mtime against `Date.now()` taken at build start — two different clocks. Linux stamps inode times from a COARSE clock (`ktime_get_coarse_real_ts64`) that advances once per timer tick, so a file written microseconds AFTER the cutoff can carry an mtime a tick BEFORE it, and the strict comparison then removed it.
370
+
371
+ The consequence is the exact failure the prune was designed to avoid: a bundle that is missing pieces mid-run. The wipe-before-build version had the same effect for a different reason, and this reintroduced it in a narrower window.
372
+
373
+ The comparison now carries a one-second tolerance. The two directions are not symmetric — too small deletes a fresh artefact, too large lets an orphan survive until the next prune — so the margin sits on the side of keeping. Real orphans are minutes or builds old.
374
+
375
+ Found by the release gate on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
362
376
  - **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".
363
377
 
364
378
  ```text
@@ -407,8 +421,10 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
407
421
  An `armed` step now sits between `step1` and the nap, and the teardown waits for it. `activityExecute` returns only once a step's result is durably recorded — that is what lets replay skip it, and what the idempotency-key scenario in the same suite already depends on — so `armed` starting IS the journal write having landed. No duration is left in that path.
408
422
 
409
423
  Distinct from the ceiling raises around it, which address a resumed run needing longer than the timeout under load. This one is not a timeout: no amount of waiting turns a re-executed step back into a skipped one.
410
- - **@voltro/runtime** — A note in `Dispatcher` recording a removed optimisation and where the cost actually is. Comment only no behaviour, no API.
424
+ - **@voltro/cli** — The dev-SSR streaming tests defined "the shell" as *every chunk that arrived before 0.6 × the deferral delay* — an assertion about the machine wearing the shape of an assertion about the renderer. Under a loaded CI runner the shell lands after that deadline, the derived `shell` string comes out EMPTY, and the failure reads `Expected SHELL_LAYOUT_EAGER_OK`, as though the renderer had dropped a field. Green on every developer machine.
425
+
426
+ The shell is now everything BEFORE the chunk carrying the deferred value, and the claim the first-byte deadline was reaching for is stated as what it actually is: the eager field's chunk index is strictly lower than the deferred value's. No duration remains in that path. The one clock that stays is the lower bound on WHEN the deferred value arrived — a slower machine only makes that more true.
411
427
 
412
- A per-dispatch read memo had been added on the theory that N subscribers cost N identical queries. They do not: `SubscriptionRegistry` keys by `fingerprintFor(table, predicate, indexHint)`, so identical subscriptions already collapse to one entry and one read. The memo was dead code, and what exposed it was that removing it left its own test green.
428
+ It still fails a buffered implementation, which is the point of the suite: one chunk means the deferred index is 0, the shell is empty, and the eager-field assertion fails.
413
429
 
414
- The measured linear cost (254µs / 1.1ms / 2.5ms per change at 1 / 10 / 50 subscribers) is therefore the per-subscriber work AFTER the read the deep-equal diff, the patch, the emit which is where the next attempt should look.
430
+ **Found because a red suite had been reporting green.** CI's test step ends in `| tee`, and GitHub's default `run` shell is `bash -e` — *without* `pipefail` — so the step's exit status was tee's. `@voltro/cli#test` failed, turbo exited 1, and the step reported SUCCESS; the comment above it asserted pipefail was on. It surfaced only because the failing package died before printing its summary, which tripped the undeclared-skip check further down. One line later and the gate would have gone green on a failing test. The workflow now sets `defaults.run.shell: bash` so no future piped step can reintroduce it.
@@ -525,34 +525,55 @@ Workflow durability and retry are related, but not identical. The engine checkpo
525
525
 
526
526
  ## Per-step retries
527
527
 
528
- Use `stepModule.retry(...)` when you want retry attempts to appear as separate step attempts in the dashboard:
528
+ Declare a `retry:` policy on `step({...})` and the framework **enforces** it — it compiles the policy to an Effect `Schedule` and retries `execute` for you. No hand-written retry needed:
529
529
 
530
530
  ```tsx
531
- import { workflow, step, stepModule } from '@voltro/workflow'
532
- import { Effect, Schedule, Schema } from 'effect'
531
+ import { workflow, step } from '@voltro/workflow'
532
+ import { Schema } from 'effect'
533
533
 
534
534
  class ProviderDown extends Schema.TaggedError<ProviderDown>()('ProviderDown', {
535
535
  message: Schema.String,
536
536
  }) {}
537
537
 
538
- const summary = yield* stepModule.retry(
539
- step({
540
- name: 'summarise-with-llm',
541
- input: { noteId },
542
- success: Schema.String,
543
- error: ProviderDown,
544
- retry: {
545
- strategy: 'exponential',
546
- maxAttempts: 5,
547
- baseDelay: '500 millis',
548
- },
549
- execute: callLlm(note.body),
550
- }),
551
- Schedule.exponential('500 millis').pipe(Schedule.recurs(4)),
552
- )
538
+ const summary = yield* step({
539
+ name: 'summarise-with-llm',
540
+ input: { noteId },
541
+ success: Schema.String,
542
+ error: ProviderDown,
543
+ retry: {
544
+ maxAttempts: 5,
545
+ strategy: 'exponential',
546
+ baseDelay: '500 millis',
547
+ },
548
+ execute: callLlm(note.body),
549
+ })
553
550
  ```
554
551
 
555
- The `retry` field on `step({...})` is metadata for inspection. The actual retry behaviour comes from `stepModule.retry(...)`, `Effect.retry(...)`, or another Effect retry combinator.
552
+ Retries run inside the one step and are transparent to the durable engine completed steps still checkpoint; the step's final outcome is recorded. `retry: { maxAttempts: 5 }` is already a good policy (exponential backoff, jittered).
553
+
554
+ ### The conditions you actually want
555
+
556
+ The useful retry question is rarely "how many times" — it's *which* failures, for *how long*, and *how spread out*. The policy covers all of it:
557
+
558
+ ```tsx
559
+ retry: {
560
+ maxAttempts: 5, // total attempts including the first (default 3)
561
+ strategy: 'exponential', // 'exponential' | 'fixed' | 'linear'
562
+ baseDelay: '500 millis',
563
+ maxDelay: '30 seconds', // ceiling so exponential growth can't run away
564
+ factor: 2, // exponential growth factor
565
+ jitter: true, // full jitter (default true) — anti-thundering-herd
566
+ maxElapsed: '5 minutes', // a total time BUDGET — stop retrying after this
567
+ retryableErrors: ['ProviderDown'], // retry ONLY these typed errors; others fail fast
568
+ respectRetryAfter: true, // honor a 429: retryAfter REPLACES this attempt's backoff
569
+ }
570
+ ```
571
+
572
+ - **`retryableErrors`** (or a `retryable: (error) => boolean` predicate) is the important one: retry the *transient* failures, fail *fast* on the permanent ones. A `ValidationError` should never be retried; a `ProviderDown` should.
573
+ - **`maxElapsed`** is a deadline across all attempts, not another count — the right bound when "keep trying for up to 5 minutes" matters more than "try 8 times".
574
+ - **`respectRetryAfter`** uses exactly the delay a provider asked for (a `retryAfterMillis` number, or `retryAfter` in seconds, on the thrown error) as the next delay, replacing the computed backoff for that attempt; falls back to backoff when there is no hint.
575
+
576
+ Retries here run *inside* the one step attempt, so the step is recorded as a SINGLE row in `_voltro_workflow_run_steps` with its final outcome — the individual in-step retries are not separate rows. `respectRetryAfter` uses the provider's delay AS the next delay (replacing the computed backoff for that attempt), falling back to backoff when the error carries no hint. For per-attempt rows in the dashboard, or a bespoke `Schedule`, use `stepModule.retry(step({...}), schedule)` instead. Do NOT set `retry:` on a step you ALSO wrap in `stepModule.retry` / `Effect.retry`: it would retry twice.
556
577
 
557
578
  ## What to retry
558
579
 
@@ -604,6 +625,64 @@ For ad-hoc ops, the same operation is available manually — the CLI (`voltro wo
604
625
  voltro workflows retry wfrun_01H...
605
626
  ```
606
627
 
628
+ `retry` starts a **fresh execution** — a new execution id, an empty journal, every step runs again. That is the right tool for a short, idempotent job, or when the input itself was wrong (`payloadOverride`). For a long multi-step pipeline where re-doing steps 1…N‑1 is expensive or unsafe, you want the opposite: resume from where it failed.
629
+
630
+ ## Resume from where it failed — `suspendOnFailure`
631
+
632
+ Declare `suspendOnFailure: true` on a workflow and a failure no longer becomes a terminal `failed` run — it **suspends** with the durable journal intact:
633
+
634
+ ```ts
635
+ export default workflow({
636
+ name: 'billing.close-month',
637
+ payload: { orgId: Schema.String },
638
+ success: Schema.Void,
639
+ error: Schema.Unknown,
640
+ idempotencyKey: ({ orgId }) => `billing.close-month:${orgId}`,
641
+ suspendOnFailure: true, // a failure suspends (recoverable), not fails (terminal)
642
+ execute: ({ orgId }) => Effect.gen(function* () {
643
+ yield* step('snapshot-ledger', /* … */) // completed steps are journaled
644
+ yield* step('call-tax-provider', /* … */) // ← a transient 503 here …
645
+ yield* step('finalise-invoices', /* … */)
646
+ }),
647
+ })
648
+ ```
649
+
650
+ When `call-tax-provider` fails, the run goes to `suspended` (not `failed`), carrying the failure reason. Fix the cause, then **resume** — the engine replays `snapshot-ledger` from the journal (it does **not** re-run) and continues from the failed step:
651
+
652
+ ```sh
653
+ voltro workflows resume wfrun_01H... # re-drives from the failure point
654
+ ```
655
+
656
+ ```ts
657
+ await ctx.workflows.resume(run.id) // the same, from a handler
658
+ ```
659
+
660
+ Because a suspended-on-failure run is **recoverable, not dead**, it shows up under `--status suspended`, NOT in the [dead-letter view](/docs/workflows/debugging) (`voltro workflows list --dead-letter`, which is failed-and-unhandled). Choose per workflow: `suspendOnFailure: true` for a long pipeline where prior work must not be redone; the default (`retry` from scratch) for short idempotent jobs.
661
+
662
+ ## Re-drive a failed run — `redrive`
663
+
664
+ `suspendOnFailure` is a decision you make **before** the run. What if a run already **failed** — it's sitting in the dead-letter view — and you still want to continue it from where it died, not re-run it from scratch? That is `redrive`:
665
+
666
+ ```sh
667
+ voltro workflows redrive wf_01H... # re-drive a FAILED run from the step it died on
668
+ ```
669
+
670
+ ```ts
671
+ await ctx.workflows.redrive(run.id) // the same, from a handler; addressed by run id
672
+ ```
673
+
674
+ `redrive` re-drives the run from its durable journal: every **completed** step replays from the journal (it does **not** re-run), and only the **failed** step(s) re-execute. It is the after-the-fact counterpart to `suspendOnFailure` + `resume` — same "continue from the failure point" outcome, but for a run that already went terminal without being marked suspend-on-failure. Fix the downstream cause first, then redrive.
675
+
676
+ The three recovery tools, and when each applies:
677
+
678
+ | Tool | Use when | Journal |
679
+ |---|---|---|
680
+ | `retry` | The input was wrong, or the job is short + idempotent | Fresh execution, empty journal — every step runs again |
681
+ | `resume` | The run is `suspended` (you set `suspendOnFailure: true`, or it awaits a signal) | Continues from the failure/suspend point — completed steps replay |
682
+ | `redrive` | The run is `failed` (dead-letter) and re-running earlier steps is expensive/unsafe | Continues from the failure point — completed steps replay, failed steps re-run |
683
+
684
+ `redrive` refuses a run that isn't a not-yet-discarded failure (use `resume` for a suspended run, `retry` for a fresh execution), and it needs a durable journal — on the memory store there is nothing to re-drive, so it declines cleanly (`redriven: false` with a reason) rather than pretending. It works under `voltro serve` as well as `voltro dev`, because dead-letter recovery happens in production. A re-drive records a `run-redriven` event on the run's timeline.
685
+
607
686
  ## Compensation
608
687
 
609
688
  For saga-style workflows, model compensation explicitly with `Effect.catchAll` around the step that can fail after an earlier side effect:
@@ -664,7 +743,7 @@ The dashboard and `voltro workflows show <runId>` read these same tables through
664
743
 
665
744
  - **Catching errors and returning success.** Operators lose the failed run.
666
745
  - **Retrying non-idempotent side effects.** Double charges and duplicate emails are workflow bugs, not retry bugs.
667
- - **Putting retry metadata on a step without `Effect.retry` or `stepModule.retry`.** Metadata only explains intent; it does not execute a policy.
746
+ - **Declaring `retry:` on a step you ALSO wrap in `Effect.retry` / `stepModule.retry`.** `retry:` is enforced now the step would retry twice. Keep one.
668
747
  - **Expecting a DLQ table.** The current triage surface is failed runs plus retry controls.
669
748
 
670
749
 
@@ -1181,12 +1260,22 @@ Voltro records workflow runs, step attempts, and lifecycle events into framework
1181
1260
  | `_voltro_workflow_start_contexts` | One row per started execution id: starter subject, trace id, source, parent execution id, parent-close policy, and creation time. Used for cross-runner context handoff. |
1182
1261
  | `_voltro_workflow_runs` | One row per run: `id`, `tag`, `executionId`, `status`, `payload`, `workflowVersion`, `workflowPatches`, `output`, error fields, subject, start source, timing, trace id, parent execution id, parent-close policy. |
1183
1262
  | `_voltro_workflow_run_steps` | One row per step attempt: step name, attempt number, recorded input, retry metadata, output or error, duration. |
1184
- | `_voltro_workflow_run_events` | Lifecycle events: `run-started`, `run-succeeded`, `run-failed`, `run-suspended`, `run-resumed`, `run-cancelled`, `timer-set`, `timer-fired`, `signal-awaited`, `signal-sent`, `signal-received`, `update-requested`, `update-received`, `update-completed`, `update-failed`. |
1263
+ | `_voltro_workflow_run_events` | Lifecycle events: `run-started`, `run-succeeded`, `run-failed`, `run-suspended`, `run-resumed`, `run-redriven`, `run-cancelled`, `timer-set`, `timer-fired`, `signal-awaited`, `signal-sent`, `signal-received`, `update-requested`, `update-received`, `update-completed`, `update-failed`. |
1185
1264
  | `_voltro_workflow_events` | Domain events emitted through `ctx.events.publish(...)`: event id, name, payload, source, subject, trace id, occurred time. |
1186
1265
  | `_voltro_workflow_event_deliveries` | One row per workflow trigger delivery: event id, trigger id, workflow name, execution id, idempotency key, status, error. |
1187
1266
 
1188
1267
  Run status is `running`, `succeeded`, `failed`, `cancelled`, or `suspended`.
1189
1268
 
1269
+ ## Dead-letter: triaging failed runs
1270
+
1271
+ The framework does not retry a workflow on its own (see [Retries](/docs/workflows/retries) — you compose retries inside `execute:` with `Effect.retry`). So a run that reaches `failed` is **terminal**: it is the dead-letter. `voltro workflows list --dead-letter` (or the Prometheus series `voltro_workflow_runs_total{status="failed"}` and the stale `voltro_workflow_last_success_timestamp_seconds` gauge — see [Prometheus](/docs/plugins/prometheus)) is your queue of unhandled failures.
1272
+
1273
+ Triage a dead-letter run one of three ways:
1274
+
1275
+ - **Retry** it — `voltro workflows retry <id>` starts a fresh execution against the original (or an overridden) payload once you've fixed the cause. Every step runs again from scratch.
1276
+ - **Re-drive** it — `voltro workflows redrive <id>` re-drives the run from its durable journal: completed steps replay, only the failed step(s) re-execute. Use this instead of `retry` for a long pipeline where redoing steps 1…N‑1 is expensive or unsafe. It is the after-the-fact counterpart to [`suspendOnFailure` + `resume`](/docs/workflows/retries) and works under `voltro serve` too. Refuses a non-failed / already-discarded run; declines cleanly when there is no durable journal (the memory store). Records a `run-redriven` event.
1277
+ - **Discard** it — `voltro workflows discard <id>` acknowledges the failure so it drops off the `--dead-letter` view. It is an **ack, not a re-classification**: the run stays `status: 'failed'` (the outcome + audit trail survive) and gains a `discardedAt` timestamp. `--status failed` still lists it, marked `discarded`; only `--dead-letter` hides it. Discarding a non-failed run is refused, and discarding is idempotent.
1278
+
1190
1279
  ## Dashboard
1191
1280
 
1192
1281
  When `voltro dev` is running, the Workflows panel lists recent runs, their status, start source, timing, payload, output/error, step attempts, and events. The run detail view is the fastest way to answer:
@@ -1199,7 +1288,7 @@ When `voltro dev` is running, the Workflows panel lists recent runs, their statu
1199
1288
  - What output or typed error did it produce?
1200
1289
  - Did a timer or signal fire?
1201
1290
 
1202
- The panel also exposes run controls for users with the right capability: cancel, retry, suspend, resume, send signal, and send a tracked update.
1291
+ The panel also exposes run controls for users with the right capability: cancel, retry, suspend, resume, redrive (re-drive a failed run from its journal), discard (acknowledge a failed run), send signal, and send a tracked update.
1203
1292
 
1204
1293
  Run filters can be saved as named views. The selected view and ad-hoc
1205
1294
  filters are mirrored into the URL query string, so a teammate can open the
@@ -1213,12 +1302,15 @@ queued/running/waiting work by workflow lane and start source.
1213
1302
  ```sh
1214
1303
  voltro workflows list --status running --tail 50
1215
1304
  voltro workflows list --tag notes.summarise --format json
1305
+ voltro workflows list --dead-letter # failed runs not yet discarded
1216
1306
  voltro workflows start notes.summarise --payload '{"noteId":"note_123"}'
1217
1307
  voltro workflows show wfrun_01H...
1218
1308
  voltro workflows retry wfrun_01H...
1219
1309
  voltro workflows cancel wfrun_01H...
1220
1310
  voltro workflows suspend wfrun_01H...
1221
1311
  voltro workflows resume wfrun_01H...
1312
+ voltro workflows redrive wfrun_01H... # re-drive a failed run from where it died
1313
+ voltro workflows discard wfrun_01H... # acknowledge a failed run
1222
1314
  voltro workflows signal wfrun_01H... --name approval --payload '{"approved":true}'
1223
1315
  voltro workflows update wfrun_01H... --name approve --payload '{"decision":true}'
1224
1316
  voltro workflows children exec_01H...
@@ -1241,6 +1333,8 @@ POST /_voltro/inspect/workflows/runs/:id/cancel
1241
1333
  POST /_voltro/inspect/workflows/runs/:id/retry
1242
1334
  POST /_voltro/inspect/workflows/runs/:id/suspend
1243
1335
  POST /_voltro/inspect/workflows/runs/:id/resume
1336
+ POST /_voltro/inspect/workflows/runs/:id/redrive
1337
+ POST /_voltro/inspect/workflows/runs/:id/discard
1244
1338
  POST /_voltro/inspect/workflows/runs/:id/signal
1245
1339
  POST /_voltro/inspect/workflows/runs/:id/update
1246
1340
  ```
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/ai": "0.25.0",
16
- "@voltro/cli": "0.25.0",
17
- "@voltro/database": "0.25.0",
18
- "@voltro/env": "0.25.0",
19
- "@voltro/protocol": "0.25.0",
20
- "@voltro/runtime": "0.25.0",
15
+ "@voltro/ai": "0.26.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/protocol": "0.26.0",
20
+ "@voltro/runtime": "0.26.0",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.25.0",
24
+ "@voltro/testing": "0.26.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.25.0",
17
- "@voltro/database": "0.25.0",
18
- "@voltro/env": "0.25.0",
19
- "@voltro/plugin-auth": "0.25.0",
20
- "@voltro/protocol": "0.25.0",
21
- "@voltro/runtime": "0.25.0",
22
- "@voltro/sql-postgres": "0.25.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-auth": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
+ "@voltro/sql-postgres": "0.26.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.25.0",
26
+ "@voltro/testing": "0.26.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.25.0",
17
- "@voltro/database": "0.25.0",
18
- "@voltro/env": "0.25.0",
19
- "@voltro/plugin-multitenancy": "0.25.0",
20
- "@voltro/protocol": "0.25.0",
21
- "@voltro/runtime": "0.25.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-multitenancy": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.25.0",
25
+ "@voltro/testing": "0.26.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.25.0",
17
- "@voltro/database": "0.25.0",
18
- "@voltro/env": "0.25.0",
19
- "@voltro/plugin-deactivation": "0.25.0",
20
- "@voltro/protocol": "0.25.0",
21
- "@voltro/runtime": "0.25.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-deactivation": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.25.0",
25
+ "@voltro/testing": "0.26.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }