@voltro/cli 0.7.0 → 0.9.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 +40 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/apiBuild-Ba4njm9m.js +2 -0
- package/dist/{apiBuild-deIjJKJm.js → apiBuild-eInHm_bJ.js} +2 -2
- package/dist/bin.js +2 -2
- package/dist/{commands-BHdc71DG.js → commands-Cxs97AFp.js} +1897 -1664
- package/dist/{dev-Bqyq62Ak.js → dev-63Ehpw2p.js} +475 -475
- package/dist/dev-CXsR8Zgo.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-BLUBAWLb.js → inspectMetrics-DVCKKSPk.js} +819 -510
- package/dist/{serveCommand-Dn6--0Bv.js → serveCommand-Cq7BhtpB.js} +3 -3
- package/dist/serveEntry.js +3 -3
- package/dist/{start-CWZW_4Et.js → start-CYe-7ueh.js} +371 -306
- package/dist/startEntry.js +2 -2
- package/package.json +18 -17
- package/templates/AGENTS.core.md +15 -4
- package/templates/AGENTS.md +15 -4
- package/templates/agent-docs/authentication.md +7 -4
- package/templates/agent-docs/cli.md +84 -5
- package/templates/agent-docs/data.md +94 -70
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/reference.md +20 -0
- package/templates/agent-docs/routing.md +183 -25
- package/templates/agent-docs/testing.md +24 -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-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-CSM74oEZ.js +0 -2
- package/dist/dev-BYfrYwrT.js +0 -2
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { I as e } from "./inspectMetrics-DVCKKSPk.js";
|
|
2
|
+
import { t } from "./start-CYe-7ueh.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.9.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.
|
|
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.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",
|
|
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": {
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -313,10 +313,21 @@ helper from a `lib/` file that ALSO imports the `database` handle drags the whol
|
|
|
313
313
|
server graph (and any `node:crypto` behind it) into the browser bundle. Keep
|
|
314
314
|
browser-safe symbols (typed errors, Schemas, pure helpers) in files with ZERO
|
|
315
315
|
server imports; put DB-backed guards/helpers in `.server.ts` or a server-only
|
|
316
|
-
module.
|
|
317
|
-
`@voltro/
|
|
318
|
-
`@voltro/ai/events`) — import typed errors /
|
|
319
|
-
never from the package root.
|
|
316
|
+
module. Several packages ship a browser-safe subpath for exactly this (e.g.
|
|
317
|
+
`@voltro/database/wire`, `@voltro/plugin-multitenancy/guard`,
|
|
318
|
+
`@voltro/plugin-webhooks/errors`, `@voltro/ai/events`) — import typed errors /
|
|
319
|
+
Schemas from there in descriptors, never from the package root.
|
|
320
|
+
|
|
321
|
+
A consequence worth stating, because it is not obvious: a descriptor's `output`
|
|
322
|
+
can never be **derived from a table**. `rowSchema(table)` / `columnSchema(def)`
|
|
323
|
+
take the table as a VALUE, so reaching one means importing your
|
|
324
|
+
`database/schema.ts`, which imports `@voltro/database` — the boot aborts with
|
|
325
|
+
the import chain. Write `output` as your own `Schema.Struct` and take the field
|
|
326
|
+
schemas from `@voltro/database/wire` (`timestampMs` for a `timestamp()` column,
|
|
327
|
+
`timestampMsOrNull` when it is `.nullable()`, `Schema.optional(timestampMs)`
|
|
328
|
+
when the field may be absent). `rowSchema` is a row CODEC for server-only code
|
|
329
|
+
(`*.server.ts`, `*.seed.ts`, jobs, scripts) — file exports, queue payloads,
|
|
330
|
+
validating seed data — not a descriptor helper.
|
|
320
331
|
|
|
321
332
|
**Symptom of a leak:** the web app fires hundreds of module requests / tens of MB
|
|
322
333
|
on first load, or crashes with `Module "node:crypto" has been externalized for
|
package/templates/AGENTS.md
CHANGED
|
@@ -313,10 +313,21 @@ helper from a `lib/` file that ALSO imports the `database` handle drags the whol
|
|
|
313
313
|
server graph (and any `node:crypto` behind it) into the browser bundle. Keep
|
|
314
314
|
browser-safe symbols (typed errors, Schemas, pure helpers) in files with ZERO
|
|
315
315
|
server imports; put DB-backed guards/helpers in `.server.ts` or a server-only
|
|
316
|
-
module.
|
|
317
|
-
`@voltro/
|
|
318
|
-
`@voltro/ai/events`) — import typed errors /
|
|
319
|
-
never from the package root.
|
|
316
|
+
module. Several packages ship a browser-safe subpath for exactly this (e.g.
|
|
317
|
+
`@voltro/database/wire`, `@voltro/plugin-multitenancy/guard`,
|
|
318
|
+
`@voltro/plugin-webhooks/errors`, `@voltro/ai/events`) — import typed errors /
|
|
319
|
+
Schemas from there in descriptors, never from the package root.
|
|
320
|
+
|
|
321
|
+
A consequence worth stating, because it is not obvious: a descriptor's `output`
|
|
322
|
+
can never be **derived from a table**. `rowSchema(table)` / `columnSchema(def)`
|
|
323
|
+
take the table as a VALUE, so reaching one means importing your
|
|
324
|
+
`database/schema.ts`, which imports `@voltro/database` — the boot aborts with
|
|
325
|
+
the import chain. Write `output` as your own `Schema.Struct` and take the field
|
|
326
|
+
schemas from `@voltro/database/wire` (`timestampMs` for a `timestamp()` column,
|
|
327
|
+
`timestampMsOrNull` when it is `.nullable()`, `Schema.optional(timestampMs)`
|
|
328
|
+
when the field may be absent). `rowSchema` is a row CODEC for server-only code
|
|
329
|
+
(`*.server.ts`, `*.seed.ts`, jobs, scripts) — file exports, queue payloads,
|
|
330
|
+
validating seed data — not a descriptor helper.
|
|
320
331
|
|
|
321
332
|
**Symptom of a leak:** the web app fires hundreds of module requests / tens of MB
|
|
322
333
|
on first load, or crashes with `Module "node:crypto" has been externalized for
|
|
@@ -1033,12 +1033,12 @@ const result = await handleSignIn(
|
|
|
1033
1033
|
What it does:
|
|
1034
1034
|
|
|
1035
1035
|
1. `userStore.findByEmail(email)` — fetch the user (or pretend, see [timing oracle](/docs/authentication/passwords#timing-oracle-defence)).
|
|
1036
|
-
2. `verifyPassword(password, user.passwordHash)` — always run, even on unknown email.
|
|
1036
|
+
2. `verifyPassword(password, user.passwordHash)` — always run, even on unknown email, and even when the user exists but has **no** stored hash.
|
|
1037
1037
|
3. On success: `issueSession(subject, config.secret, {...})` → set the `Set-Cookie` header.
|
|
1038
1038
|
4. If `redirectAfter` is true (form POST): 302 to `AUTH_CONFIG.successRedirect`.
|
|
1039
1039
|
5. If `redirectAfter` is false (XHR / fetch): 200 + JSON `{ ok: true, subject }`.
|
|
1040
1040
|
|
|
1041
|
-
Failures return a 401 `HandlerResult` with a generic `{ error: 'invalid credentials' }` body — no email-existence disclosure. The handlers encode failures as 4xx/5xx `HandlerResult`s rather than throwing domain errors across the wire.
|
|
1041
|
+
Failures return a 401 `HandlerResult` with a generic `{ error: 'invalid credentials' }` body — no email-existence disclosure. That includes an account with no `passwordHash` at all (SSO-only, magic-link-only, passkey-only, provider PAT): the password strategy **refuses** it, taking the same branch as an unknown email — same decoy scrypt, same status, same body, no cookie. An absent hash is never "nothing to compare, let them in", and the response does not reveal which accounts are password-less. The handlers encode failures as 4xx/5xx `HandlerResult`s rather than throwing domain errors across the wire.
|
|
1042
1042
|
|
|
1043
1043
|
## handleSignUp
|
|
1044
1044
|
|
|
@@ -1233,7 +1233,7 @@ interface UserStore {
|
|
|
1233
1233
|
interface UserRecord {
|
|
1234
1234
|
readonly id: string
|
|
1235
1235
|
readonly email: string
|
|
1236
|
-
readonly passwordHash
|
|
1236
|
+
readonly passwordHash?: string | null // absent = this account has no password
|
|
1237
1237
|
readonly tenantId: string
|
|
1238
1238
|
readonly createdAt: Date
|
|
1239
1239
|
readonly mfaSecret?: string | null // base32 TOTP secret; null = not enrolled
|
|
@@ -1241,6 +1241,10 @@ interface UserRecord {
|
|
|
1241
1241
|
}
|
|
1242
1242
|
```
|
|
1243
1243
|
|
|
1244
|
+
`passwordHash` is **optional** — not every identity has a password. An SSO-only, magic-link-only, passkey-only or provider-PAT app simply omits it, and the `users` table column is nullable to match. Do not invent a placeholder: a fake hash is a real value sitting in the column a verifier compares against, which is strictly worse than storing nothing. `null`, `undefined` and `''` all mean "no password".
|
|
1245
|
+
|
|
1246
|
+
`handleSignIn` refuses a password sign-in for such an account the same way it refuses an unknown email — it still burns a decoy scrypt and returns the identical 401 with no cookie — so the response reveals neither that the account exists nor that it is password-less. Assigning a hash later (password reset, set-password) promotes the account to a password user normally. `createdAt` stays required; every store can supply it, and `insert` takes `Omit<UserRecord, 'createdAt'>` so you never pass one yourself.
|
|
1247
|
+
|
|
1244
1248
|
There is no `updatePasswordHash` method and no `updatedAt` field. The interface is deliberately small. Add your own fields (display name, avatar URL, locale) on a sibling table joined by `userId` — keep the auth-critical fields in the auth table.
|
|
1245
1249
|
|
|
1246
1250
|
## memoryUserStore
|
|
@@ -1318,7 +1322,6 @@ const okta = (): UserStore => ({
|
|
|
1318
1322
|
return {
|
|
1319
1323
|
id: `okta:${oktaUser.id}`,
|
|
1320
1324
|
email: oktaUser.profile.email,
|
|
1321
|
-
passwordHash: '', // we never auth password locally — see below
|
|
1322
1325
|
tenantId: oktaUser.profile.tenantId,
|
|
1323
1326
|
createdAt: new Date(oktaUser.created),
|
|
1324
1327
|
}
|
|
@@ -421,13 +421,51 @@ voltro dev apps/acme/api # explicit path
|
|
|
421
421
|
|
|
422
422
|
1. Reads `app.config.ts`. Bails if not `type: 'web'`.
|
|
423
423
|
2. Walks `src/pages/` for queries + special files.
|
|
424
|
-
3. Generates `.framework/main.tsx`, `.framework/app.tsx`, `index.html`.
|
|
424
|
+
3. Generates `.framework/main.tsx`, `.framework/app.tsx`, `.framework/routeTable.ts`, `index.html`.
|
|
425
425
|
4. Starts Vite with the framework's plugin chain (React, Tailwind v4, page discovery, inspect, dashboard registry).
|
|
426
426
|
5. Binds to the configured port from `app.config.ts.port` (strict — fails on conflict).
|
|
427
427
|
6. Watches `src/`. On save, Vite HMR fires:
|
|
428
|
-
-
|
|
428
|
+
- A page / layout **component** change → React Fast Refresh patches the live
|
|
429
|
+
component in place; client state survives (see below).
|
|
430
|
+
- A page / layout **`loader`** (or other non-component export) change → a
|
|
431
|
+
full page reload, on purpose (see below).
|
|
429
432
|
- CSS changes → swap stylesheets in place.
|
|
430
|
-
- New page file → regen
|
|
433
|
+
- New page file → regen the entry files → reload the route tree.
|
|
434
|
+
|
|
435
|
+
`renderMode: 'ssr'` pages compile on demand the first time each route is hit. To
|
|
436
|
+
keep a burst of cold pages — several browser tabs, or a health-check sweep across
|
|
437
|
+
many routes — from spiking memory, `voltro dev` compiles at most **4** of them at
|
|
438
|
+
once and collapses duplicate concurrent requests for the same route into a single
|
|
439
|
+
compile. Already-compiled (warm) pages are never throttled, so a hot app stays
|
|
440
|
+
fully concurrent. Tune the cap with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` (below) —
|
|
441
|
+
drop it on a low-memory box, raise it on a big machine.
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
### Fast Refresh: what hot-updates and what reloads
|
|
445
|
+
|
|
446
|
+
Editing a **page or layout component** applies as a hot update — the React tree
|
|
447
|
+
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.
|
|
455
|
+
|
|
456
|
+
The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
|
|
457
|
+
whose exports are all components, and a page exporting `loader` beside its
|
|
458
|
+
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".
|
|
463
|
+
|
|
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.
|
|
431
469
|
|
|
432
470
|
### The in-page devtools overlay
|
|
433
471
|
|
|
@@ -485,6 +523,7 @@ A `<DevtoolsStringsProvider strings={…}>` mounted above the overlay works too;
|
|
|
485
523
|
| `PORT=4001` | Override the listen port (api or web). Both kinds read `PORT`, then `app.config.ts` `port:`, then the default. |
|
|
486
524
|
| `VOLTRO_DASHBOARD_PORT=5180` | Override the auto-launched dashboard port (default `5179`). |
|
|
487
525
|
| `VOLTRO_LOG_LEVEL=debug` | Verbose framework logs. |
|
|
526
|
+
| `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. |
|
|
488
527
|
|
|
489
528
|
## Multi-app dev (turbo)
|
|
490
529
|
|
|
@@ -585,6 +624,7 @@ If you must override per-process from the shell, set `PORT` inline on each one (
|
|
|
585
624
|
|
|
586
625
|
- **Running `voltro dev` against a `voltro start` build directory.** The dev server expects source files; pointing it at `dist/` confuses it. Use `voltro start` for that.
|
|
587
626
|
- **Ignoring `app.config.ts` changes.** They require a process restart (the discovery walker re-reads them at boot only). Save, watch the process die + come back up.
|
|
627
|
+
- **Reading "the page reloaded" as "HMR is broken".** A page/layout component edit hot-updates and keeps client state; a `loader` edit reloads *by design* — it also runs server-side, and the rendered page came from the old one. If a pure component edit reloads, look for a non-component export on that route module that changed in the same save.
|
|
588
628
|
- **Expecting a `.env` edit to hot-reload.** It can't — `process.env` is read once at boot. `voltro dev` hard-restarts the server on a `.env` change (you'll see `.env changed … — hard-restarting`); wait for the process to come back before testing, rather than assuming the new value is already live.
|
|
589
629
|
- **Disabling the dashboard "to save resources".** It's a few MB of RAM + the inspect endpoints fail gracefully. Keep it on; it's the best debugging tool you have.
|
|
590
630
|
|
|
@@ -771,7 +811,7 @@ It covers both halves of the stack:
|
|
|
771
811
|
| server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
|
|
772
812
|
| server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
|
|
773
813
|
| server | `hasMore` + `limit + 1` | `paginateById` |
|
|
774
|
-
| server | `.getTime()` / `.toISOString()` mapping a row on the way out | `
|
|
814
|
+
| server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
|
|
775
815
|
| client | per-field `useState` + a submit flag | `useFormBinding` |
|
|
776
816
|
| client | a table with local sort/filter state | `useDataTable` |
|
|
777
817
|
| client | `FileReader` / `readAsDataURL` | `useUpload` |
|
|
@@ -842,13 +882,52 @@ voltro doctor . --json # the complete scan: every file path, machine-re
|
|
|
842
882
|
"use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
|
|
843
883
|
"files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
|
|
844
884
|
}
|
|
845
|
-
]
|
|
885
|
+
],
|
|
886
|
+
"spaCandidates": []
|
|
846
887
|
}
|
|
847
888
|
```
|
|
848
889
|
|
|
849
890
|
That is the form to hand an agent, or to pipe into a script that works the list
|
|
850
891
|
file by file.
|
|
851
892
|
|
|
893
|
+
### `renderMode:'spa'` candidates
|
|
894
|
+
|
|
895
|
+
`voltro doctor` also flags web pages that could adopt `renderMode: 'spa'` without
|
|
896
|
+
losing their server-rendered shell. A page under a layout renders that LAYOUT
|
|
897
|
+
chain on the server — nav, sidebar, auth gate, via the layout's own loader — even
|
|
898
|
+
when the page itself is `'spa'`. So a page whose BODY needs no SSR can skip its
|
|
899
|
+
per-page SSR compile while the shell still server-renders. Like the hand-roll
|
|
900
|
+
detector, this is **advisory and never blocking**.
|
|
901
|
+
|
|
902
|
+
A page is listed when ALL of these hold:
|
|
903
|
+
|
|
904
|
+
- it is a **page file** — not `layout.tsx` / `loading.tsx` / `error.tsx` /
|
|
905
|
+
`not-found.tsx`;
|
|
906
|
+
- it exports **no `loader`** (so `'spa'` loses nothing the page contributed
|
|
907
|
+
server-side);
|
|
908
|
+
- its `renderMode` is **`'ssr'` or unset/default** — not a page that already
|
|
909
|
+
opted into a non-SSR render (`'spa'` / `'static'` / `'isr'`, or any other
|
|
910
|
+
explicit mode);
|
|
911
|
+
- a **`layout.tsx` sits somewhere in its directory chain** — root, an ancestor, or
|
|
912
|
+
the page's own dir. This is the load-bearing condition: only then does a layout
|
|
913
|
+
still SSR the shell. A page with no layout would, as `'spa'`, ship no server
|
|
914
|
+
HTML at all — so it is never flagged.
|
|
915
|
+
|
|
916
|
+
```text
|
|
917
|
+
• renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
|
|
918
|
+
src/pages/dashboard/index.tsx (/dashboard) — default renderMode
|
|
919
|
+
src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
|
|
920
|
+
→ 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.
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
Adopt `'spa'` for internal or authenticated pages whose content needs no SEO or
|
|
924
|
+
server first-paint; keep `'ssr'` (or the `'static'` default) when it does. There
|
|
925
|
+
is deliberately **no codemod** to flip pages automatically — dropping a page
|
|
926
|
+
body's server render is a per-page product decision, not a mechanically-safe
|
|
927
|
+
transform. Every candidate (with its `file`, `pattern`, and `currentMode`) is
|
|
928
|
+
also in `voltro doctor --json` under a `spaCandidates` array; the human view
|
|
929
|
+
above caps at ten pages and points to `--json` for the rest.
|
|
930
|
+
|
|
852
931
|
## `voltro capabilities` — what the framework actually exports
|
|
853
932
|
|
|
854
933
|
Asked "what does this framework export", a language model will produce a
|
|
@@ -304,7 +304,7 @@ defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' },
|
|
|
304
304
|
|
|
305
305
|
With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
|
|
306
306
|
|
|
307
|
-
## `output` is the serializer — `
|
|
307
|
+
## `output` is the serializer — `timestampMs`
|
|
308
308
|
|
|
309
309
|
A descriptor's `output` is not documentation of the shape. It **is** the
|
|
310
310
|
serializer: it is handed to the rpc as the success schema, so a handler's result
|
|
@@ -325,32 +325,81 @@ output: Schema.Array(Schema.Struct({ createdAt: Schema.Number })),
|
|
|
325
325
|
return rows.map((row) => ({ ...row, createdAt: row.createdAt.getTime() }))
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
Declaring `Schema.DateFromNumber` instead makes the conversion automatic
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
Declaring `Schema.DateFromNumber` instead makes the conversion automatic.
|
|
329
|
+
`@voltro/database/wire` ships that mapping under names you can drop straight
|
|
330
|
+
into your own struct — including the nullable case, which is the one that goes
|
|
331
|
+
wrong silently:
|
|
331
332
|
|
|
332
333
|
```ts
|
|
333
|
-
// apps/api/queries/
|
|
334
|
+
// apps/api/queries/projects.list.query.ts
|
|
334
335
|
import { defineQuery } from '@voltro/protocol'
|
|
335
|
-
import {
|
|
336
|
+
import { timestampMs, timestampMsOrNull } from '@voltro/database/wire'
|
|
336
337
|
import { Schema } from 'effect'
|
|
337
|
-
import { notes } from '../database/schema'
|
|
338
338
|
|
|
339
|
-
export const
|
|
340
|
-
name: '
|
|
341
|
-
source: '
|
|
339
|
+
export const listProjects = defineQuery({
|
|
340
|
+
name: 'projects.list',
|
|
341
|
+
source: 'projects',
|
|
342
342
|
input: Schema.Struct({}),
|
|
343
|
-
output: Schema.Array(
|
|
343
|
+
output: Schema.Array(
|
|
344
|
+
Schema.Struct({
|
|
345
|
+
id: Schema.String,
|
|
346
|
+
name: Schema.String,
|
|
347
|
+
jiraProjectKey: Schema.String,
|
|
348
|
+
addedAt: timestampMs, // Date in the handler, epoch ms on the wire
|
|
349
|
+
archivedAt: timestampMsOrNull, // for a nullable timestamp column
|
|
350
|
+
seenAt: Schema.optional(timestampMs),
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
344
353
|
})
|
|
345
354
|
```
|
|
346
355
|
|
|
347
|
-
The executor returns rows
|
|
356
|
+
The executor returns its rows and computes nothing at the tail:
|
|
348
357
|
|
|
349
358
|
```ts no-check
|
|
350
|
-
// apps/api/queries/
|
|
351
|
-
export default () => database.
|
|
359
|
+
// apps/api/queries/projects.list.query.server.ts
|
|
360
|
+
export default () => database.projects.orderBy('addedAt', 'desc').limit(100)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
- `timestampMs` — `Date` in the handler, `number` (epoch ms) on the wire.
|
|
364
|
+
- `timestampMsOrNull` — for a `.nullable()` timestamp. Use this rather than
|
|
365
|
+
converting a null by hand: `new Date(null)` is `1970-01-01`, so "never
|
|
366
|
+
archived" renders as a plausible date instead of as nothing.
|
|
367
|
+
- An **optional** field is `Schema.optional(timestampMs)` — there is no third
|
|
368
|
+
export for it.
|
|
369
|
+
|
|
370
|
+
This also matches the shape real handlers have. Most return a struct assembled
|
|
371
|
+
by hand across several tables — `{ id, name, slug, addedAt, jiraProjectKey }` —
|
|
372
|
+
where there is no single table to derive an output schema from anyway, and those
|
|
373
|
+
are exactly the places the hand-written `Date → epoch` converters pile up.
|
|
374
|
+
|
|
375
|
+
### Why the field schemas import from `@voltro/database/wire`
|
|
376
|
+
|
|
377
|
+
`@voltro/database/wire` is a **browser-safe** entry: it contains plain
|
|
378
|
+
`effect/Schema` values and imports `effect` and nothing else. That matters
|
|
379
|
+
because a descriptor is loaded **value-level by the web client** — the rpc client
|
|
380
|
+
needs every procedure's schema — so everything a `*.query.ts` transitively
|
|
381
|
+
imports ends up in the browser bundle.
|
|
382
|
+
|
|
383
|
+
The package root is not browser-safe, and neither is anything that reaches a
|
|
384
|
+
**table value**. Importing `../database/schema` to get at `notes` imports
|
|
385
|
+
`@voltro/database` itself, which pulls the store, the query builder and the SQL
|
|
386
|
+
driver into the client graph. `voltro dev` refuses to boot in that case and
|
|
387
|
+
prints the import chain:
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
browser-safety violation — the generated rpcGroup pulls a SERVER-ONLY module
|
|
391
|
+
into the client bundle.
|
|
392
|
+
import chain:
|
|
393
|
+
→ rpcGroup.generated.ts
|
|
394
|
+
→ ./queries/notes.list.query
|
|
395
|
+
→ ../database/schema
|
|
396
|
+
→ @voltro/database
|
|
352
397
|
```
|
|
353
398
|
|
|
399
|
+
So a descriptor's `output` is always written as your own `Schema.Struct` with
|
|
400
|
+
field schemas — never derived from a table. Deriving from a table is a
|
|
401
|
+
server-side operation; see below.
|
|
402
|
+
|
|
354
403
|
### The Encoded / Type split
|
|
355
404
|
|
|
356
405
|
That split is the whole point — the handler works in domain types, the wire
|
|
@@ -369,79 +418,50 @@ carries something JSON can represent:
|
|
|
369
418
|
number rounds silently past 2^53, and a value that is quietly wrong is worse than
|
|
370
419
|
one that is rejected.
|
|
371
420
|
|
|
372
|
-
`json()` / `vector()` / `raw()` map to `Schema.Unknown` rather than a guess.
|
|
373
|
-
These are shapes the column declaration does not pin down, and a schema that
|
|
374
|
-
guesses **wrong** rejects valid rows at the wire boundary — reporting the failure
|
|
375
|
-
far from the column responsible. Loose beats wrong here; declare the precise
|
|
376
|
-
shape yourself when you want one.
|
|
377
|
-
|
|
378
421
|
A `.nullable()` column wraps its mapped type, so a null timestamp round-trips as
|
|
379
422
|
`null` rather than becoming epoch 0 (which would render as a plausible
|
|
380
423
|
`1970-01-01` instead of "never").
|
|
381
424
|
|
|
382
|
-
###
|
|
425
|
+
### Row codecs for server-side code — `rowSchema`, `columnSchema`
|
|
383
426
|
|
|
384
|
-
`rowSchema(table)`
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
427
|
+
`rowSchema(table)` builds the whole struct from a table definition, and
|
|
428
|
+
`columnSchema(def)` maps one column. Both take the table as a **value**, so by
|
|
429
|
+
the section above they can only be used in code that already runs on the server
|
|
430
|
+
alone — a `*.query.server.ts` / `*.mutation.server.ts` executor, a `*.seed.ts`,
|
|
431
|
+
a startup or job module, a maintenance script, a test. **Not** a descriptor's
|
|
432
|
+
`output`.
|
|
389
433
|
|
|
390
|
-
|
|
434
|
+
What they are for is encoding or decoding table rows outside the rpc path, where
|
|
435
|
+
no `output` schema is doing it for you: writing rows to a file export or a queue
|
|
436
|
+
payload, or validating seed / import data against the actual table shape before
|
|
437
|
+
it is written.
|
|
391
438
|
|
|
392
|
-
```ts
|
|
393
|
-
// apps/api/
|
|
394
|
-
import {
|
|
395
|
-
import { timestampMs, timestampMsOrNull } from '@voltro/database'
|
|
439
|
+
```ts no-check
|
|
440
|
+
// apps/api/database/notes.seed.ts — server-only, so the table value is fine
|
|
441
|
+
import { rowSchema } from '@voltro/database'
|
|
396
442
|
import { Schema } from 'effect'
|
|
443
|
+
import { notes } from './schema'
|
|
397
444
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
input: Schema.Struct({}),
|
|
402
|
-
output: Schema.Array(
|
|
403
|
-
Schema.Struct({
|
|
404
|
-
id: Schema.String,
|
|
405
|
-
name: Schema.String,
|
|
406
|
-
jiraProjectKey: Schema.String,
|
|
407
|
-
addedAt: timestampMs, // Date in the handler, epoch ms on the wire
|
|
408
|
-
archivedAt: timestampMsOrNull, // for a nullable timestamp column
|
|
409
|
-
seenAt: Schema.optional(timestampMs),
|
|
410
|
-
}),
|
|
411
|
-
),
|
|
412
|
-
})
|
|
445
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(rowSchema(notes)))(
|
|
446
|
+
JSON.parse(await readFile('seed/notes.json', 'utf8')),
|
|
447
|
+
)
|
|
413
448
|
```
|
|
414
449
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
different wire representations.
|
|
418
|
-
|
|
419
|
-
- `timestampMs` — `Date` in the handler, `number` (epoch ms) on the wire.
|
|
420
|
-
- `timestampMsOrNull` — for a `.nullable()` timestamp. Use this rather than
|
|
421
|
-
converting a null by hand: `new Date(null)` is `1970-01-01`, so "never
|
|
422
|
-
archived" renders as a plausible date instead of as nothing.
|
|
423
|
-
- An **optional** field is `Schema.optional(timestampMs)` — there is no third
|
|
424
|
-
export for it.
|
|
425
|
-
|
|
426
|
-
### Keeping a column off the wire — `omit`
|
|
427
|
-
|
|
428
|
-
```ts
|
|
429
|
-
import { rowSchema } from '@voltro/database'
|
|
430
|
-
import { users } from '../database/schema'
|
|
450
|
+
`omit` drops columns from the schema — the way to keep an internal column out of
|
|
451
|
+
an export:
|
|
431
452
|
|
|
453
|
+
```ts no-check
|
|
432
454
|
rowSchema(users, { omit: ['passwordHash'] })
|
|
433
455
|
```
|
|
434
456
|
|
|
435
|
-
|
|
436
|
-
from *this* schema;
|
|
437
|
-
still
|
|
457
|
+
It is a **convenience, not a security boundary**. The column is simply absent
|
|
458
|
+
from *this* schema; code that serializes the same row under a different schema
|
|
459
|
+
still emits it. For a real boundary, see
|
|
438
460
|
[`.encrypted()` and column sensitivity](/docs/database/sensitivity).
|
|
439
461
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
`table.fields` holds — not the builder that `text()` or `timestamp()` returns
|
|
444
|
-
(a builder's definition is private, so it cannot be read from outside):
|
|
462
|
+
`columnSchema` takes a column **definition** — what `table.fields` holds — not
|
|
463
|
+
the builder that `text()` or `timestamp()` returns (a builder's definition is
|
|
464
|
+
private, so it cannot be read from outside):
|
|
445
465
|
|
|
446
466
|
```ts no-check
|
|
447
467
|
import { columnSchema } from '@voltro/database'
|
|
@@ -450,6 +470,10 @@ columnSchema(notes.fields.createdAt) // ✅ a definition, from table.fields
|
|
|
450
470
|
columnSchema(timestamp()) // ❌ a builder — not readable
|
|
451
471
|
```
|
|
452
472
|
|
|
473
|
+
The mapping is single-sourced: `columnSchema` returns the very same
|
|
474
|
+
`timestampMs` value for a `timestamp()` column, so a derived row schema and a
|
|
475
|
+
hand-written descriptor struct can never disagree about the wire shape.
|
|
476
|
+
|
|
453
477
|
## What gets sent on the wire
|
|
454
478
|
|
|
455
479
|
Queries are streaming RPCs whose elements are **subscription events**: an initial `snapshot` followed by `delta`s. See [Wire protocol](/docs/data/wire-protocol#subscription-events-snapshot-delta) for the envelope shape. For plain element streams, use [Streams](/docs/data/streams).
|
|
@@ -423,7 +423,7 @@ the framework ships richer types for specific use cases:
|
|
|
423
423
|
- **`timestamp` without `tz`.** Use `timestamp()` which is `timestamptz`.
|
|
424
424
|
- **Storing JSON blobs as `text`.** Use `json<T>()` for type-safety + JSONB performance.
|
|
425
425
|
- **`references('table')`.** The constructor is `reference(() => table)` — singular, thunk-arg.
|
|
426
|
-
- **Declaring a `timestamp()` column as `Schema.Number` in a descriptor's `output`.** That describes the *wire* type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand.
|
|
426
|
+
- **Declaring a `timestamp()` column as `Schema.Number` in a descriptor's `output`.** That describes the *wire* type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand. Declare the field schema instead — [`timestampMs` / `timestampMsOrNull`](/docs/data/queries#output-is-the-serializer-timestampms) from `@voltro/database/wire`. Do **not** reach for `rowSchema(table)` here: a descriptor cannot import a table value without dragging `@voltro/database` into the browser bundle, and the boot aborts. `rowSchema` is a [server-side row codec](/docs/data/queries#row-codecs-for-server-side-code-rowschema-columnschema).
|
|
427
427
|
|
|
428
428
|
|
|
429
429
|
|
|
@@ -630,6 +630,26 @@ Available in:
|
|
|
630
630
|
|
|
631
631
|
Returns `null` on pages without a `loader`. The generic narrows the type.
|
|
632
632
|
|
|
633
|
+
Precisely, it returns `LoaderData<T>`. For every ordinary loader that IS `T`. For
|
|
634
|
+
a loader that returned `defer()`, `LoaderData<T>` flattens the two buckets into
|
|
635
|
+
one object — eager fields as values, deferred fields as `Promise<T>` — so the
|
|
636
|
+
compiler tells you which fields have to be rendered through
|
|
637
|
+
[`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await):
|
|
638
|
+
|
|
639
|
+
```tsx
|
|
640
|
+
export const loader = async ({ query }) => defer(
|
|
641
|
+
{ user: await query('users.me') },
|
|
642
|
+
{ report: query('reports.quarterly') },
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
// user: User report: Promise<Report>
|
|
646
|
+
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
If you wrap this hook in your own generic helper, propagate the mapped type
|
|
650
|
+
(`<D,>(): LoaderData<D> => useLoaderData<D>()`) — `LoaderData<D>` is not
|
|
651
|
+
assignable to a bare type parameter `D`.
|
|
652
|
+
|
|
633
653
|
See [Loaders & meta](/docs/routing/loaders-and-meta) for the server-side counterpart.
|
|
634
654
|
|
|
635
655
|
## Compositions
|