@voltro/cli 0.8.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 (60) hide show
  1. package/CHANGELOG.md +329 -7
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/apiBuild-BbR1nYcE.js +2 -0
  4. package/dist/{apiBuild-OrDkXGFX.js → apiBuild-DTLPSlt9.js} +8 -8
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DT4PipBz.js → commands-B0QT8dUY.js} +2080 -1638
  7. package/dist/dev-BJvbwjSz.js +2 -0
  8. package/dist/{dev-Ga65K4jc.js → dev-CK9fbuAM.js} +1433 -1367
  9. package/dist/index.js +1 -1
  10. package/dist/{inspectMetrics-EK0CcHyu.js → inspectMetrics-BeMjgt8x.js} +1239 -957
  11. package/dist/inspectState.d.ts +6 -2
  12. package/dist/{serveCommand-CR5xnYZt.js → serveCommand-YR3mKjvf.js} +367 -384
  13. package/dist/serveEntry.js +3 -3
  14. package/dist/{start-DuyBQxW3.js → start-C_M4fGS2.js} +391 -340
  15. package/dist/startEntry.js +2 -2
  16. package/package.json +18 -17
  17. package/templates/agent-docs/cli.md +94 -18
  18. package/templates/agent-docs/data.md +109 -1
  19. package/templates/agent-docs/database/seedsdialects.md +20 -4
  20. package/templates/agent-docs/routing.md +77 -14
  21. package/templates/agent-docs/scheduling.md +32 -0
  22. package/templates/apps/api-ai/package.json +7 -7
  23. package/templates/apps/api-auth/package.json +8 -8
  24. package/templates/apps/api-backend/package.json +7 -7
  25. package/templates/apps/api-backend-deactivation/package.json +7 -7
  26. package/templates/apps/api-backend-mail/package.json +8 -8
  27. package/templates/apps/api-backend-mariadb/package.json +9 -9
  28. package/templates/apps/api-backend-storage/package.json +8 -8
  29. package/templates/apps/api-data-advanced/package.json +8 -8
  30. package/templates/apps/api-durable/package.json +8 -8
  31. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +5 -0
  32. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +8 -2
  33. package/templates/apps/api-feature-flags/package.json +9 -9
  34. package/templates/apps/api-governance/package.json +8 -8
  35. package/templates/apps/api-kv/package.json +8 -8
  36. package/templates/apps/api-moderation/package.json +8 -8
  37. package/templates/apps/api-observability/package.json +8 -8
  38. package/templates/apps/api-ratelimit/package.json +8 -8
  39. package/templates/apps/api-rbac/package.json +8 -8
  40. package/templates/apps/api-rest/package.json +7 -7
  41. package/templates/apps/api-saas/package.json +11 -11
  42. package/templates/apps/api-search/package.json +8 -8
  43. package/templates/apps/api-versioning/package.json +8 -8
  44. package/templates/apps/api-webhooks/package.json +8 -8
  45. package/templates/apps/changelog/package.json +6 -6
  46. package/templates/apps/edge-functions/package.json +2 -2
  47. package/templates/apps/frontend-admin/package.json +8 -8
  48. package/templates/apps/frontend-app/package.json +8 -8
  49. package/templates/apps/frontend-blank/package.json +7 -7
  50. package/templates/apps/frontend-contact/package.json +7 -7
  51. package/templates/apps/frontend-dashboard/package.json +7 -7
  52. package/templates/apps/frontend-docs/package.json +7 -7
  53. package/templates/apps/frontend-i18n/package.json +6 -6
  54. package/templates/apps/frontend-landing/package.json +7 -7
  55. package/templates/apps/frontend-spa/package.json +7 -7
  56. package/templates/apps/frontend-ssr/package.json +7 -7
  57. package/templates/apps/frontend-ssr-api/package.json +8 -8
  58. package/templates/apps/frontend-static-blog/package.json +6 -6
  59. package/dist/apiBuild-CmtI6Ygh.js +0 -2
  60. package/dist/dev-Ca1jdwnt.js +0 -2
@@ -1,3 +1,3 @@
1
- import { P as e } from "./inspectMetrics-EK0CcHyu.js";
2
- import { t } from "./start-DuyBQxW3.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.8.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,24 +57,25 @@
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.8.0",
61
- "@voltro/cache": "0.8.0",
62
- "@voltro/data-transfer": "0.8.0",
63
- "@voltro/database": "0.8.0",
64
- "@voltro/env": "0.8.0",
65
- "@voltro/kv": "0.8.0",
66
- "@voltro/logger": "0.8.0",
67
- "@voltro/plugin-auth": "0.8.0",
68
- "@voltro/plugin-broadcast": "0.8.0",
69
- "@voltro/plugin-mail": "0.8.0",
70
- "@voltro/plugin-storage": "0.8.0",
71
- "@voltro/plugin-webhooks": "0.8.0",
72
- "@voltro/protocol": "0.8.0",
73
- "@voltro/runtime": "0.8.0",
74
- "@voltro/serverless": "0.8.0",
75
- "@voltro/workflow": "0.8.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
+ "tinyglobby": "^0.2.17",
78
79
  "ts-morph": "^28.0.0"
79
80
  },
80
81
  "optionalDependencies": {
@@ -427,37 +427,63 @@ 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.
434
436
 
437
+ `renderMode: 'ssr'` pages compile on demand the first time each route is hit. To
438
+ keep a burst of cold pages — several browser tabs, or a health-check sweep across
439
+ many routes — from spiking memory, `voltro dev` compiles at most **4** of them at
440
+ once and collapses duplicate concurrent requests for the same route into a single
441
+ compile. Already-compiled (warm) pages are never throttled, so a hot app stays
442
+ fully concurrent. Tune the cap with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` (below) —
443
+ drop it on a low-memory box, raise it on a big machine.
444
+
435
445
 
436
446
  ### Fast Refresh: what hot-updates and what reloads
437
447
 
438
448
  Editing a **page or layout component** applies as a hot update — the React tree
439
449
  stays mounted, so form input, scroll position, open dialogs and every `useState`
440
- survive. Editing a **`loader`** (or `meta` / `renderMode` / any other
441
- non-component export of a route module) triggers a full page reload instead.
442
-
443
- That split is deliberate, not a gap. A `loader` also runs on the server: the page
444
- in front of you was rendered from the OLD one, and the router caches loader
445
- results per route + params, so hot-swapping the function would leave stale data
446
- 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.
447
473
 
448
474
  The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
449
475
  whose exports are all components, and a page exporting `loader` beside its
450
476
  component fails that test. `voltro dev` registers each route module's
451
- non-component exports with the React plugin's ignore hook, and decides the
452
- reload itself by comparing those exports' VALUES across the update a function
453
- by its source text, anything else by its JSON form so a JSX-only edit (which
454
- 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".
455
482
 
456
- One consequence worth knowing: a page that exports a non-component value which
457
- changes on the same save as its JSX a `const COLUMNS = [...]` you edit
458
- together with the table that renders it reloads rather than hot-updates. Move
459
- that value into a sibling module (`./columns.ts`) and the page hot-updates
460
- 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.
461
487
 
462
488
  ### The in-page devtools overlay
463
489
 
@@ -515,6 +541,7 @@ A `<DevtoolsStringsProvider strings={…}>` mounted above the overlay works too;
515
541
  | `PORT=4001` | Override the listen port (api or web). Both kinds read `PORT`, then `app.config.ts` `port:`, then the default. |
516
542
  | `VOLTRO_DASHBOARD_PORT=5180` | Override the auto-launched dashboard port (default `5179`). |
517
543
  | `VOLTRO_LOG_LEVEL=debug` | Verbose framework logs. |
544
+ | `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY=4` | Max `ssr` pages compiled on demand at once (default `4`). Lower it (`1`/`2`) on a low-memory box if a burst of first-time `ssr` page loads spikes memory; raise it on a big machine. Warm (already-compiled) pages are never throttled. |
518
545
 
519
546
  ## Multi-app dev (turbo)
520
547
 
@@ -810,6 +837,16 @@ It covers both halves of the stack:
810
837
  | client | `useMemo` fanning in several subscriptions | `useDerived` |
811
838
  | client | a local Next.js compat shim | the native `@voltro/web` exports |
812
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.
813
850
 
814
851
  The rules are deliberately conservative — a detector that cries wolf trains you
815
852
  to ignore it. A column that already carries `.encrypted()`, or a handler that
@@ -873,13 +910,52 @@ voltro doctor . --json # the complete scan: every file path, machine-re
873
910
  "use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
874
911
  "files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
875
912
  }
876
- ]
913
+ ],
914
+ "spaCandidates": []
877
915
  }
878
916
  ```
879
917
 
880
918
  That is the form to hand an agent, or to pipe into a script that works the list
881
919
  file by file.
882
920
 
921
+ ### `renderMode:'spa'` candidates
922
+
923
+ `voltro doctor` also flags web pages that could adopt `renderMode: 'spa'` without
924
+ losing their server-rendered shell. A page under a layout renders that LAYOUT
925
+ chain on the server — nav, sidebar, auth gate, via the layout's own loader — even
926
+ when the page itself is `'spa'`. So a page whose BODY needs no SSR can skip its
927
+ per-page SSR compile while the shell still server-renders. Like the hand-roll
928
+ detector, this is **advisory and never blocking**.
929
+
930
+ A page is listed when ALL of these hold:
931
+
932
+ - it is a **page file** — not `layout.tsx` / `loading.tsx` / `error.tsx` /
933
+ `not-found.tsx`;
934
+ - it exports **no `loader`** (so `'spa'` loses nothing the page contributed
935
+ server-side);
936
+ - its `renderMode` is **`'ssr'` or unset/default** — not a page that already
937
+ opted into a non-SSR render (`'spa'` / `'static'` / `'isr'`, or any other
938
+ explicit mode);
939
+ - a **`layout.tsx` sits somewhere in its directory chain** — root, an ancestor, or
940
+ the page's own dir. This is the load-bearing condition: only then does a layout
941
+ still SSR the shell. A page with no layout would, as `'spa'`, ship no server
942
+ HTML at all — so it is never flagged.
943
+
944
+ ```text
945
+ • renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
946
+ src/pages/dashboard/index.tsx (/dashboard) — default renderMode
947
+ src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
948
+ → renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.
949
+ ```
950
+
951
+ Adopt `'spa'` for internal or authenticated pages whose content needs no SEO or
952
+ server first-paint; keep `'ssr'` (or the `'static'` default) when it does. There
953
+ is deliberately **no codemod** to flip pages automatically — dropping a page
954
+ body's server render is a per-page product decision, not a mechanically-safe
955
+ transform. Every candidate (with its `file`, `pattern`, and `currentMode`) is
956
+ also in `voltro doctor --json` under a `spaCandidates` array; the human view
957
+ above caps at ten pages and points to `--json` for the rest.
958
+
883
959
  ## `voltro capabilities` — what the framework actually exports
884
960
 
885
961
  Asked "what does this framework export", a language model will produce a
@@ -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
@@ -625,6 +628,35 @@ The framework reads Postgres logical replication; writes to `posts` or `comments
625
628
 
626
629
  Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.
627
630
 
631
+ ## spa (client-only, with an optional SSR layout shell)
632
+
633
+ ```tsx
634
+ export const renderMode = 'spa' as const
635
+ ```
636
+
637
+ A `spa` page renders entirely in the browser — the page itself is never server-rendered. Reach for it when a page genuinely needs a fresh client render every load (most reactive dashboards) and doesn't need its own first-paint HTML or SEO.
638
+
639
+ **If the page's route has a layout, that layout is still server-rendered.** The server renders the layout chain — running its layout loaders — around an empty page slot (`<div data-voltro-page-slot>`), inlines the layout data, and marks the page client-only. The browser hydrates that shell and mounts the page into the slot after hydration. So the shell (nav, sidebar, auth gate) gets an instant first paint while the page stays client-only. The page's own `loader` still runs in the browser.
640
+
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.
642
+
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.
659
+
628
660
  ## Picking between them
629
661
 
630
662
  | You have… | Use |
@@ -663,12 +695,15 @@ A request to `/foo`:
663
695
  1. **Pre-rendered HTML exists at `dist/foo/index.html`?** Serve it. (static + isr-already-cached.)
664
696
  2. **No pre-render, page is `ssr`?** Render fresh, serve.
665
697
  3. **No pre-render, page is `isr`?** Cache lookup → MISS → render → store → serve.
666
- 4. **No pre-render, page is `static`?** Serve the SPA shell the client router takes over.
698
+ 4. **No pre-render, page is `spa` with a layout?** Render the SSR layout shell (layouts + an empty page slot) on demand; the client mounts the page into the slot.
699
+ 5. **No pre-render, page is `spa` with no layout, or `static`?** Serve the SPA shell — the client router takes over.
667
700
 
668
701
  That last case is how dynamic `static` routes work in dev / when `getStaticPaths` didn't include the URL.
669
702
 
670
703
  ## What doesn't work
671
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.
672
707
  - **Switching `renderMode` per request.** It's a static module export — one value per build.
673
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.
674
709
  - **`isr` with `cacheInvalidatesOn` against memory cache.** Memory cache is per-process; CDC events fire across processes. Use `SSR_CACHE=postgres`.
@@ -763,12 +798,19 @@ loaders in the browser as usual. A `spa` page has no server render, so its
763
798
  loader runs on the client on first mount.
764
799
 
765
800
  Layout loaders are inlined the same way, keyed per layout, so each layout reads
766
- its OWN data on the first render. The one exception is `voltro build`'s static
767
- prerender, which runs page loaders but not layout loaders a `static` page's
768
- layouts resolve their data on the client after mount. The PAGE keeps its own
769
- inlined data for the whole of that window, however slow those layout loaders
770
- are, so the no-guard promise above holds on a prerendered page too: only the
771
- 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.
772
814
 
773
815
 
774
816
  ## Deferring slow data: `defer()` + `<Await>`
@@ -789,7 +831,7 @@ export const loader = async ({ query }: { query?: <T>(tag: string, input?: Recor
789
831
  // EAGER — awaited before the shell renders. Keep this fast.
790
832
  { user: await query?.<User>('users.me') },
791
833
  // DEFERRED — NOT awaited. Each becomes a promise on useLoaderData().
792
- { report: query!<Report>('reports.quarterly') },
834
+ { report: query!<QuarterlyReport>('reports.quarterly') },
793
835
  )
794
836
 
795
837
  export default function Dashboard() {
@@ -835,8 +877,12 @@ otherwise guarantees by construction.
835
877
  A rejected deferred value never takes the page down: it renders
836
878
  `errorFallback` in place, on the server and on the client alike.
837
879
 
838
- ### `defer()` requires `renderMode: 'ssr'` and full interactivity
880
+ ### `defer()` requires a streamed response and full interactivity
839
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)).
840
886
  Every other combination is a **hard error at boot or build**, naming the page —
841
887
  because each one fails silently otherwise:
842
888
 
@@ -846,8 +892,9 @@ because each one fails silently otherwise:
846
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. |
847
893
  | `interactive: 'none'` | Revealing a streamed boundary needs React's inline reveal scripts, and this mode ships no JS. The fallback would be permanent. |
848
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. |
849
896
 
850
- 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
851
898
  return it directly) and let the page render as it did before.
852
899
 
853
900
  ### Layout loaders can defer too
@@ -856,6 +903,13 @@ A `layout.tsx` loader may return `defer()` under the same rules. Its deferred
856
903
  fields are keyed per layout, so a layout reads its own promises via
857
904
  `useLoaderData()` exactly as a page does.
858
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
+
859
913
  ### Client-side navigation
860
914
 
861
915
  On a client-side navigation there is no server render, so the loader runs in the
@@ -931,6 +985,15 @@ Two rules that fall out of this:
931
985
 
932
986
  Under the hood, `ctx.query` is a one-shot `POST /rpc` call (see [Wire protocol](/docs/data/wire-protocol#http-one-shot-rpc-post-rpc)).
933
987
 
988
+ ### Authentication in loaders
989
+
990
+ Auth is resolved by the **api**, never the web app. Two rules follow:
991
+
992
+ - **Strategies live on the `type:'api'` app.** A `type:'web'` app has no auth middleware, so `auth.strategies` in a web `app.config.ts` does nothing. Configure your IdP (`supabaseStrategy`, `workosStrategy`, the built-in password strategy, …) on the api's `app.config.ts`.
993
+ - **`ctx.query` forwards the request's cookie automatically.** A server-side loader's `ctx.query` sends the browser's `Cookie` header on the one-shot `POST /rpc`, so the api resolves the SAME Subject + tenant it would over the WebSocket. You never thread a token through by hand — a logged-in user's cookie-mode session (e.g. `@supabase/ssr`'s `sb-<ref>-auth-token`) is verified by the api's strategies, and tenant-scoped queries return that user's rows during SSR.
994
+
995
+ So a cookie-mode Supabase app configures `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` on the **api**; the web loader's `ctx.query` then authenticates for free. See [Supabase Auth](/docs/plugins/auth-supabase).
996
+
934
997
  ## Errors from loaders
935
998
 
936
999
  If the loader throws, the framework:
@@ -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.8.0",
15
- "@voltro/cli": "0.8.0",
16
- "@voltro/database": "0.8.0",
17
- "@voltro/env": "0.8.0",
18
- "@voltro/protocol": "0.8.0",
19
- "@voltro/runtime": "0.8.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.8.0",
23
+ "@voltro/testing": "0.10.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }