@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.
- package/CHANGELOG.md +41 -0
- package/bin/voltro.mjs +15 -6
- package/dist/apiBuild-CmtI6Ygh.js +2 -0
- package/dist/{apiBuild-BxY44VGj.js → apiBuild-OrDkXGFX.js} +21 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-My_YlUJV.js → commands-DT4PipBz.js} +2489 -2972
- package/dist/dev-Ca1jdwnt.js +2 -0
- package/dist/dev-Ga65K4jc.js +6492 -0
- package/dist/index.js +1 -1
- package/dist/inspectMetrics-EK0CcHyu.js +3929 -0
- package/dist/{serveCommand-C-XQ89NJ.js → serveCommand-CR5xnYZt.js} +209 -208
- package/dist/serveEntry.js +3 -2
- package/dist/start-DuyBQxW3.js +977 -0
- package/dist/startEntry.d.ts +5 -0
- package/dist/startEntry.js +3 -0
- package/package.json +17 -17
- package/templates/AGENTS.core.md +15 -4
- package/templates/AGENTS.md +15 -4
- package/templates/agent-docs/authentication.md +111 -13
- package/templates/agent-docs/cli.md +94 -6
- package/templates/agent-docs/data.md +99 -31
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/reference.md +20 -0
- package/templates/agent-docs/routing.md +158 -24
- package/templates/agent-docs/testing.md +82 -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-Bk4JBt-i.js +0 -2
- package/dist/dev-Bjq-nIvH.js +0 -10186
- package/dist/dev-D6b74iXI.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "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.
|
|
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.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"
|
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
|
|
@@ -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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
-
-
|
|
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
|
+
|
|
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 | `
|
|
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
|
|
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
|
|
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 — `
|
|
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,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
|
-
###
|
|
425
|
+
### Row codecs for server-side code — `rowSchema`, `columnSchema`
|
|
383
426
|
|
|
384
|
-
|
|
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 {
|
|
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
|
-
|
|
392
|
-
from *this* schema;
|
|
393
|
-
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
|
|
394
460
|
[`.encrypted()` and column sensitivity](/docs/database/sensitivity).
|
|
395
461
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
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
|
|