@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.
- package/CHANGELOG.md +541 -0
- package/dist/apiBuild-BrjrVJJh.js +2 -0
- package/dist/{apiBuild-BqhCSytw.js → apiBuild-D22_EpoR.js} +2 -2
- package/dist/bin.js +3 -3
- package/dist/{commands-7EmYJ9Xg.js → commands-jBX8no1I.js} +185 -84
- package/dist/dbCommand-DrzXimKf.js +2 -0
- package/dist/{dbCommand-FUU12FvD.js → dbCommand-uuNCrFAb.js} +238 -238
- package/dist/{dev-BvHT7WZa.js → dev-DNkso403.js} +1 -1
- package/dist/{dev-MacSQ1Ll.js → dev-DcbIJrWg.js} +2036 -1775
- package/dist/{frameworkTableAssembly-Cw5zJz6n.js → frameworkTableAssembly-BwHU9Euq.js} +10 -6
- package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-DuLUrZp9.js → inspect-CUCCzw2I.js} +6 -3
- package/dist/inspect-gt8bq-Tz.js +2 -0
- package/dist/{inspectMetrics-EQwH7BI4.js → inspectMetrics-BU90mvJN.js} +1 -1
- package/dist/{manifestBuild-Dneq4_Jx.js → manifestBuild-BnzAxp2O.js} +1 -1
- package/dist/manifestBuild-ifczArzr.js +2 -0
- package/dist/serveCommand-DfkisVWP.js +1310 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-C-ZWSDpg.js → start-BGXIf6zT.js} +2 -2
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/data.md +254 -2
- package/templates/agent-docs/database/schema.md +47 -0
- package/templates/agent-docs/deployment.md +19 -0
- package/templates/agent-docs/plugins.md +53 -3
- package/templates/agent-docs/whats-new.md +128 -294
- package/templates/agent-docs/workflows.md +116 -22
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-N1R4V792.js +0 -2
- package/dist/dbCommand-CIrdFLp9.js +0 -2
- package/dist/frameworkTableAssembly-BsnCKzQ6.js +0 -2
- package/dist/inspect-C9gjHwBk.js +0 -2
- package/dist/manifestBuild-BVwS1Z_6.js +0 -2
- package/dist/serveCommand-5ZFiNO1R.js +0 -1241
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Z as e } from "./inspectMetrics-
|
|
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-
|
|
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-
|
|
2
|
-
import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-
|
|
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";
|
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { Z as e } from "./inspectMetrics-
|
|
2
|
-
import { t } from "./start-
|
|
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.
|
|
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.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "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",
|
package/templates/AGENTS.md
CHANGED
|
@@ -602,7 +602,7 @@ each plugin's own README.
|
|
|
602
602
|
|
|
603
603
|
| Topic | Open | Summary |
|
|
604
604
|
|---|---|---|
|
|
605
|
-
| **What's new in 0.
|
|
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.
|
|
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
|
|
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 `
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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.
|