@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
|
@@ -1132,6 +1132,59 @@ If `down` throws, the rollback is treated as failed — the schema stays
|
|
|
1132
1132
|
in the half-rolled-back state + the operator handles it manually. The
|
|
1133
1133
|
framework can't auto-recover from a broken inverse.
|
|
1134
1134
|
|
|
1135
|
+
## Remote databases: boot will not apply them unattended
|
|
1136
|
+
|
|
1137
|
+
`voltro dev` applies pending migration files at boot. Against a local
|
|
1138
|
+
database that is the whole point of the escape hatch. Against a remote
|
|
1139
|
+
one it means that saving a file is enough to change production — before
|
|
1140
|
+
review, before CI, without typing a command.
|
|
1141
|
+
|
|
1142
|
+
So when the configured database is **not local** and there are pending
|
|
1143
|
+
migration files, dev boot refuses instead:
|
|
1144
|
+
|
|
1145
|
+
```text
|
|
1146
|
+
file-based migrations: refusing to auto-apply 1 file-based migration(s)
|
|
1147
|
+
to a REMOTE database (ep-cool-dawn.eu-central-1.aws.neon.tech).
|
|
1148
|
+
|
|
1149
|
+
Pending:
|
|
1150
|
+
• 20260725_150000_drop_legacy_subscriptions
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
A database counts as **local** when its host is loopback
|
|
1154
|
+
(`localhost`, `127.0.0.1`, `::1`), a private LAN address
|
|
1155
|
+
(`10.x`, `192.168.x`, `172.16–31.x`), `host.docker.internal`, a `.local`
|
|
1156
|
+
/ `.localhost` name, a `file:` / `sqlite:` URL, or a **bare hostname**
|
|
1157
|
+
like `postgres` or `db` — only a container network resolves those, so
|
|
1158
|
+
`docker compose up` keeps working untouched. Everything else — a managed
|
|
1159
|
+
provider, any dotted public hostname, an unparseable `DB_URL` — counts as
|
|
1160
|
+
remote.
|
|
1161
|
+
|
|
1162
|
+
Three ways forward:
|
|
1163
|
+
|
|
1164
|
+
```sh
|
|
1165
|
+
# 1. Point the app at a local database (what dev boot assumes)
|
|
1166
|
+
DB_URL=postgres://app:app@localhost:5432/app voltro dev
|
|
1167
|
+
|
|
1168
|
+
# 2. Apply them deliberately, once
|
|
1169
|
+
voltro db files .
|
|
1170
|
+
|
|
1171
|
+
# 3. Accept unattended applies for this environment
|
|
1172
|
+
VOLTRO_REMOTE_MIGRATIONS_OK=1 voltro dev
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
The gate is **silent when nothing is pending**, which is the normal
|
|
1176
|
+
case — running dev against a remote database is unaffected until the
|
|
1177
|
+
moment a file would actually execute against it. It refuses rather than
|
|
1178
|
+
skipping quietly: a skipped migration leaves the database in a shape the
|
|
1179
|
+
app does not expect (a half-done table split, a column the handlers
|
|
1180
|
+
already read), and the failures that follow point everywhere except at
|
|
1181
|
+
the cause.
|
|
1182
|
+
|
|
1183
|
+
It does **not** try to detect destructive SQL. In arbitrary SQL that is
|
|
1184
|
+
not decidable, so such a check would be either leaky or noisy. What the
|
|
1185
|
+
gate separates is the two things boot used to conflate: *saving a file*
|
|
1186
|
+
and *applying it to production*.
|
|
1187
|
+
|
|
1135
1188
|
## Applying file-based migrations from the CLI
|
|
1136
1189
|
|
|
1137
1190
|
`voltro db files` applies pending file-based migrations under
|
|
@@ -2242,7 +2295,7 @@ If multiple apps target the same database, they're showing the same `_voltro_mig
|
|
|
2242
2295
|
|
|
2243
2296
|
## Source code
|
|
2244
2297
|
|
|
2245
|
-
The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.tsx`. Both repos are open to extension.
|
|
2298
|
+
The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.page.tsx`. Both repos are open to extension.
|
|
2246
2299
|
|
|
2247
2300
|
|
|
2248
2301
|
|
|
@@ -2430,8 +2483,8 @@ server enforces this regardless of which buttons the UI renders.
|
|
|
2430
2483
|
|
|
2431
2484
|
## Source code
|
|
2432
2485
|
|
|
2433
|
-
- Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx` — shared
|
|
2434
|
-
- Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.tsx`
|
|
2486
|
+
- Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx` — shared
|
|
2487
|
+
- Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.page.tsx`
|
|
2435
2488
|
- Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
|
|
2436
2489
|
- Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
|
|
2437
2490
|
- Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
|
|
@@ -39,6 +39,17 @@ This section covers everything about how the framework reads + writes data.
|
|
|
39
39
|
|
|
40
40
|
You declare tables once. The migrator produces SQL; the runtime gives you a typed `ctx.store`; the reactive engine watches the same rows.
|
|
41
41
|
|
|
42
|
+
**Every table is reactive by default.** There is nothing to opt into — this is a
|
|
43
|
+
reactive framework, so a capability you had to remember to switch on is one half
|
|
44
|
+
your tables would not have. Writes emit change events, subscriptions stay live,
|
|
45
|
+
and on a multi-instance deployment the change reaches the other instances too.
|
|
46
|
+
|
|
47
|
+
`.nonReactive()` turns it off for one table, on every dialect: no subscriber
|
|
48
|
+
fires, locally or across instances. The write still happens — this is about
|
|
49
|
+
notification, never persistence. Use it for a genuinely hot table nobody
|
|
50
|
+
subscribes to (an append-only event log, a metrics sink); never for one a query
|
|
51
|
+
reads, because that subscription would never fire. `voltro dev` warns if you do.
|
|
52
|
+
|
|
42
53
|
## A first table
|
|
43
54
|
|
|
44
55
|
```ts
|
|
@@ -150,6 +150,13 @@ See [migrations](/docs/database/migrations) for schema changes — seeds populat
|
|
|
150
150
|
|
|
151
151
|
_Six SQL backends, one schema DSL. Decision matrix, configuration, boot-log shape, and the cross-dialect feature parity table the framework hides for you._
|
|
152
152
|
|
|
153
|
+
**Reactivity is dialect-independent.** Every table is reactive by default on all
|
|
154
|
+
six backends — you write nothing to get it, and `.nonReactive()` turns it off
|
|
155
|
+
for one table everywhere. What differs is only the TRANSPORT that carries a
|
|
156
|
+
change between instances: Postgres uses LISTEN/NOTIFY, MySQL and MariaDB read
|
|
157
|
+
the binlog, MSSQL uses Change Tracking, and SQLite / Turso have no native one —
|
|
158
|
+
[`@voltro/plugin-broadcast`](../multi-replica) closes that gap.
|
|
159
|
+
|
|
153
160
|
Voltro runs on six SQL backends. The application code — schema, queries, workflows, subscriptions, the reactive engine — is written ONCE and compiles down to the dialect-native idiom at runtime. Selecting a dialect is a single environment variable.
|
|
154
161
|
|
|
155
162
|
```
|
|
@@ -361,7 +368,27 @@ The framework wraps `pg` (`node-postgres`) via Effect's typed connection layer.
|
|
|
361
368
|
|
|
362
369
|
## CDC — LISTEN/NOTIFY
|
|
363
370
|
|
|
364
|
-
Every
|
|
371
|
+
Every table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
|
|
372
|
+
|
|
373
|
+
**Every table gets this by default.** Reactivity is what the framework is for,
|
|
374
|
+
so you write nothing to opt in — the trigger above is installed for every table
|
|
375
|
+
unless you opt it out.
|
|
376
|
+
|
|
377
|
+
`.nonReactive()` turns reactivity **off** for a table — not "off across
|
|
378
|
+
instances". It emits no change events at all: no local subscriber fires and no
|
|
379
|
+
cross-instance transport carries it. That holds on every dialect, including
|
|
380
|
+
sqlite and turso, because the guard sits at each store's emit. On postgres it
|
|
381
|
+
additionally drops the trigger and `REPLICA IDENTITY FULL`; on mysql/mariadb and
|
|
382
|
+
mssql it drops the table from the reader's filter.
|
|
383
|
+
|
|
384
|
+
The write itself is unaffected — this is about notification, never persistence.
|
|
385
|
+
|
|
386
|
+
Worth using for a genuinely hot table nobody subscribes to: an append-only event
|
|
387
|
+
log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the
|
|
388
|
+
WAL and the trigger fires on every write, so that is a real saving.
|
|
389
|
+
|
|
390
|
+
Do **not** use it on a table a query reads — that subscription will never fire.
|
|
391
|
+
`voltro dev` and `voltro serve` warn at boot when that combination exists.
|
|
365
392
|
|
|
366
393
|
Latency: very low on a local-network postgres (it varies with network and load). The framework instruments this — `voltro traces` shows the notification → dispatcher → subscriber waterfall.
|
|
367
394
|
|
|
@@ -514,6 +541,11 @@ rows.map((r) => r.id) // ← the full generated range, in input order
|
|
|
514
541
|
|
|
515
542
|
MySQL has no `LISTEN/NOTIFY`, but MySQL 8's ROW-format binary log is a real out-of-band CDC source — the SAME binlog the framework already tails for mariadb. The framework tails the primary's binlog via the `@vlasky/zongji` replication client, so **every replica tails the binlog itself and a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy). This is at parity with mariadb: binlog CDC is **one path** with per-variant detection, not two implementations.
|
|
516
543
|
|
|
544
|
+
**Every table is reactive by default** — nothing to opt into. `.nonReactive()`
|
|
545
|
+
turns it off for one table on *every* dialect: no subscriber fires, locally or
|
|
546
|
+
across instances. The write still happens; only the notification is suppressed.
|
|
547
|
+
See [the schema overview](/docs/database/overview).
|
|
548
|
+
|
|
517
549
|
### How it works
|
|
518
550
|
|
|
519
551
|
1. With `CDC=1` (the default for any SQL dialect) on `mysql`, the store starts a binlog reader on a **separate replication connection** — distinct from the SQL pool.
|
|
@@ -739,6 +771,11 @@ The framework uses it for the no-pagination case of `many()` / `manyToMany()` on
|
|
|
739
771
|
|
|
740
772
|
MariaDB has no `LISTEN/NOTIFY`, but it gets a real out-of-band CDC source: the framework tails the primary's **ROW-format binary log** via the `@vlasky/zongji` replication client. This is the MariaDB equivalent of postgres's `LISTEN/NOTIFY` — **every replica tails the binlog itself, so a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy).
|
|
741
773
|
|
|
774
|
+
**Every table is reactive by default** — nothing to opt into. `.nonReactive()`
|
|
775
|
+
turns it off for one table on *every* dialect: no subscriber fires, locally or
|
|
776
|
+
across instances. The write still happens; only the notification is suppressed.
|
|
777
|
+
See [the schema overview](/docs/database/overview).
|
|
778
|
+
|
|
742
779
|
This is **one binlog-CDC path shared with `mysql`** — mysql-8 and mariadb speak the same ROW binlog to the reader, so both get cross-instance CDC. The only engine difference is the binlog-end query (mariadb `SHOW MASTER STATUS`; mysql 8.4 `SHOW BINARY LOG STATUS`), which the store picks per variant.
|
|
743
780
|
|
|
744
781
|
### How it works
|
|
@@ -979,6 +1016,11 @@ Requires the deployment to use Always-On AGs (the modern HA story since SQL Serv
|
|
|
979
1016
|
|
|
980
1017
|
SQL Server ships **Change Tracking** (CT) — a lightweight, built-in change source available on every edition (unlike the heavier Change Data Capture feature). The framework's mssql store uses it as an out-of-band CDC reader so a write on ANY instance surfaces on EVERY instance's `onChange` — the mssql equivalent of postgres `LISTEN/NOTIFY` or mariadb binlog CDC.
|
|
981
1018
|
|
|
1019
|
+
**Every table is reactive by default** — nothing to opt into. `.nonReactive()`
|
|
1020
|
+
turns it off for one table on *every* dialect: no subscriber fires, locally or
|
|
1021
|
+
across instances. The write still happens; only the notification is suppressed.
|
|
1022
|
+
See [the schema overview](/docs/database/overview).
|
|
1023
|
+
|
|
982
1024
|
Enable it and set `changeStrategy: 'cdc'` (the default when `CDC` is not `0`):
|
|
983
1025
|
|
|
984
1026
|
```sh
|
|
@@ -1098,6 +1140,11 @@ SQLite is single-writer by definition. Setting `DB_REPLICA_URLS=…` is a no-op
|
|
|
1098
1140
|
|
|
1099
1141
|
Sqlite has no LISTEN/NOTIFY equivalent and no trigger-based fan-out is needed (everything runs in one process). The framework's `SqliteStore` uses an in-process Node `EventEmitter` (composed as a private field, not subclassed):
|
|
1100
1142
|
|
|
1143
|
+
**Every table is reactive by default** — nothing to opt into. `.nonReactive()`
|
|
1144
|
+
turns it off for one table on *every* dialect: no subscriber fires, locally or
|
|
1145
|
+
across instances. The write still happens; only the notification is suppressed.
|
|
1146
|
+
See [the schema overview](/docs/database/overview).
|
|
1147
|
+
|
|
1101
1148
|
- Insert/update/delete emit `'change'` events synchronously to the dispatcher.
|
|
1102
1149
|
- The dispatcher's `onChange` callback is registered against the emitter — no polling, no triggers, no log table.
|
|
1103
1150
|
|
|
@@ -1237,6 +1284,11 @@ Like `sqlite`, Turso is single-node here. The workflow engine boots with `runner
|
|
|
1237
1284
|
|
|
1238
1285
|
Same as `sqlite`: an in-process Node `EventEmitter` fans insert/update/delete events to the dispatcher. Sub-millisecond, single-process. No `LISTEN/NOTIFY`, no triggers.
|
|
1239
1286
|
|
|
1287
|
+
**Every table is reactive by default** — nothing to opt into. `.nonReactive()`
|
|
1288
|
+
turns it off for one table on *every* dialect: no subscriber fires, locally or
|
|
1289
|
+
across instances. The write still happens; only the notification is suppressed.
|
|
1290
|
+
See [the schema overview](/docs/database/overview).
|
|
1291
|
+
|
|
1240
1292
|
## Not supported on Turso (beta gaps)
|
|
1241
1293
|
|
|
1242
1294
|
These are SQLite features the `sqlite` dialect has but Turso's MVCC mode does not. The framework fails LOUD at migrate rather than emit DDL the engine rejects:
|
|
@@ -423,7 +423,7 @@ export default function Layout({ children }) {
|
|
|
423
423
|
}
|
|
424
424
|
```
|
|
425
425
|
|
|
426
|
-
Pair this with locale-prefixed page files: `src/pages/[locale]/index.tsx`, `src/pages/[locale]/docs/[...slug].tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
|
|
426
|
+
Pair this with locale-prefixed page files: `src/pages/[locale]/index.page.tsx`, `src/pages/[locale]/docs/[...slug].page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
|
|
427
427
|
|
|
428
428
|
Reference implementations: `voltro-dev/apps/voltro-dev/docs/` (URL-prefix on dynamic + static content) and `voltro-dev/apps/voltro-dev/landing/` (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined `voltro build` run).
|
|
429
429
|
|
|
@@ -441,7 +441,7 @@ dist/de/features/foo/index.html ← de mirror
|
|
|
441
441
|
Each variant ships with the right `<I18nProvider>`-wrapped body **and** the right per-locale `<title>` / `<meta description>` / `<link rel="canonical">` / OG tags — **if** the page's `meta` is exported as a function of `({ locale })`. With a plain static `meta: PageMeta` object, the body is correctly localised but the head tags stay default-locale on every variant.
|
|
442
442
|
|
|
443
443
|
```tsx
|
|
444
|
-
// src/pages/features/foo.tsx — meta as a function of locale
|
|
444
|
+
// src/pages/features/foo.page.tsx — meta as a function of locale
|
|
445
445
|
import { getCatalog } from '../lib/locale'
|
|
446
446
|
import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'
|
|
447
447
|
|
|
@@ -472,7 +472,7 @@ The `[locale]/…` mirror is the trigger for SSG **output** — without mirror f
|
|
|
472
472
|
12 lines per page. Re-export the canonical page's `default`, `renderMode`, `interactive`, `meta`, plus a `getStaticPaths` that enumerates non-default locales:
|
|
473
473
|
|
|
474
474
|
```tsx
|
|
475
|
-
// src/pages/[locale]/features/foo.tsx
|
|
475
|
+
// src/pages/[locale]/features/foo.page.tsx
|
|
476
476
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
|
|
477
477
|
export { default } from '../../features/foo'
|
|
478
478
|
export { renderMode, interactive, meta } from '../../features/foo'
|
|
@@ -383,11 +383,28 @@ Procedure descriptors are intentionally separate from server executors. Descript
|
|
|
383
383
|
|
|
384
384
|
The browser-safe rule is **transitive**, and that is where it usually breaks. The codegen pulls every descriptor (and every workflow descriptor) value-level into `rpcGroup.generated.ts`, which the web client loads — so a descriptor plus *everything it imports* must stay free of server-only code (`node:*`, the `database` handle, `@voltro/ai`, cluster, plugins, `@voltro/protocol/session`). The classic mistake is not a literal `import 'node:crypto'` but a descriptor importing a shared typed-error or helper from a `lib/` file that *also* imports the database — which drags the whole schema graph into the browser bundle. Keep typed errors, Schemas, and pure helpers in files with zero server imports; put DB-backed guards in `.server.ts`. A leak shows up as the web app fetching hundreds of modules / tens of MB on first load, or crashing with `Module "node:crypto" has been externalized for browser compatibility`.
|
|
385
385
|
|
|
386
|
+
### Declaring a shared file browser-safe: `*.client.ts`
|
|
387
|
+
|
|
388
|
+
A shared `lib/` helper can state the rule about itself. Name it `*.client.ts` (or `*.client.tsx`) and it declares: *I, and everything I transitively import, are browser-safe.* `voltro dev` walks its import graph at boot and refuses to start if the claim is false, printing the chain.
|
|
389
|
+
|
|
390
|
+
```text
|
|
391
|
+
lib/orderErrors.client.ts # I and my imports are browser-safe — checked at boot
|
|
392
|
+
lib/orderGuards.server.ts # I may touch the database handle
|
|
393
|
+
lib/orderTypes.ts # unmarked: no claim, the graph decides
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
This is the mirror of `*.server.ts`, and it works for the same reason: both declare a **permission**, which is something an import graph cannot derive. The graph can tell you what a file imports; it can never tell you what a file is *allowed* to import.
|
|
397
|
+
|
|
398
|
+
Without the marker the leak is still caught — by the rpcGroup guard — but only once some descriptor happens to reach the file, and the error is a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
|
|
399
|
+
|
|
400
|
+
An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
|
|
401
|
+
|
|
402
|
+
|
|
386
403
|
## The web side (`apps/*/web/`)
|
|
387
404
|
|
|
388
405
|
| Path | What it is |
|
|
389
406
|
|---|---|
|
|
390
|
-
| `src/pages/*.tsx` | A page. URL is the file path; `[id].tsx` -> `/:id`, `[...slug].tsx` -> catch-all. |
|
|
407
|
+
| `src/pages/*.tsx` | A page. URL is the file path; `[id].page.tsx` -> `/:id`, `[...slug].page.tsx` -> catch-all. |
|
|
391
408
|
| `src/pages/layout.tsx` | Outer layout — wraps every page. |
|
|
392
409
|
| `src/pages/error.tsx` | Error boundary for the whole subtree. |
|
|
393
410
|
| `src/pages/not-found.tsx` | Fallback rendered when no page matches. |
|
|
@@ -398,7 +415,7 @@ The browser-safe rule is **transitive**, and that is where it usually breaks. Th
|
|
|
398
415
|
Each page can opt into a render strategy via two exports:
|
|
399
416
|
|
|
400
417
|
```tsx
|
|
401
|
-
// src/pages/blog/[slug].tsx
|
|
418
|
+
// src/pages/blog/[slug].page.tsx
|
|
402
419
|
export const renderMode = 'isr' as const // 'static' | 'spa' | 'ssr' | 'isr'
|
|
403
420
|
export const interactive = 'islands' as const // 'none' | 'islands' | 'full'
|
|
404
421
|
```
|
|
@@ -436,3 +453,117 @@ apps/acme/api/
|
|
|
436
453
|
- **Don't put two procedure descriptors with the same `name`.** The CLI fails the boot — fix the name collision.
|
|
437
454
|
- **Don't import `.server.ts` files from your web app.** Use the generated client. Importing the server module straight into the browser bundle leaks server-only deps (Postgres driver, secret keys).
|
|
438
455
|
- **Don't move generated files in `.framework/` into the user source tree.** They're disposable; the CLI rewrites them on every boot.
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
<!-- source: en/intro/file-taxonomy.md -->
|
|
462
|
+
## The web file taxonomy
|
|
463
|
+
|
|
464
|
+
_The contract suffixes for web code — component, component.ui, hook, types, internal, fixture, tracking — what each one promises and which rule enforces it._
|
|
465
|
+
|
|
466
|
+
Every suffix on this page is a **contract**, not a label. Something else in the codebase depends on the promise, and `voltro doctor` enforces it. That is the whole admission test, and it is why the list is short:
|
|
467
|
+
|
|
468
|
+
> Does another file's correctness depend on this file keeping its promise?
|
|
469
|
+
|
|
470
|
+
If yes, the promise belongs in the name — you cannot see a contract before you break it otherwise. If no, it is a category, and categories are read out of the file.
|
|
471
|
+
|
|
472
|
+
## The catalogue
|
|
473
|
+
|
|
474
|
+
| Suffix | Promise | Enforced by |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
|
|
477
|
+
| `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
|
|
478
|
+
| `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
|
|
479
|
+
| `*.types.ts` | zero runtime exports | `types/runtime-export` |
|
|
480
|
+
| `*.internal.ts` | only its own subtree imports it | `internal/foreign-import` |
|
|
481
|
+
| `*.fixture.ts` | no production path reaches it | `fixture/production-import` |
|
|
482
|
+
| `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
|
|
483
|
+
| `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
|
|
484
|
+
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
485
|
+
|
|
486
|
+
## `*.component.ui.tsx` — reads, never writes
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
// OrderRow.component.ui.tsx
|
|
490
|
+
import { useCan } from '@voltro/client'
|
|
491
|
+
import { useT } from '@voltro/i18n'
|
|
492
|
+
|
|
493
|
+
export const OrderRow = (props: { order: Order; onCancel: () => void }) => {
|
|
494
|
+
const cancelLabel = useT('orders.cancel')
|
|
495
|
+
const mayCancel = useCan('orders:write')
|
|
496
|
+
return <tr>{/* … */}</tr>
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Reading is allowed on purpose. Threading translations and permissions through props is prop-drilling — it makes every call site worse without making the component more portable.
|
|
501
|
+
|
|
502
|
+
**Writing** is what breaks the contract. A component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading what it does — and that property is exactly what its callers rely on. Lift the mutation into the `*.component.tsx` that owns it and pass a handler down.
|
|
503
|
+
|
|
504
|
+
The same file must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page. An unrendered presentational component is carried, reviewed and refactored forever without ever reaching a user; that is how a design system quietly doubles in size.
|
|
505
|
+
|
|
506
|
+
## `*.internal.ts` — the feature boundary
|
|
507
|
+
|
|
508
|
+
```text
|
|
509
|
+
src/features/orders/
|
|
510
|
+
├── index.ts # the public surface
|
|
511
|
+
├── orderState.internal.ts # only this directory may import it
|
|
512
|
+
└── OrderList.component.tsx
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`.internal` is the promise that refactoring inside that directory breaks nobody. An import from another feature revokes it — silently, and without a single review comment, which is how a boundary rots.
|
|
516
|
+
|
|
517
|
+
## `*.types.ts` — provably free to import
|
|
518
|
+
|
|
519
|
+
No runtime export at all. That is not tidiness: it is what makes importing the module cost nothing in the bundle **and** makes it impossible for it to participate in a runtime import cycle. In a large codebase the second guarantee is the valuable one — an import cycle is only visible when it finally throws.
|
|
520
|
+
|
|
521
|
+
## `*.tracking.ts` — analytics is confined
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
// checkout.tracking.ts
|
|
525
|
+
import { defineTracking } from '@voltro/client'
|
|
526
|
+
|
|
527
|
+
export const checkoutTracking = defineTracking('CheckoutButton', {
|
|
528
|
+
onMount: (props) => ({ event: 'checkout_started', orderId: props.orderId }),
|
|
529
|
+
onClick: 'checkout.confirmed',
|
|
530
|
+
})
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
A component then wires it up with `useTracking(checkoutTracking, props, sink)` — it names a spec, it does not author one. Event names, property bags and the decisions about which fields leave the building all live in one place.
|
|
534
|
+
|
|
535
|
+
`useTracking` itself is a hook, so it is *not* confined — a rule nobody could satisfy is a rule everybody disables. What is confined is `defineTracking`, the declaration.
|
|
536
|
+
|
|
537
|
+
The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
|
|
538
|
+
|
|
539
|
+
## What deliberately has NO suffix
|
|
540
|
+
|
|
541
|
+
A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
|
|
542
|
+
|
|
543
|
+
`*.store.ts` was in this section until `defineStore` shipped, and the reason it moved out is the rule itself: **suffixes follow primitives, never the reverse.** While client state was something you brought yourself, a suffix for it would have promised nobody anything. Now `voltro check` reads those files — one store per file, and no store mirroring server state — so the name carries a contract.
|
|
544
|
+
|
|
545
|
+
There is no `*.form.tsx`, and it is the most-requested one. A form is a component; what makes it a form is the schema it validates against, which is already declared and already typed. A suffix would add a rename without adding a checkable promise — "contains a `<form>`" is not something another file's correctness depends on. If forms ever gain a framework primitive that other code binds to, the rule above will produce the suffix on its own.
|
|
546
|
+
|
|
547
|
+
## Code you did not write
|
|
548
|
+
|
|
549
|
+
Our rules are **ours**. A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions (many exports per file, a hook beside the component), and is overwritten by the next `add`. Renaming it would break their convention, be undone on the next generator run, and leave the directory half-migrated the moment one file fails to classify.
|
|
550
|
+
|
|
551
|
+
So the taxonomy applies to what you write, always — and to nothing else. Two signals mark a directory as not-yours:
|
|
552
|
+
|
|
553
|
+
1. **`components.json`** at the app root. Its `aliases.ui` names the directory shadcn owns, so a shadcn project needs no configuration at all. Only `aliases.ui` is honoured — `aliases.components` is where your own components live too, and exempting it would silence the taxonomy across most of a codebase.
|
|
554
|
+
2. **A `.voltro-vendored` file** in any directory, whose first line names the source:
|
|
555
|
+
|
|
556
|
+
```text
|
|
557
|
+
src/vendor/.voltro-vendored # "copied from acme-design-system v3"
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The marker is a file with a reason in it rather than a config list on purpose. A config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. `voltro doctor` prints every exemption it honoured, so the escape hatch is never silent.
|
|
561
|
+
|
|
562
|
+
## Migrating
|
|
563
|
+
|
|
564
|
+
`voltro update` renames what it can decide from the exports alone: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file.
|
|
565
|
+
|
|
566
|
+
Two things it will NOT do:
|
|
567
|
+
|
|
568
|
+
- A file exporting a component **and** a hook is left alone and reported. That is the file the taxonomy most wants split, and no codemod can decide which half keeps the name.
|
|
569
|
+
- `*.component.ui.tsx` is never inferred. "Presentational" is a promise about what a component *may* do; one that merely happens not to write today has not made it.
|
|
@@ -471,7 +471,8 @@ definePluginRoute({
|
|
|
471
471
|
})
|
|
472
472
|
```
|
|
473
473
|
|
|
474
|
-
`source` must name a table
|
|
474
|
+
`source` must name a table that is reactive — every table is, unless it opted
|
|
475
|
+
out with `.nonReactive()`. Omit `source` for a plain
|
|
475
476
|
poll-only plugin query, a mutation, or an action. This is exactly how
|
|
476
477
|
`@voltro/plugin-presence` makes `presence.list` push-driven — the
|
|
477
478
|
`usePresence` roster updates live with no client polling.
|
|
@@ -482,7 +482,7 @@ className={pathname.startsWith('/dashboard') ? 'active' : ''}
|
|
|
482
482
|
URL params from `[name]` segments. Typed via the generic.
|
|
483
483
|
|
|
484
484
|
```tsx
|
|
485
|
-
// src/pages/users/[id].tsx
|
|
485
|
+
// src/pages/users/[id].page.tsx
|
|
486
486
|
import { useParams } from '@voltro/web'
|
|
487
487
|
|
|
488
488
|
const { id } = useParams<{ id: string }>()
|
|
@@ -491,7 +491,7 @@ const { id } = useParams<{ id: string }>()
|
|
|
491
491
|
For catch-all queries:
|
|
492
492
|
|
|
493
493
|
```tsx
|
|
494
|
-
// src/pages/docs/[...slug].tsx
|
|
494
|
+
// src/pages/docs/[...slug].page.tsx
|
|
495
495
|
const { slug } = useParams<{ slug: string }>()
|
|
496
496
|
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
497
497
|
```
|
|
@@ -499,7 +499,7 @@ const { slug } = useParams<{ slug: string }>()
|
|
|
499
499
|
For multi-segment dynamic paths:
|
|
500
500
|
|
|
501
501
|
```tsx
|
|
502
|
-
// src/pages/orgs/[orgId]/projects/[projectId].tsx
|
|
502
|
+
// src/pages/orgs/[orgId]/projects/[projectId].page.tsx
|
|
503
503
|
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
504
504
|
```
|
|
505
505
|
|
|
@@ -604,7 +604,7 @@ Idempotent — multiple calls for the same path fire one loader. The prefetched
|
|
|
604
604
|
Page's loader output, typed.
|
|
605
605
|
|
|
606
606
|
```tsx
|
|
607
|
-
// src/pages/notes/[id].tsx
|
|
607
|
+
// src/pages/notes/[id].page.tsx
|
|
608
608
|
import { useLoaderData } from '@voltro/web'
|
|
609
609
|
|
|
610
610
|
interface Note {
|