@voltro/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +310 -7
  2. package/dist/apiBuild-BbR1nYcE.js +2 -0
  3. package/dist/{apiBuild-eInHm_bJ.js → apiBuild-DTLPSlt9.js} +8 -8
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-Cxs97AFp.js → commands-B0QT8dUY.js} +1948 -1599
  6. package/dist/dev-BJvbwjSz.js +2 -0
  7. package/dist/{dev-63Ehpw2p.js → dev-CK9fbuAM.js} +1408 -1342
  8. package/dist/index.js +1 -1
  9. package/dist/{inspectMetrics-DVCKKSPk.js → inspectMetrics-BeMjgt8x.js} +1018 -827
  10. package/dist/inspectState.d.ts +6 -2
  11. package/dist/{serveCommand-Cq7BhtpB.js → serveCommand-YR3mKjvf.js} +367 -384
  12. package/dist/serveEntry.js +3 -3
  13. package/dist/{start-CYe-7ueh.js → start-C_M4fGS2.js} +331 -309
  14. package/dist/startEntry.js +2 -2
  15. package/package.json +17 -17
  16. package/templates/agent-docs/cli.md +45 -17
  17. package/templates/agent-docs/data.md +109 -1
  18. package/templates/agent-docs/database/seedsdialects.md +20 -4
  19. package/templates/agent-docs/routing.md +52 -13
  20. package/templates/agent-docs/scheduling.md +32 -0
  21. package/templates/apps/api-ai/package.json +7 -7
  22. package/templates/apps/api-auth/package.json +8 -8
  23. package/templates/apps/api-backend/package.json +7 -7
  24. package/templates/apps/api-backend-deactivation/package.json +7 -7
  25. package/templates/apps/api-backend-mail/package.json +8 -8
  26. package/templates/apps/api-backend-mariadb/package.json +9 -9
  27. package/templates/apps/api-backend-storage/package.json +8 -8
  28. package/templates/apps/api-data-advanced/package.json +8 -8
  29. package/templates/apps/api-durable/package.json +8 -8
  30. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +5 -0
  31. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +8 -2
  32. package/templates/apps/api-feature-flags/package.json +9 -9
  33. package/templates/apps/api-governance/package.json +8 -8
  34. package/templates/apps/api-kv/package.json +8 -8
  35. package/templates/apps/api-moderation/package.json +8 -8
  36. package/templates/apps/api-observability/package.json +8 -8
  37. package/templates/apps/api-ratelimit/package.json +8 -8
  38. package/templates/apps/api-rbac/package.json +8 -8
  39. package/templates/apps/api-rest/package.json +7 -7
  40. package/templates/apps/api-saas/package.json +11 -11
  41. package/templates/apps/api-search/package.json +8 -8
  42. package/templates/apps/api-versioning/package.json +8 -8
  43. package/templates/apps/api-webhooks/package.json +8 -8
  44. package/templates/apps/changelog/package.json +6 -6
  45. package/templates/apps/edge-functions/package.json +2 -2
  46. package/templates/apps/frontend-admin/package.json +8 -8
  47. package/templates/apps/frontend-app/package.json +8 -8
  48. package/templates/apps/frontend-blank/package.json +7 -7
  49. package/templates/apps/frontend-contact/package.json +7 -7
  50. package/templates/apps/frontend-dashboard/package.json +7 -7
  51. package/templates/apps/frontend-docs/package.json +7 -7
  52. package/templates/apps/frontend-i18n/package.json +6 -6
  53. package/templates/apps/frontend-landing/package.json +7 -7
  54. package/templates/apps/frontend-spa/package.json +7 -7
  55. package/templates/apps/frontend-ssr/package.json +7 -7
  56. package/templates/apps/frontend-ssr-api/package.json +8 -8
  57. package/templates/apps/frontend-static-blog/package.json +6 -6
  58. package/dist/apiBuild-Ba4njm9m.js +0 -2
  59. package/dist/dev-CXsR8Zgo.js +0 -2
@@ -1,3 +1,3 @@
1
- import { I as e } from "./inspectMetrics-DVCKKSPk.js";
2
- import { t } from "./start-CYe-7ueh.js";
1
+ import { N as e } from "./inspectMetrics-BeMjgt8x.js";
2
+ import { t } from "./start-C_M4fGS2.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.9.0",
3
+ "version": "0.10.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",
@@ -57,22 +57,22 @@
57
57
  "@effect/platform-node": "^0.107.0",
58
58
  "@effect/sql": "^0.51.1",
59
59
  "@effect/workflow": "^0.18.2",
60
- "@voltro/ai": "0.9.0",
61
- "@voltro/cache": "0.9.0",
62
- "@voltro/data-transfer": "0.9.0",
63
- "@voltro/database": "0.9.0",
64
- "@voltro/env": "0.9.0",
65
- "@voltro/kv": "0.9.0",
66
- "@voltro/logger": "0.9.0",
67
- "@voltro/plugin-auth": "0.9.0",
68
- "@voltro/plugin-broadcast": "0.9.0",
69
- "@voltro/plugin-mail": "0.9.0",
70
- "@voltro/plugin-storage": "0.9.0",
71
- "@voltro/plugin-webhooks": "0.9.0",
72
- "@voltro/protocol": "0.9.0",
73
- "@voltro/runtime": "0.9.0",
74
- "@voltro/serverless": "0.9.0",
75
- "@voltro/workflow": "0.9.0",
60
+ "@voltro/ai": "0.10.0",
61
+ "@voltro/cache": "0.10.0",
62
+ "@voltro/data-transfer": "0.10.0",
63
+ "@voltro/database": "0.10.0",
64
+ "@voltro/env": "0.10.0",
65
+ "@voltro/kv": "0.10.0",
66
+ "@voltro/logger": "0.10.0",
67
+ "@voltro/plugin-auth": "0.10.0",
68
+ "@voltro/plugin-broadcast": "0.10.0",
69
+ "@voltro/plugin-mail": "0.10.0",
70
+ "@voltro/plugin-storage": "0.10.0",
71
+ "@voltro/plugin-webhooks": "0.10.0",
72
+ "@voltro/protocol": "0.10.0",
73
+ "@voltro/runtime": "0.10.0",
74
+ "@voltro/serverless": "0.10.0",
75
+ "@voltro/workflow": "0.10.0",
76
76
  "chokidar": "^5.0.0",
77
77
  "ioredis": "^5.11.1",
78
78
  "tinyglobby": "^0.2.17",
@@ -427,7 +427,9 @@ voltro dev apps/acme/api # explicit path
427
427
  6. Watches `src/`. On save, Vite HMR fires:
428
428
  - A page / layout **component** change → React Fast Refresh patches the live
429
429
  component in place; client state survives (see below).
430
- - A page / layout **`loader`** (or other non-component export) change → a
430
+ - A page-local **value export** (`const COLUMNS = [...]`) change → also a hot
431
+ update, even in the same save as the JSX (see below).
432
+ - A **server-read export** — `loader`, `renderMode`, `meta`, … — change → a
431
433
  full page reload, on purpose (see below).
432
434
  - CSS changes → swap stylesheets in place.
433
435
  - New page file → regen the entry files → reload the route tree.
@@ -445,27 +447,43 @@ drop it on a low-memory box, raise it on a big machine.
445
447
 
446
448
  Editing a **page or layout component** applies as a hot update — the React tree
447
449
  stays mounted, so form input, scroll position, open dialogs and every `useState`
448
- survive. Editing a **`loader`** (or `meta` / `renderMode` / any other
449
- non-component export of a route module) triggers a full page reload instead.
450
-
451
- That split is deliberate, not a gap. A `loader` also runs on the server: the page
452
- in front of you was rendered from the OLD one, and the router caches loader
453
- results per route + params, so hot-swapping the function would leave stale data
454
- on screen with nothing to signal it. A reload re-runs SSR with the new loader.
450
+ survive. So does editing a **page-local value** the page happens to export — a
451
+ `const COLUMNS = [...]` you change together with the table that renders it. The
452
+ module re-evaluates, the component renders the new value, and your client state
453
+ is untouched.
454
+
455
+ A full page reload happens for exactly one class of edit: an export the
456
+ **server** already read to produce the page in front of you.
457
+
458
+ | Export | What the server does with it |
459
+ | --- | --- |
460
+ | `loader` | runs it (SSR / prerender), and the router caches the result per route + params |
461
+ | `renderMode`, `dynamic` | picks the render strategy for the route |
462
+ | `meta` | renders it into `<head>` |
463
+ | `getStaticPaths` | enumerates which paths get prerendered |
464
+ | `revalidate`, `staleWhileRevalidate` | sets the ISR cache window |
465
+ | `cacheInvalidatesOn` | wires the page into the ISR cache invalidator |
466
+ | `interactive` | decides how much client JS is shipped |
467
+ | `tenantAware` | forms part of the server-side cache key |
468
+
469
+ That reload is deliberate, not a gap. The HTML you are looking at was produced
470
+ from the OLD value, so hot-swapping the export would leave stale output on
471
+ screen with nothing to signal it. A reload re-runs SSR with the new value, and
472
+ the console line names the export and the server step that consumed it.
455
473
 
456
474
  The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
457
475
  whose exports are all components, and a page exporting `loader` beside its
458
476
  component fails that test. `voltro dev` registers each route module's
459
- non-component exports with the React plugin's ignore hook, and decides the
460
- reload itself by comparing those exports' VALUES across the update a function
461
- by its source text, anything else by its JSON form so a JSX-only edit (which
462
- recreates the `loader` object) is correctly read as "unchanged".
477
+ non-component exports with the React plugin's ignore hook (so Fast Refresh
478
+ judges only the components), then makes the reload call itself by comparing the
479
+ server-read exports' VALUES across the update a function by its source text,
480
+ anything else by its JSON form so a JSX-only edit, which recreates the
481
+ `loader` function object, is correctly read as "unchanged".
463
482
 
464
- One consequence worth knowing: a page that exports a non-component value which
465
- changes on the same save as its JSX a `const COLUMNS = [...]` you edit
466
- together with the table that renders it reloads rather than hot-updates. Move
467
- that value into a sibling module (`./columns.ts`) and the page hot-updates
468
- again; the sibling's own edits still propagate through the page's boundary.
483
+ One residual caveat: **adding or removing** a non-component export still
484
+ reloads once, whatever it is. Fast Refresh sees an export that was not on the
485
+ ignore list yet and refuses the boundary; the next edit to that page hot-updates
486
+ normally.
469
487
 
470
488
  ### The in-page devtools overlay
471
489
 
@@ -819,6 +837,16 @@ It covers both halves of the stack:
819
837
  | client | `useMemo` fanning in several subscriptions | `useDerived` |
820
838
  | client | a local Next.js compat shim | the native `@voltro/web` exports |
821
839
  | client | a hand-rolled presence heartbeat | `@voltro/plugin-presence` |
840
+ | client | `data === undefined` / `!data` on a subscription result | branch on `loading` (and `idle`, if you pass `skip`) |
841
+
842
+ The subscription rule **resolves the binding** rather than matching text, and
843
+ that distinction is the reason this scanner parses at all. A consumer migrating
844
+ these call sites wrote a regex codemod for the same job, and it rewrote a
845
+ `summary === undefined` check inside a child component where `summary` was a
846
+ PROP. Their compiler happened to catch it, because that name was out of scope
847
+ there; had the names matched, a silent behaviour change would have shipped. Text
848
+ cannot tell you which declaration an identifier refers to — so a rule about
849
+ identifiers has no business being written in text.
822
850
 
823
851
  The rules are deliberately conservative — a detector that cries wolf trains you
824
852
  to ignore it. A column that already carries `.encrypted()`, or a handler that
@@ -994,6 +994,51 @@ try {
994
994
  }
995
995
  ```
996
996
 
997
+ ### Multi-step sequences — `useSequence`
998
+
999
+ The rule above says a loop keeps its `try/catch`, and that left multi-step
1000
+ writes as the only verbose thing on the write path — while being the hardest
1001
+ case, not the easiest. `useSequence` runs the steps in order, gives the whole
1002
+ sequence **one** `onError`, and lets each step say how to undo itself:
1003
+
1004
+ ```tsx
1005
+ const seq = useSequence({ onError: (e) => toast.error(readError(e)) })
1006
+
1007
+ const result = await seq.run(
1008
+ sequence()
1009
+ .step('upload', () => upload.run({ file }), {
1010
+ undo: (created) => removeObject.run({ id: created.id }),
1011
+ })
1012
+ .step('attach', (c) => createAttachment.run({ refId: c.upload.id })),
1013
+ )
1014
+
1015
+ if (result.ok) setAttachmentId(result.data.attach.id)
1016
+ ```
1017
+
1018
+ Each step's context is typed and accumulates, so a fourth step can read the
1019
+ first step's result by name. `run` resolves with a discriminated result rather
1020
+ than rejecting — same "handled" semantics as `useMutation`/`useAction`.
1021
+
1022
+ **`undo` receives its own step's result**, which is the point: the id you just
1023
+ created is what you need to delete it again. Undos run in reverse for the steps
1024
+ that already succeeded.
1025
+
1026
+ **This is not a transaction, and the difference matters.** After `createTicket`
1027
+ returns, the ticket exists in Jira; nothing the browser does un-creates it, it
1028
+ can only issue a delete and hope. And the compensation runs *in the tab* — close
1029
+ it, lose the network, or navigate away mid-rollback and the remaining undos
1030
+ never happen. Two rules follow, and the primitive enforces both:
1031
+
1032
+ - the step that **failed** is never compensated (it may or may not have had an
1033
+ effect — undoing it is a guess, and a wrong guess deletes something else);
1034
+ - a failing `undo` never replaces the original error, and never stops the
1035
+ remaining undos. Cleanup failures come back in `compensationFailures`, so an
1036
+ orphan is something you can see rather than something you find later.
1037
+
1038
+ **When the rollback has to survive a closed tab, this is the wrong tool.** Put
1039
+ the sequence in a [workflow](/docs/workflows/overview): the engine owns retries
1040
+ and compensation there, and a crash resumes instead of leaking.
1041
+
997
1042
  ## Action vs Mutation vs Stream
998
1043
 
999
1044
  | Need | Use |
@@ -1118,6 +1163,40 @@ const { data } = useSubscription(
1118
1163
 
1119
1164
  While skipped, no WebSocket subscription opens and `data` stays `undefined`.
1120
1165
 
1166
+ ### Skipped is `idle`, not `loading`
1167
+
1168
+ A skipped subscription reports **`idle: true`, `loading: false`**. The two are
1169
+ different questions — "waiting for the first snapshot" and "not asking at all" —
1170
+ and conflating them breaks the pattern this hook otherwise blesses:
1171
+
1172
+ ```tsx
1173
+ // WRONG on a skipping call site — renders a skeleton for a query you switched off
1174
+ if (loading) return <Skeleton/>
1175
+ ```
1176
+
1177
+ Passing a dynamic `skip` therefore changes the return type: you get a third
1178
+ state, and TypeScript will not let you ignore it.
1179
+
1180
+ ```tsx
1181
+ const s = useSubscription<Team[]>('app', 'teams.list', {}, { skip: !open })
1182
+
1183
+ if (s.idle) return null // deliberately not asking
1184
+ if (s.loading) return <Skeleton/> // asking, no answer yet
1185
+ return <TeamsTable teams={s.data}/> // `data` is Team[] — narrowed
1186
+ ```
1187
+
1188
+ Call sites that never skip are untouched — `if (loading)` still proves `data` is
1189
+ present there, and a literal `{ skip: false }` counts as never skipping. The
1190
+ third state exists only where "not asking" is a real outcome.
1191
+
1192
+ With a `fallback` there is nothing to narrow either way: `data` is always
1193
+ present, and `idle` tells you whether what is on screen is the fallback because
1194
+ you chose not to ask.
1195
+
1196
+ A subscription that was live and is then skipped goes idle — it does **not**
1197
+ keep serving the snapshot it still holds. Otherwise `skip: !open` would show
1198
+ last time's data the moment a dialog reopens.
1199
+
1121
1200
  ## Streams Are Different
1122
1201
 
1123
1202
  For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
@@ -1794,6 +1873,35 @@ export default defineSubscriber({
1794
1873
 
1795
1874
  The default export must be a `defineSubscriber({...})` result. The file is identified by suffix (`*.subscribe.ts` / `*.subscribe.tsx`).
1796
1875
 
1876
+ ## `ctx.store` — reading and writing back
1877
+
1878
+ `ctx` carries `log`, `id`, and **`store`** — the same store surface a handler
1879
+ uses, so a subscriber can act on the change it just saw:
1880
+
1881
+ ```ts
1882
+ export default defineSubscriber({
1883
+ table: 'orders',
1884
+ on: 'insert',
1885
+ handler: async (event, ctx) => {
1886
+ await ctx.store.insert('order_audit', {
1887
+ orderId: String(event.new?.['id']),
1888
+ tenantId: String(event.new?.['tenantId']), // explicit — see below
1889
+ })
1890
+ },
1891
+ })
1892
+ ```
1893
+
1894
+ **`ctx.store` is NOT tenant-scoped.** A subscriber fires from the change stream,
1895
+ not from a request, so there is no subject to scope to and no tenant to infer.
1896
+ Reads see every tenant's rows; a write to a `tenant()` table without an explicit
1897
+ `tenantId` fails with `TenantScopeViolation` rather than landing in an arbitrary
1898
+ tenant. When your reaction is per-tenant, take the tenant from the row that
1899
+ changed, as above. This is the same posture a schedule's `ctx.app.store` has —
1900
+ both are post-request system work.
1901
+
1902
+ Writes are mixin-stamped exactly as a request-path write is: id scheme,
1903
+ timestamps, audit columns. Only the subject differs.
1904
+
1797
1905
  ## What fires when
1798
1906
 
1799
1907
  The framework binds to the store's `onChange` channel. Subscribers fire **after the transaction commits** — the row IS persisted when your handler runs. This means:
@@ -1818,7 +1926,7 @@ Subscribers are **non-durable** by design:
1818
1926
  - **Async handlers are NOT awaited by the dispatcher.** Fire-and-forget — a slow handler can't back-pressure the change stream. Errors propagate to the structured log via `.catch()`, but the change-emission path returns immediately.
1819
1927
  - **No retry, no resume.** If the process crashes mid-handler, the work is gone. Same if the network call inside the handler fails — there's no built-in retry policy.
1820
1928
 
1821
- If you need any of those properties (transactional, durable, retried), don't hand-roll the kickoff in the handler — a subscriber's `ctx` carries exactly two members (`ctx.log` and `ctx.id`), so there is no framework handle to start a workflow with. "A row changed → start a workflow" has its own primitive: a [reaction](/docs/data/reactions). A `*.reaction.tsx` watches the same post-commit change stream and its `act` starts the workflow for you, behind mandatory guards:
1929
+ If you need any of those properties (transactional, durable, retried), don't hand-roll the kickoff in the handler — a subscriber's `ctx` carries no workflow handle, deliberately. "A row changed → start a workflow" has its own primitive: a [reaction](/docs/data/reactions). A `*.reaction.tsx` watches the same post-commit change stream and its `act` starts the workflow for you, behind mandatory guards:
1822
1930
 
1823
1931
  ```tsx
1824
1932
  // reactions/fulfillOrder.reaction.tsx
@@ -38,7 +38,11 @@ export default defineSeed({
38
38
 
39
39
  Seeds are meant to be safe to re-run. The `upsertByUnique(table, matchFields, fullRow)` helper does "row matching `matchFields` exists? update it : insert it", returning `{ row, created }`. Use stable string ids (not random tokens) so subsequent runs match the same rows instead of duplicating.
40
40
 
41
- The runner also fingerprints each seed by hashing its source. On a `boot`-lifecycle seed it only re-runs when the fingerprint changes — so an unchanged seed doesn't re-execute on every `voltro dev` restart. Override the fingerprint when the seed depends on external state (env vars) that should force a re-run:
41
+ The runner also fingerprints each seed by hashing its source. On a `boot`-lifecycle seed it only re-runs when the fingerprint changes — so an unchanged seed doesn't re-execute on every `voltro dev` restart. The record lives in `_voltro_seeds`, one row per seed.
42
+
43
+ Two things the skip deliberately does **not** do. A **failed** run is recorded as failed and never satisfies the skip, so a broken seed retries on the next boot instead of disabling itself permanently. And if the ledger cannot be read at all — unmigrated database, memory store, missing table — every boot seed **runs**: re-doing idempotent work costs time, whereas skipping data restoration on a database we could not inspect costs data.
44
+
45
+ Override the fingerprint when the seed depends on external state (env vars) that should force a re-run:
42
46
 
43
47
  ```ts
44
48
  fingerprint: ({ src }) => `${src}:${process.env.SEED_VERSION ?? ''}`,
@@ -50,9 +54,21 @@ fingerprint: ({ src }) => `${src}:${process.env.SEED_VERSION ?? ''}`,
50
54
  |---|---|---|
51
55
  | `boot` | On every `voltro dev` boot, **only if the fingerprint changed** | — |
52
56
  | `manual` | Only via `voltro db seed --id <name>` or the dashboard | — |
53
- | `onTenantCreate` | After a successful `tenant()` insert | — |
54
- | `onSchemaChange` | After a [migration](/docs/database/migrations) touches a watched table | `watchedTables` |
55
- | `cron` | On a cron cadence (via the [scheduling](/docs/scheduling/overview) runtime) | `cron` |
57
+ | `onTenantCreate` | **Not wired yet** declared and validated, never triggered | — |
58
+ | `onSchemaChange` | **Not wired yet** declared and validated, never triggered | `watchedTables` |
59
+ | `cron` | **Not wired yet** declared and validated, never triggered | `cron` |
60
+
61
+ Only `boot` and `manual` actually execute today. The other three lifecycles
62
+ validate at definition time (a `cron` seed without a `cron` field throws, an
63
+ `onSchemaChange` seed without `watchedTables` throws) and are registered so the
64
+ dashboard can list them — but nothing fires them. A seed declared with one of
65
+ them will never run, silently.
66
+
67
+ Until they are wired, express the same intent with a primitive that does run: a
68
+ `*.cron.tsx` schedule whose handler does the seeding, or an explicit `voltro db
69
+ seed --id <name>` from your migration/provisioning step. This table said
70
+ otherwise until 0.10.0, which is exactly the kind of promise that costs someone
71
+ a debugging afternoon — it is corrected here rather than quietly dropped.
56
72
 
57
73
  ```ts
58
74
  defineSeed({
@@ -469,22 +469,25 @@ The generated `.framework/app.tsx` records this chain explicitly per route — y
469
469
  <!-- source: en/routing/render-modes.md -->
470
470
  ## Render modes
471
471
 
472
- _static (SSG) vs ssr vs isr — when each runs, what it caches, and how to pick._
472
+ _static (SSG) vs spa vs ssr vs isr — when each runs, what it caches, and how to pick._
473
473
 
474
- Every page declares a `renderMode`. The mode controls **when** the HTML is produced — at build time, on every request, or build-once-revalidate-occasionally.
474
+ Every page declares a `renderMode`. The mode controls **when** the HTML is produced — at build time, on every request, build-once-revalidate-occasionally, or not on the server at all.
475
475
 
476
476
  ```tsx
477
- export const renderMode = 'static' as const // 'static' | 'ssr' | 'isr'
477
+ export const renderMode = 'static' as const // 'static' | 'spa' | 'ssr' | 'isr'
478
478
  ```
479
479
 
480
- ## The three modes
480
+ ## The four modes
481
481
 
482
482
  | Mode | When HTML is produced | Cached? | Best for |
483
483
  |---|---|---|---|
484
484
  | `static` *(default)* | `voltro build` time | Forever | Marketing pages, docs, anything that doesn't change per-request |
485
+ | `spa` | Never for the page itself (its layout chain may still be server-rendered) | — | Reactive dashboards whose state lives in the browser |
485
486
  | `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
486
487
  | `isr` | First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |
487
488
 
489
+ Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesnt-work).
490
+
488
491
  ## static (SSG)
489
492
 
490
493
  ```tsx
@@ -637,7 +640,22 @@ A `spa` page renders entirely in the browser — the page itself is never server
637
640
 
638
641
  Because a layout now runs on the server for spa routes too, a layout used **only** by spa pages must be SSR-safe — no unguarded `window` / `document` in its render or its `loader`. Layouts shared with any `static` / `ssr` / `isr` page already render server-side (and `static` is the default), so they are unaffected. A spa page with **no** layout is a pure client mount, unchanged.
639
642
 
640
- > **Deferred (follow-ups, not yet shipped):** `voltro build` does not prerender the spa layout shell to a file `voltro start` renders it on demand. `defer()` / streaming inside a spa-shell layout is unsupported (the shell is buffered). Fast Refresh of the shell path is not yet specialised.
643
+ **`voltro build` prerenders that shell to a file — but only when no layout in the page's chain exports a `loader`.** A layout loader may resolve per-visitor data (the signed-in user, a tenant), and freezing one render of it into a static file would serve the first visitor's data to everyone. So a chain with any layout loader is left to `voltro start`, which runs the loader per request; the build logs which route it skipped and why. A loader-less chain is request-independent by construction and is written to `dist/<route>/index.html`, so a static host paints the layout immediately instead of an empty `#root`.
644
+
645
+ **On a static host, that file is also the SPA fallback.** A static host answers every URL it has no file for with `index.html` — so once your ROOT route is prerendered, a deep link to `/reports` is served the root's document. The framework handles this: the inlined hydration payload records the pathname it was rendered for, and the client refuses to adopt markup that belongs to another route, falling back to a normal client render instead. Without that check React would hydrate the root's layout while rendering `/reports`, report a hydration mismatch, and silently re-render the whole tree.
646
+
647
+ Nothing to configure. Two things follow from it, though:
648
+
649
+ - Deep links into a static deployment are **client-rendered**, not hydrated. The visitor sees the app; they don't get the prerendered paint. If that matters for a route, give it `static` (or `isr`/`ssr` behind `voltro start`) so it has a file of its own.
650
+ - The pathname is compared *after* normalising a trailing slash, a trailing `/index.html`, and percent-encoding — the shapes a static host varies on. Your own routes are unaffected.
651
+
652
+ **A layout in that chain may use `defer()`.** The shell then STREAMS: the layout chain and the empty page slot flush immediately, and the deferred layout value arrives afterwards behind its `<Await>` boundary — the same mechanism an `ssr` page gets, applied to the shell. So a sidebar whose nav counts take 300ms no longer holds back the first paint of the rest of the shell. Nothing about the hydration seam changes: the first flush still carries the empty page slot, and the page still mounts into it after hydration.
653
+
654
+ What still cannot defer on this path:
655
+
656
+ - **A prerendered shell.** `voltro build` only prerenders a shell whose chain has no layout loader (see above), and `defer()` can only come from a loader — so the two never meet. If they did, the build would refuse by name rather than freeze the `<Await>` fallback into the file.
657
+ - **The page's own loader.** A `spa` page's loader runs in the *browser*, so there is no server render to stream into. Use `<Await>` on a client promise instead, or move the data into a layout loader.
658
+ - **`interactive: 'none'` / `'islands'`**, for the same reason as on an `ssr` page: revealing a streamed boundary needs React's inline reveal scripts, and neither mode ever hydrates the root.
641
659
 
642
660
  ## Picking between them
643
661
 
@@ -684,6 +702,8 @@ That last case is how dynamic `static` routes work in dev / when `getStaticPaths
684
702
 
685
703
  ## What doesn't work
686
704
 
705
+ - **Any value outside the four modes.** `renderMode` is a closed set — `'static' | 'spa' | 'ssr' | 'isr'`. Anything else (`'client'`, `'csr'`, a typo) fails the build and `voltro dev` at codegen, naming the page, the value and the valid set. There are no aliases: a page that renders only in the browser is `'spa'`.
706
+ - **Declaring `renderMode` on a `layout.tsx` / `error.tsx` / `loading.tsx`.** The mode is a property of the PAGE; the framework never reads one off a special file. Whether a layout renders on the server follows from the page's mode.
687
707
  - **Switching `renderMode` per request.** It's a static module export — one value per build.
688
708
  - **Assuming `ssr` is client-only in dev.** It is not: `voltro dev` runs the same SSR path `voltro start` does, streaming included, so cookie-driven gates and `useServerRequest()` behave the same in both. What dev does NOT do is pre-render `static` pages — those fall through to the SPA shell.
689
709
  - **`isr` with `cacheInvalidatesOn` against memory cache.** Memory cache is per-process; CDC events fire across processes. Use `SSR_CACHE=postgres`.
@@ -778,12 +798,19 @@ loaders in the browser as usual. A `spa` page has no server render, so its
778
798
  loader runs on the client on first mount.
779
799
 
780
800
  Layout loaders are inlined the same way, keyed per layout, so each layout reads
781
- its OWN data on the first render. The one exception is `voltro build`'s static
782
- prerender, which runs page loaders but not layout loaders a `static` page's
783
- layouts resolve their data on the client after mount. The PAGE keeps its own
784
- inlined data for the whole of that window, however slow those layout loaders
785
- are, so the no-guard promise above holds on a prerendered page too: only the
786
- layout shows its no-data fallback until its loader settles.
801
+ its OWN data on the first render. This includes `voltro build`'s static
802
+ prerender: it runs the page loader **and** every layout loader in the chain at
803
+ build time, so a CMS-backed nav or footer is baked into the prerendered file and
804
+ the layout loader does not re-run after hydration. Layout loaders see the same
805
+ build-time context the page loader does `params`, `pathname`, `signal` and
806
+ nothing request-shaped: there is no `headers` and no `query` at build time. A
807
+ layout loader that needs either belongs on an `ssr` page.
808
+
809
+ If a layout loader throws during the build, the page is still prerendered — with
810
+ no layout data, and a warning in the build log. The layout then resolves its
811
+ data on the client after mount, and the PAGE keeps its own inlined data for the
812
+ whole of that window, so the no-guard promise above still holds: only the layout
813
+ shows its no-data fallback until its loader settles.
787
814
 
788
815
 
789
816
  ## Deferring slow data: `defer()` + `<Await>`
@@ -850,8 +877,12 @@ otherwise guarantees by construction.
850
877
  A rejected deferred value never takes the page down: it renders
851
878
  `errorFallback` in place, on the server and on the client alike.
852
879
 
853
- ### `defer()` requires `renderMode: 'ssr'` and full interactivity
880
+ ### `defer()` requires a streamed response and full interactivity
854
881
 
882
+ That means `renderMode: 'ssr'`, or — for a **layout** loader — the SSR layout
883
+ shell of a `renderMode: 'spa'` page under it, which `voltro dev` and
884
+ `voltro serve` also stream (see
885
+ [render modes](/docs/routing/render-modes#spa-client-only-with-an-optional-ssr-layout-shell)).
855
886
  Every other combination is a **hard error at boot or build**, naming the page —
856
887
  because each one fails silently otherwise:
857
888
 
@@ -861,8 +892,9 @@ because each one fails silently otherwise:
861
892
  | `renderMode: 'isr'` | ISR caches a completed HTML string. Filling it in would make `defer()` a silent no-op that still reads like it streams. |
862
893
  | `interactive: 'none'` | Revealing a streamed boundary needs React's inline reveal scripts, and this mode ships no JS. The fallback would be permanent. |
863
894
  | `interactive: 'islands'` | The page's React root never hydrates, so nothing consumes the streamed value. |
895
+ | A **prerendered** spa layout shell | `voltro build` writes it to a file, which has no "after". Unreachable in practice — the build only prerenders a shell whose chain has no layout loader — but refused by name if it ever is reached. |
864
896
 
865
- In all four cases the fix is the same: put the value in the eager bucket (or
897
+ In all of these the fix is the same: put the value in the eager bucket (or
866
898
  return it directly) and let the page render as it did before.
867
899
 
868
900
  ### Layout loaders can defer too
@@ -871,6 +903,13 @@ A `layout.tsx` loader may return `defer()` under the same rules. Its deferred
871
903
  fields are keyed per layout, so a layout reads its own promises via
872
904
  `useLoaderData()` exactly as a page does.
873
905
 
906
+ This includes a layout that wraps a **client-only (`renderMode: 'spa'`) page**.
907
+ The server renders that route as a layout shell — the layout chain around an
908
+ empty page slot — and a deferring layout makes that shell stream: chain and slot
909
+ first, the deferred layout value afterwards. The page still mounts on the client
910
+ after hydration, unchanged. What a spa page's **own** loader cannot do is defer:
911
+ it runs in the browser, so there is no server render to stream into.
912
+
874
913
  ### Client-side navigation
875
914
 
876
915
  On a client-side navigation there is no server render, so the loader runs in the
@@ -224,6 +224,38 @@ Use `scheduledAt`, not `Date.now()`, for any time-bucketed query (the "prune ses
224
224
 
225
225
  `ctx.trigger === 'manual'` distinguishes a dashboard **Run now** click from a clock firing — handy when a manual run should skip a guard (e.g. "only on weekdays") that the scheduled path enforces.
226
226
 
227
+ ## A schedule runs as the SYSTEM subject — no tenant
228
+
229
+ `ctx.app.store` is **not tenant-scoped**. A schedule has no request, so it has
230
+ no signed-in user and no tenant to infer, and the framework refuses to pick one
231
+ for you. Reads see every tenant's rows.
232
+
233
+ That is the right default for what crons usually are — a backfill, a reconcile,
234
+ a GC sweep — but it means a **per-tenant** cron has to say which tenant it means:
235
+
236
+ ```tsx
237
+ handler: async (ctx) => {
238
+ const tenants = await ctx.app.store.select('tenants').all()
239
+ for (const t of tenants) {
240
+ const stale = await ctx.app.store.select('sessions')
241
+ .where('tenantId', t.id) // explicit, not inferred
242
+ .where('expiresAt', '<', ctx.scheduledAt)
243
+ .all()
244
+ // …
245
+ }
246
+ }
247
+ ```
248
+
249
+ Writes to a `tenant()` table need the same treatment: pass `tenantId`
250
+ explicitly, or the write fails with `TenantScopeViolation` rather than landing
251
+ somewhere arbitrary.
252
+
253
+ This behaves identically under `voltro dev` and `voltro serve`. It did not
254
+ always — before 0.10.0, dev scoped schedules to `$TENANT` (default `acme`)
255
+ while production ran them unscoped, so the same cron read one tenant in
256
+ development and all of them in production. If you added `.unscoped()` to a cron
257
+ to work around that, it is now a no-op and can go.
258
+
227
259
  ## Discovery
228
260
 
229
261
  `voltro dev` and `voltro build` glob `**/*.cron.{ts,tsx}` under your api app. Each discovered schedule is logged at boot:
@@ -11,16 +11,16 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/ai": "0.9.0",
15
- "@voltro/cli": "0.9.0",
16
- "@voltro/database": "0.9.0",
17
- "@voltro/env": "0.9.0",
18
- "@voltro/protocol": "0.9.0",
19
- "@voltro/runtime": "0.9.0",
14
+ "@voltro/ai": "0.10.0",
15
+ "@voltro/cli": "0.10.0",
16
+ "@voltro/database": "0.10.0",
17
+ "@voltro/env": "0.10.0",
18
+ "@voltro/protocol": "0.10.0",
19
+ "@voltro/runtime": "0.10.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.9.0",
23
+ "@voltro/testing": "0.10.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.9.0",
16
- "@voltro/database": "0.9.0",
17
- "@voltro/env": "0.9.0",
18
- "@voltro/plugin-auth": "0.9.0",
19
- "@voltro/protocol": "0.9.0",
20
- "@voltro/runtime": "0.9.0",
21
- "@voltro/sql-postgres": "0.9.0",
15
+ "@voltro/cli": "0.10.0",
16
+ "@voltro/database": "0.10.0",
17
+ "@voltro/env": "0.10.0",
18
+ "@voltro/plugin-auth": "0.10.0",
19
+ "@voltro/protocol": "0.10.0",
20
+ "@voltro/runtime": "0.10.0",
21
+ "@voltro/sql-postgres": "0.10.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.9.0",
25
+ "@voltro/testing": "0.10.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.9.0",
16
- "@voltro/database": "0.9.0",
17
- "@voltro/env": "0.9.0",
18
- "@voltro/plugin-multitenancy": "0.9.0",
19
- "@voltro/protocol": "0.9.0",
20
- "@voltro/runtime": "0.9.0",
15
+ "@voltro/cli": "0.10.0",
16
+ "@voltro/database": "0.10.0",
17
+ "@voltro/env": "0.10.0",
18
+ "@voltro/plugin-multitenancy": "0.10.0",
19
+ "@voltro/protocol": "0.10.0",
20
+ "@voltro/runtime": "0.10.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.9.0",
24
+ "@voltro/testing": "0.10.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.9.0",
16
- "@voltro/database": "0.9.0",
17
- "@voltro/env": "0.9.0",
18
- "@voltro/plugin-deactivation": "0.9.0",
19
- "@voltro/protocol": "0.9.0",
20
- "@voltro/runtime": "0.9.0",
15
+ "@voltro/cli": "0.10.0",
16
+ "@voltro/database": "0.10.0",
17
+ "@voltro/env": "0.10.0",
18
+ "@voltro/plugin-deactivation": "0.10.0",
19
+ "@voltro/protocol": "0.10.0",
20
+ "@voltro/runtime": "0.10.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.9.0",
24
+ "@voltro/testing": "0.10.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@react-email/components": "^1.0.12",
14
14
  "@react-email/render": "^1.4.0",
15
- "@voltro/cli": "0.9.0",
16
- "@voltro/database": "0.9.0",
17
- "@voltro/env": "0.9.0",
18
- "@voltro/plugin-mail": "0.9.0",
19
- "@voltro/plugin-multitenancy": "0.9.0",
20
- "@voltro/protocol": "0.9.0",
21
- "@voltro/runtime": "0.9.0",
15
+ "@voltro/cli": "0.10.0",
16
+ "@voltro/database": "0.10.0",
17
+ "@voltro/env": "0.10.0",
18
+ "@voltro/plugin-mail": "0.10.0",
19
+ "@voltro/plugin-multitenancy": "0.10.0",
20
+ "@voltro/protocol": "0.10.0",
21
+ "@voltro/runtime": "0.10.0",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.9.0",
26
+ "@voltro/testing": "0.10.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }