@voltro/cli 0.6.0 → 0.8.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 (63) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/voltro.mjs +15 -6
  3. package/dist/apiBuild-CmtI6Ygh.js +2 -0
  4. package/dist/{apiBuild-BxY44VGj.js → apiBuild-OrDkXGFX.js} +21 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-My_YlUJV.js → commands-DT4PipBz.js} +2489 -2972
  7. package/dist/dev-Ca1jdwnt.js +2 -0
  8. package/dist/dev-Ga65K4jc.js +6492 -0
  9. package/dist/index.js +1 -1
  10. package/dist/inspectMetrics-EK0CcHyu.js +3929 -0
  11. package/dist/{serveCommand-C-XQ89NJ.js → serveCommand-CR5xnYZt.js} +209 -208
  12. package/dist/serveEntry.js +3 -2
  13. package/dist/start-DuyBQxW3.js +977 -0
  14. package/dist/startEntry.d.ts +5 -0
  15. package/dist/startEntry.js +3 -0
  16. package/package.json +17 -17
  17. package/templates/AGENTS.core.md +15 -4
  18. package/templates/AGENTS.md +15 -4
  19. package/templates/agent-docs/authentication.md +111 -13
  20. package/templates/agent-docs/cli.md +94 -6
  21. package/templates/agent-docs/data.md +99 -31
  22. package/templates/agent-docs/database/schema.md +1 -1
  23. package/templates/agent-docs/reference.md +20 -0
  24. package/templates/agent-docs/routing.md +158 -24
  25. package/templates/agent-docs/testing.md +82 -0
  26. package/templates/apps/api-ai/package.json +7 -7
  27. package/templates/apps/api-auth/package.json +8 -8
  28. package/templates/apps/api-backend/package.json +7 -7
  29. package/templates/apps/api-backend-deactivation/package.json +7 -7
  30. package/templates/apps/api-backend-mail/package.json +8 -8
  31. package/templates/apps/api-backend-mariadb/package.json +9 -9
  32. package/templates/apps/api-backend-storage/package.json +8 -8
  33. package/templates/apps/api-data-advanced/package.json +8 -8
  34. package/templates/apps/api-durable/package.json +8 -8
  35. package/templates/apps/api-feature-flags/package.json +9 -9
  36. package/templates/apps/api-governance/package.json +8 -8
  37. package/templates/apps/api-kv/package.json +8 -8
  38. package/templates/apps/api-moderation/package.json +8 -8
  39. package/templates/apps/api-observability/package.json +8 -8
  40. package/templates/apps/api-ratelimit/package.json +8 -8
  41. package/templates/apps/api-rbac/package.json +8 -8
  42. package/templates/apps/api-rest/package.json +7 -7
  43. package/templates/apps/api-saas/package.json +11 -11
  44. package/templates/apps/api-search/package.json +8 -8
  45. package/templates/apps/api-versioning/package.json +8 -8
  46. package/templates/apps/api-webhooks/package.json +8 -8
  47. package/templates/apps/changelog/package.json +6 -6
  48. package/templates/apps/edge-functions/package.json +2 -2
  49. package/templates/apps/frontend-admin/package.json +8 -8
  50. package/templates/apps/frontend-app/package.json +8 -8
  51. package/templates/apps/frontend-blank/package.json +7 -7
  52. package/templates/apps/frontend-contact/package.json +7 -7
  53. package/templates/apps/frontend-dashboard/package.json +7 -7
  54. package/templates/apps/frontend-docs/package.json +7 -7
  55. package/templates/apps/frontend-i18n/package.json +6 -6
  56. package/templates/apps/frontend-landing/package.json +7 -7
  57. package/templates/apps/frontend-spa/package.json +7 -7
  58. package/templates/apps/frontend-ssr/package.json +7 -7
  59. package/templates/apps/frontend-ssr-api/package.json +8 -8
  60. package/templates/apps/frontend-static-blog/package.json +6 -6
  61. package/dist/apiBuild-Bk4JBt-i.js +0 -2
  62. package/dist/dev-Bjq-nIvH.js +0 -10186
  63. package/dist/dev-D6b74iXI.js +0 -2
@@ -0,0 +1,5 @@
1
+ export declare const loadDotEnv: (argv: ReadonlyArray<string>) => void;
2
+
3
+ export declare const runStartCommand: (args: ReadonlyArray<string>) => Promise<number>;
4
+
5
+ export { }
@@ -0,0 +1,3 @@
1
+ import { P as e } from "./inspectMetrics-EK0CcHyu.js";
2
+ import { t } from "./start-DuyBQxW3.js";
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.6.0",
3
+ "version": "0.8.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.6.0",
61
- "@voltro/cache": "0.6.0",
62
- "@voltro/data-transfer": "0.6.0",
63
- "@voltro/database": "0.6.0",
64
- "@voltro/env": "0.6.0",
65
- "@voltro/kv": "0.6.0",
66
- "@voltro/logger": "0.6.0",
67
- "@voltro/plugin-auth": "0.6.0",
68
- "@voltro/plugin-broadcast": "0.6.0",
69
- "@voltro/plugin-mail": "0.6.0",
70
- "@voltro/plugin-storage": "0.6.0",
71
- "@voltro/plugin-webhooks": "0.6.0",
72
- "@voltro/protocol": "0.6.0",
73
- "@voltro/runtime": "0.6.0",
74
- "@voltro/serverless": "0.6.0",
75
- "@voltro/workflow": "0.6.0",
60
+ "@voltro/ai": "0.8.0",
61
+ "@voltro/cache": "0.8.0",
62
+ "@voltro/data-transfer": "0.8.0",
63
+ "@voltro/database": "0.8.0",
64
+ "@voltro/env": "0.8.0",
65
+ "@voltro/kv": "0.8.0",
66
+ "@voltro/logger": "0.8.0",
67
+ "@voltro/plugin-auth": "0.8.0",
68
+ "@voltro/plugin-broadcast": "0.8.0",
69
+ "@voltro/plugin-mail": "0.8.0",
70
+ "@voltro/plugin-storage": "0.8.0",
71
+ "@voltro/plugin-webhooks": "0.8.0",
72
+ "@voltro/protocol": "0.8.0",
73
+ "@voltro/runtime": "0.8.0",
74
+ "@voltro/serverless": "0.8.0",
75
+ "@voltro/workflow": "0.8.0",
76
76
  "chokidar": "^5.0.0",
77
77
  "ioredis": "^5.11.1",
78
78
  "ts-morph": "^28.0.0"
@@ -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
@@ -351,6 +351,29 @@ if (ctx.subject.type === 'anonymous') {
351
351
  }
352
352
  ```
353
353
 
354
+ ## App metadata — `subjectFromUser(user, { metadata })`
355
+
356
+ `metadata` is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's `credentialsResolver` reads it back per request (`@voltro/plugin-atlassian` looks for `subject.metadata.jiraToken`, say), so nothing has to be re-fetched or stored server-side per call.
357
+
358
+ Pass it when you build the Subject:
359
+
360
+ ```ts
361
+ import { subjectFromUser } from '@voltro/plugin-auth'
362
+
363
+ const subject = subjectFromUser(user, {
364
+ memberships, // → metadata.memberships
365
+ metadata: { jiraToken: atlassianPat }, // → metadata.jiraToken
366
+ })
367
+ ```
368
+
369
+ The two merge — neither clobbers the other. **When a `memberships` key appears in both,** the dedicated `memberships` option wins: it is the typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the tenant switcher read. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option has no `metadata` key at all.
370
+
371
+ Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge `sessionId` onto the existing slot, and the password strategy merges `provider` — they add, they don't replace. The one exception is naming a key `sessionId` or `provider` yourself; those two are overwritten by design.
372
+
373
+ **It survives a tenant switch too.** A switch rebuilds the Subject from the user record, so the built-in `/switch-tenant` route passes the caller's current `subject.metadata` through to `handleSwitchTenant` — a credential parked here keeps working after the user changes tenant. Calling `handleSwitchTenant` yourself? Pass `metadata` or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. `memberships` is deliberately *not* carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.
374
+
375
+ Two things not to put here. **Anything the caller could benefit from changing** — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And **anything large** — it is re-serialised into every session cookie.
376
+
354
377
  ## Resolution
355
378
 
356
379
  `AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:
@@ -1010,12 +1033,12 @@ const result = await handleSignIn(
1010
1033
  What it does:
1011
1034
 
1012
1035
  1. `userStore.findByEmail(email)` — fetch the user (or pretend, see [timing oracle](/docs/authentication/passwords#timing-oracle-defence)).
1013
- 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.
1014
1037
  3. On success: `issueSession(subject, config.secret, {...})` → set the `Set-Cookie` header.
1015
1038
  4. If `redirectAfter` is true (form POST): 302 to `AUTH_CONFIG.successRedirect`.
1016
1039
  5. If `redirectAfter` is false (XHR / fetch): 200 + JSON `{ ok: true, subject }`.
1017
1040
 
1018
- 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.
1019
1042
 
1020
1043
  ## handleSignUp
1021
1044
 
@@ -1210,7 +1233,7 @@ interface UserStore {
1210
1233
  interface UserRecord {
1211
1234
  readonly id: string
1212
1235
  readonly email: string
1213
- readonly passwordHash: string
1236
+ readonly passwordHash?: string | null // absent = this account has no password
1214
1237
  readonly tenantId: string
1215
1238
  readonly createdAt: Date
1216
1239
  readonly mfaSecret?: string | null // base32 TOTP secret; null = not enrolled
@@ -1218,6 +1241,10 @@ interface UserRecord {
1218
1241
  }
1219
1242
  ```
1220
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
+
1221
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.
1222
1249
 
1223
1250
  ## memoryUserStore
@@ -1295,7 +1322,6 @@ const okta = (): UserStore => ({
1295
1322
  return {
1296
1323
  id: `okta:${oktaUser.id}`,
1297
1324
  email: oktaUser.profile.email,
1298
- passwordHash: '', // we never auth password locally — see below
1299
1325
  tenantId: oktaUser.profile.tenantId,
1300
1326
  createdAt: new Date(oktaUser.created),
1301
1327
  }
@@ -1975,16 +2001,88 @@ await ctx.store.query(tickets.descriptor) // filtered
1975
2001
  await ctx.store.select('tickets').all() // filtered
1976
2002
  ```
1977
2003
 
1978
- ## A failing `load` denies
2004
+ ## When `load` fails
2005
+
2006
+ `load` reads your store — for relational visibility it *must* — which makes it
2007
+ exactly the kind of call that blips. Two separate questions follow from a
2008
+ failure, and the answers are deliberately different.
2009
+
2010
+ ### First: is the failure even real? (`retry`)
2011
+
2012
+ A transient failure must never reach the decision below, because once it gets
2013
+ there it is indistinguishable from an authorization answer. So `load` runs under
2014
+ a bounded retry before anything is concluded from it:
2015
+
2016
+ ```ts no-check
2017
+ import { Schedule } from 'effect'
2018
+
2019
+ setRowFilter({
2020
+ load,
2021
+ predicate,
2022
+ retry: Schedule.recurs(5), // your own schedule
2023
+ // retry: false, // exactly one attempt
2024
+ })
2025
+ ```
2026
+
2027
+ The default is `DEFAULT_ROW_FILTER_RETRY` (exported from `@voltro/runtime`):
2028
+ **three attempts, backing off exponentially from 20ms** — about 60ms of added
2029
+ latency in the worst case. It is sized for a blip (a connection reaped from the
2030
+ pool, a failover flap), not for an outage. A `load` still failing after that is
2031
+ not having a bad moment, and stretching the schedule only turns a fast honest
2032
+ error into a slow one while holding the request open.
2033
+
2034
+ ### Then: what does a real failure mean? (`onLoadError`)
2035
+
2036
+ Not "you may see nothing". **We cannot tell what you may see.** Those are
2037
+ different facts and only one of them is a fact — so the default raises a typed
2038
+ error:
2039
+
2040
+ ```ts no-check
2041
+ setRowFilter({
2042
+ load,
2043
+ predicate,
2044
+ onLoadError: 'fail', // default — raises RowFilterUnavailable
2045
+ // onLoadError: 'deny', // degrade to zero rows instead
2046
+ })
2047
+ ```
2048
+
2049
+ - **`'fail'` (default)** — the request fails with the typed
2050
+ `RowFilterUnavailable`. Handle it in your UI as an error state, the same as any
2051
+ other failed request.
2052
+ - **`'deny'`** — refusal is expressed as a predicate matching nothing, so the
2053
+ read returns an empty result. Choose this only if you have looked at the screen
2054
+ and are content for it to render empty during an outage. Your `onError`
2055
+ reporter still fires, so the failure stays findable in logs even though the
2056
+ response is a 200.
2057
+
2058
+ The default changed *to* `'fail'`, and the reasoning is worth stating plainly
2059
+ because the old default looked defensible: an empty result for an infrastructure
2060
+ failure is byte-identical to legitimate emptiness. The user reads "you have no
2061
+ tickets". The operator reads a healthy 200. The outage is invisible to both —
2062
+ the most misleading outcome on offer. Every constrained page **is** broken when
2063
+ this happens, and saying so is the only outcome either party can act on.
2064
+
2065
+ ### There is no fail-open option
2066
+
2067
+ A frequent request, and a deliberate refusal: there is no policy that serves
2068
+ **unfiltered** rows when the filter is unavailable, falling back to whatever
2069
+ check the handler carries.
2070
+
2071
+ Failing open on an authorization filter leaks data precisely when the system is
2072
+ under stress and nobody is reading dashboards. And it is only safe if every
2073
+ handler still carries its own row-level check — which is the entire thing a row
2074
+ filter exists to remove. A codebase where fail-open is safe is a codebase that
2075
+ did not need `setRowFilter`.
2076
+
2077
+ Both policies above are fail-**closed**: neither can ever produce an unfiltered
2078
+ read.
1979
2079
 
1980
- If `load` fails, every constrained read is **refused** rather than silently
1981
- running unfiltered. A row filter that degrades to "no filter" under load failure
1982
- is worse than no filter at all, because the system keeps serving and nothing
1983
- looks wrong.
2080
+ ### Subscriptions
1984
2081
 
1985
- The refusal is expressed as a predicate that matches nothing, not as an error —
1986
- so a failure surfaces as an **empty result** rather than a 500 on every page. The
1987
- underlying cause is reported separately, so it cannot pass unnoticed.
2082
+ A resolution failure mid-stream **revokes** the subscription and emits a typed
2083
+ error frame, rather than delivering an empty snapshot an empty snapshot on a
2084
+ live subscription reads to a client as "every row you could see was just
2085
+ deleted". Make sure your subscription error handling surfaces it.
1988
2086
 
1989
2087
  ## What does *not* bypass it
1990
2088
 
@@ -2022,7 +2120,7 @@ They answer different questions, and a complete policy usually wants both:
2022
2120
  | | Question | Failure |
2023
2121
  |---|---|---|
2024
2122
  | [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
2025
- | `setRowFilter` | Which rows may you see? | the rows are simply absent |
2123
+ | `setRowFilter` | Which rows may you see? | the rows are simply absent — unless the filter itself could not load, which is a typed `RowFilterUnavailable` |
2026
2124
 
2027
2125
  A guard is the right tool for "may this caller edit *this* ticket". A row filter
2028
2126
  is the right tool for "which tickets appear in the list at all" — a question a
@@ -421,13 +421,43 @@ 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
+
436
+ ### Fast Refresh: what hot-updates and what reloads
437
+
438
+ Editing a **page or layout component** applies as a hot update — the React tree
439
+ stays mounted, so form input, scroll position, open dialogs and every `useState`
440
+ survive. Editing a **`loader`** (or `meta` / `renderMode` / any other
441
+ non-component export of a route module) triggers a full page reload instead.
442
+
443
+ That split is deliberate, not a gap. A `loader` also runs on the server: the page
444
+ in front of you was rendered from the OLD one, and the router caches loader
445
+ results per route + params, so hot-swapping the function would leave stale data
446
+ on screen with nothing to signal it. A reload re-runs SSR with the new loader.
447
+
448
+ The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
449
+ whose exports are all components, and a page exporting `loader` beside its
450
+ component fails that test. `voltro dev` registers each route module's
451
+ non-component exports with the React plugin's ignore hook, and decides the
452
+ reload itself by comparing those exports' VALUES across the update — a function
453
+ by its source text, anything else by its JSON form — so a JSX-only edit (which
454
+ recreates the `loader` object) is correctly read as "unchanged".
455
+
456
+ One consequence worth knowing: a page that exports a non-component value which
457
+ changes on the same save as its JSX — a `const COLUMNS = [...]` you edit
458
+ together with the table that renders it — reloads rather than hot-updates. Move
459
+ that value into a sibling module (`./columns.ts`) and the page hot-updates
460
+ again; the sibling's own edits still propagate through the page's boundary.
431
461
 
432
462
  ### The in-page devtools overlay
433
463
 
@@ -585,6 +615,7 @@ If you must override per-process from the shell, set `PORT` inline on each one (
585
615
 
586
616
  - **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
617
  - **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.
618
+ - **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
619
  - **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
620
  - **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
621
 
@@ -771,7 +802,7 @@ It covers both halves of the stack:
771
802
  | server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
772
803
  | server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
773
804
  | server | `hasMore` + `limit + 1` | `paginateById` |
774
- | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `rowSchema(table)` as the descriptor's `output` |
805
+ | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
775
806
  | client | per-field `useState` + a submit flag | `useFormBinding` |
776
807
  | client | a table with local sort/filter state | `useDataTable` |
777
808
  | client | `FileReader` / `readAsDataURL` | `useUpload` |
@@ -1822,7 +1853,7 @@ _voltro update — bump the framework to the latest version and run the codemods
1822
1853
  `voltro update` upgrades an app to the latest framework release. It does three things in order:
1823
1854
 
1824
1855
  1. **Bump** every `@voltro/*` dependency in `package.json` to the target version.
1825
- 2. **Install** with your package manager (detected from the lockfile pnpm / npm / yarn / bun).
1856
+ 2. **Install** with your project's package manager — see [Which package manager](#which-package-manager) below.
1826
1857
  3. **Run the codemods** shipped with the target version — automatic source rewrites for any breaking API change, plus printed manual steps for anything that can't be automated.
1827
1858
 
1828
1859
  ```bash
@@ -1831,12 +1862,47 @@ voltro update --to 0.4.0 # pin an explicit target version
1831
1862
  voltro update --dry-run # preview the bump + which codemods would run — writes nothing
1832
1863
  voltro update --force # allow a dirty working tree (not recommended)
1833
1864
  voltro update --exact # pin exact versions (drop the ^ / ~ range prefix)
1865
+ voltro update --help # every flag — always answered, even on a dirty tree
1834
1866
 
1835
1867
  # Recover the codemods after a MANUAL version bump (no bump, no install):
1836
1868
  voltro update --codemods-only --from 0.3.0 # re-run codemods 0.3.0 → installed
1837
1869
  voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit delta
1838
1870
  ```
1839
1871
 
1872
+ ## In a workspace, the whole workspace moves
1873
+
1874
+ Run `voltro update` anywhere inside a workspace — a `pnpm-workspace.yaml`, or a
1875
+ `workspaces` field in an ancestor `package.json` — and **every member
1876
+ `package.json` that declares `@voltro/*` is bumped to the same version**, with
1877
+ the install running **once at the workspace root**.
1878
+
1879
+ This is not a convenience. Your api and your web app share generated types (the
1880
+ rpcGroup) and a session cookie shape; if the api moves to 0.6.0 while
1881
+ `apps/web` and `packages/ui-*` stay on 0.5.0, the mismatch shows up as a runtime
1882
+ decode error in the browser, not as a build failure. Half-upgraded is the worst
1883
+ state to be in, so `voltro update` never leaves you there.
1884
+
1885
+ The plan output — and `--dry-run` — lists every file it will touch:
1886
+
1887
+ ```text
1888
+ voltro update: 0.5.0 → 0.6.0
1889
+ workspace: /repo (4 package.json with @voltro/* deps)
1890
+ package.json
1891
+ @voltro/cli: ^0.5.0 → ^0.6.0
1892
+ apps/api/package.json
1893
+ @voltro/cli: ^0.5.0 → ^0.6.0
1894
+ @voltro/database: ^0.5.0 → ^0.6.0
1895
+ apps/web/package.json
1896
+ @voltro/client: ^0.5.0 → ^0.6.0
1897
+ packages/ui-admin/package.json
1898
+ @voltro/web: ~0.5.0 → ~0.6.0
1899
+ package manager: pnpm
1900
+ install runs in: /repo
1901
+ ```
1902
+
1903
+ A standalone (non-workspace) project is unaffected: its own `package.json`, its
1904
+ own install, in place.
1905
+
1840
1906
  ## Already bumped by hand? Recover the codemods
1841
1907
 
1842
1908
  If you bump `@voltro/*` versions in `package.json` yourself and install first, a
@@ -1859,9 +1925,31 @@ source version.
1859
1925
 
1860
1926
  Codemods **rewrite your source**, so you need a clean diff to review afterwards. `voltro update` refuses to run on a dirty git working tree — commit or stash first. Use `--dry-run` to preview without touching anything, or `--force` to override the guard (you accept a mixed diff).
1861
1927
 
1928
+ `--help` / `-h` is answered *before* the guard, so `voltro update --help` prints the flag list even on a dirty tree. The same holds for `voltro doctor --help`.
1929
+
1930
+ ## If the install fails
1931
+
1932
+ The bump is written before the install runs, so a failed install leaves your `package.json` on the target version — and **no codemods applied**. `voltro update` says so explicitly, because the codemods for a jump ship *inside* the target version: a failed install never put them on disk, so there is nothing that could have run them. Fix the install, run it, then apply the codemods you are missing with the command the failure message prints for you:
1933
+
1934
+ ```bash
1935
+ voltro update --codemods-only --from 0.5.0 --to 0.6.0
1936
+ ```
1937
+
1862
1938
  ## What gets bumped
1863
1939
 
1864
- Every `@voltro/*` entry in `dependencies` and `devDependencies`, with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.
1940
+ Every `@voltro/*` entry in `dependencies` and `devDependencies` — in every workspace member, see above — with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.
1941
+
1942
+ ## Which package manager
1943
+
1944
+ `voltro update` never assumes npm. It resolves your project's package manager in this order, starting in the app directory and walking **up to the repo root**:
1945
+
1946
+ 1. The **`packageManager` field** in a `package.json` (the corepack standard) — authoritative, wins over any lockfile.
1947
+ 2. A **lockfile** at that level — `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` / `bun.lockb`, `package-lock.json`.
1948
+ 3. **npm**, only when nothing declares one.
1949
+
1950
+ Walking up matters in a workspace: a scaffolded Voltro project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` still finds `pnpm` rather than falling back to npm and running `npm install` against a pnpm workspace.
1951
+
1952
+ The same resolved manager is used for the **registry lookup** of the latest version (`pnpm view`, `yarn npm info`, `bun pm view`), so a private or scoped registry configured in your `.npmrc` / `.yarnrc.yml` is honored. `npm view` is only a last-resort fallback.
1865
1953
 
1866
1954
  ## Codemods
1867
1955
 
@@ -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,35 +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
- ### Keeping a column off the wire — `omit`
425
+ ### Row codecs for server-side code — `rowSchema`, `columnSchema`
383
426
 
384
- ```ts
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`.
433
+
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.
438
+
439
+ ```ts no-check
440
+ // apps/api/database/notes.seed.ts — server-only, so the table value is fine
385
441
  import { rowSchema } from '@voltro/database'
386
- import { users } from '../database/schema'
442
+ import { Schema } from 'effect'
443
+ import { notes } from './schema'
387
444
 
445
+ const rows = Schema.decodeUnknownSync(Schema.Array(rowSchema(notes)))(
446
+ JSON.parse(await readFile('seed/notes.json', 'utf8')),
447
+ )
448
+ ```
449
+
450
+ `omit` drops columns from the schema — the way to keep an internal column out of
451
+ an export:
452
+
453
+ ```ts no-check
388
454
  rowSchema(users, { omit: ['passwordHash'] })
389
455
  ```
390
456
 
391
- `omit` is a **convenience, not a security boundary**. The column is simply absent
392
- from *this* schema; a handler that returns the same row under a different schema
393
- 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
394
460
  [`.encrypted()` and column sensitivity](/docs/database/sensitivity).
395
461
 
396
- ### One column at a time`columnSchema`
397
-
398
- `columnSchema` maps a single column, and it takes a column **definition** — what
399
- `table.fields` holds — not the builder that `text()` or `timestamp()` returns
400
- (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):
401
465
 
402
466
  ```ts no-check
403
467
  import { columnSchema } from '@voltro/database'
@@ -406,6 +470,10 @@ columnSchema(notes.fields.createdAt) // ✅ a definition, from table.fields
406
470
  columnSchema(timestamp()) // ❌ a builder — not readable
407
471
  ```
408
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
+
409
477
  ## What gets sent on the wire
410
478
 
411
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).
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