@voltro/cli 0.9.0 → 0.11.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 +394 -7
- package/dist/apiBuild-BCv3bekE.js +2 -0
- package/dist/{apiBuild-eInHm_bJ.js → apiBuild-BUbgGz73.js} +8 -8
- package/dist/bin.js +2 -2
- package/dist/{commands-Cxs97AFp.js → commands-C1qBfIBI.js} +1977 -1599
- package/dist/dev-DKASElZP.js +2 -0
- package/dist/{dev-63Ehpw2p.js → dev-ITlO-epO.js} +1600 -1512
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-DVCKKSPk.js → inspectMetrics-BeMjgt8x.js} +1018 -827
- package/dist/inspectState.d.ts +6 -2
- package/dist/{serveCommand-Cq7BhtpB.js → serveCommand-mpsE3vLY.js} +407 -420
- package/dist/serveEntry.js +3 -3
- package/dist/{start-CYe-7ueh.js → start-C_M4fGS2.js} +331 -309
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/agent-docs/cli.md +45 -17
- package/templates/agent-docs/data.md +145 -1
- package/templates/agent-docs/database/seedsdialects.md +40 -4
- package/templates/agent-docs/routing.md +52 -13
- package/templates/agent-docs/scheduling.md +32 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +5 -0
- package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +8 -2
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-Ba4njm9m.js +0 -2
- package/dist/dev-CXsR8Zgo.js +0 -2
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t } from "./start-
|
|
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.
|
|
3
|
+
"version": "0.11.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.
|
|
61
|
-
"@voltro/cache": "0.
|
|
62
|
-
"@voltro/data-transfer": "0.
|
|
63
|
-
"@voltro/database": "0.
|
|
64
|
-
"@voltro/env": "0.
|
|
65
|
-
"@voltro/kv": "0.
|
|
66
|
-
"@voltro/logger": "0.
|
|
67
|
-
"@voltro/plugin-auth": "0.
|
|
68
|
-
"@voltro/plugin-broadcast": "0.
|
|
69
|
-
"@voltro/plugin-mail": "0.
|
|
70
|
-
"@voltro/plugin-storage": "0.
|
|
71
|
-
"@voltro/plugin-webhooks": "0.
|
|
72
|
-
"@voltro/protocol": "0.
|
|
73
|
-
"@voltro/runtime": "0.
|
|
74
|
-
"@voltro/serverless": "0.
|
|
75
|
-
"@voltro/workflow": "0.
|
|
60
|
+
"@voltro/ai": "0.11.0",
|
|
61
|
+
"@voltro/cache": "0.11.0",
|
|
62
|
+
"@voltro/data-transfer": "0.11.0",
|
|
63
|
+
"@voltro/database": "0.11.0",
|
|
64
|
+
"@voltro/env": "0.11.0",
|
|
65
|
+
"@voltro/kv": "0.11.0",
|
|
66
|
+
"@voltro/logger": "0.11.0",
|
|
67
|
+
"@voltro/plugin-auth": "0.11.0",
|
|
68
|
+
"@voltro/plugin-broadcast": "0.11.0",
|
|
69
|
+
"@voltro/plugin-mail": "0.11.0",
|
|
70
|
+
"@voltro/plugin-storage": "0.11.0",
|
|
71
|
+
"@voltro/plugin-webhooks": "0.11.0",
|
|
72
|
+
"@voltro/protocol": "0.11.0",
|
|
73
|
+
"@voltro/runtime": "0.11.0",
|
|
74
|
+
"@voltro/serverless": "0.11.0",
|
|
75
|
+
"@voltro/workflow": "0.11.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
|
|
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.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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,87 @@ 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
|
+
**Undos are independent, so keep them idempotent — or say they overlap.** Every
|
|
1039
|
+
succeeded step's undo runs, and the runner cannot tell whether two of them
|
|
1040
|
+
reverse the same thing. If `deleteJiraDraftTicket` deletes the issue *and*
|
|
1041
|
+
discards the draft, the earlier `discardDraft` undo runs on something already
|
|
1042
|
+
gone. Harmless when discarding is idempotent; a real defect for a refund or a
|
|
1043
|
+
cancellation email. Declare the overlap instead of relying on luck:
|
|
1044
|
+
|
|
1045
|
+
```tsx
|
|
1046
|
+
sequence()
|
|
1047
|
+
.step('draft', () => createDraft.run(input), { undo: (d) => discardDraft.run({ id: d.id }) })
|
|
1048
|
+
.step('jira', (c) => createTicket.run({ draftId: c.draft.id }), {
|
|
1049
|
+
undo: (t) => deleteJiraDraftTicket.run({ key: t.key }),
|
|
1050
|
+
covers: ['draft'],
|
|
1051
|
+
})
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
If the covering undo *fails*, the covered steps are neither run nor claimed —
|
|
1055
|
+
whether the cascade got that far is unknown, and both guesses are wrong. They
|
|
1056
|
+
come back in `compensationUncertain` so you can reconcile.
|
|
1057
|
+
|
|
1058
|
+
**One optional step is fine; a loop or a real branch is not.** `when` skips a
|
|
1059
|
+
step, and a skipped step contributes `undefined` to the context (the type says
|
|
1060
|
+
so) and gets no undo:
|
|
1061
|
+
|
|
1062
|
+
```tsx
|
|
1063
|
+
sequence()
|
|
1064
|
+
.step('save', () => save.run(input))
|
|
1065
|
+
.step('summary', (c) => scheduleSummary.run({ id: c.save.id }), {
|
|
1066
|
+
when: (c) => c.save.changed,
|
|
1067
|
+
})
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
That is deliberately narrow: it catches "linear except for one `if`", which is a
|
|
1071
|
+
different shape from a loop. Loops and full branches still keep their
|
|
1072
|
+
`try/catch`.
|
|
1073
|
+
|
|
1074
|
+
**When the rollback has to survive a closed tab, this is the wrong tool.** Put
|
|
1075
|
+
the sequence in a [workflow](/docs/workflows/overview): the engine owns retries
|
|
1076
|
+
and compensation there, and a crash resumes instead of leaking.
|
|
1077
|
+
|
|
997
1078
|
## Action vs Mutation vs Stream
|
|
998
1079
|
|
|
999
1080
|
| Need | Use |
|
|
@@ -1118,6 +1199,40 @@ const { data } = useSubscription(
|
|
|
1118
1199
|
|
|
1119
1200
|
While skipped, no WebSocket subscription opens and `data` stays `undefined`.
|
|
1120
1201
|
|
|
1202
|
+
### Skipped is `idle`, not `loading`
|
|
1203
|
+
|
|
1204
|
+
A skipped subscription reports **`idle: true`, `loading: false`**. The two are
|
|
1205
|
+
different questions — "waiting for the first snapshot" and "not asking at all" —
|
|
1206
|
+
and conflating them breaks the pattern this hook otherwise blesses:
|
|
1207
|
+
|
|
1208
|
+
```tsx
|
|
1209
|
+
// WRONG on a skipping call site — renders a skeleton for a query you switched off
|
|
1210
|
+
if (loading) return <Skeleton/>
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
Passing a dynamic `skip` therefore changes the return type: you get a third
|
|
1214
|
+
state, and TypeScript will not let you ignore it.
|
|
1215
|
+
|
|
1216
|
+
```tsx
|
|
1217
|
+
const s = useSubscription<Team[]>('app', 'teams.list', {}, { skip: !open })
|
|
1218
|
+
|
|
1219
|
+
if (s.idle) return null // deliberately not asking
|
|
1220
|
+
if (s.loading) return <Skeleton/> // asking, no answer yet
|
|
1221
|
+
return <TeamsTable teams={s.data}/> // `data` is Team[] — narrowed
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
Call sites that never skip are untouched — `if (loading)` still proves `data` is
|
|
1225
|
+
present there, and a literal `{ skip: false }` counts as never skipping. The
|
|
1226
|
+
third state exists only where "not asking" is a real outcome.
|
|
1227
|
+
|
|
1228
|
+
With a `fallback` there is nothing to narrow either way: `data` is always
|
|
1229
|
+
present, and `idle` tells you whether what is on screen is the fallback because
|
|
1230
|
+
you chose not to ask.
|
|
1231
|
+
|
|
1232
|
+
A subscription that was live and is then skipped goes idle — it does **not**
|
|
1233
|
+
keep serving the snapshot it still holds. Otherwise `skip: !open` would show
|
|
1234
|
+
last time's data the moment a dialog reopens.
|
|
1235
|
+
|
|
1121
1236
|
## Streams Are Different
|
|
1122
1237
|
|
|
1123
1238
|
For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
|
|
@@ -1794,6 +1909,35 @@ export default defineSubscriber({
|
|
|
1794
1909
|
|
|
1795
1910
|
The default export must be a `defineSubscriber({...})` result. The file is identified by suffix (`*.subscribe.ts` / `*.subscribe.tsx`).
|
|
1796
1911
|
|
|
1912
|
+
## `ctx.store` — reading and writing back
|
|
1913
|
+
|
|
1914
|
+
`ctx` carries `log`, `id`, and **`store`** — the same store surface a handler
|
|
1915
|
+
uses, so a subscriber can act on the change it just saw:
|
|
1916
|
+
|
|
1917
|
+
```ts
|
|
1918
|
+
export default defineSubscriber({
|
|
1919
|
+
table: 'orders',
|
|
1920
|
+
on: 'insert',
|
|
1921
|
+
handler: async (event, ctx) => {
|
|
1922
|
+
await ctx.store.insert('order_audit', {
|
|
1923
|
+
orderId: String(event.new?.['id']),
|
|
1924
|
+
tenantId: String(event.new?.['tenantId']), // explicit — see below
|
|
1925
|
+
})
|
|
1926
|
+
},
|
|
1927
|
+
})
|
|
1928
|
+
```
|
|
1929
|
+
|
|
1930
|
+
**`ctx.store` is NOT tenant-scoped.** A subscriber fires from the change stream,
|
|
1931
|
+
not from a request, so there is no subject to scope to and no tenant to infer.
|
|
1932
|
+
Reads see every tenant's rows; a write to a `tenant()` table without an explicit
|
|
1933
|
+
`tenantId` fails with `TenantScopeViolation` rather than landing in an arbitrary
|
|
1934
|
+
tenant. When your reaction is per-tenant, take the tenant from the row that
|
|
1935
|
+
changed, as above. This is the same posture a schedule's `ctx.app.store` has —
|
|
1936
|
+
both are post-request system work.
|
|
1937
|
+
|
|
1938
|
+
Writes are mixin-stamped exactly as a request-path write is: id scheme,
|
|
1939
|
+
timestamps, audit columns. Only the subject differs.
|
|
1940
|
+
|
|
1797
1941
|
## What fires when
|
|
1798
1942
|
|
|
1799
1943
|
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 +1962,7 @@ Subscribers are **non-durable** by design:
|
|
|
1818
1962
|
- **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
1963
|
- **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
1964
|
|
|
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
|
|
1965
|
+
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
1966
|
|
|
1823
1967
|
```tsx
|
|
1824
1968
|
// reactions/fulfillOrder.reaction.tsx
|
|
@@ -38,21 +38,57 @@ 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.
|
|
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. If the runner cannot write it, it says so at **warn** level and names the seed — a ledger that silently fails to record looks exactly like a working one whose seeds all changed, so it is not something to find out from a debug stream.
|
|
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 ?? ''}`,
|
|
45
49
|
```
|
|
46
50
|
|
|
51
|
+
### What a step's `ctx.store` can do
|
|
52
|
+
|
|
53
|
+
`query` (full descriptor — `order` / `take` / `skip` / `projection`), `insert`,
|
|
54
|
+
**`insertIgnore`**, `update`, `delete`, plus the `upsertByUnique` helper.
|
|
55
|
+
|
|
56
|
+
Reach for `insertIgnore` when restoring a snapshot: it is one statement per row
|
|
57
|
+
and leaves an existing row alone. `upsertByUnique` costs a read per row and
|
|
58
|
+
*overwrites* what it finds, which is wrong whenever the live row is newer than
|
|
59
|
+
the snapshot.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
await ctx.store.insertIgnore('ai_models', row, { conflictColumns: ['id'] })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Reads are unscoped and include soft-deleted rows** — by construction, not by
|
|
66
|
+
flag. A seed runs at boot with no request, so there is no subject to scope to
|
|
67
|
+
and nothing applies the `deletedAt IS NULL` predicate. That is why there is no
|
|
68
|
+
`.unscoped()` / `.withDeleted()` to reach for: a seed already sees the whole
|
|
69
|
+
table. If you want one tenant's rows, say so in your own predicate.
|
|
70
|
+
|
|
47
71
|
## Lifecycles
|
|
48
72
|
|
|
49
73
|
| `lifecycle` | Runs… | Requires |
|
|
50
74
|
|---|---|---|
|
|
51
75
|
| `boot` | On every `voltro dev` boot, **only if the fingerprint changed** | — |
|
|
52
76
|
| `manual` | Only via `voltro db seed --id <name>` or the dashboard | — |
|
|
53
|
-
| `onTenantCreate` |
|
|
54
|
-
| `onSchemaChange` |
|
|
55
|
-
| `cron` |
|
|
77
|
+
| `onTenantCreate` | **Not wired yet** — declared and validated, never triggered | — |
|
|
78
|
+
| `onSchemaChange` | **Not wired yet** — declared and validated, never triggered | `watchedTables` |
|
|
79
|
+
| `cron` | **Not wired yet** — declared and validated, never triggered | `cron` |
|
|
80
|
+
|
|
81
|
+
Only `boot` and `manual` actually execute today. The other three lifecycles
|
|
82
|
+
validate at definition time (a `cron` seed without a `cron` field throws, an
|
|
83
|
+
`onSchemaChange` seed without `watchedTables` throws) and are registered so the
|
|
84
|
+
dashboard can list them — but nothing fires them. A seed declared with one of
|
|
85
|
+
them will never run, silently.
|
|
86
|
+
|
|
87
|
+
Until they are wired, express the same intent with a primitive that does run: a
|
|
88
|
+
`*.cron.tsx` schedule whose handler does the seeding, or an explicit `voltro db
|
|
89
|
+
seed --id <name>` from your migration/provisioning step. This table said
|
|
90
|
+
otherwise until 0.10.0, which is exactly the kind of promise that costs someone
|
|
91
|
+
a debugging afternoon — it is corrected here rather than quietly dropped.
|
|
56
92
|
|
|
57
93
|
```ts
|
|
58
94
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
782
|
-
prerender
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
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
|
|
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.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
14
|
+
"@voltro/ai": "0.11.0",
|
|
15
|
+
"@voltro/cli": "0.11.0",
|
|
16
|
+
"@voltro/database": "0.11.0",
|
|
17
|
+
"@voltro/env": "0.11.0",
|
|
18
|
+
"@voltro/protocol": "0.11.0",
|
|
19
|
+
"@voltro/runtime": "0.11.0",
|
|
20
20
|
"effect": "^3.21.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@voltro/testing": "0.
|
|
23
|
+
"@voltro/testing": "0.11.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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-auth": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.11.0",
|
|
16
|
+
"@voltro/database": "0.11.0",
|
|
17
|
+
"@voltro/env": "0.11.0",
|
|
18
|
+
"@voltro/plugin-auth": "0.11.0",
|
|
19
|
+
"@voltro/protocol": "0.11.0",
|
|
20
|
+
"@voltro/runtime": "0.11.0",
|
|
21
|
+
"@voltro/sql-postgres": "0.11.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.11.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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.11.0",
|
|
16
|
+
"@voltro/database": "0.11.0",
|
|
17
|
+
"@voltro/env": "0.11.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.11.0",
|
|
19
|
+
"@voltro/protocol": "0.11.0",
|
|
20
|
+
"@voltro/runtime": "0.11.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.11.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|