@voltro/cli 0.25.0 → 0.27.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 (71) hide show
  1. package/CHANGELOG.md +541 -0
  2. package/dist/apiBuild-BrjrVJJh.js +2 -0
  3. package/dist/{apiBuild-BqhCSytw.js → apiBuild-D22_EpoR.js} +2 -2
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-7EmYJ9Xg.js → commands-jBX8no1I.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-DNkso403.js} +1 -1
  9. package/dist/{dev-MacSQ1Ll.js → dev-DcbIJrWg.js} +2036 -1775
  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-DfkisVWP.js +1310 -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/AGENTS.md +1 -1
  24. package/templates/agent-docs/_index.md +1 -1
  25. package/templates/agent-docs/data.md +254 -2
  26. package/templates/agent-docs/database/schema.md +47 -0
  27. package/templates/agent-docs/deployment.md +19 -0
  28. package/templates/agent-docs/plugins.md +53 -3
  29. package/templates/agent-docs/whats-new.md +128 -294
  30. package/templates/agent-docs/workflows.md +116 -22
  31. package/templates/apps/api-ai/package.json +7 -7
  32. package/templates/apps/api-auth/package.json +8 -8
  33. package/templates/apps/api-backend/package.json +7 -7
  34. package/templates/apps/api-backend-deactivation/package.json +7 -7
  35. package/templates/apps/api-backend-mail/package.json +8 -8
  36. package/templates/apps/api-backend-mariadb/package.json +9 -9
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-data-advanced/package.json +8 -8
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/package.json +8 -8
  42. package/templates/apps/api-kv/package.json +8 -8
  43. package/templates/apps/api-moderation/package.json +8 -8
  44. package/templates/apps/api-observability/package.json +8 -8
  45. package/templates/apps/api-ratelimit/package.json +8 -8
  46. package/templates/apps/api-rbac/package.json +8 -8
  47. package/templates/apps/api-rest/package.json +7 -7
  48. package/templates/apps/api-saas/package.json +11 -11
  49. package/templates/apps/api-search/package.json +8 -8
  50. package/templates/apps/api-versioning/package.json +8 -8
  51. package/templates/apps/api-webhooks/package.json +9 -9
  52. package/templates/apps/changelog/package.json +6 -6
  53. package/templates/apps/edge-functions/package.json +2 -2
  54. package/templates/apps/frontend-admin/package.json +8 -8
  55. package/templates/apps/frontend-app/package.json +8 -8
  56. package/templates/apps/frontend-blank/package.json +7 -7
  57. package/templates/apps/frontend-contact/package.json +7 -7
  58. package/templates/apps/frontend-dashboard/package.json +7 -7
  59. package/templates/apps/frontend-docs/package.json +7 -7
  60. package/templates/apps/frontend-i18n/package.json +6 -6
  61. package/templates/apps/frontend-landing/package.json +7 -7
  62. package/templates/apps/frontend-spa/package.json +7 -7
  63. package/templates/apps/frontend-ssr/package.json +7 -7
  64. package/templates/apps/frontend-ssr-api/package.json +8 -8
  65. package/templates/apps/frontend-static-blog/package.json +6 -6
  66. package/dist/apiBuild-N1R4V792.js +0 -2
  67. package/dist/dbCommand-CIrdFLp9.js +0 -2
  68. package/dist/frameworkTableAssembly-BsnCKzQ6.js +0 -2
  69. package/dist/inspect-C9gjHwBk.js +0 -2
  70. package/dist/manifestBuild-BVwS1Z_6.js +0 -2
  71. 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-DfkisVWP.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.27.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.27.0",
66
+ "@voltro/cache": "0.27.0",
67
+ "@voltro/data-transfer": "0.27.0",
68
+ "@voltro/database": "0.27.0",
69
+ "@voltro/env": "0.27.0",
70
+ "@voltro/kv": "0.27.0",
71
+ "@voltro/logger": "0.27.0",
72
+ "@voltro/plugin-auth": "0.27.0",
73
+ "@voltro/plugin-broadcast": "0.27.0",
74
+ "@voltro/plugin-mail": "0.27.0",
75
+ "@voltro/plugin-storage": "0.27.0",
76
+ "@voltro/plugin-webhooks": "0.27.0",
77
+ "@voltro/protocol": "0.27.0",
78
+ "@voltro/runtime": "0.27.0",
79
+ "@voltro/serverless": "0.27.0",
80
+ "@voltro/workflow": "0.27.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -602,7 +602,7 @@ each plugin's own README.
602
602
 
603
603
  | Topic | Open | Summary |
604
604
  |---|---|---|
605
- | **What's new in 0.25.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
605
+ | **What's new in 0.26.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
606
606
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
607
607
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
608
608
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.25.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.26.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -536,6 +536,32 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
536
536
  - **Using a stream for durable data.** Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
537
537
 
538
538
 
539
+ ## Contradictions refused at declaration
540
+
541
+ ```ts
542
+ defineQuery({ name: 'q', guards: [], … }) // ✗ enforces nothing
543
+ defineQuery({ name: 'q', source: '', … }) // ✗ reactive, subscribed to nothing
544
+ defineQuery({ name: 'q', internal: true, overridesPlugin: true, … }) // ✗ removes, replaces nothing
545
+ ```
546
+
547
+ The same three shapes `defineEvent` refuses, for the same reasons — a rule that
548
+ holds for one primitive and not another is worse than no rule, because the
549
+ answer then depends on which file you happened to open.
550
+
551
+ **`guards: []`** reads at the call site as if the procedure were protected and
552
+ enforces nothing; the check runs only for a non-empty list. Omit the field.
553
+
554
+ **An empty `source`** declares reactivity and subscribes to nothing: the query
555
+ serves one snapshot and never updates, which is indistinguishable from "nothing
556
+ changed". Worse than a *stale* source, which the boot warning can at least name
557
+ — this one names no table at all, so nothing can report it.
558
+
559
+ **`internal: true` + `overridesPlugin`** removes the plugin's route and puts
560
+ something unreachable in its place: callers get a 404 for something that used to
561
+ work, with no diff that says so. Joins the existing refusals of `internal` with
562
+ `publicApi` or `exposeAsTool`.
563
+
564
+
539
565
  ## Loading vs empty — don't conflate them
540
566
 
541
567
  `useSubscription` returns `loading` and `isEmpty` alongside `data`. They are
@@ -1469,6 +1495,38 @@ Tables with the `tenant()` mixin are scoped by the runtime using `ctx.request.su
1469
1495
 
1470
1496
  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
1497
 
1498
+ ## Cost — how large may a live query be?
1499
+
1500
+ Every change re-runs the query and diffs the WHOLE result against the previous
1501
+ one, so the cost is linear in the RESULT SIZE, not in the size of the change.
1502
+ Measured on `diffRows`:
1503
+
1504
+ | result rows | one column changed | every row replaced |
1505
+ | --- | --- | --- |
1506
+ | 50 | 45 µs | — |
1507
+ | 500 | 480 µs | — |
1508
+ | 2 000 | 1.23 ms | 1.29 ms |
1509
+ | 5 000 | 3.1 ms | — |
1510
+
1511
+ Two things follow, and the second is the one that surprises people:
1512
+
1513
+ - **The curve is linear, not quadratic.** Per-row cost is flat across a 100×
1514
+ growth (910 ns → 625 ns), so a large result gets slower in proportion and
1515
+ never falls off a cliff.
1516
+ - **A one-column edit costs the same as replacing everything.** 2 000 rows with
1517
+ a single change is 1.23 ms; the same 2 000 rows entirely replaced is 1.29 ms —
1518
+ 5 % more. The cost is the WALK, not the delta. Making your mutation smaller
1519
+ does not make the subscription cheaper.
1520
+
1521
+ So the number to design against is the RESULT SIZE. A few hundred rows is free.
1522
+ A 5 000-row live query costs 3.1 ms of CPU per change, per replica — fine for a
1523
+ dashboard that changes a few times a minute, wrong for one fed by a high-rate
1524
+ writer. Page the query, or narrow it with a predicate, rather than reaching for
1525
+ a bigger machine.
1526
+
1527
+ These numbers are asserted by `rowPatch.perf.test.ts`, so they are current
1528
+ rather than a note somebody wrote down once.
1529
+
1472
1530
  ## See also
1473
1531
 
1474
1532
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -1663,6 +1721,13 @@ export default (input, ctx) => Effect.gen(function* () {
1663
1721
 
1664
1722
  Three typed errors reach the **producer**, so a mismatch is one failing call rather than every consumer's handler breaking on a field that is not there: `EventPayloadInvalid`, `EventKeyInvalid`, `EventPayloadTooLarge`.
1665
1723
 
1724
+ `EventPayloadTooLarge` fires at **7,500 bytes** for the encoded envelope (event
1725
+ name + key + payload, as JSON). The ceiling is not arbitrary and it is not a
1726
+ transport limit to tune around: an event says that something *happened*, so
1727
+ `photo.added` carries a photo REFERENCE and the consumer fetches the photo
1728
+ through a route that can stream, cache and authorize it. A payload approaching
1729
+ this size is usually a read that has been pushed into a notification.
1730
+
1666
1731
  <Callout>
1667
1732
  **Both handler styles publish.** `ctx.events.publish` returns an Effect, so the
1668
1733
  `Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)`
@@ -1689,11 +1754,36 @@ Everything you would otherwise hand-roll is gone, and each of these was a real b
1689
1754
  - **A key change is a clean switch** — the old subscription ends before the new one starts.
1690
1755
  - **`key: null` means "not yet"**: no subscription, `status: 'idle'`. You never need a placeholder key.
1691
1756
 
1757
+ ## Showing that it is showing stale
1758
+
1759
+ `useEvent` returns `{ status, missed, lastMiss }` — `status` is
1760
+ `'idle' | 'connecting' | 'live'`, so `status === 'live'` is your connected flag
1761
+ and needs no extra plumbing.
1762
+
1763
+ ```tsx
1764
+ const { status, missed } = useEvent(gameStarted, { arenaId }, onStart)
1765
+
1766
+ // A screen nobody is standing at should say when it stopped being current.
1767
+ {status !== 'live' && <Badge>reconnecting…</Badge>}
1768
+ {missed > 0 && <Badge>{missed} missed — refreshing</Badge>}
1769
+ ```
1770
+
1771
+ This matters most where nobody is watching the tab. A wall display that loses
1772
+ its connection keeps rendering the last thing it received, and from across the
1773
+ room "stale" and "current" look identical. The difference between *showing old
1774
+ data* and *showing that it is showing old data* is one badge.
1775
+
1776
+ `missed` is the other half: it is COMPUTED, never estimated, so a non-zero value
1777
+ means deliveries provably did not arrive — worth surfacing rather than hiding,
1778
+ because the refresh that follows is visible anyway.
1779
+
1780
+
1692
1781
  ## What it guarantees — read this before you build on it
1693
1782
 
1694
1783
  - **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
1784
  - **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.
1785
+ - **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.
1786
+ - **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
1787
  - **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
1788
 
1699
1789
  ### `missed` is a number, not a feeling
@@ -1742,7 +1832,7 @@ export const orderPaid = defineEvent({
1742
1832
 
1743
1833
  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
1834
 
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.
1835
+ 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
1836
 
1747
1837
  ## Across instances
1748
1838
 
@@ -1763,6 +1853,144 @@ consequence: during a **rolling deploy** replicas on different framework version
1763
1853
  use different channel names, so cross-replica delivery is degraded for the length
1764
1854
  of the rollout. Local delivery on each replica is unaffected throughout.
1765
1855
 
1856
+ ## Measured against socket.io
1857
+
1858
+ Same machine, same Redis, same topology, back to back:
1859
+
1860
+ | | p50 (median of 5) | p50 range | p99 (median of 5) | p99 range |
1861
+ | --- | --- | --- | --- | --- |
1862
+ | **Voltro**, publisher → Redis → subscriber | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 |
1863
+ | **socket.io + @socket.io/redis-adapter** | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |
1864
+
1865
+ Five runs each, alternating, on one machine. **The p50 ranges do not overlap —
1866
+ that is a real ~2x advantage.** The p99 ranges DO overlap, so the tail difference
1867
+ is weaker evidence than the medians.
1868
+
1869
+ **An earlier version of this table reported one run each** and claimed "32%
1870
+ faster at the median, 44% worse at the tail". Both numbers were noise: the
1871
+ median advantage is nearer 2x and the tail gap is inside the overlap. A single
1872
+ measurement presented as a fact is the defect this framework spends its time
1873
+ removing, and it was committed here.
1874
+
1875
+ **Where our tail comes from, measured rather than guessed.** Splitting the
1876
+ publish path: our own code — building the envelope, the Effect fiber, handing
1877
+ off — costs **p50 0.056 ms / p99 0.444 ms**. Waiting for Redis to acknowledge
1878
+ costs **p50 1.17 ms / p99 6.43 ms**. So roughly 0.4 ms of a 7 ms tail is ours;
1879
+ the rest is the broker round-trip, which socket.io pays too. There is no
1880
+ code-level tail defect to fix here — on this machine the number is dominated by
1881
+ Docker's network stack.
1882
+
1883
+ The script is in the repo (`scripts/bench/socketio-cross-replica.mjs`) so the
1884
+ number can be re-taken rather than believed. It is not a test: keeping a
1885
+ competitor in the dependency tree to hold a number green is the wrong trade.
1886
+
1887
+ **Topology is what makes this a comparison at all.** Two server instances share
1888
+ one Redis; the client hangs off instance B and every emit is issued on instance
1889
+ A. The first version measured socket.io on a plain localhost websocket and came
1890
+ out 3x faster — which proved nothing, because that is one hop and this is two
1891
+ through a broker.
1892
+
1893
+ Hosted products (Firebase, Pusher, Ably) are deliberately absent. Measuring them
1894
+ honestly needs their accounts, regions and tiers, and a wrong number about
1895
+ someone else's product is worse than no number.
1896
+
1897
+
1898
+ ## The hard questions, and our answers
1899
+
1900
+ | the question | the answer here |
1901
+ | --- | --- |
1902
+ | Does a client learn that deliveries were missed? | Yes — `missed` is COMPUTED from per-origin serials against a watermark, never estimated |
1903
+ | Can a late arrival tell "nothing happened" from "I was not listening"? | Yes — every delivery carries `prior`, the watermark before it was accepted |
1904
+ | Is a subscription authorized, or only the connection? | Per subscription, on the routing key, re-checked per delivery |
1905
+ | Does a subscription outlive the credential that authorized it? | No — bounded by the credential's verified expiry, cookie AND bearer |
1906
+ | Does the link heal itself after a broker outage? | Yes — no restart, no app-side retry, no resubscribe |
1907
+ | Does a degraded network lose messages or only slow them? | Only slows them — 7x the median latency, zero loss |
1908
+ | Does fan-out cost grow with subscriber count? | No — 0.027 µs per delivery, flat from 1 to 100 |
1909
+ | Are channels typed, or strings? | Typed — a rename is a compile error |
1910
+ | Is a declared event nothing publishes reported? | Yes, at boot, reading sibling apps in the workspace |
1911
+ | Is cross-replica fan-out separated per app by default? | Yes — the namespace derives from the app name |
1912
+
1913
+ **Every row is enforced by a test** (`realtimeProperties.test.ts`) that fails if
1914
+ the proof behind it disappears. A property may not be claimed here without
1915
+ something in the repository that demonstrates it.
1916
+
1917
+ **Why this is not a benchmark against other products.** A table of our measured
1918
+ numbers beside someone else's published ones is not a comparison — it is two
1919
+ things in a row. Benchmarking a hosted competitor honestly needs their accounts,
1920
+ regions, tiers and retry policies, and a wrong number about someone else's
1921
+ product is worse than no number. What decides a choice anyway is not the
1922
+ microseconds; it is whether the system answers these questions at all. Each
1923
+ answer above is checkable against this repository by anyone, which is the
1924
+ opposite of a claim.
1925
+
1926
+
1927
+ ## What you can build — and what to reach for
1928
+
1929
+ | what you want to build | reach for |
1930
+ | --- | --- |
1931
+ | a list that updates as rows change | `useSubscription` |
1932
+ | a huge list without loading all of it | `useWindowedSubscription` |
1933
+ | an edit that appears before the server confirms | `useMutation` (optimistic is derived) |
1934
+ | a signal with no row behind it — a game start, a trigger | `defineEvent` |
1935
+ | a 60 Hz value where only the newest matters | `defineEvent` + `delivery: 'latest'` |
1936
+ | knowing a delivery was provably missed | `useEvent` → `missed` |
1937
+ | who is online in a room | `usePresence` |
1938
+ | who is typing right now | `useTyping` |
1939
+ | showing that the screen went stale | `useEvent` → `status`, or `useConnectionStatus` |
1940
+ | fan-out across replicas | `broadcastPlugin()` |
1941
+ | durable work with progress a client can watch | `useWorkflow` |
1942
+ | an in-app inbox | `useInbox` |
1943
+ | delivering an event to a third party | `@voltro/plugin-webhooks` |
1944
+ | gating who may subscribe | `guards:` on the event |
1945
+ | an upload whose progress the UI follows | `useUpload` |
1946
+
1947
+ This table is a TEST (`realtimeCapabilities.test.ts`), not a claim: each row
1948
+ asserts its primitive is still exported, so a capability that loses its
1949
+ primitive to a rename goes red in CI rather than being discovered by whoever
1950
+ tries to build it.
1951
+
1952
+ **Three of these are the ones people usually reach for wrongly.** A value that
1953
+ changes many times a second is an EVENT, not a row — writing it to a table wakes
1954
+ every subscriber of every query reading that table, and each pays a full re-diff.
1955
+ "Who is online" is presence rather than a table, because the answer is ephemeral
1956
+ and per-connection. And "did anything get lost" has a real answer (`missed`),
1957
+ computed rather than estimated, so you do not have to build a heartbeat of your
1958
+ own to find out.
1959
+
1960
+
1961
+ ## The four costs side by side
1962
+
1963
+ Every number below is asserted by a test in the repo, not quoted from a report —
1964
+ each surface has a `*.perf.test.ts` that prints what it measured.
1965
+
1966
+ | primitive | operation | cost | scales with |
1967
+ | --- | --- | --- | --- |
1968
+ | **Events** | `ctx.events.publish` | **4.3 µs** (~232 k/s) | nothing — flat |
1969
+ | **Events** | delivery to a subscriber | **0.027 µs** | subscribers, linearly and cheaply |
1970
+ | **Presence** | a heartbeat | **0.16 µs** | nothing — flat |
1971
+ | **Presence** | a roster read, 10 k members | **547 µs** | the ROOM |
1972
+ | **Records** | a live query re-diff, 5 000 rows | **3 062 µs** | the RESULT SET |
1973
+ | **Broadcast** | cross-replica, real Redis | **p50 1.1 ms · p99 11.2 ms** | the network |
1974
+
1975
+ **The comparison is the useful part.** Publishing an event costs about a
1976
+ thousandth of what re-diffing a large live query does, and that ratio — not
1977
+ either number — is what should decide between them. A value that changes at 60 Hz
1978
+ belongs in an event; the same value written to a table wakes every subscriber of
1979
+ every query reading it, and each pays the full walk.
1980
+
1981
+ **Two of the four are flat and two are not.** A publish and a heartbeat cost the
1982
+ same whatever the load, so they scale by adding replicas. A roster read is
1983
+ linear in the room and a live-query diff is linear in the RESULT SET — including
1984
+ when one column of one row changed, because the cost is the walk rather than the
1985
+ patch. Those are the two numbers to keep an eye on as an app grows.
1986
+
1987
+ **Cross-replica adds milliseconds, not microseconds**, and that is a network
1988
+ crossing rather than framework overhead. Under an injected 20 ms ± 10 jitter it
1989
+ becomes p50 26 ms / p99 89 ms; adding a 50 KB/s ceiling makes it p50 188 ms — and
1990
+ in every one of those runs, all 200 messages arrive. Degradation costs latency,
1991
+ never messages.
1992
+
1993
+
1766
1994
  ## Throughput — the numbers, and where this is the wrong primitive
1767
1995
 
1768
1996
  Measured on one core, publish path only:
@@ -1891,6 +2119,30 @@ Three bugs in nine lines, and every consumer has to get all three right: the `se
1891
2119
 
1892
2120
  Migrating is mechanical: declare the event, replace the insert with `ctx.events.publish`, replace the hook with `useEvent`, and drop the table.
1893
2121
 
2122
+ ## What `defineEvent` refuses, and why
2123
+
2124
+ ```ts
2125
+ defineEvent({ name: 'orders paid', … }) // ✗ whitespace — see below
2126
+ defineEvent({ name: 'orders.paid', guards: [], … }) // ✗ enforces nothing
2127
+ defineEvent({ name: 'x', webhook: { rateLimit: { perMinute: 0 } } }) // ✗ never delivers
2128
+ ```
2129
+
2130
+ **Whitespace in a name is a broker-level failure, not a style rule.** The name
2131
+ becomes a broker SUBJECT segment. NATS refuses a subject containing whitespace
2132
+ and delivers nothing — with no error on the publishing side. An app that works
2133
+ on Redis therefore stops working when the transport changes, silently and in
2134
+ production only. Use a dot to namespace: `orders.paid`.
2135
+
2136
+ **`guards: []` is refused** because it reads at the call site as if the event
2137
+ were protected and enforces nothing — the empty list never reaches the check.
2138
+ Omit the field for an unguarded event.
2139
+
2140
+ **`rateLimit: { perMinute: 0 }`** defers every delivery forever. There is no
2141
+ "unlimited" spelling — omit `rateLimit` for no ceiling. **`version: 0`** would
2142
+ make a subscriber pinned to 1 read the event as *behind*, the opposite of what a
2143
+ bump is for.
2144
+
2145
+
1894
2146
  ## See also
1895
2147
 
1896
2148
  [Subscriptions](/docs/data/subscriptions) · [Outbox](/docs/data/outbox) · [Subscribers](/docs/data/subscribers) · [Streams](/docs/data/streams)
@@ -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,14 +166,64 @@ 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
175
197
  runtime semantics. Runtime behaviour comes from `onDelete`.
176
198
 
199
+ ## Composing with a plugin's namespace
200
+
201
+ Sharing a namespace with a plugin already works: the collision check compares
202
+ FULL tags, so `notifications.list` of yours beside the plugin's
203
+ `notifications.inbox` is not a clash. Only an identical name is — two handlers
204
+ behind one tag is not something a caller can reason about.
205
+
206
+ To REPLACE one deliberately, declare it:
207
+
208
+ ```ts
209
+ // Adopt the plugin's namespace, add your own leaves beside it…
210
+ defineQuery({ name: 'notifications.archive', … })
211
+
212
+ // …and REPLACE just the one you need to behave differently.
213
+ defineMutation({ name: 'notifications.markRead', overridesPlugin: true, … })
214
+ ```
215
+
216
+ The plugin's route is dropped, not merely permitted alongside yours — permitting
217
+ both would leave two handlers bound, which is the state the check exists to
218
+ prevent. The boot logs which routes were replaced.
219
+
220
+ **Explicit, never inferred.** Letting your route win silently would mean a
221
+ plugin upgrade that adds a route could shadow one of yours with no diff to read.
222
+ It is also why the two obvious alternatives are worse: renaming your procedure,
223
+ or `alias`ing the whole plugin away, both move the split from a domain boundary
224
+ to "who built it" — for whoever calls the api, the worst possible partition.
225
+
226
+
177
227
  ## When NOT to write a plugin
178
228
 
179
229
  - **One-off side effect** — just call it from the mutation directly.