@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/apiBuild-Ba4njm9m.js +2 -0
  4. package/dist/{apiBuild-deIjJKJm.js → apiBuild-eInHm_bJ.js} +2 -2
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-BHdc71DG.js → commands-Cxs97AFp.js} +1897 -1664
  7. package/dist/{dev-Bqyq62Ak.js → dev-63Ehpw2p.js} +475 -475
  8. package/dist/dev-CXsR8Zgo.js +2 -0
  9. package/dist/index.js +1 -1
  10. package/dist/{inspectMetrics-BLUBAWLb.js → inspectMetrics-DVCKKSPk.js} +819 -510
  11. package/dist/{serveCommand-Dn6--0Bv.js → serveCommand-Cq7BhtpB.js} +3 -3
  12. package/dist/serveEntry.js +3 -3
  13. package/dist/{start-CWZW_4Et.js → start-CYe-7ueh.js} +371 -306
  14. package/dist/startEntry.js +2 -2
  15. package/package.json +18 -17
  16. package/templates/AGENTS.core.md +15 -4
  17. package/templates/AGENTS.md +15 -4
  18. package/templates/agent-docs/authentication.md +7 -4
  19. package/templates/agent-docs/cli.md +84 -5
  20. package/templates/agent-docs/data.md +94 -70
  21. package/templates/agent-docs/database/schema.md +1 -1
  22. package/templates/agent-docs/reference.md +20 -0
  23. package/templates/agent-docs/routing.md +183 -25
  24. package/templates/agent-docs/testing.md +24 -0
  25. package/templates/apps/api-ai/package.json +7 -7
  26. package/templates/apps/api-auth/package.json +8 -8
  27. package/templates/apps/api-backend/package.json +7 -7
  28. package/templates/apps/api-backend-deactivation/package.json +7 -7
  29. package/templates/apps/api-backend-mail/package.json +8 -8
  30. package/templates/apps/api-backend-mariadb/package.json +9 -9
  31. package/templates/apps/api-backend-storage/package.json +8 -8
  32. package/templates/apps/api-data-advanced/package.json +8 -8
  33. package/templates/apps/api-durable/package.json +8 -8
  34. package/templates/apps/api-feature-flags/package.json +9 -9
  35. package/templates/apps/api-governance/package.json +8 -8
  36. package/templates/apps/api-kv/package.json +8 -8
  37. package/templates/apps/api-moderation/package.json +8 -8
  38. package/templates/apps/api-observability/package.json +8 -8
  39. package/templates/apps/api-ratelimit/package.json +8 -8
  40. package/templates/apps/api-rbac/package.json +8 -8
  41. package/templates/apps/api-rest/package.json +7 -7
  42. package/templates/apps/api-saas/package.json +11 -11
  43. package/templates/apps/api-search/package.json +8 -8
  44. package/templates/apps/api-versioning/package.json +8 -8
  45. package/templates/apps/api-webhooks/package.json +8 -8
  46. package/templates/apps/changelog/package.json +6 -6
  47. package/templates/apps/edge-functions/package.json +2 -2
  48. package/templates/apps/frontend-admin/package.json +8 -8
  49. package/templates/apps/frontend-app/package.json +8 -8
  50. package/templates/apps/frontend-blank/package.json +7 -7
  51. package/templates/apps/frontend-contact/package.json +7 -7
  52. package/templates/apps/frontend-dashboard/package.json +7 -7
  53. package/templates/apps/frontend-docs/package.json +7 -7
  54. package/templates/apps/frontend-i18n/package.json +6 -6
  55. package/templates/apps/frontend-landing/package.json +7 -7
  56. package/templates/apps/frontend-spa/package.json +7 -7
  57. package/templates/apps/frontend-ssr/package.json +7 -7
  58. package/templates/apps/frontend-ssr-api/package.json +8 -8
  59. package/templates/apps/frontend-static-blog/package.json +6 -6
  60. package/dist/apiBuild-CSM74oEZ.js +0 -2
  61. package/dist/dev-BYfrYwrT.js +0 -2
@@ -1,3 +1,3 @@
1
- import { k as e } from "./inspectMetrics-BLUBAWLb.js";
2
- import { t } from "./start-CWZW_4Et.js";
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.7.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.7.0",
61
- "@voltro/cache": "0.7.0",
62
- "@voltro/data-transfer": "0.7.0",
63
- "@voltro/database": "0.7.0",
64
- "@voltro/env": "0.7.0",
65
- "@voltro/kv": "0.7.0",
66
- "@voltro/logger": "0.7.0",
67
- "@voltro/plugin-auth": "0.7.0",
68
- "@voltro/plugin-broadcast": "0.7.0",
69
- "@voltro/plugin-mail": "0.7.0",
70
- "@voltro/plugin-storage": "0.7.0",
71
- "@voltro/plugin-webhooks": "0.7.0",
72
- "@voltro/protocol": "0.7.0",
73
- "@voltro/runtime": "0.7.0",
74
- "@voltro/serverless": "0.7.0",
75
- "@voltro/workflow": "0.7.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": {
@@ -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. Some plugins ship a browser-safe subpath for exactly this (e.g.
317
- `@voltro/plugin-multitenancy/guard`, `@voltro/plugin-webhooks/errors`,
318
- `@voltro/ai/events`) — import typed errors / Schemas from there in descriptors,
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
@@ -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. Some plugins ship a browser-safe subpath for exactly this (e.g.
317
- `@voltro/plugin-multitenancy/guard`, `@voltro/plugin-webhooks/errors`,
318
- `@voltro/ai/events`) — import typed errors / Schemas from there in descriptors,
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: string
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
- - TSX changes → React Refresh updates the live component.
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 `.framework/app.tsx` → reload the query tree.
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 | `rowSchema(table)` as the descriptor's `output` |
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 — `rowSchema(table)`
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 — but
329
- nobody wants to hand-map thirty columns to work that out per table.
330
- `rowSchema(table)` derives the whole struct from the table definition:
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/notes.list.query.ts
334
+ // apps/api/queries/projects.list.query.ts
334
335
  import { defineQuery } from '@voltro/protocol'
335
- import { rowSchema } from '@voltro/database'
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 listNotes = defineQuery({
340
- name: 'notes.list',
341
- source: 'notes',
339
+ export const listProjects = defineQuery({
340
+ name: 'projects.list',
341
+ source: 'projects',
342
342
  input: Schema.Struct({}),
343
- output: Schema.Array(rowSchema(notes)),
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 straight from the store. No converters:
356
+ The executor returns its rows and computes nothing at the tail:
348
357
 
349
358
  ```ts no-check
350
- // apps/api/queries/notes.list.query.server.ts
351
- export default () => database.notes.orderBy('createdAt', 'desc').limit(100)
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
- ### Computed outputs — `timestampMs`
425
+ ### Row codecs for server-side code — `rowSchema`, `columnSchema`
383
426
 
384
- `rowSchema(table)` only helps a handler that returns a **raw, full table row**.
385
- Most handlers don't: they return a struct assembled by hand from several tables
386
- `{ id, name, slug, addedAt, jiraProjectKey }` where there is no single table
387
- to derive from. That is exactly where the hand-written `Date epoch` converters
388
- pile up.
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
- For those, use the field-level exports directly in your own `Schema.Struct`:
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/queries/projects.list.query.ts
394
- import { defineQuery } from '@voltro/protocol'
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
- export const listProjects = defineQuery({
399
- name: 'projects.list',
400
- source: 'projects',
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
- Same semantics as a `timestamp()` column inside `rowSchema` literally the same
416
- schema, which `columnSchema` reads from, so the two can never drift into
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
- `omit` is a **convenience, not a security boundary**. The column is simply absent
436
- from *this* schema; a handler that returns the same row under a different schema
437
- still sends it. For a real boundary, see
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
- ### One column at a time`columnSchema`
441
-
442
- `columnSchema` maps a single column, and it takes a column **definition** — what
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. Derive the row schema instead — [`rowSchema(table)`](/docs/data/queries#output-is-the-serializer-rowschema-table) for a whole row, or [`timestampMs` / `timestampMsOrNull`](/docs/data/queries#computed-outputs-timestampms) for one field of a hand-written struct.
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