@voltro/cli 0.13.0 → 0.14.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 +285 -0
- package/dist/{apiBuild-B6_ehbem.js → apiBuild-370ITNRk.js} +2 -2
- package/dist/apiBuild-DatWP5hh.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-DYOuGlEC.js → commands-D-teL_TM.js} +3709 -2789
- package/dist/dbCommand-B3CeMU3u.js +2 -0
- package/dist/{dbCommand-DIOJQwsq.js → dbCommand-Dmi-vHfA.js} +2 -1
- package/dist/{dev-Fb9BmavX.js → dev-CKyW_LAv.js} +1620 -1509
- package/dist/{dev-xdijqojc.js → dev-CPKbckaN.js} +1 -1
- package/dist/fileConventions-COg0ZytD.js +9 -0
- package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-DCLhpHsF.js} +164 -163
- package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CyVMeofr.js} +955 -945
- package/dist/{serveCommand-CqYxzBZR.js → serveCommand-CU3Sw4ZM.js} +410 -396
- package/dist/serveEntry.js +2 -2
- package/dist/{start-CjG_0xhR.js → start-D8ATvTzo.js} +246 -246
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +77 -3
- package/templates/AGENTS.md +77 -3
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/authentication.md +5 -2
- package/templates/agent-docs/cli.md +36 -2
- package/templates/agent-docs/data.md +193 -1
- package/templates/agent-docs/database/hosting.md +2 -2
- package/templates/agent-docs/database/migrations.md +56 -3
- package/templates/agent-docs/database/overview.md +11 -0
- package/templates/agent-docs/database/seedsdialects.md +53 -1
- package/templates/agent-docs/internationalization.md +3 -3
- package/templates/agent-docs/introduction.md +133 -2
- package/templates/agent-docs/plugins.md +2 -1
- package/templates/agent-docs/reference.md +4 -4
- package/templates/agent-docs/routing.md +48 -43
- package/templates/agent-docs/schema-driven-ui.md +12 -2
- package/templates/agent-docs/templates/apibackends.md +1 -8
- package/templates/agent-docs/templates/appshells.md +24 -24
- package/templates/agent-docs/testing.md +16 -1
- package/templates/agent-docs/whats-new.md +16 -0
- package/templates/apps/api-ai/database/schema.ts +0 -2
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/database/schema.ts +0 -3
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/database/schema.ts +0 -3
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/database/schema.ts +0 -3
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
- package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/database/schema.ts +0 -3
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/database/schema.ts +0 -1
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/database/schema.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/database/schema.ts +0 -3
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/database/schema.ts +0 -2
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/database/schema.ts +0 -1
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/database/schema.ts +0 -1
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/database/schema.ts +0 -2
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/database/schema.ts +0 -3
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/database/schema.ts +0 -1
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/database/schema.ts +0 -1
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/database/schema.ts +0 -1
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/database/schema.ts +0 -1
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
- package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
- package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/dist/apiBuild-Bzpb383i.js +0 -2
- package/dist/dbCommand-ADD4mrTI.js +0 -2
- package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
- /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
- /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { $ as e } from "./inspectMetrics-CyVMeofr.js";
|
|
2
|
+
import { t } from "./start-D8ATvTzo.js";
|
|
3
3
|
export { e as loadDotEnv, t as runStartCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -62,22 +62,22 @@
|
|
|
62
62
|
"@effect/platform-node": "^0.107.0",
|
|
63
63
|
"@effect/sql": "^0.51.1",
|
|
64
64
|
"@effect/workflow": "^0.18.2",
|
|
65
|
-
"@voltro/ai": "0.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "0.
|
|
65
|
+
"@voltro/ai": "0.14.0",
|
|
66
|
+
"@voltro/cache": "0.14.0",
|
|
67
|
+
"@voltro/data-transfer": "0.14.0",
|
|
68
|
+
"@voltro/database": "0.14.0",
|
|
69
|
+
"@voltro/env": "0.14.0",
|
|
70
|
+
"@voltro/kv": "0.14.0",
|
|
71
|
+
"@voltro/logger": "0.14.0",
|
|
72
|
+
"@voltro/plugin-auth": "0.14.0",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.14.0",
|
|
74
|
+
"@voltro/plugin-mail": "0.14.0",
|
|
75
|
+
"@voltro/plugin-storage": "0.14.0",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.14.0",
|
|
77
|
+
"@voltro/protocol": "0.14.0",
|
|
78
|
+
"@voltro/runtime": "0.14.0",
|
|
79
|
+
"@voltro/serverless": "0.14.0",
|
|
80
|
+
"@voltro/workflow": "0.14.0",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -219,7 +219,7 @@ a `web/` (frontend).
|
|
|
219
219
|
│ │ └── rpcGroup.generated.ts # codegen — do NOT edit
|
|
220
220
|
│ └── web/
|
|
221
221
|
│ ├── app.config.ts # { type:'web', apis, theme, locales, … }
|
|
222
|
-
│ └── src/pages/**/*.tsx # file-based routing
|
|
222
|
+
│ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
|
|
223
223
|
├── packages/ # shared libs
|
|
224
224
|
└── AGENTS.md / CLAUDE.md # this file
|
|
225
225
|
```
|
|
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
|
|
|
249
249
|
| `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
|
|
250
250
|
| `*.tool.tsx` | tool an agent can call |
|
|
251
251
|
| `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
|
|
252
|
-
| `src/pages
|
|
252
|
+
| `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
|
|
253
|
+
| `*.component.tsx` | exactly ONE component (+ types) |
|
|
254
|
+
| `*.component.ui.tsx` | presentational: one component, READS only — never writes |
|
|
255
|
+
| `*.hook.ts` | exactly ONE `use*` hook (+ types) |
|
|
256
|
+
| `*.types.ts` | types only — zero runtime exports |
|
|
257
|
+
| `*.internal.ts` | only its own directory subtree may import it |
|
|
258
|
+
| `*.fixture.ts` | test material — no production path may reach it |
|
|
259
|
+
| `*.tracking.ts` | the ONLY place analytics may be called from |
|
|
260
|
+
| `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
|
|
261
|
+
| `*.client.ts` | declares itself + its transitive imports browser-safe |
|
|
262
|
+
|
|
263
|
+
**Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
|
|
264
|
+
hooks, tests — is ordinary code with no URL, so put it next to the page that uses
|
|
265
|
+
it rather than in a distant `components/` directory. The suffix decides *whether*
|
|
266
|
+
a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
|
|
267
|
+
outside `src/pages/` never routes. A page MUST default-export its component —
|
|
268
|
+
without it `voltro dev` refuses to boot and names the file.
|
|
269
|
+
|
|
270
|
+
**Every suffix above is a CONTRACT, not a label.** Something else depends on each
|
|
271
|
+
promise, and `voltro doctor` enforces all of them:
|
|
272
|
+
|
|
273
|
+
- `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
|
|
274
|
+
never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
|
|
275
|
+
what lets a caller render it ten thousand times in a list, reuse it across
|
|
276
|
+
features, and prerender it without reading its source. Lift the write into the
|
|
277
|
+
owning `*.component.tsx` and pass a handler down.
|
|
278
|
+
- `*.internal.ts` is the promise that refactoring inside that directory breaks
|
|
279
|
+
nobody. An import from another subtree revokes it.
|
|
280
|
+
- `*.types.ts` having no runtime export is what makes importing it free and makes
|
|
281
|
+
it impossible for it to sit in a runtime import cycle.
|
|
282
|
+
- `*.store.ts` holds ONE `defineStore`. Reads go through a selector
|
|
283
|
+
(`wizard.use((s) => s.step)`) — there is no whole-state read, because a
|
|
284
|
+
component holding all of it re-renders on every field. Scope a per-entity
|
|
285
|
+
instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
|
|
286
|
+
from a loader with `seedStore(...)`; it rides the hydration payload the router
|
|
287
|
+
already writes. An action that writes SEVERAL fields wraps them in
|
|
288
|
+
`store.batch('label', () => …)` — one notification, one devtools entry, one
|
|
289
|
+
undo step, and a rollback if it throws; `await` first, then batch (an async
|
|
290
|
+
callback is an error). `{ persist: { key } }` survives a reload.
|
|
291
|
+
NEVER put server data in a store: a subscription is already
|
|
292
|
+
live, and a copy is not — the page then shows the stale one.
|
|
293
|
+
- `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
|
|
294
|
+
catalogue, so "what do we send to third parties" is a file listing rather than
|
|
295
|
+
archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
|
|
296
|
+
NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
|
|
297
|
+
confined — only the declaration is.)
|
|
298
|
+
- Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
|
|
299
|
+
write today. The suffix is a promise about what it MAY do.
|
|
300
|
+
|
|
301
|
+
**These rules apply to code YOU write — never to vendored code.** A shadcn
|
|
302
|
+
component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
|
|
303
|
+
the next `add`; renaming it breaks their convention and is undone next run. A
|
|
304
|
+
directory is exempt when the app's `components.json` names it (`aliases.ui`) or
|
|
305
|
+
it carries a `.voltro-vendored` file whose first line says where the code came
|
|
306
|
+
from. Never add that marker to a directory of your own code — the point of the
|
|
307
|
+
rules is that everything we author follows them.
|
|
308
|
+
|
|
309
|
+
**Write the test in the same step as the code — always, for every primitive.**
|
|
310
|
+
Not "afterwards", not in a cleanup pass: the filename is derivable
|
|
311
|
+
(`users/[id].page.tsx` → `users/[id].page.test.tsx`,
|
|
312
|
+
`Card.component.tsx` → `Card.component.test.tsx`,
|
|
313
|
+
`order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
|
|
314
|
+
nothing to decide. A change is not finished until its test exists and passes.
|
|
315
|
+
|
|
316
|
+
Two traps worth naming, because a green run can hide both:
|
|
317
|
+
|
|
318
|
+
- **`voltro test` transpiles without type-checking.** A type error in a TEST file
|
|
319
|
+
passes the suite and fails the package's `typecheck`. Run both.
|
|
320
|
+
- **Never fake a service to make a test pass.** A test that only re-asserts its
|
|
321
|
+
own mock's return value is worse than no test — it reports coverage it does not
|
|
322
|
+
have. Assert the descriptor shape instead and cover the real path elsewhere.
|
|
253
323
|
|
|
254
324
|
## app.config.ts
|
|
255
325
|
|
|
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
|
|
|
344
414
|
```ts
|
|
345
415
|
export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
|
|
346
416
|
.with(softDelete(), tenant()) // tenant() transitively requires audit()
|
|
347
|
-
.reactive() // opt into the matcher engine (subscriptions)
|
|
348
417
|
```
|
|
349
418
|
|
|
419
|
+
- Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
|
|
420
|
+
turns reactivity OFF for a table entirely: no subscriber fires, locally or
|
|
421
|
+
across instances (the write still happens). Use it for a hot table nobody
|
|
422
|
+
subscribes to; never for one a query reads — that subscription never fires,
|
|
423
|
+
and `voltro dev` warns.
|
|
350
424
|
- `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
|
|
351
425
|
- Indexes are **table-level** (`.index('name', [...])`) — there is NO
|
|
352
426
|
column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
|
package/templates/AGENTS.md
CHANGED
|
@@ -219,7 +219,7 @@ a `web/` (frontend).
|
|
|
219
219
|
│ │ └── rpcGroup.generated.ts # codegen — do NOT edit
|
|
220
220
|
│ └── web/
|
|
221
221
|
│ ├── app.config.ts # { type:'web', apis, theme, locales, … }
|
|
222
|
-
│ └── src/pages/**/*.tsx # file-based routing
|
|
222
|
+
│ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
|
|
223
223
|
├── packages/ # shared libs
|
|
224
224
|
└── AGENTS.md / CLAUDE.md # this file
|
|
225
225
|
```
|
|
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
|
|
|
249
249
|
| `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
|
|
250
250
|
| `*.tool.tsx` | tool an agent can call |
|
|
251
251
|
| `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
|
|
252
|
-
| `src/pages
|
|
252
|
+
| `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
|
|
253
|
+
| `*.component.tsx` | exactly ONE component (+ types) |
|
|
254
|
+
| `*.component.ui.tsx` | presentational: one component, READS only — never writes |
|
|
255
|
+
| `*.hook.ts` | exactly ONE `use*` hook (+ types) |
|
|
256
|
+
| `*.types.ts` | types only — zero runtime exports |
|
|
257
|
+
| `*.internal.ts` | only its own directory subtree may import it |
|
|
258
|
+
| `*.fixture.ts` | test material — no production path may reach it |
|
|
259
|
+
| `*.tracking.ts` | the ONLY place analytics may be called from |
|
|
260
|
+
| `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
|
|
261
|
+
| `*.client.ts` | declares itself + its transitive imports browser-safe |
|
|
262
|
+
|
|
263
|
+
**Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
|
|
264
|
+
hooks, tests — is ordinary code with no URL, so put it next to the page that uses
|
|
265
|
+
it rather than in a distant `components/` directory. The suffix decides *whether*
|
|
266
|
+
a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
|
|
267
|
+
outside `src/pages/` never routes. A page MUST default-export its component —
|
|
268
|
+
without it `voltro dev` refuses to boot and names the file.
|
|
269
|
+
|
|
270
|
+
**Every suffix above is a CONTRACT, not a label.** Something else depends on each
|
|
271
|
+
promise, and `voltro doctor` enforces all of them:
|
|
272
|
+
|
|
273
|
+
- `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
|
|
274
|
+
never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
|
|
275
|
+
what lets a caller render it ten thousand times in a list, reuse it across
|
|
276
|
+
features, and prerender it without reading its source. Lift the write into the
|
|
277
|
+
owning `*.component.tsx` and pass a handler down.
|
|
278
|
+
- `*.internal.ts` is the promise that refactoring inside that directory breaks
|
|
279
|
+
nobody. An import from another subtree revokes it.
|
|
280
|
+
- `*.types.ts` having no runtime export is what makes importing it free and makes
|
|
281
|
+
it impossible for it to sit in a runtime import cycle.
|
|
282
|
+
- `*.store.ts` holds ONE `defineStore`. Reads go through a selector
|
|
283
|
+
(`wizard.use((s) => s.step)`) — there is no whole-state read, because a
|
|
284
|
+
component holding all of it re-renders on every field. Scope a per-entity
|
|
285
|
+
instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
|
|
286
|
+
from a loader with `seedStore(...)`; it rides the hydration payload the router
|
|
287
|
+
already writes. An action that writes SEVERAL fields wraps them in
|
|
288
|
+
`store.batch('label', () => …)` — one notification, one devtools entry, one
|
|
289
|
+
undo step, and a rollback if it throws; `await` first, then batch (an async
|
|
290
|
+
callback is an error). `{ persist: { key } }` survives a reload.
|
|
291
|
+
NEVER put server data in a store: a subscription is already
|
|
292
|
+
live, and a copy is not — the page then shows the stale one.
|
|
293
|
+
- `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
|
|
294
|
+
catalogue, so "what do we send to third parties" is a file listing rather than
|
|
295
|
+
archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
|
|
296
|
+
NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
|
|
297
|
+
confined — only the declaration is.)
|
|
298
|
+
- Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
|
|
299
|
+
write today. The suffix is a promise about what it MAY do.
|
|
300
|
+
|
|
301
|
+
**These rules apply to code YOU write — never to vendored code.** A shadcn
|
|
302
|
+
component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
|
|
303
|
+
the next `add`; renaming it breaks their convention and is undone next run. A
|
|
304
|
+
directory is exempt when the app's `components.json` names it (`aliases.ui`) or
|
|
305
|
+
it carries a `.voltro-vendored` file whose first line says where the code came
|
|
306
|
+
from. Never add that marker to a directory of your own code — the point of the
|
|
307
|
+
rules is that everything we author follows them.
|
|
308
|
+
|
|
309
|
+
**Write the test in the same step as the code — always, for every primitive.**
|
|
310
|
+
Not "afterwards", not in a cleanup pass: the filename is derivable
|
|
311
|
+
(`users/[id].page.tsx` → `users/[id].page.test.tsx`,
|
|
312
|
+
`Card.component.tsx` → `Card.component.test.tsx`,
|
|
313
|
+
`order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
|
|
314
|
+
nothing to decide. A change is not finished until its test exists and passes.
|
|
315
|
+
|
|
316
|
+
Two traps worth naming, because a green run can hide both:
|
|
317
|
+
|
|
318
|
+
- **`voltro test` transpiles without type-checking.** A type error in a TEST file
|
|
319
|
+
passes the suite and fails the package's `typecheck`. Run both.
|
|
320
|
+
- **Never fake a service to make a test pass.** A test that only re-asserts its
|
|
321
|
+
own mock's return value is worse than no test — it reports coverage it does not
|
|
322
|
+
have. Assert the descriptor shape instead and cover the real path elsewhere.
|
|
253
323
|
|
|
254
324
|
## app.config.ts
|
|
255
325
|
|
|
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
|
|
|
344
414
|
```ts
|
|
345
415
|
export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
|
|
346
416
|
.with(softDelete(), tenant()) // tenant() transitively requires audit()
|
|
347
|
-
.reactive() // opt into the matcher engine (subscriptions)
|
|
348
417
|
```
|
|
349
418
|
|
|
419
|
+
- Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
|
|
420
|
+
turns reactivity OFF for a table entirely: no subscriber fires, locally or
|
|
421
|
+
across instances (the write still happens). Use it for a hot table nobody
|
|
422
|
+
subscribes to; never for one a query reads — that subscription never fires,
|
|
423
|
+
and `voltro dev` warns.
|
|
350
424
|
- `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
|
|
351
425
|
- Indexes are **table-level** (`.index('name', [...])`) — there is NO
|
|
352
426
|
column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"group": null,
|
|
54
54
|
"description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
|
|
55
55
|
"path": "agent-docs/data.md",
|
|
56
|
-
"files":
|
|
56
|
+
"files": 17
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"id": "database/advancedqueries",
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"group": null,
|
|
180
180
|
"description": "Scaffold a Voltro project and boot it locally in under a minute.",
|
|
181
181
|
"path": "agent-docs/introduction.md",
|
|
182
|
-
"files":
|
|
182
|
+
"files": 6
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
185
|
"id": "multi-tenancy",
|
|
@@ -573,8 +573,9 @@ If your authorization is a database ROLE rather than a scope on the token, the f
|
|
|
573
573
|
// app.config.ts
|
|
574
574
|
export default defineApiConfig({
|
|
575
575
|
auth: {
|
|
576
|
-
resolveScopes: async (subject) => {
|
|
577
|
-
|
|
576
|
+
resolveScopes: async (subject, { store }) => {
|
|
577
|
+
if (store === undefined) return [] // still booting — claim nothing
|
|
578
|
+
const role = await roleCache.get(subject.id, store) // cache it — see below
|
|
578
579
|
return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
|
|
579
580
|
},
|
|
580
581
|
},
|
|
@@ -591,6 +592,8 @@ export const payrollList = defineQuery({
|
|
|
591
592
|
})
|
|
592
593
|
```
|
|
593
594
|
|
|
595
|
+
**You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `[]` then rather than guessing.
|
|
596
|
+
|
|
594
597
|
**Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
|
|
595
598
|
|
|
596
599
|
**It does not run for anonymous callers** — there is no identity to look a role up for.
|
|
@@ -154,6 +154,20 @@ requiring a scope no role grants, which makes that procedure permanently and
|
|
|
154
154
|
silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
|
|
155
155
|
exhaustive, so the rule stays quiet rather than flagging correct code.)
|
|
156
156
|
|
|
157
|
+
`rbac/unenforced-scope` is the same registry read in the other direction: a
|
|
158
|
+
scope a role GRANTS that no handler ever guards on. That direction has no
|
|
159
|
+
artefact to inspect — you cannot grep for an authorization check that was never
|
|
160
|
+
written, which is exactly why it survives review. One app modelled
|
|
161
|
+
`api-keys:write` in its role catalogue, complete and reviewed, and no handler
|
|
162
|
+
checked it: any member could mint a shared credential, and nothing failed.
|
|
163
|
+
Tests pass when an authorization check is missing.
|
|
164
|
+
|
|
165
|
+
It is a **warning**, because three innocent explanations exist: a plugin route
|
|
166
|
+
enforces it internally (the graph cannot see inside a plugin), a REST route
|
|
167
|
+
carries its own guards, or it is a UI-affordance scope that `useCan` reads to
|
|
168
|
+
hide a button and no server check backs on purpose. All three are fine. Not
|
|
169
|
+
knowing which is not.
|
|
170
|
+
|
|
157
171
|
#### Declared vs OBSERVED — reconciled against reality
|
|
158
172
|
|
|
159
173
|
A query's `source` and a mutation's `targets` are not documentation: the
|
|
@@ -466,6 +480,26 @@ The cleaner fix is on your side and worth doing anyway: start the container as
|
|
|
466
480
|
the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
|
|
467
481
|
handing over at all.
|
|
468
482
|
|
|
483
|
+
### What the boot tells you
|
|
484
|
+
|
|
485
|
+
Three checks run at boot and print one line each when they have something to say
|
|
486
|
+
— never fatal, and silent when the answer is fine:
|
|
487
|
+
|
|
488
|
+
- **A reactive table with no change trigger** (postgres). The schema fingerprint
|
|
489
|
+
covers columns, not triggers, so a restored dump or a hand-run `DROP TRIGGER`
|
|
490
|
+
leaves the schema "up to date" and the subscription silently not reaching other
|
|
491
|
+
instances. The reverse is reported too: a `.nonReactive()` table still carrying
|
|
492
|
+
a trigger pays `REPLICA IDENTITY FULL` and a NOTIFY per write for nothing.
|
|
493
|
+
- **A `.nonReactive()` table that a query reads.** That subscription will never
|
|
494
|
+
fire — first snapshot, then silence forever.
|
|
495
|
+
- **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
|
|
496
|
+
on and reachable by nobody; every issue request fails its guard.
|
|
497
|
+
|
|
498
|
+
`voltro doctor` adds a fourth, over your source: an executor that writes
|
|
499
|
+
`ctx.request.subject.id` while naming no guard, which is how an anonymous caller
|
|
500
|
+
reaches a NOT NULL column and gets a raw statement failure instead of a typed
|
|
501
|
+
refusal.
|
|
502
|
+
|
|
469
503
|
## `voltro dev <appDir>`
|
|
470
504
|
|
|
471
505
|
```bash
|
|
@@ -1138,8 +1172,8 @@ A page is listed when ALL of these hold:
|
|
|
1138
1172
|
|
|
1139
1173
|
```text
|
|
1140
1174
|
• renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
|
|
1141
|
-
src/pages/dashboard/index.tsx (/dashboard) — default renderMode
|
|
1142
|
-
src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
|
|
1175
|
+
src/pages/dashboard/index.page.tsx (/dashboard) — default renderMode
|
|
1176
|
+
src/pages/admin/settings.page.tsx (/admin/settings) — renderMode:'ssr'
|
|
1143
1177
|
→ 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.
|
|
1144
1178
|
```
|
|
1145
1179
|
|
|
@@ -971,6 +971,27 @@ const echo = useAction('app', 'demo.echo')
|
|
|
971
971
|
await echo.run({ message }) // one call → one typed result
|
|
972
972
|
```
|
|
973
973
|
|
|
974
|
+
### Actions declare what they touch
|
|
975
|
+
|
|
976
|
+
An action is non-transactional external I/O, and it often touches a table on the
|
|
977
|
+
way — a cache it fills, a job row it stamps. Declare it the same way a query and
|
|
978
|
+
a mutation do:
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
export const syncIssue = defineAction({
|
|
982
|
+
name: 'jira.syncIssue',
|
|
983
|
+
input: Schema.Struct({ key: Schema.String }),
|
|
984
|
+
output: Schema.Struct({ ok: Schema.Boolean }),
|
|
985
|
+
source: 'jiraIssueCache',
|
|
986
|
+
target: { table: 'jiraIssueCache', op: 'upsert' },
|
|
987
|
+
})
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
Without it the table is invisible to `voltro check`, which then reports it as an
|
|
991
|
+
orphan. While any action declares neither, the orphan rule says so and asks for
|
|
992
|
+
the declaration rather than proposing you delete the table — a wrong finding
|
|
993
|
+
whose remedy is destructive is worse than a wrong finding.
|
|
994
|
+
|
|
974
995
|
## Action Pair
|
|
975
996
|
|
|
976
997
|
Descriptor:
|
|
@@ -1207,6 +1228,12 @@ _How reactive query subscriptions stay live over WebSocket._
|
|
|
1207
1228
|
|
|
1208
1229
|
A **subscription** is what the browser gets when it calls `useSubscription(...)` for a `defineQuery` RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.
|
|
1209
1230
|
|
|
1231
|
+
Every table is reactive by default, so a query over any table is live with
|
|
1232
|
+
nothing to configure. A table explicitly marked
|
|
1233
|
+
[`.nonReactive()`](/docs/database/overview) emits no change events at all — a
|
|
1234
|
+
subscription over one returns its first snapshot and then stays silent forever,
|
|
1235
|
+
which is why `voltro dev` warns about that combination at boot.
|
|
1236
|
+
|
|
1210
1237
|
Subscriptions are not a separate file convention anymore. The file convention is [queries](/docs/data/queries): `*.query.ts` for the descriptor and `*.query.server.ts` for the executor.
|
|
1211
1238
|
|
|
1212
1239
|
Live — add a todo (or open this page in a second tab) and the list updates with
|
|
@@ -2012,6 +2039,171 @@ When no analytics sink is configured the framework provides the no-op sink: the
|
|
|
2012
2039
|
|
|
2013
2040
|
|
|
2014
2041
|
|
|
2042
|
+
---
|
|
2043
|
+
|
|
2044
|
+
<!-- source: en/data/client-state.md -->
|
|
2045
|
+
## Client state (defineStore)
|
|
2046
|
+
|
|
2047
|
+
_defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes._
|
|
2048
|
+
|
|
2049
|
+
Server state already has a home: a subscription **is** live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
|
|
2050
|
+
|
|
2051
|
+
Without a primitive for that you reach for zustand or jotai, which is a *parallel runtime* — the one thing the framework asks you not to bring. So it ships one.
|
|
2052
|
+
|
|
2053
|
+
```ts
|
|
2054
|
+
// wizard.store.ts
|
|
2055
|
+
import { defineStore } from '@voltro/client'
|
|
2056
|
+
|
|
2057
|
+
export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
```tsx
|
|
2061
|
+
const step = wizard.use((s) => s.step) // the global instance
|
|
2062
|
+
const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
2063
|
+
|
|
2064
|
+
wizard.set({ step: 2 })
|
|
2065
|
+
wizard.set((s) => ({ ...s, step: s.step + 1 }))
|
|
2066
|
+
```
|
|
2067
|
+
|
|
2068
|
+
## Reads go through a selector — there is no `useStore()`
|
|
2069
|
+
|
|
2070
|
+
A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when **that slice** changes:
|
|
2071
|
+
|
|
2072
|
+
```tsx
|
|
2073
|
+
const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
|
|
2074
|
+
const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
|
|
2075
|
+
```
|
|
2076
|
+
|
|
2077
|
+
### Computed values need `equals: shallow`
|
|
2078
|
+
|
|
2079
|
+
A selector that *builds* something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on **every** store change:
|
|
2080
|
+
|
|
2081
|
+
```tsx
|
|
2082
|
+
import { shallow } from '@voltro/client'
|
|
2083
|
+
|
|
2084
|
+
const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
|
|
2085
|
+
const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
|
|
2086
|
+
```
|
|
2087
|
+
|
|
2088
|
+
You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
|
|
2089
|
+
|
|
2090
|
+
## Scoping is by key, not by a Provider
|
|
2091
|
+
|
|
2092
|
+
A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that *is* what makes context painful at scale. So an instance is addressed by a key you already have:
|
|
2093
|
+
|
|
2094
|
+
```tsx
|
|
2095
|
+
wizard.use((s) => s.step, { key: orderId })
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
which is the same model as everywhere else in the framework: `useSubscription('orders.list', { orgId })` is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
|
|
2099
|
+
|
|
2100
|
+
`wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
|
|
2101
|
+
|
|
2102
|
+
## SSR seeding adds no new channel
|
|
2103
|
+
|
|
2104
|
+
Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
|
|
2105
|
+
|
|
2106
|
+
```ts
|
|
2107
|
+
export const loader = async ({ params }) => {
|
|
2108
|
+
seedStore(wizard, { step: 2 }, { key: params.orderId })
|
|
2109
|
+
return { /* … */ }
|
|
2110
|
+
}
|
|
2111
|
+
```
|
|
2112
|
+
|
|
2113
|
+
There is no `dehydrate()` to remember and no `hydrate()` to forget — the seed rides the hydration payload the router already writes, and `mount()` applies it before the tree exists. A step you can forget is a step somebody will.
|
|
2114
|
+
|
|
2115
|
+
On the client `seedStore` **throws**. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
|
|
2116
|
+
|
|
2117
|
+
**The key is part of the address.** Seed with `{ key: orderId }` and read with `{ key: orderId }`. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
|
|
2118
|
+
|
|
2119
|
+
## Surviving a reload
|
|
2120
|
+
|
|
2121
|
+
`persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
|
|
2122
|
+
|
|
2123
|
+
```tsx
|
|
2124
|
+
export const filters = defineStore(
|
|
2125
|
+
'inbox:filters',
|
|
2126
|
+
() => ({ status: 'open', sort: 'newest', draft: '' }),
|
|
2127
|
+
{
|
|
2128
|
+
persist: {
|
|
2129
|
+
key: 'inbox:filters',
|
|
2130
|
+
storage: 'local', // 'session' lasts the tab
|
|
2131
|
+
pick: (s) => ({ status: s.status, sort: s.sort }),
|
|
2132
|
+
migrate: (stored) => (isFilters(stored) ? stored : undefined),
|
|
2133
|
+
},
|
|
2134
|
+
},
|
|
2135
|
+
)
|
|
2136
|
+
```
|
|
2137
|
+
|
|
2138
|
+
Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
|
|
2139
|
+
|
|
2140
|
+
**The stored value is merged over `initial()`, not substituted for it.** Ship a new field and every returning user has state without it — `undefined` where the type promises a string. Merging means an old payload gains the new defaults.
|
|
2141
|
+
|
|
2142
|
+
**`migrate` returning `undefined` discards the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
|
|
2143
|
+
|
|
2144
|
+
**Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`, not just on writing. A store that throws at import time takes the page with it.
|
|
2145
|
+
|
|
2146
|
+
**A persisted store on a server-rendered page hydrates against the *server* value.** The server has no `localStorage`, so it renders `initial()`; the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. `get()` is not deferred, only the render: an action reading the draft before the first paint reads the draft.
|
|
2147
|
+
|
|
2148
|
+
`pick` narrows what gets written — persist the filters, not the open/closed state of every panel. And only the **global** instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
|
|
2149
|
+
|
|
2150
|
+
## Actions that write more than once
|
|
2151
|
+
|
|
2152
|
+
An action rarely touches one field. Applying a coupon writes the coupon *and* the recomputed total; that is one thing the user did, and `batch` says so:
|
|
2153
|
+
|
|
2154
|
+
```ts
|
|
2155
|
+
checkout.batch('applyCoupon', () => {
|
|
2156
|
+
checkout.set({ coupon })
|
|
2157
|
+
checkout.set({ total: recompute(coupon) })
|
|
2158
|
+
})
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
One notification, one entry in the devtools feed named `applyCoupon`, and **one undo step**. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own; it cannot batch the meaning.
|
|
2162
|
+
|
|
2163
|
+
**If the callback throws, every write it made is rolled back.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested `batch` joins its parent rather than opening a second one.
|
|
2164
|
+
|
|
2165
|
+
### Async work goes around the batch, not inside it
|
|
2166
|
+
|
|
2167
|
+
```ts
|
|
2168
|
+
const quote = await fetchQuote(coupon) // await FIRST
|
|
2169
|
+
checkout.batch('applyCoupon', () => { // then batch the writes
|
|
2170
|
+
checkout.set({ coupon, total: quote.total })
|
|
2171
|
+
})
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
Passing an `async` function to `batch` is an **error**, not a warning. Everything after the first `await` would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
|
|
2175
|
+
|
|
2176
|
+
## Undo and redo
|
|
2177
|
+
|
|
2178
|
+
Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
|
|
2179
|
+
|
|
2180
|
+
```tsx
|
|
2181
|
+
draft.undo(orderId) // back one write
|
|
2182
|
+
draft.redo(orderId) // forward again
|
|
2183
|
+
draft.canUndo(orderId) // for disabling the button
|
|
2184
|
+
draft.canRedo(orderId)
|
|
2185
|
+
```
|
|
2186
|
+
|
|
2187
|
+
It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a **new write after an undo drops the redo tail** — the behaviour every editor has.
|
|
2188
|
+
|
|
2189
|
+
An undo never becomes undoable itself, and each keyed instance has its own history.
|
|
2190
|
+
|
|
2191
|
+
## Devtools: inspect, and travel
|
|
2192
|
+
|
|
2193
|
+
The `voltro dev` overlay has a **Stores** tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
|
|
2194
|
+
|
|
2195
|
+
`◀ Back` and `Forward ▶` step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
|
|
2196
|
+
|
|
2197
|
+
No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
|
|
2198
|
+
|
|
2199
|
+
## What a store must never hold
|
|
2200
|
+
|
|
2201
|
+
Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as *"reactivity is broken"*. `voltro doctor` reports a `*.store.ts` that reads a subscription.
|
|
2202
|
+
|
|
2203
|
+
Read server state where you render it, and keep the store for what is genuinely client-side.
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
|
|
2015
2207
|
---
|
|
2016
2208
|
|
|
2017
2209
|
<!-- source: en/data/crud.md -->
|
|
@@ -3098,7 +3290,7 @@ export const blogPost = defineContentType({
|
|
|
3098
3290
|
register them in your `database/index.ts` so auto-migrate creates them. Both
|
|
3099
3291
|
carry the lifecycle `status` column (`draft` / `published` / `archived`), the
|
|
3100
3292
|
`tenant()` mixin (tenant-scoped reads/writes through the request store), and
|
|
3101
|
-
are
|
|
3293
|
+
are reactive (the default) so a publish wakes live subscriptions.
|
|
3102
3294
|
|
|
3103
3295
|
## Reading — `ctx.cms`
|
|
3104
3296
|
|
|
@@ -657,11 +657,11 @@ DB_URL=postgresql://tsdbadmin:[PASSWORD]@[SERVICE].[PROJECT].tsdb.cloud.timescal
|
|
|
657
657
|
|
|
658
658
|
Voltro's schema DSL emits ordinary Postgres tables. To turn one into a TimescaleDB hypertable (chunked by time for fast time-series queries), run `create_hypertable(...)` yourself after the table exists — via a custom migration or a `*.seed.ts` that issues the SQL. The framework's `voltro migrate` doesn't generate hypertable DDL; it's a deliberate, per-table decision.
|
|
659
659
|
|
|
660
|
-
A table you convert to a hypertable still works with the reactive engine and `ctx.store` exactly as before — the conversion is transparent to Voltro's read/write path. Heavy time-series ingestion tables are usually
|
|
660
|
+
A table you convert to a hypertable still works with the reactive engine and `ctx.store` exactly as before — the conversion is transparent to Voltro's read/write path. Heavy time-series ingestion tables are usually the ones you mark `.nonReactive()` (you don't want a notification firing on every metric insert), so the two concerns rarely collide.
|
|
661
661
|
|
|
662
662
|
## Enabling CDC
|
|
663
663
|
|
|
664
|
-
Timescale is real Postgres, so Voltro's change-data-capture is `LISTEN/NOTIFY` and **on by default** (`CDC=1`) — no extension, no flag, and TimescaleDB's presence changes nothing here. Timescale Cloud puts **no transaction pooler in front by default**, so the `DB_URL` you connect with is session-mode and reactivity works immediately. (
|
|
664
|
+
Timescale is real Postgres, so Voltro's change-data-capture is `LISTEN/NOTIFY` and **on by default** (`CDC=1`) — no extension, no flag, and TimescaleDB's presence changes nothing here. Timescale Cloud puts **no transaction pooler in front by default**, so the `DB_URL` you connect with is session-mode and reactivity works immediately. (Every table is reactive by default, so on a hypertable every chunk insert fires a notification — usually not what you want on high-ingest time-series tables. Mark it `.nonReactive()`; see the hypertable note above.)
|
|
665
665
|
|
|
666
666
|
## Pooling / SSL
|
|
667
667
|
|