@voltro/cli 0.23.0 → 0.25.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 +537 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/bin/voltro.mjs +39 -4
- package/dist/{apiBuild-JQtIhZPy.js → apiBuild-BqhCSytw.js} +46 -24
- package/dist/apiBuild-N1R4V792.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-Bsu9Buln.js → commands-7EmYJ9Xg.js} +2796 -2478
- package/dist/dbCommand-CIrdFLp9.js +2 -0
- package/dist/{dbCommand-GI7-BVZt.js → dbCommand-FUU12FvD.js} +285 -246
- package/dist/{dev-DRxF_qOB.js → dev-BvHT7WZa.js} +1 -1
- package/dist/{dev-jM07kq_D.js → dev-MacSQ1Ll.js} +2042 -1631
- package/dist/fileConventions-3bffWssN.js +30 -0
- package/dist/frameworkTableAssembly-BsnCKzQ6.js +2 -0
- package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-Cw5zJz6n.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/inspect-C9gjHwBk.js +2 -0
- package/dist/{inspect-_ldwsAwH.js → inspect-DuLUrZp9.js} +14 -1
- package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-EQwH7BI4.js} +15 -15
- package/dist/manifestBuild-BVwS1Z_6.js +2 -0
- package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-Dneq4_Jx.js} +1 -1
- package/dist/serveCommand-5ZFiNO1R.js +1241 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-C_4VUDT8.js → start-C-ZWSDpg.js} +2 -2
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +12 -5
- package/templates/AGENTS.md +14 -7
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/authentication.md +21 -0
- package/templates/agent-docs/cli.md +32 -0
- package/templates/agent-docs/data.md +491 -167
- package/templates/agent-docs/database/migrations.md +132 -63
- package/templates/agent-docs/database/schema.md +80 -0
- package/templates/agent-docs/database/seedsdialects.md +12 -0
- package/templates/agent-docs/deployment.md +75 -1
- package/templates/agent-docs/plugins.md +1 -1
- package/templates/agent-docs/reference.md +2 -2
- package/templates/agent-docs/security.md +18 -0
- package/templates/agent-docs/templates/apibackends.md +5 -5
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +263 -169
- package/templates/agent-docs/workflows.md +74 -20
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/package.json +8 -7
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-durable/README.md +2 -1
- package/templates/apps/api-durable/events/orders.event.ts +18 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/README.md +2 -2
- package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/template.json +1 -1
- package/templates/apps/changelog/package.json +7 -6
- package/templates/apps/edge-functions/package.json +3 -2
- package/templates/apps/frontend-admin/package.json +9 -8
- package/templates/apps/frontend-app/package.json +9 -8
- package/templates/apps/frontend-blank/package.json +8 -7
- package/templates/apps/frontend-contact/package.json +8 -7
- package/templates/apps/frontend-contact/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/package.json +8 -7
- package/templates/apps/frontend-docs/package.json +8 -7
- package/templates/apps/frontend-docs/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/package.json +7 -6
- package/templates/apps/frontend-landing/package.json +8 -7
- package/templates/apps/frontend-landing/src/globals.d.ts +6 -0
- package/templates/apps/frontend-spa/package.json +8 -7
- package/templates/apps/frontend-spa/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr/package.json +8 -7
- package/templates/apps/frontend-ssr/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/package.json +9 -8
- package/templates/apps/frontend-static-blog/package.json +7 -6
- package/templates/apps/frontend-static-blog/src/globals.d.ts +6 -0
- package/dist/apiBuild-CD-4JLLA.js +0 -2
- package/dist/dbCommand-CSguuUQn.js +0 -2
- package/dist/fileConventions-CBNPZVOV.js +0 -29
- package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
- package/dist/inspect-BA67TF6v.js +0 -2
- package/dist/manifestBuild-i-fRHg_H.js +0 -2
- package/dist/serveCommand-Cl8sicD5.js +0 -1162
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
|
@@ -60,6 +60,7 @@ Voltro ships more building blocks than this one section holds — each is a type
|
|
|
60
60
|
| **Mutation** | `*.mutation.ts` | transactional write → [Mutations](/docs/data/mutations) |
|
|
61
61
|
| **Action** | `*.action.ts` | unary external I/O → [Actions](/docs/data/actions) |
|
|
62
62
|
| **Stream** | `*.stream.ts` | one-shot element push → [Streams](/docs/data/streams) |
|
|
63
|
+
| **Event** | `*.event.ts` | ephemeral fan-out to many clients → [Events](/docs/data/events) |
|
|
63
64
|
| **REST route** | `*.route.tsx` | public raw-HTTP endpoint → [REST routes](/docs/data/rest-routes) |
|
|
64
65
|
| **Aggregate** | `*.aggregate.ts` | scheduled materialised query → [Aggregates](/docs/data/aggregates) |
|
|
65
66
|
| **Subscriber** | `*.subscribe.ts` | per-table post-commit reaction → [Subscribers](/docs/data/subscribers) |
|
|
@@ -75,7 +76,7 @@ Voltro ships more building blocks than this one section holds — each is a type
|
|
|
75
76
|
|
|
76
77
|
## What's in this section
|
|
77
78
|
|
|
78
|
-
**Primitives** — [Queries](/docs/data/queries) (reactive reads + dependency tracking) · [Mutations](/docs/data/mutations) (transactional writes, typed errors, auto-optimistic) · [Actions](/docs/data/actions) (unary RPC, no transaction) · [Subscriptions](/docs/data/subscriptions) (the reactive engine behind snapshots/deltas) · [Streams](/docs/data/streams) (`defineStream` element push) · [REST routes](/docs/data/rest-routes) (public raw-HTTP for third parties) · [Aggregates](/docs/data/aggregates) (scheduled materialised queries) · [Subscribers](/docs/data/subscribers) (per-table post-commit reactions).
|
|
79
|
+
**Primitives** — [Queries](/docs/data/queries) (reactive reads + dependency tracking) · [Mutations](/docs/data/mutations) (transactional writes, typed errors, auto-optimistic) · [Actions](/docs/data/actions) (unary RPC, no transaction) · [Subscriptions](/docs/data/subscriptions) (the reactive engine behind snapshots/deltas) · [Streams](/docs/data/streams) (`defineStream` element push) · [Events](/docs/data/events) (`defineEvent` — things that HAPPEN, with no row behind them) · [REST routes](/docs/data/rest-routes) (public raw-HTTP for third parties) · [Aggregates](/docs/data/aggregates) (scheduled materialised queries) · [Subscribers](/docs/data/subscribers) (per-table post-commit reactions).
|
|
79
80
|
|
|
80
81
|
**Protocol & errors** — [Wire protocol](/docs/data/wire-protocol) (framing, multiplexing) · [Error handling](/docs/data/errors) (Schema-tagged errors, retries, client narrowing).
|
|
81
82
|
|
|
@@ -130,7 +131,7 @@ Every executor receives `ctx.request.subject` — the typed identity of the call
|
|
|
130
131
|
| Transient server-to-client element stream | `*.stream.ts` + `*.stream.server.ts` + `useAgentStream` |
|
|
131
132
|
| Durable persisted AI chat | `*.agent.tsx` or action + query over `agent_messages` |
|
|
132
133
|
| Background job | `*.workflow.tsx` |
|
|
133
|
-
| Fan a domain event out to one or more workflows | [`*.trigger.tsx`](/docs/workflows/event-triggers) + `ctx.events.
|
|
134
|
+
| Fan a domain event out to one or more workflows | [`*.trigger.tsx`](/docs/workflows/event-triggers) + `ctx.events.publish(...)` |
|
|
134
135
|
| Pre-computed query result (top-N, summary) | [`*.aggregate.ts`](/docs/data/aggregates) |
|
|
135
136
|
| React to every commit on a table (server-side) | [`*.subscribe.ts`](/docs/data/subscribers) |
|
|
136
137
|
| Event ingestion + analytical aggregates over events | [Analytics sink](/docs/plugins/analytics) |
|
|
@@ -816,6 +817,26 @@ try {
|
|
|
816
817
|
|
|
817
818
|
The same applies to `run` on [actions](/docs/data/actions).
|
|
818
819
|
|
|
820
|
+
## Idempotency — a retried mutation runs exactly once
|
|
821
|
+
|
|
822
|
+
The reactive client resends an in-flight mutation after a network blip. Without a guard, "create order" or "charge card" would run twice. `useMutation` (and `useAction`) mint a fresh **idempotency key** per call and attach it to the rpc frame; when idempotency is enabled the server dedupes a repeat of that key — the handler runs once and the retry replays the first result.
|
|
823
|
+
|
|
824
|
+
Enable it once (this also covers the REST `Idempotency-Key` header — one switch, both surfaces):
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
// app.config.ts
|
|
828
|
+
export default {
|
|
829
|
+
idempotency: true, // or { ttlMs: 600_000 } — the dedup window (default 24h)
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
For a HIGHER-level guarantee — dedupe a double-click or an offline resend of the *same logical action* — pass a STABLE key derived from the action's identity, instead of the per-call one:
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
await placeOrder.mutate(cart, { idempotencyKey: `order:${cart.id}` })
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
The key is scoped to `(tenant, subject, mutation)`, so one user's key can never replay another's. The replayed result is byte-for-byte the first one — a `Date` in the output comes back a `Date`, not a string — because it round-trips through the mutation's output schema. Off by default: with no `idempotency` config, every call runs.
|
|
819
840
|
|
|
820
841
|
## Auto-Optimistic
|
|
821
842
|
|
|
@@ -1573,6 +1594,309 @@ See [AI streaming](/docs/ai/streaming) and [Agents](/docs/ai/agents) for those p
|
|
|
1573
1594
|
|
|
1574
1595
|
|
|
1575
1596
|
|
|
1597
|
+
---
|
|
1598
|
+
|
|
1599
|
+
<!-- source: en/data/events.md -->
|
|
1600
|
+
## Events
|
|
1601
|
+
|
|
1602
|
+
_`*.event.ts` — ephemeral fan-out to connected clients with defineEvent, ctx.events.publish and useEvent. At-most-once, live, and it tells you when it lost something._
|
|
1603
|
+
|
|
1604
|
+
An **event** is a thing that *happened*. It has a time and no value afterwards — where a table row has a value and no time.
|
|
1605
|
+
|
|
1606
|
+
That distinction decides which primitive you want, and it is the only decision here that is hard to reverse later:
|
|
1607
|
+
|
|
1608
|
+
| You are modelling | Use | Because |
|
|
1609
|
+
| --- | --- | --- |
|
|
1610
|
+
| **what happened** — a game started, a door opened, a terminal confirmed a payment | **Events** (this page) | nothing to store; a late arrival wants what happens *next*, not the history |
|
|
1611
|
+
| **what is** — the current roster, an order's status, a document | [Queries](/docs/data/queries) + [Subscriptions](/docs/data/subscriptions) | a late arrival wants the current value immediately |
|
|
1612
|
+
| **what must happen, even if we crash** — charge a card, send an invoice | [Outbox](/docs/data/outbox) | needs persistence, retries and a delivery guarantee |
|
|
1613
|
+
|
|
1614
|
+
If you find yourself writing a table so that a subscriber fires, you want an event.
|
|
1615
|
+
|
|
1616
|
+
## Declare it
|
|
1617
|
+
|
|
1618
|
+
```ts
|
|
1619
|
+
// events/gameLifecycle.event.ts
|
|
1620
|
+
import { defineEvent } from '@voltro/protocol'
|
|
1621
|
+
import { Schema } from 'effect'
|
|
1622
|
+
|
|
1623
|
+
export const gameStarted = defineEvent({
|
|
1624
|
+
name: 'games.started',
|
|
1625
|
+
key: Schema.Struct({ arenaId: Schema.String }),
|
|
1626
|
+
payload: Schema.Struct({
|
|
1627
|
+
gameId: Schema.String,
|
|
1628
|
+
gameType: Schema.Literal('evo5', 'evo6'),
|
|
1629
|
+
startedAt: Schema.Number,
|
|
1630
|
+
}),
|
|
1631
|
+
guards: [{ scope: 'display:read' }],
|
|
1632
|
+
})
|
|
1633
|
+
```
|
|
1634
|
+
|
|
1635
|
+
A `*.event.ts` file is **browser-safe** and may hold several declarations — a lifecycle's stages are one concept. Client and server import the *same* value, which is what makes the key and payload types identical at both ends.
|
|
1636
|
+
|
|
1637
|
+
**`key` is the address, and only the address.** A subscriber receives events published under a key it asked for, so the server never sends the others at all. Put in it what *routes* (`arenaId`) and nothing else — a discriminator your handler reads (`gameType`) is payload. Every key field fragments the subscriber set.
|
|
1638
|
+
|
|
1639
|
+
**`guards` decide who may listen**, in the same vocabulary a query uses, and they are checked *before* the subscription is registered — a refused client never holds one. The routing key is the guard input, so a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) sees which arena was asked for. A refusal reaches the client as the `ScopeError` the rpc declares.
|
|
1640
|
+
|
|
1641
|
+
**The tenant is not part of the key and must never be added.** It comes from the subject on both sides, so a cross-tenant delivery is impossible by construction rather than by remembering to filter.
|
|
1642
|
+
|
|
1643
|
+
The name shares the rpc tag space. Two declarations answering to one name fail the **boot**, not the first delivery.
|
|
1644
|
+
|
|
1645
|
+
## Publish it
|
|
1646
|
+
|
|
1647
|
+
Only the server publishes. A client-originated event is an [action](/docs/data/actions) that publishes — which means every publish has already passed a guard-checked, typed handler, and there is no "who may write to this channel" question to answer.
|
|
1648
|
+
|
|
1649
|
+
```ts
|
|
1650
|
+
// actions/reportGameEvent.action.server.ts
|
|
1651
|
+
export default (input, ctx) => Effect.gen(function* () {
|
|
1652
|
+
yield* ctx.events.publish(gameStarted, { arenaId: input.arenaId }, {
|
|
1653
|
+
gameId: input.gameId,
|
|
1654
|
+
gameType: 'evo5',
|
|
1655
|
+
startedAt: Date.now(),
|
|
1656
|
+
})
|
|
1657
|
+
})
|
|
1658
|
+
```
|
|
1659
|
+
|
|
1660
|
+
`publish` works from anywhere with a `ctx`: an action, a mutation, a workflow, a subscriber, a cron, a startup hook.
|
|
1661
|
+
|
|
1662
|
+
**Inside a mutation it fires on COMMIT, and not at all on rollback.** That is not a nicety: a display reacting to a game start the database rolled back happens on every constraint violation, every deadlock retry, every guard that fails *after* the publish line. Outside a transaction it fires immediately.
|
|
1663
|
+
|
|
1664
|
+
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
|
+
|
|
1666
|
+
<Callout>
|
|
1667
|
+
**Both handler styles publish.** `ctx.events.publish` returns an Effect, so the
|
|
1668
|
+
`Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)`
|
|
1669
|
+
in a plain `async (input, ctx) => { … }` handler works too and resolves with the
|
|
1670
|
+
same result. It used to hand back an unrun Effect: nothing published, nothing
|
|
1671
|
+
errored, and the handler returned success.
|
|
1672
|
+
</Callout>
|
|
1673
|
+
|
|
1674
|
+
## Consume it
|
|
1675
|
+
|
|
1676
|
+
```tsx
|
|
1677
|
+
const { missed, status } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
|
|
1678
|
+
scene.switchTo('running', payload.gameId)
|
|
1679
|
+
})
|
|
1680
|
+
```
|
|
1681
|
+
|
|
1682
|
+
`payload` is typed from the descriptor — a wrong field name is a `tsc` error at this call site.
|
|
1683
|
+
|
|
1684
|
+
Everything you would otherwise hand-roll is gone, and each of these was a real bug in apps that built this on a reactive list:
|
|
1685
|
+
|
|
1686
|
+
- **No history on mount.** A fresh subscriber gets what happens *from now on*. No `seen` set, no `initialized` flag, no window.
|
|
1687
|
+
- **Exactly once, even under React `StrictMode`** — where every effect runs twice and a naive subscription fires each handler twice, in development only.
|
|
1688
|
+
- **A changing handler does not resubscribe.** Every call site passes an inline arrow; putting it in a dependency array rebuilds the subscription on every render and loses whatever arrives in the gap.
|
|
1689
|
+
- **A key change is a clean switch** — the old subscription ends before the new one starts.
|
|
1690
|
+
- **`key: null` means "not yet"**: no subscription, `status: 'idle'`. You never need a placeholder key.
|
|
1691
|
+
|
|
1692
|
+
## What it guarantees — read this before you build on it
|
|
1693
|
+
|
|
1694
|
+
- **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
|
+
- **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.
|
|
1697
|
+
- **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
|
+
|
|
1699
|
+
### `missed` is a number, not a feeling
|
|
1700
|
+
|
|
1701
|
+
When deliveries are lost, you are told **how many** and **why**:
|
|
1702
|
+
|
|
1703
|
+
```tsx
|
|
1704
|
+
useEvent(gameStarted, key, handler, {
|
|
1705
|
+
onMissed: ({ count, reason }) => resyncFromServer(count), // reason: 'buffer' | 'resume'
|
|
1706
|
+
})
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
Every delivery carries a serial, and the server keeps the highest it has seen — so a loss is arithmetic (what you were owed, minus what could be replayed), never an estimate. `buffer` means your client fell behind and the oldest were dropped; `resume` means a reconnect asked for messages older than the server still holds.
|
|
1710
|
+
|
|
1711
|
+
This matters more than it sounds. Silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".
|
|
1712
|
+
|
|
1713
|
+
### Reconnects resume; mounts do not
|
|
1714
|
+
|
|
1715
|
+
These read as one contradiction — *never replay history* against *never lose a message* — and they are two different questions:
|
|
1716
|
+
|
|
1717
|
+
- **A first attach** starts empty. Set `rewind: true` on the descriptor if you want the recent buffer instead.
|
|
1718
|
+
- **A reconnect** continues from the last serial that subscription saw. `useEvent` does this for you, including after a deploy or a proxy timeout.
|
|
1719
|
+
|
|
1720
|
+
The buffer is deliberately small — tens of messages, minutes. Anything larger is a durable queue, and the framework already has one.
|
|
1721
|
+
|
|
1722
|
+
## Triggering a workflow from an event
|
|
1723
|
+
|
|
1724
|
+
```ts
|
|
1725
|
+
triggerWorkflow({ on: gameStarted, workflow: 'postGameReport' })
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
`on:` takes the descriptor and reads its name, so renaming the event moves this call site with it. The older string form (`event: 'games.started'`) still works and is going away: with a string, a rename leaves the trigger matching nothing and the workflow simply never runs again — nothing errors, which is the same silence this whole primitive exists to remove.
|
|
1729
|
+
|
|
1730
|
+
## Reaching HTTP receivers too
|
|
1731
|
+
|
|
1732
|
+
An event can also be delivered to subscribed webhook targets — the third audience of the same declaration:
|
|
1733
|
+
|
|
1734
|
+
```ts
|
|
1735
|
+
export const orderPaid = defineEvent({
|
|
1736
|
+
name: 'orders.paid',
|
|
1737
|
+
key: Schema.Struct({ orderId: Schema.String }),
|
|
1738
|
+
payload: Schema.Struct({ total: Schema.Number }),
|
|
1739
|
+
webhook: { description: 'An order was paid', version: 2 },
|
|
1740
|
+
})
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
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
|
+
|
|
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.
|
|
1746
|
+
|
|
1747
|
+
## Across instances
|
|
1748
|
+
|
|
1749
|
+
Local delivery always works. For fan-out across replicas the event rides postgres `LISTEN/NOTIFY` or [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) (Redis / NATS), exactly like change events — and a broker outage degrades cross-replica delivery without touching local subscribers.
|
|
1750
|
+
|
|
1751
|
+
**Each declared event gets its own channel** (`<namespace>:events:<name>`, where
|
|
1752
|
+
the namespace defaults to your app's name — see
|
|
1753
|
+
[broadcast](/docs/plugins/broadcast)), and a
|
|
1754
|
+
replica subscribes to it only while it actually has a local subscriber. This
|
|
1755
|
+
matters as soon as one event is much busier than the others: on a single shared
|
|
1756
|
+
channel every replica receives, decodes and tracks every event of every peer,
|
|
1757
|
+
including the ones it serves no clients for. With five replicas and a high-rate
|
|
1758
|
+
event whose subscribers all sit on one of them, four were doing that work and
|
|
1759
|
+
discarding the result.
|
|
1760
|
+
|
|
1761
|
+
Nothing to configure — it follows from the declaration. The one operational
|
|
1762
|
+
consequence: during a **rolling deploy** replicas on different framework versions
|
|
1763
|
+
use different channel names, so cross-replica delivery is degraded for the length
|
|
1764
|
+
of the rollout. Local delivery on each replica is unaffected throughout.
|
|
1765
|
+
|
|
1766
|
+
## Throughput — the numbers, and where this is the wrong primitive
|
|
1767
|
+
|
|
1768
|
+
Measured on one core, publish path only:
|
|
1769
|
+
|
|
1770
|
+
| | |
|
|
1771
|
+
| --- | --- |
|
|
1772
|
+
| `bus.publish`, 1–100 subscribers | ~1.5µs (**~670,000/s**) |
|
|
1773
|
+
| `bus.publish`, 1000 subscribers | ~3.1µs (~325,000/s) |
|
|
1774
|
+
| `ctx.events.publish` (validation + size gate + bus) | ~4.1µs (**~240,000/s**) |
|
|
1775
|
+
|
|
1776
|
+
**Across replicas**, measured over a real Redis with two processes — 200 of 200
|
|
1777
|
+
delivered, no loss:
|
|
1778
|
+
|
|
1779
|
+
| p50 | p95 | p99 | max |
|
|
1780
|
+
| --- | --- | --- | --- |
|
|
1781
|
+
| 1.67 ms | 2.91 ms | 6.58 ms | 9.95 ms |
|
|
1782
|
+
|
|
1783
|
+
That is the broker round trip plus both bus hops. It is the number that matters
|
|
1784
|
+
for a display in another pod, and it is the one to compare against a hosted
|
|
1785
|
+
realtime service — where the same hop is a network round trip to someone else's
|
|
1786
|
+
region.
|
|
1787
|
+
|
|
1788
|
+
**Fan-out is nearly free.** One subscriber and a hundred cost the same — the
|
|
1789
|
+
per-publish work dominates, not the delivery loop. What you pay per subscriber is
|
|
1790
|
+
the wire encode on its own subscription, not anything in the bus.
|
|
1791
|
+
|
|
1792
|
+
For a game lifecycle — eight stage events per game, one publish each — that is
|
|
1793
|
+
several orders of magnitude of headroom. Even 100 players at 60Hz (6,000
|
|
1794
|
+
events/s) sits at ~2.5% of one core.
|
|
1795
|
+
|
|
1796
|
+
### Where it stops being the right tool
|
|
1797
|
+
|
|
1798
|
+
Not at a throughput number, but at a **semantic** one: this primitive guarantees
|
|
1799
|
+
at-most-once delivery of *every* message, with gap accounting. For a 60Hz stream
|
|
1800
|
+
of positions or cursors, that guarantee costs something and buys nothing —
|
|
1801
|
+
**nobody needs frame 1 once frame 2 has arrived.** You want last-value-wins state,
|
|
1802
|
+
not a delivery log.
|
|
1803
|
+
|
|
1804
|
+
### `delivery: 'latest'` — when only the current value matters
|
|
1805
|
+
|
|
1806
|
+
Declare it, and the framework stops treating a superseded value as a loss:
|
|
1807
|
+
|
|
1808
|
+
```ts
|
|
1809
|
+
export default defineEvent({
|
|
1810
|
+
name: 'player.moved',
|
|
1811
|
+
key: Schema.Struct({ arenaId: Schema.String }),
|
|
1812
|
+
payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
|
|
1813
|
+
access: 'authenticated',
|
|
1814
|
+
delivery: 'latest',
|
|
1815
|
+
})
|
|
1816
|
+
```
|
|
1817
|
+
|
|
1818
|
+
| | `each` (default) | `latest` |
|
|
1819
|
+
| --- | --- | --- |
|
|
1820
|
+
| A slow subscriber | keeps the newest, is told how many it lost | receives the current value |
|
|
1821
|
+
| Gap reporting | `missed` is computed and delivered | none — nothing was lost |
|
|
1822
|
+
| Server retention | up to 64 messages / 5 minutes | one value |
|
|
1823
|
+
| Reconnect | replays what is retained, reports the shortfall | hands over the current value |
|
|
1824
|
+
|
|
1825
|
+
The distinction is **semantic, not performance**. `latest` is not "the fast mode":
|
|
1826
|
+
it changes what a missing message *means*. Choosing it for a stream where each
|
|
1827
|
+
delivery matters drops the ones in between; choosing `each` for a per-frame
|
|
1828
|
+
stream makes a slow client work through a backlog to reach a state it could have
|
|
1829
|
+
had immediately, and report a "loss" that was never one.
|
|
1830
|
+
|
|
1831
|
+
The test: **would a consumer be wrong to miss one?**
|
|
1832
|
+
|
|
1833
|
+
<Callout type="warn">
|
|
1834
|
+
`delivery: 'latest'` cannot be combined with `webhook`, and the declaration is
|
|
1835
|
+
refused. `latest` says a superseded delivery did not matter — but a webhook
|
|
1836
|
+
delivery is a durable side effect at a third party, and one already sent cannot
|
|
1837
|
+
be superseded. A 60Hz event with an HTTP audience is also 60 deliveries per
|
|
1838
|
+
second *per subscribed target*, and the webhook rate limit **defers** the excess
|
|
1839
|
+
as pending rows rather than dropping it, so the symptom is a growing table rather
|
|
1840
|
+
than an error anyone would look at. Publish the high-rate event for clients and a
|
|
1841
|
+
separate, coarser one for the outside world.
|
|
1842
|
+
</Callout>
|
|
1843
|
+
|
|
1844
|
+
### Still worth avoiding
|
|
1845
|
+
|
|
1846
|
+
- **Payloads over a few hundred bytes at high rate.** The size gate stops you at
|
|
1847
|
+
7,500 bytes, and long before that the wire encode per subscriber becomes the
|
|
1848
|
+
cost. Send a reference.
|
|
1849
|
+
- **Per-frame data as an event at all.** Even under `latest`, 60Hz of positions
|
|
1850
|
+
is 60 encodes per second per subscriber. Coalescing on the client and
|
|
1851
|
+
publishing at 10–20Hz is usually indistinguishable to a human and an order of
|
|
1852
|
+
magnitude cheaper.
|
|
1853
|
+
|
|
1854
|
+
The honest rule: **use an event when a consumer would be wrong to miss one.** If
|
|
1855
|
+
missing one is fine because the next one supersedes it, either declare
|
|
1856
|
+
`delivery: 'latest'` or model it as state — a table, or a value the client
|
|
1857
|
+
replaces.
|
|
1858
|
+
|
|
1859
|
+
## Testing
|
|
1860
|
+
|
|
1861
|
+
```ts
|
|
1862
|
+
import { testEventBus } from '@voltro/testing'
|
|
1863
|
+
|
|
1864
|
+
const events = testEventBus()
|
|
1865
|
+
const display = events.subscribe(gameStarted, { arenaId: 'a1' })
|
|
1866
|
+
await events.publish(gameStarted, { arenaId: 'a1' }, { gameId: 'g1', gameType: 'evo5', startedAt: 0 })
|
|
1867
|
+
expect(display.received).toEqual([{ gameId: 'g1', gameType: 'evo5', startedAt: 0 }])
|
|
1868
|
+
|
|
1869
|
+
// Force a loss deterministically instead of racing a queue:
|
|
1870
|
+
events.skipSerials(gameStarted, { arenaId: 'a1' }, 5)
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
It drives the real bus and the real publish path — validation, the size gate and serials all behave as they do in production — so a test cannot pass on a payload the server would reject.
|
|
1874
|
+
|
|
1875
|
+
## Evolving a payload
|
|
1876
|
+
|
|
1877
|
+
Clients decode against **their own** copy of the schema. Adding a field is safe. **Removing a required field breaks clients still running the old bundle**, loudly, at decode time — which is better than a silent `undefined` in a handler, and worth knowing if you ship to devices that do not reload for months. Treat those deployments as additive-only.
|
|
1878
|
+
|
|
1879
|
+
## Anti-pattern: events as rows
|
|
1880
|
+
|
|
1881
|
+
If you have this, replace it:
|
|
1882
|
+
|
|
1883
|
+
```tsx
|
|
1884
|
+
// ✗ an events TABLE, reconstructed into "new" on the client
|
|
1885
|
+
const { data } = useSubscription('app', 'realtime.list', { limit: 500 })
|
|
1886
|
+
const seen = useRef(new Set()); const initialized = useRef(false)
|
|
1887
|
+
useEffect(() => { /* mark everything seen on the first pass, then diff */ }, [data])
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
Three bugs in nine lines, and every consumer has to get all three right: the `seen` set, the `initialized` flag (without it, loading the page replays 500 old events into a live system), and `limit` (a silent ceiling — nothing tells you when more than 500 arrive between renders). The table also grows forever and holds rows nobody reads twice.
|
|
1891
|
+
|
|
1892
|
+
Migrating is mechanical: declare the event, replace the insert with `ctx.events.publish`, replace the hook with `useEvent`, and drop the table.
|
|
1893
|
+
|
|
1894
|
+
## See also
|
|
1895
|
+
|
|
1896
|
+
[Subscriptions](/docs/data/subscriptions) · [Outbox](/docs/data/outbox) · [Subscribers](/docs/data/subscribers) · [Streams](/docs/data/streams)
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
|
|
1576
1900
|
---
|
|
1577
1901
|
|
|
1578
1902
|
<!-- source: en/data/rest-routes.md -->
|
|
@@ -2123,171 +2447,6 @@ When no analytics sink is configured the framework provides the no-op sink: the
|
|
|
2123
2447
|
|
|
2124
2448
|
|
|
2125
2449
|
|
|
2126
|
-
---
|
|
2127
|
-
|
|
2128
|
-
<!-- source: en/data/client-state.md -->
|
|
2129
|
-
## Client state (defineStore)
|
|
2130
|
-
|
|
2131
|
-
_defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes._
|
|
2132
|
-
|
|
2133
|
-
Server state already has a home: a subscription **is** live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
|
|
2134
|
-
|
|
2135
|
-
Without a primitive for that you reach for zustand or jotai, which is a *parallel runtime* — the one thing the framework asks you not to bring. So it ships one.
|
|
2136
|
-
|
|
2137
|
-
```ts
|
|
2138
|
-
// wizard.store.ts
|
|
2139
|
-
import { defineStore } from '@voltro/client'
|
|
2140
|
-
|
|
2141
|
-
export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
|
|
2142
|
-
```
|
|
2143
|
-
|
|
2144
|
-
```tsx
|
|
2145
|
-
const step = wizard.use((s) => s.step) // the global instance
|
|
2146
|
-
const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
2147
|
-
|
|
2148
|
-
wizard.set({ step: 2 })
|
|
2149
|
-
wizard.set((s) => ({ ...s, step: s.step + 1 }))
|
|
2150
|
-
```
|
|
2151
|
-
|
|
2152
|
-
## Reads go through a selector — there is no `useStore()`
|
|
2153
|
-
|
|
2154
|
-
A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when **that slice** changes:
|
|
2155
|
-
|
|
2156
|
-
```tsx
|
|
2157
|
-
const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
|
|
2158
|
-
const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
|
|
2159
|
-
```
|
|
2160
|
-
|
|
2161
|
-
### Computed values need `equals: shallow`
|
|
2162
|
-
|
|
2163
|
-
A selector that *builds* something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on **every** store change:
|
|
2164
|
-
|
|
2165
|
-
```tsx
|
|
2166
|
-
import { shallow } from '@voltro/client'
|
|
2167
|
-
|
|
2168
|
-
const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
|
|
2169
|
-
const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
|
|
2170
|
-
```
|
|
2171
|
-
|
|
2172
|
-
You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
|
|
2173
|
-
|
|
2174
|
-
## Scoping is by key, not by a Provider
|
|
2175
|
-
|
|
2176
|
-
A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that *is* what makes context painful at scale. So an instance is addressed by a key you already have:
|
|
2177
|
-
|
|
2178
|
-
```tsx
|
|
2179
|
-
wizard.use((s) => s.step, { key: orderId })
|
|
2180
|
-
```
|
|
2181
|
-
|
|
2182
|
-
which is the same model as everywhere else in the framework: `useSubscription('orders.list', { orgId })` is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
|
|
2183
|
-
|
|
2184
|
-
`wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
|
|
2185
|
-
|
|
2186
|
-
## SSR seeding adds no new channel
|
|
2187
|
-
|
|
2188
|
-
Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
|
|
2189
|
-
|
|
2190
|
-
```ts
|
|
2191
|
-
export const loader = async ({ params }) => {
|
|
2192
|
-
seedStore(wizard, { step: 2 }, { key: params.orderId })
|
|
2193
|
-
return { /* … */ }
|
|
2194
|
-
}
|
|
2195
|
-
```
|
|
2196
|
-
|
|
2197
|
-
There is no `dehydrate()` to remember and no `hydrate()` to forget — the seed rides the hydration payload the router already writes, and `mount()` applies it before the tree exists. A step you can forget is a step somebody will.
|
|
2198
|
-
|
|
2199
|
-
On the client `seedStore` **throws**. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
|
|
2200
|
-
|
|
2201
|
-
**The key is part of the address.** Seed with `{ key: orderId }` and read with `{ key: orderId }`. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
|
|
2202
|
-
|
|
2203
|
-
## Surviving a reload
|
|
2204
|
-
|
|
2205
|
-
`persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
|
|
2206
|
-
|
|
2207
|
-
```tsx
|
|
2208
|
-
export const filters = defineStore(
|
|
2209
|
-
'inbox:filters',
|
|
2210
|
-
() => ({ status: 'open', sort: 'newest', draft: '' }),
|
|
2211
|
-
{
|
|
2212
|
-
persist: {
|
|
2213
|
-
key: 'inbox:filters',
|
|
2214
|
-
storage: 'local', // 'session' lasts the tab
|
|
2215
|
-
pick: (s) => ({ status: s.status, sort: s.sort }),
|
|
2216
|
-
migrate: (stored) => (isFilters(stored) ? stored : undefined),
|
|
2217
|
-
},
|
|
2218
|
-
},
|
|
2219
|
-
)
|
|
2220
|
-
```
|
|
2221
|
-
|
|
2222
|
-
Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
|
|
2223
|
-
|
|
2224
|
-
**The stored value is merged over `initial()`, not substituted for it.** Ship a new field and every returning user has state without it — `undefined` where the type promises a string. Merging means an old payload gains the new defaults.
|
|
2225
|
-
|
|
2226
|
-
**`migrate` returning `undefined` discards the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
|
|
2227
|
-
|
|
2228
|
-
**Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`, not just on writing. A store that throws at import time takes the page with it.
|
|
2229
|
-
|
|
2230
|
-
**A persisted store on a server-rendered page hydrates against the *server* value.** The server has no `localStorage`, so it renders `initial()`; the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. `get()` is not deferred, only the render: an action reading the draft before the first paint reads the draft.
|
|
2231
|
-
|
|
2232
|
-
`pick` narrows what gets written — persist the filters, not the open/closed state of every panel. And only the **global** instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
|
|
2233
|
-
|
|
2234
|
-
## Actions that write more than once
|
|
2235
|
-
|
|
2236
|
-
An action rarely touches one field. Applying a coupon writes the coupon *and* the recomputed total; that is one thing the user did, and `batch` says so:
|
|
2237
|
-
|
|
2238
|
-
```ts
|
|
2239
|
-
checkout.batch('applyCoupon', () => {
|
|
2240
|
-
checkout.set({ coupon })
|
|
2241
|
-
checkout.set({ total: recompute(coupon) })
|
|
2242
|
-
})
|
|
2243
|
-
```
|
|
2244
|
-
|
|
2245
|
-
One notification, one entry in the devtools feed named `applyCoupon`, and **one undo step**. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own; it cannot batch the meaning.
|
|
2246
|
-
|
|
2247
|
-
**If the callback throws, every write it made is rolled back.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested `batch` joins its parent rather than opening a second one.
|
|
2248
|
-
|
|
2249
|
-
### Async work goes around the batch, not inside it
|
|
2250
|
-
|
|
2251
|
-
```ts
|
|
2252
|
-
const quote = await fetchQuote(coupon) // await FIRST
|
|
2253
|
-
checkout.batch('applyCoupon', () => { // then batch the writes
|
|
2254
|
-
checkout.set({ coupon, total: quote.total })
|
|
2255
|
-
})
|
|
2256
|
-
```
|
|
2257
|
-
|
|
2258
|
-
Passing an `async` function to `batch` is an **error**, not a warning. Everything after the first `await` would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
|
|
2259
|
-
|
|
2260
|
-
## Undo and redo
|
|
2261
|
-
|
|
2262
|
-
Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
|
|
2263
|
-
|
|
2264
|
-
```tsx
|
|
2265
|
-
draft.undo(orderId) // back one write
|
|
2266
|
-
draft.redo(orderId) // forward again
|
|
2267
|
-
draft.canUndo(orderId) // for disabling the button
|
|
2268
|
-
draft.canRedo(orderId)
|
|
2269
|
-
```
|
|
2270
|
-
|
|
2271
|
-
It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a **new write after an undo drops the redo tail** — the behaviour every editor has.
|
|
2272
|
-
|
|
2273
|
-
An undo never becomes undoable itself, and each keyed instance has its own history.
|
|
2274
|
-
|
|
2275
|
-
## Devtools: inspect, and travel
|
|
2276
|
-
|
|
2277
|
-
The `voltro dev` overlay has a **Stores** tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
|
|
2278
|
-
|
|
2279
|
-
`◀ Back` and `Forward ▶` step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
|
|
2280
|
-
|
|
2281
|
-
No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
|
|
2282
|
-
|
|
2283
|
-
## What a store must never hold
|
|
2284
|
-
|
|
2285
|
-
Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as *"reactivity is broken"*. `voltro doctor` reports a `*.store.ts` that reads a subscription.
|
|
2286
|
-
|
|
2287
|
-
Read server state where you render it, and keep the store for what is genuinely client-side.
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
2450
|
---
|
|
2292
2451
|
|
|
2293
2452
|
<!-- source: en/data/crud.md -->
|
|
@@ -2735,6 +2894,171 @@ into the agent's prompt.
|
|
|
2735
2894
|
|
|
2736
2895
|
|
|
2737
2896
|
|
|
2897
|
+
---
|
|
2898
|
+
|
|
2899
|
+
<!-- source: en/data/client-state.md -->
|
|
2900
|
+
## Client state (defineStore)
|
|
2901
|
+
|
|
2902
|
+
_defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes._
|
|
2903
|
+
|
|
2904
|
+
Server state already has a home: a subscription **is** live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
|
|
2905
|
+
|
|
2906
|
+
Without a primitive for that you reach for zustand or jotai, which is a *parallel runtime* — the one thing the framework asks you not to bring. So it ships one.
|
|
2907
|
+
|
|
2908
|
+
```ts
|
|
2909
|
+
// wizard.store.ts
|
|
2910
|
+
import { defineStore } from '@voltro/client'
|
|
2911
|
+
|
|
2912
|
+
export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
|
|
2913
|
+
```
|
|
2914
|
+
|
|
2915
|
+
```tsx
|
|
2916
|
+
const step = wizard.use((s) => s.step) // the global instance
|
|
2917
|
+
const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
2918
|
+
|
|
2919
|
+
wizard.set({ step: 2 })
|
|
2920
|
+
wizard.set((s) => ({ ...s, step: s.step + 1 }))
|
|
2921
|
+
```
|
|
2922
|
+
|
|
2923
|
+
## Reads go through a selector — there is no `useStore()`
|
|
2924
|
+
|
|
2925
|
+
A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when **that slice** changes:
|
|
2926
|
+
|
|
2927
|
+
```tsx
|
|
2928
|
+
const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
|
|
2929
|
+
const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
|
|
2930
|
+
```
|
|
2931
|
+
|
|
2932
|
+
### Computed values need `equals: shallow`
|
|
2933
|
+
|
|
2934
|
+
A selector that *builds* something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on **every** store change:
|
|
2935
|
+
|
|
2936
|
+
```tsx
|
|
2937
|
+
import { shallow } from '@voltro/client'
|
|
2938
|
+
|
|
2939
|
+
const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
|
|
2940
|
+
const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
|
|
2941
|
+
```
|
|
2942
|
+
|
|
2943
|
+
You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
|
|
2944
|
+
|
|
2945
|
+
## Scoping is by key, not by a Provider
|
|
2946
|
+
|
|
2947
|
+
A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that *is* what makes context painful at scale. So an instance is addressed by a key you already have:
|
|
2948
|
+
|
|
2949
|
+
```tsx
|
|
2950
|
+
wizard.use((s) => s.step, { key: orderId })
|
|
2951
|
+
```
|
|
2952
|
+
|
|
2953
|
+
which is the same model as everywhere else in the framework: `useSubscription('orders.list', { orgId })` is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
|
|
2954
|
+
|
|
2955
|
+
`wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
|
|
2956
|
+
|
|
2957
|
+
## SSR seeding adds no new channel
|
|
2958
|
+
|
|
2959
|
+
Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
|
|
2960
|
+
|
|
2961
|
+
```ts
|
|
2962
|
+
export const loader = async ({ params }) => {
|
|
2963
|
+
seedStore(wizard, { step: 2 }, { key: params.orderId })
|
|
2964
|
+
return { /* … */ }
|
|
2965
|
+
}
|
|
2966
|
+
```
|
|
2967
|
+
|
|
2968
|
+
There is no `dehydrate()` to remember and no `hydrate()` to forget — the seed rides the hydration payload the router already writes, and `mount()` applies it before the tree exists. A step you can forget is a step somebody will.
|
|
2969
|
+
|
|
2970
|
+
On the client `seedStore` **throws**. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
|
|
2971
|
+
|
|
2972
|
+
**The key is part of the address.** Seed with `{ key: orderId }` and read with `{ key: orderId }`. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
|
|
2973
|
+
|
|
2974
|
+
## Surviving a reload
|
|
2975
|
+
|
|
2976
|
+
`persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
|
|
2977
|
+
|
|
2978
|
+
```tsx
|
|
2979
|
+
export const filters = defineStore(
|
|
2980
|
+
'inbox:filters',
|
|
2981
|
+
() => ({ status: 'open', sort: 'newest', draft: '' }),
|
|
2982
|
+
{
|
|
2983
|
+
persist: {
|
|
2984
|
+
key: 'inbox:filters',
|
|
2985
|
+
storage: 'local', // 'session' lasts the tab
|
|
2986
|
+
pick: (s) => ({ status: s.status, sort: s.sort }),
|
|
2987
|
+
migrate: (stored) => (isFilters(stored) ? stored : undefined),
|
|
2988
|
+
},
|
|
2989
|
+
},
|
|
2990
|
+
)
|
|
2991
|
+
```
|
|
2992
|
+
|
|
2993
|
+
Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
|
|
2994
|
+
|
|
2995
|
+
**The stored value is merged over `initial()`, not substituted for it.** Ship a new field and every returning user has state without it — `undefined` where the type promises a string. Merging means an old payload gains the new defaults.
|
|
2996
|
+
|
|
2997
|
+
**`migrate` returning `undefined` discards the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
|
|
2998
|
+
|
|
2999
|
+
**Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`, not just on writing. A store that throws at import time takes the page with it.
|
|
3000
|
+
|
|
3001
|
+
**A persisted store on a server-rendered page hydrates against the *server* value.** The server has no `localStorage`, so it renders `initial()`; the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. `get()` is not deferred, only the render: an action reading the draft before the first paint reads the draft.
|
|
3002
|
+
|
|
3003
|
+
`pick` narrows what gets written — persist the filters, not the open/closed state of every panel. And only the **global** instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
|
|
3004
|
+
|
|
3005
|
+
## Actions that write more than once
|
|
3006
|
+
|
|
3007
|
+
An action rarely touches one field. Applying a coupon writes the coupon *and* the recomputed total; that is one thing the user did, and `batch` says so:
|
|
3008
|
+
|
|
3009
|
+
```ts
|
|
3010
|
+
checkout.batch('applyCoupon', () => {
|
|
3011
|
+
checkout.set({ coupon })
|
|
3012
|
+
checkout.set({ total: recompute(coupon) })
|
|
3013
|
+
})
|
|
3014
|
+
```
|
|
3015
|
+
|
|
3016
|
+
One notification, one entry in the devtools feed named `applyCoupon`, and **one undo step**. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own; it cannot batch the meaning.
|
|
3017
|
+
|
|
3018
|
+
**If the callback throws, every write it made is rolled back.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested `batch` joins its parent rather than opening a second one.
|
|
3019
|
+
|
|
3020
|
+
### Async work goes around the batch, not inside it
|
|
3021
|
+
|
|
3022
|
+
```ts
|
|
3023
|
+
const quote = await fetchQuote(coupon) // await FIRST
|
|
3024
|
+
checkout.batch('applyCoupon', () => { // then batch the writes
|
|
3025
|
+
checkout.set({ coupon, total: quote.total })
|
|
3026
|
+
})
|
|
3027
|
+
```
|
|
3028
|
+
|
|
3029
|
+
Passing an `async` function to `batch` is an **error**, not a warning. Everything after the first `await` would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
|
|
3030
|
+
|
|
3031
|
+
## Undo and redo
|
|
3032
|
+
|
|
3033
|
+
Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
|
|
3034
|
+
|
|
3035
|
+
```tsx
|
|
3036
|
+
draft.undo(orderId) // back one write
|
|
3037
|
+
draft.redo(orderId) // forward again
|
|
3038
|
+
draft.canUndo(orderId) // for disabling the button
|
|
3039
|
+
draft.canRedo(orderId)
|
|
3040
|
+
```
|
|
3041
|
+
|
|
3042
|
+
It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a **new write after an undo drops the redo tail** — the behaviour every editor has.
|
|
3043
|
+
|
|
3044
|
+
An undo never becomes undoable itself, and each keyed instance has its own history.
|
|
3045
|
+
|
|
3046
|
+
## Devtools: inspect, and travel
|
|
3047
|
+
|
|
3048
|
+
The `voltro dev` overlay has a **Stores** tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
|
|
3049
|
+
|
|
3050
|
+
`◀ Back` and `Forward ▶` step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
|
|
3051
|
+
|
|
3052
|
+
No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
|
|
3053
|
+
|
|
3054
|
+
## What a store must never hold
|
|
3055
|
+
|
|
3056
|
+
Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as *"reactivity is broken"*. `voltro doctor` reports a `*.store.ts` that reads a subscription.
|
|
3057
|
+
|
|
3058
|
+
Read server state where you render it, and keep the store for what is genuinely client-side.
|
|
3059
|
+
|
|
3060
|
+
|
|
3061
|
+
|
|
2738
3062
|
---
|
|
2739
3063
|
|
|
2740
3064
|
<!-- source: en/data/outbox.md -->
|