@voltro/cli 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/apiBuild-BmPdmhgi.js +2 -0
  3. package/dist/{apiBuild-D_kEJTxc.js → apiBuild-zFDv0u8y.js} +7 -3
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-BiQsbV50.js → commands-BDGYrBhK.js} +30 -7
  6. package/dist/{dev-zJGsTTNb.js → dev-CGt0PP1f.js} +1 -1
  7. package/dist/dev-D_PGP9Kx.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/{inspectMetrics-BuBwg1yW.js → inspectMetrics-SRtv8KDy.js} +8 -4
  10. package/dist/{serveCommand-BU454-ay.js → serveCommand-p6e5Ahgx.js} +3 -3
  11. package/dist/serveEntry.js +3 -3
  12. package/dist/{start-ChN6PO-c.js → start-Crl39M38.js} +1 -1
  13. package/dist/startEntry.js +2 -2
  14. package/package.json +17 -17
  15. package/templates/agent-docs/_manifest.json +1 -1
  16. package/templates/agent-docs/authentication.md +22 -0
  17. package/templates/agent-docs/data.md +68 -0
  18. package/templates/agent-docs/database/columntypes.md +7 -0
  19. package/templates/agent-docs/database/querying.md +16 -0
  20. package/templates/agent-docs/deployment.md +4 -2
  21. package/templates/agent-docs/internationalization.md +49 -2
  22. package/templates/agent-docs/routing.md +17 -0
  23. package/templates/agent-docs/testing.md +26 -0
  24. package/templates/apps/api-ai/package.json +7 -7
  25. package/templates/apps/api-auth/package.json +8 -8
  26. package/templates/apps/api-backend/package.json +7 -7
  27. package/templates/apps/api-backend-deactivation/package.json +7 -7
  28. package/templates/apps/api-backend-mail/package.json +8 -8
  29. package/templates/apps/api-backend-mariadb/package.json +9 -9
  30. package/templates/apps/api-backend-storage/package.json +8 -8
  31. package/templates/apps/api-data-advanced/package.json +8 -8
  32. package/templates/apps/api-durable/package.json +8 -8
  33. package/templates/apps/api-feature-flags/package.json +9 -9
  34. package/templates/apps/api-governance/package.json +8 -8
  35. package/templates/apps/api-kv/package.json +8 -8
  36. package/templates/apps/api-moderation/package.json +8 -8
  37. package/templates/apps/api-observability/package.json +8 -8
  38. package/templates/apps/api-ratelimit/package.json +8 -8
  39. package/templates/apps/api-rbac/package.json +8 -8
  40. package/templates/apps/api-rest/package.json +7 -7
  41. package/templates/apps/api-saas/package.json +11 -11
  42. package/templates/apps/api-search/package.json +8 -8
  43. package/templates/apps/api-versioning/package.json +8 -8
  44. package/templates/apps/api-webhooks/package.json +8 -8
  45. package/templates/apps/changelog/package.json +6 -6
  46. package/templates/apps/edge-functions/package.json +2 -2
  47. package/templates/apps/frontend-admin/package.json +8 -8
  48. package/templates/apps/frontend-app/package.json +8 -8
  49. package/templates/apps/frontend-blank/package.json +7 -7
  50. package/templates/apps/frontend-contact/package.json +7 -7
  51. package/templates/apps/frontend-dashboard/package.json +7 -7
  52. package/templates/apps/frontend-docs/package.json +7 -7
  53. package/templates/apps/frontend-i18n/package.json +6 -6
  54. package/templates/apps/frontend-landing/package.json +7 -7
  55. package/templates/apps/frontend-spa/package.json +7 -7
  56. package/templates/apps/frontend-ssr/package.json +7 -7
  57. package/templates/apps/frontend-ssr-api/package.json +8 -8
  58. package/templates/apps/frontend-static-blog/package.json +6 -6
  59. package/dist/apiBuild-BGd-BnHq.js +0 -2
  60. package/dist/dev-DH13Ysgs.js +0 -2
package/CHANGELOG.md CHANGED
@@ -39,6 +39,99 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.11.3] — 2026-07-24
43
+
44
+ ### Added
45
+
46
+ - **@voltro/runtime** — `crud.*` secure-default CRUD handler helpers + `redactColumns` (A1 core). Each returns an executor you export as a `*.query.server.ts` / `*.mutation.server.ts` default — the descriptor (schemas + `guards`) stays hand-written and browser-safe:
47
+
48
+ ```ts
49
+ // accounts.list.query.server.ts
50
+ import { crud } from '@voltro/runtime'
51
+ export default crud.list('accounts', { redact: ['apiSecret'] })
52
+ ```
53
+
54
+ They bake in the invariants a hand-rolled CRUD generator kept getting wrong (the leak class was in the HANDLERS, not the schemas):
55
+
56
+ - **Tenant scope** — `list` / `getById` read through `ctx.store`, which auto-scopes a `tenant()` table; they never `.unscoped()`, so a cross-tenant read is impossible. - **Redaction** — `redact` columns are stripped from every returned row (a credential / secret / salary a read must never ship), on reads AND on the row a `create` / `update` echoes. `redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD. - **`getById` returns `null`, never throws** — a reactive getter that throws stalls its shared-WS siblings (pairs with the per-subscription error isolation).
57
+
58
+ What they deliberately DON'T do is authorize: a guard runs before the executor, so gating stays on the DESCRIPTOR (`guards: [...]`) — an executor can't gate itself. Keep write descriptors guarded.
59
+
60
+ Scope note: this is the browser-safe, codegen-free core. Deriving the descriptor SCHEMAS from a table (to drop the hand-written `Schema.Struct`) is structurally a codegen concern — a table VALUE can't be imported into a browser-loaded descriptor (it drags the store into the bundle; `rowSchema` is server-only for exactly this reason) — so full schema-derivation + a `.crud()` boot audit for the scope/gating discipline are a separate, planned pass. See `plans/framework-a1-defineCrud.md`.
61
+ - **@voltro/runtime** — `ctx.store.links(junctionTable, anchor)` — a diff-based writer for a many-to-many JUNCTION table (A2). It reconciles the links from one anchor row against a target-id list by writing only the DIFFERENCE:
62
+
63
+ ```ts
64
+ await ctx.store.links('post_tags', { postId: post.id }).set(tagIds) // add missing, remove surplus
65
+ await ctx.store.links('post_tags', { postId: post.id }).add([tagId]) // idempotent
66
+ await ctx.store.links('post_tags', { postId: post.id }).remove([tagId])
67
+ await ctx.store.links('post_tags', { postId: post.id }).list() // current target ids
68
+ ```
69
+
70
+ Why it belongs in the framework rather than every app: a drop-all-then-reinsert `setLinks` loses data when two writers overlap and makes a reactive subscription on the junction churn every row (flicker) even when nothing changed. `links().set()` touches only the rows that actually differ — the added are inserted, the removed deleted, the unchanged left in place — so a reactive consumer sees a change only for what changed, and `set()` returns `{ added, removed }`. `add`/`remove` are likewise idempotent (they read first and act only on the genuine delta).
71
+
72
+ `anchor` names the source column and its id (`{ postId: 'p1' }`); the target column is the junction's OTHER `reference()` column, auto-detected. A junction with anything but exactly two reference columns is refused with a message naming what it found — use plain `insertMany`/`deleteMany` for a non-standard junction. The writes go through the normal stamped/tenant-scoped store path, so tenant and audit columns are filled as usual. Additive: a new `links` method on `FluentStore` + the `JunctionLinks` interface.
73
+ - **@voltro/client, @voltro/web** — `useSubscription(..., { initialSnapshot })` — the last mile of "SSR-correct first paint, then live" (A5). Pass the value an SSR loader already fetched with `ctx.query` (read it in the component with `useLoaderData()`) and the subscription shows it at the first paint with `loading: false` — it IS real server data — then swaps to the live stream the instant its first snapshot arrives:
74
+
75
+ ```tsx
76
+ const seed = useLoaderData<Employee>()
77
+ const { data } = useSubscription('app', 'employees.me', {}, { initialSnapshot: seed })
78
+ ```
79
+
80
+ The SSR markup and the hydration render read the same loader value, so they match (no hydration flicker), and the app no longer hand-builds a seed store to bridge loader data into the first render. This is the difference from `fallback`, whose value never came from the server and so keeps `loading: true`; use exactly one of the two. Like `fallback`, `initialSnapshot` guarantees `data` is present, so the call gets the non-union result and needs no `loading` branch. Additive: a new `initialSnapshot` field on `SubscriptionOptions` + an overload; `@voltro/web` re-exports the client surface.
81
+ - **@voltro/cli** — `apis.<name>.authHeaders` in a web `app.config.ts` — a declarative per-reconnect auth-header resolver, so an authenticated split-origin web app no longer hand-mounts `VoltroRuntimeProvider` just to inject a rotating-token thunk (A4). The framework owns the client mount, the reconnect re-resolve, and the SSR-null case (the resolver runs browser-only — it never fires on the server):
82
+
83
+ ```ts
84
+ // app.config.ts
85
+ apis: {
86
+ api: {
87
+ package: '@app/api',
88
+ authHeaders: async () => ({ authorization: `Bearer ${await getToken()}` }),
89
+ },
90
+ }
91
+ ```
92
+
93
+ Because it's a FUNCTION, the codegen imports it from `app.config.ts` into the client bundle rather than serializing it — so a config that declares `authHeaders` must stay browser-safe (no `node:*` / server-only value imports; a pure env schema is fine, and tree-shakes out). It supersedes a static `headers` on the same api. The provider already resolved a `ResolvableHeaders` thunk fresh per connection generation; this just lets you declare it in config instead of hand-writing a `mount()` call.
94
+
95
+ ---
96
+
97
+ ## [0.11.2] — 2026-07-24
98
+
99
+ ### Added
100
+
101
+ - **@voltro/i18n** — Two escapes for adopting typed messages (`createTypedMessages`, #16) app-wide (#19):
102
+
103
+ - **`t.dynamic(runtimeKey, values?)`** — a first-class escape for a genuinely runtime-computed key, on both `useT` and the `useTFn()` result. It takes a plain string with NO forced ICU args, so it doesn't fight the strict literal-key surface. Until now the natural escape — casting a computed key to the catalog key union — made things WORSE: that union spans placeholder-bearing keys, so the call then demanded a spurious 2nd ICU arg. `t.dynamic` is the documented, discoverable alternative. - **`LooseTFunction`** — the widened `(id: string, values?) => string` signature to type a `t` pass-through across a package boundary that can't import the app catalog, instead of falling back to `(...args: any[]) => string`. A strict `TypedTFunction` is deliberately NOT assignable to it (a narrowed key param can't satisfy a wider one — that would erase the checking); pass `t.dynamic` at the boundary, which IS a `LooseTFunction`.
104
+
105
+ Additive: `TypedTFunction<C>` gains a `.dynamic` member (the callable surface is unchanged, so `Parameters<TypedTFunction<C>>[0]` and existing typed call sites still resolve). `createTypedMessages` attaches `.dynamic` in place on the two translate functions — no new per-render closure, so a captured `t`'s identity stays stable.
106
+ - **@voltro/testing, @voltro/database** — `fixtureRow(table, overrides)` (`@voltro/testing`) completes a partial test row so it satisfies the 0.11.1 required-column insert validation — WITHOUT disabling the check. It fills every NOT-NULL, no-default, non-auto-stamped column the payload omits with a schema-typed placeholder (a `oneOf` column takes its first allowed value; a `unique` column gets a distinct value per call so two fixtures don't collide; `timestamp`/`date` get a fixed epoch), then merges your overrides on top (an explicit value always wins). It leaves out exactly what a caller may omit — nullable, defaulted, and framework auto-stamped columns (id / tenant / audit) — and refuses to guess a structured type (`json` / `bytes` / `vector` / `array` / `interval` / `raw`), throwing a message that names the column and says to pass it explicitly.
107
+
108
+ The motivating case: 0.11.1 made the in-memory/test store reject the same partial inserts real Postgres always would (correct — it surfaced a latent prod bug), which turned lean fixtures (`insert(users, { id })`, an omitted required FK) into `TableValidationFailed`. The wrong fix is a `validateInserts: false` knob — it re-hides that bug class, and a test store laxer than production is a fake testing itself. `fixtureRow` is the right one: it makes the fixture COMPLETE.
109
+
110
+ ```ts
111
+ await ctx.store.insert('journal_entries', fixtureRow(journalEntries, {
112
+ tenantId, amount: '100.00', // the columns THIS test cares about
113
+ })) // entryNumber, postedAt, … auto-filled + unique
114
+ ```
115
+
116
+ It is a runtime filler for the loose `store.insert(name, row)` path (what fixtures use). For COMPILE-time payload typing, use `insertRow` / `upsertRow` from `@voltro/database`. The auto-stamped column set it skips is now exported as `AUTO_FILLED_COLUMNS` from `@voltro/database` — the same list `InferInsertRow` derives its optional columns from, single-sourced so the two can't drift.
117
+
118
+ ### Changed
119
+
120
+ - **@voltro/cli** — `voltro build` now emits **directly-executable** boot bundles for BOTH app kinds: the web start bundle (`.framework/dist-web/startBundle/startEntry.js`) and the api serve bundle (`.framework/dist-api/serveBundle/serveEntry.js`) each carry a main-guard that boots the app when run as `node <entry>.js`, and stays inert when imported (the `voltro start` / `voltro serve` dev fast paths are unchanged). Production containers can now use `CMD ["node", "…/startEntry.js"]` (or `serveEntry.js`) instead of `pnpm voltro start` / `pnpm voltro serve` — no pnpm process, no `@voltro/cli` bin at runtime — which is what makes `voltro prune-runtime` safe to enable on both: with the self-contained bundle as the real entrypoint, the @vercel/nft trace roots there and legitimately drops `@voltro/cli` and the whole inlined framework tree (a static site's `node_modules` collapses to ~0; a memory api's 146 MB → 11 MB). `prune-runtime` now also roots the trace at the serve bundle. The serve entry chdir's to the app root BEFORE its app-module registry keys are computed from cwd, preserving relocation-safety. Existing `pnpm voltro start` / `pnpm voltro serve` entrypoints keep working. The standalone Dockerfiles gain a build-time boot smoke that fails the build unless the pruned tree reaches ready.
121
+
122
+ ### Fixed
123
+
124
+ - **@voltro/i18n** — `createTypedMessages` (#16) no longer extracts phantom required vars from a nested plural/select message (#19). For `'{count, plural, one {# day} other {# days total duration}}'`, the type-level `ICUVars` parse was reading a branch's TEXT (`"# days total duration"`) as a bogus required arg name, so `useT('key', { count })` failed to typecheck even though it renders perfectly — and a real var nested inside a branch was dropped. `ICUArgName` now resolves to `never` for any candidate that isn't a valid ICU identifier (`^[A-Za-z0-9_]+$`), so branch text — which contains spaces / `#` / `—` — is never mistaken for a var. Only the top-level arg (`count`) is required, matching what the message actually needs.
125
+
126
+ Scope note: a REAL var nested inside a plural branch (`other {# — {discipline}}`) is still not collected, so it reads as not-required rather than wrongly-required — the safe direction. Apps that pluralise in JS over simple `{count}` messages (the Voltro idiom) were already fully typed and are unaffected.
127
+ - **@voltro/database, @voltro/runtime** — `InferInsertRow` (and thus `insertRow` / `upsertRow`, #15) no longer requires a non-nullable DB-generated (`generatedAs`) column (#20). A stored/virtual generated column declared without `.nullable()` and without a default was typed **required**, but MariaDB/Postgres REJECT an explicit value for a generated column — so the type forced the caller to pass a value the database refuses at runtime. `.generatedAs()` now marks the column optional-for-insert exactly like a `.default()` column (the DB supplies it), so it may be omitted; the whole payload guard on every real column stays intact.
128
+
129
+ Two runtime halves complete it, so the loose `store.insert(name, row)` path agrees: the required-column validation (`missingRequiredColumns`) skips generated columns — omitting one is correct, never a missing-column error — and the store write path now STRIPS any value a caller supplied for a generated column before the INSERT reaches the dialect (tracked on the schema registry as `generatedColumns`), so a value from an untyped insert can't blow up on MariaDB. A generated column is never caller-supplied; the framework and the DB own it end to end.
130
+
131
+ The `.generatedAs()` return type narrows from `this` to `ColumnBuilder<…, true>` (the HasDefault flag) — a purely more-permissive refinement: it only makes the column omittable, so no existing code stops compiling.
132
+
133
+ ---
134
+
42
135
  ## [0.11.1] — 2026-07-23
43
136
 
44
137
  ### Added
@@ -71,6 +164,8 @@ _Changes staged for the next release accumulate here (rolled up from
71
164
  - **@voltro/database, @voltro/runtime** — `store.insert` / `upsert` / `insertIgnore` now raise a clear, typed `TableValidationFailed` naming the column when the payload omits one that is NOT NULL, has no default, and isn't auto-stamped — instead of a raw dialect `SqlError: Failed to execute statement` (`Field '…' doesn't have a default value`) surfaced only on the INSERT path (so it lay dormant until the first row with no existing cache entry). An upsert / insertIgnore whose payload is missing one of its own `conflictColumns` is likewise named at the call (an absent conflict key can't match its target). The check runs AFTER stamping, so auto-id / tenant / audit columns never trip it, and skips nullable, defaulted, and id (`idScheme`) columns — exactly the ones a caller may legitimately omit.
72
165
 
73
166
  Two pure helpers back it — `missingRequiredColumns(table, row)` and `missingConflictColumns(conflictColumns, row)` (exported from `@voltro/database`). This is the runtime half of the "handler data silently disagrees with the schema" class; a compile-time payload type needs the column DSL to track `hasDefault` at the type level, which is a separate change.
167
+
168
+ **Migration impact — behaviour-breaking for lenient test fixtures.** The in-memory/test store now rejects the same partial inserts a real Postgres always would, so it stops being laxer than production — which is the point (it surfaced at least one latent prod bug where a NOT-NULL `text().unique()` column was written without a value). But a fixture that inserted a partial row (`{ id }` parents, an omitted required FK) and passed against the old lenient memory store now throws `TableValidationFailed`. There is no code-level codemod — the fix is fixture DATA: fill the required columns. Use the new `fixtureRow(table, overrides)` helper in `@voltro/testing`, which fills every NOT-NULL-no-default column with a schema-typed placeholder and merges your overrides on top, so a fixture complies without disabling the check. There is deliberately no opt-out to turn the validation off: a test store that accepts rows production rejects is a fake testing itself.
74
169
  - **@voltro/database, @voltro/plugin-ai-flows, @voltro/plugin-audit, @voltro/plugin-deactivation, @voltro/plugin-soft-delete** — Compile-time payload typing for writes (#15) — the type-level half that the runtime `TableValidationFailed` guard flagged as a separate change. `insertRow` / `upsertRow` take the TABLE OBJECT (not a string name), so the payload is checked against `InferInsertRow<T>`: every column is required EXCEPT nullable ones, columns with a default, and the framework-filled id/tenant/audit columns. A missing NOT-NULL-no-default column — the exact `lastRefreshedAt` / `teamId` omission from the report — is now a COMPILE error at the call, not a runtime SqlError only on the INSERT path; `upsertRow`'s `conflictColumns` are constrained to the table's own columns too.
75
170
 
76
171
  import { insertRow } from '@voltro/database' await insertRow(ctx.store, roadmapEpicStats, { teamId, lastRefreshedAt: new Date() })
@@ -0,0 +1,2 @@
1
+ import { a as e, o as t } from "./apiBuild-zFDv0u8y.js";
2
+ export { e as runApiBuild, t as runServeBundleBuild };
@@ -1,5 +1,5 @@
1
- import { Mt as e } from "./inspectMetrics-BuBwg1yW.js";
2
- import { d as t, lt as n } from "./dev-zJGsTTNb.js";
1
+ import { Mt as e } from "./inspectMetrics-SRtv8KDy.js";
2
+ import { d as t, lt as n } from "./dev-CGt0PP1f.js";
3
3
  import { isAbsolute as r, join as i, relative as a, resolve as o } from "node:path";
4
4
  import { createLogger as s } from "@voltro/logger";
5
5
  import { existsSync as c, promises as l } from "node:fs";
@@ -153,15 +153,19 @@ const drivers = ${JSON.stringify(t)}\nlet mod\nfor (const d of drivers) {\n try
153
153
  ...c(_) ? { tsconfig: _ } : {}
154
154
  }), y = Object.keys(v.metafile.inputs).map((e) => o(t, e)).filter((e) => e.startsWith(`${t}/`) && !e.includes("/node_modules/") && !e.includes("/.framework/") && m(e)), b = await f(t), x = /* @__PURE__ */ new Set(), S = [
155
155
  "// GENERATED BY `voltro build` — do not edit; overwritten on next build.",
156
- "import { join as __join } from 'node:path'",
156
+ "import { join as __join, dirname as __dirname, resolve as __resolve } from 'node:path'",
157
+ "import { pathToFileURL as __pathToFileURL, fileURLToPath as __fileURLToPath } from 'node:url'",
157
158
  "import { runServe as __runServe, registerAppModules, registerDriver, loadDotEnv } from '@voltro/cli/serveEntry'",
158
159
  ...b.map((e, t) => `import * as __drv${t} from ${JSON.stringify(e)}`),
160
+ "const __isMain = Boolean(process.argv[1]) && import.meta.url === __pathToFileURL(process.argv[1]).href",
161
+ "if (__isMain) process.chdir(__resolve(__dirname(__fileURLToPath(import.meta.url)), '..', '..', '..'))",
159
162
  "const __root = process.cwd()",
160
163
  "registerAppModules({",
161
164
  ...y.map((e) => ` [__join(__root, ${JSON.stringify(a(t, e))})]: () => import(${JSON.stringify(e)}),`),
162
165
  "})",
163
166
  ...b.map((e, t) => `registerDriver(${JSON.stringify(e)}, __drv${t})`),
164
167
  "export const runServe = (args = []) => { loadDotEnv([]); return __runServe(args) }",
168
+ "if (__isMain) runServe(process.argv.slice(2)).then((c) => process.exit(typeof c === 'number' ? c : 0))",
165
169
  ""
166
170
  ], w = i(r, "serveEntry.ts");
167
171
  return await l.writeFile(w, S.join("\n"), "utf8"), await n.build({
package/dist/bin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { bt as e } from "./inspectMetrics-BuBwg1yW.js";
3
- import { n as t } from "./commands-BiQsbV50.js";
2
+ import { bt as e } from "./inspectMetrics-SRtv8KDy.js";
3
+ import { n as t } from "./commands-BDGYrBhK.js";
4
4
  import { createLogger as n } from "@voltro/logger";
5
5
  //#region src/bin.ts
6
6
  var r = n({ scope: "voltro:cli" }), i = process.argv.slice(2);
@@ -1,9 +1,9 @@
1
- import { At as e, D as t, Dt as n, F as r, Ft as i, It as a, K as o, M as s, Mt as c, N as l, Nt as u, Ot as d, P as f, Pt as p, T as m, a as h, b as g, c as _, d as v, f as y, jt as ee, k as b, kt as te, n as ne, o as re, p as ie, s as ae, u as oe, v as se, w as ce, x as le, y as ue } from "./inspectMetrics-BuBwg1yW.js";
2
- import { B as de, K as fe, L as pe, R as me, V as he, ct as ge, i as _e, l as ve, n as ye, o as be, p as xe, rt as Se, tt as Ce, ut as we, z as Te } from "./dev-zJGsTTNb.js";
1
+ import { At as e, D as t, Dt as n, F as r, Ft as i, It as a, K as o, M as s, Mt as c, N as l, Nt as u, Ot as d, P as f, Pt as p, T as m, a as h, b as g, c as _, d as v, f as y, jt as ee, k as b, kt as te, n as ne, o as re, p as ie, s as ae, u as oe, v as se, w as ce, x as le, y as ue } from "./inspectMetrics-SRtv8KDy.js";
2
+ import { B as de, K as fe, L as pe, R as me, V as he, ct as ge, i as _e, l as ve, n as ye, o as be, p as xe, rt as Se, tt as Ce, ut as we, z as Te } from "./dev-CGt0PP1f.js";
3
3
  import { a as x, c as Ee, i as De, o as Oe, s as ke } from "./devActivity-1WtIVyHc.js";
4
- import { i as Ae, n as je, s as Me, t as Ne } from "./apiBuild-D_kEJTxc.js";
5
- import { t as Pe } from "./start-ChN6PO-c.js";
6
- import { n as Fe, r as Ie } from "./serveCommand-BU454-ay.js";
4
+ import { i as Ae, n as je, s as Me, t as Ne } from "./apiBuild-zFDv0u8y.js";
5
+ import { t as Pe } from "./start-Crl39M38.js";
6
+ import { n as Fe, r as Ie } from "./serveCommand-p6e5Ahgx.js";
7
7
  import { basename as S, dirname as C, isAbsolute as Le, join as w, relative as T, resolve as E, sep as Re } from "node:path";
8
8
  import { fileURLToPath as ze, pathToFileURL as Be } from "node:url";
9
9
  import { FileSystem as Ve } from "@effect/platform";
@@ -282,8 +282,25 @@ external scheduler owns the once-only guarantee.
282
282
  return await N.writeFile(o, [
283
283
  "// GENERATED BY `voltro build` — do not edit; overwritten on next build.",
284
284
  "import { runStartCommand as __run, loadDotEnv } from '@voltro/cli/startEntry'",
285
+ "import { pathToFileURL, fileURLToPath } from 'node:url'",
286
+ "import { dirname, resolve } from 'node:path'",
285
287
  "export const runStartCommand = (args = []) => { loadDotEnv([]); return __run(args) }",
286
288
  "export { loadDotEnv }",
289
+ "",
290
+ "// DIRECT-INVOKE ENTRY. `node startEntry.js` is the PRODUCTION container command",
291
+ "// — no `pnpm voltro start` wrapper, so the container never resolves the @voltro/cli",
292
+ "// bin and `voltro prune-runtime` can drop the CLI (and the whole inlined",
293
+ "// effect/@voltro/react tree) from the runtime image: the trace roots at THIS",
294
+ "// self-contained bundle, which is now also the real entrypoint. When this module",
295
+ "// is IMPORTED instead (bin/voltro.mjs's dev fast path) the guard is inert.",
296
+ "if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {",
297
+ " // Relocation-safe: the app root is 3 dirs up from",
298
+ " // .framework/dist-web/startBundle/startEntry.js — chdir there so tryRunStart",
299
+ " // resolves the SSR bundle + appConfig from cwd regardless of the launch dir",
300
+ " // (WORKDIR already sets it in our image; this makes it robust anywhere).",
301
+ " process.chdir(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'))",
302
+ " runStartCommand(process.argv.slice(2)).then((c) => process.exit(typeof c === 'number' ? c : 0))",
303
+ "}",
287
304
  ""
288
305
  ].join("\n"), "utf8"), await t.build({
289
306
  entryPoints: [o],
@@ -711,10 +728,10 @@ external scheduler owns the once-only guarantee.
711
728
  }, tr = async (e) => {
712
729
  let t = await ae(e);
713
730
  if (!t) {
714
- let { loadApiConfig: t } = await import("./dev-DH13Ysgs.js"), n = await t(e);
731
+ let { loadApiConfig: t } = await import("./dev-D_PGP9Kx.js"), n = await t(e);
715
732
  if (n) {
716
733
  B.info("building api app", { app: n.name ?? "(unnamed)" });
717
- let { runApiBuild: t, runServeBundleBuild: r } = await import("./apiBuild-BGd-BnHq.js");
734
+ let { runApiBuild: t, runServeBundleBuild: r } = await import("./apiBuild-BmPdmhgi.js");
718
735
  await t(e);
719
736
  try {
720
737
  await r(e);
@@ -8330,6 +8347,12 @@ image, use \`voltro serverless serve\` from the project root.
8330
8347
  "dist",
8331
8348
  "server",
8332
8349
  "appConfig.js"
8350
+ ],
8351
+ [
8352
+ ".framework",
8353
+ "dist-api",
8354
+ "serveBundle",
8355
+ "serveEntry.js"
8333
8356
  ]
8334
8357
  ], sp = ["pg", ...Ne.filter((e) => !e.includes("*"))], cp = async (e) => {
8335
8358
  let t = w(e, "node_modules");
@@ -1,4 +1,4 @@
1
- import { $ as e, A as t, B as n, C as r, Dt as i, E as a, Et as o, G as s, H as c, Lt as l, O as u, Pt as d, Q as f, R as p, U as m, V as h, W as g, X as _, Y as ee, Z as te, _t as ne, at as re, ct as ie, dt as ae, et as v, ft as oe, gt as y, ht as se, it as ce, j as le, kt as ue, l as b, lt as de, mt as fe, nt as pe, ot as me, pt as he, rt as ge, st as _e, t as ve, tt as ye, ut as be, vt as xe, w as Se, xt as Ce, yt as we } from "./inspectMetrics-BuBwg1yW.js";
1
+ import { $ as e, A as t, B as n, C as r, Dt as i, E as a, Et as o, G as s, H as c, Lt as l, O as u, Pt as d, Q as f, R as p, U as m, V as h, W as g, X as _, Y as ee, Z as te, _t as ne, at as re, ct as ie, dt as ae, et as v, ft as oe, gt as y, ht as se, it as ce, j as le, kt as ue, l as b, lt as de, mt as fe, nt as pe, ot as me, pt as he, rt as ge, st as _e, t as ve, tt as ye, ut as be, vt as xe, w as Se, xt as Ce, yt as we } from "./inspectMetrics-SRtv8KDy.js";
2
2
  import { i as x, n as Te, t as Ee } from "./startupRunner-DhlX9nqd.js";
3
3
  import { r as De } from "./devActivity-1WtIVyHc.js";
4
4
  import { t as Oe } from "./frameworkInspectState-CX2250XB.js";
@@ -0,0 +1,2 @@
1
+ import { o as e } from "./dev-CGt0PP1f.js";
2
+ export { e as loadApiConfig };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as e, n as t, r as n, t as r } from "./commands-BiQsbV50.js";
1
+ import { i as e, n as t, r as n, t as r } from "./commands-BDGYrBhK.js";
2
2
  //#region src/index.ts
3
3
  var i = "framework";
4
4
  //#endregion
@@ -2754,7 +2754,8 @@ import {
2754
2754
  browserWsUrl: t.url,
2755
2755
  proxyTarget: void 0,
2756
2756
  ...t.serverUrl ? { serverOrigin: t.serverUrl } : {},
2757
- ...t.headers ? { headers: t.headers } : {}
2757
+ ...t.headers ? { headers: t.headers } : {},
2758
+ ...t.authHeaders ? { hasAuthHeaders: !0 } : {}
2758
2759
  }), Di = (e, t, n) => {
2759
2760
  let r = t.transport?.wsPath ?? n.wsPath;
2760
2761
  return {
@@ -2764,7 +2765,8 @@ import {
2764
2765
  proxyTarget: `ws://localhost:${n.port}`,
2765
2766
  ...r ? { wsPath: r } : {},
2766
2767
  ...t.serverUrl ? { serverOrigin: t.serverUrl } : {},
2767
- ...t.headers ? { headers: t.headers } : {}
2768
+ ...t.headers ? { headers: t.headers } : {},
2769
+ ...t.authHeaders ? { hasAuthHeaders: !0 } : {}
2768
2770
  };
2769
2771
  }, Oi = (e) => {
2770
2772
  let t = /* @__PURE__ */ new Map();
@@ -2891,9 +2893,11 @@ import {
2891
2893
  ""
2892
2894
  ].join("\n"), u = r(e, "src", "pages"), { pages: d, dirs: f } = await $(u);
2893
2895
  await rr(u, d);
2894
- let p = (e) => `${e.replace(/[^a-zA-Z0-9]/g, "_")}AppGroup`, m = (e) => `${e.replace(/[^a-zA-Z0-9]/g, "_")}AppDescriptors`, h = n.map((e) => `import { appGroup as ${p(e.name)}, appDescriptors as ${m(e.name)} } from '${e.pkg}/rpcGroup'`), g = n.map((e) => {
2896
+ let p = (e) => `${e.replace(/[^a-zA-Z0-9]/g, "_")}AppGroup`, m = (e) => `${e.replace(/[^a-zA-Z0-9]/g, "_")}AppDescriptors`, h = n.map((e) => `import { appGroup as ${p(e.name)}, appDescriptors as ${m(e.name)} } from '${e.pkg}/rpcGroup'`);
2897
+ n.some((e) => e.hasAuthHeaders) && (h.push("import __voltroAppConfig from '../app.config'"), h.push("const __voltroAuthHeaders = (__voltroAppConfig as { readonly apis?: Record<string, { readonly authHeaders?: () => Record<string, string> | Promise<Record<string, string>> }> }).apis"));
2898
+ let g = n.map((e) => {
2895
2899
  let t = [`group: ${p(e.name)}`];
2896
- return t.push(`descriptors: ${m(e.name)}`), t.push(`wsUrl: ${JSON.stringify(e.browserWsUrl)}`), e.headers && t.push(`headers: ${JSON.stringify(e.headers)}`), ` ${JSON.stringify(e.name)}: { ${t.join(", ")} },`;
2900
+ return t.push(`descriptors: ${m(e.name)}`), t.push(`wsUrl: ${JSON.stringify(e.browserWsUrl)}`), e.hasAuthHeaders ? t.push(`headers: __voltroAuthHeaders?.[${JSON.stringify(e.name)}]?.authHeaders`) : e.headers && t.push(`headers: ${JSON.stringify(e.headers)}`), ` ${JSON.stringify(e.name)}: { ${t.join(", ")} },`;
2897
2901
  }), _, v, b;
2898
2902
  if (d.length > 0) {
2899
2903
  let e = d.map((e) => ` ${JSON.stringify(e.pattern)}: () => import('../src/pages/${e.file}'),`), n = [];
@@ -1,7 +1,7 @@
1
- import { kt as e, t } from "./inspectMetrics-BuBwg1yW.js";
2
- import { $ as n, A as r, C as i, D as a, E as o, F as s, G as c, H as l, I as u, J as d, M as f, N as p, O as ee, P as m, Q as h, S as g, T as te, U as ne, W as re, X as _, Y as ie, Z as ae, _ as oe, at as v, b as se, d as ce, et as y, f as b, g as le, h as ue, i as x, it as de, j as S, k as C, m as fe, n as pe, nt as me, o as w, ot as T, q as he, s as ge, st as _e, t as ve, ut as E, v as ye, w as be, x as xe, y as D } from "./dev-zJGsTTNb.js";
1
+ import { kt as e, t } from "./inspectMetrics-SRtv8KDy.js";
2
+ import { $ as n, A as r, C as i, D as a, E as o, F as s, G as c, H as l, I as u, J as d, M as f, N as p, O as ee, P as m, Q as h, S as g, T as te, U as ne, W as re, X as _, Y as ie, Z as ae, _ as oe, at as v, b as se, d as ce, et as y, f as b, g as le, h as ue, i as x, it as de, j as S, k as C, m as fe, n as pe, nt as me, o as w, ot as T, q as he, s as ge, st as _e, t as ve, ut as E, v as ye, w as be, x as xe, y as D } from "./dev-CGt0PP1f.js";
3
3
  import { a as Se, r as Ce } from "./startupRunner-DhlX9nqd.js";
4
- import { n as we, s as O } from "./apiBuild-D_kEJTxc.js";
4
+ import { n as we, s as O } from "./apiBuild-zFDv0u8y.js";
5
5
  import { t as Te } from "./bootTiming-BdyP9nYw.js";
6
6
  import { join as Ee } from "node:path";
7
7
  import { pathToFileURL as De } from "node:url";
@@ -1,5 +1,5 @@
1
- import { bt as e } from "./inspectMetrics-BuBwg1yW.js";
2
- import { dt as t } from "./dev-zJGsTTNb.js";
1
+ import { bt as e } from "./inspectMetrics-SRtv8KDy.js";
2
+ import { dt as t } from "./dev-CGt0PP1f.js";
3
3
  import { a as n } from "./startupRunner-DhlX9nqd.js";
4
- import { t as r } from "./serveCommand-BU454-ay.js";
4
+ import { t as r } from "./serveCommand-p6e5Ahgx.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,4 +1,4 @@
1
- import { A as e, C as t, Ct as n, E as r, Et as i, G as a, H as o, I as s, J as c, Mt as l, N as u, O as d, R as ee, St as f, Tt as p, U as m, V as h, W as g, Z as _, _ as v, _t as te, a as y, at as b, b as x, c as ne, f as S, g as re, gt as C, h as w, i as T, j as E, k as D, kt as O, m as k, o as ie, p as ae, q as A, r as oe, s as se, t as ce, v as j, w as le, wt as M, y as N } from "./inspectMetrics-BuBwg1yW.js";
1
+ import { A as e, C as t, Ct as n, E as r, Et as i, G as a, H as o, I as s, J as c, Mt as l, N as u, O as d, R as ee, St as f, Tt as p, U as m, V as h, W as g, Z as _, _ as v, _t as te, a as y, at as b, b as x, c as ne, f as S, g as re, gt as C, h as w, i as T, j as E, k as D, kt as O, m as k, o as ie, p as ae, q as A, r as oe, s as se, t as ce, v as j, w as le, wt as M, y as N } from "./inspectMetrics-SRtv8KDy.js";
2
2
  import { t as ue } from "./bootTiming-BdyP9nYw.js";
3
3
  import { dirname as P, extname as F, join as I, resolve as L } from "node:path";
4
4
  import { fileURLToPath as R, pathToFileURL as de } from "node:url";
@@ -1,3 +1,3 @@
1
- import { bt as e } from "./inspectMetrics-BuBwg1yW.js";
2
- import { t } from "./start-ChN6PO-c.js";
1
+ import { bt as e } from "./inspectMetrics-SRtv8KDy.js";
2
+ import { t } from "./start-Crl39M38.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.11.1",
3
+ "version": "0.11.3",
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.11.1",
66
- "@voltro/cache": "0.11.1",
67
- "@voltro/data-transfer": "0.11.1",
68
- "@voltro/database": "0.11.1",
69
- "@voltro/env": "0.11.1",
70
- "@voltro/kv": "0.11.1",
71
- "@voltro/logger": "0.11.1",
72
- "@voltro/plugin-auth": "0.11.1",
73
- "@voltro/plugin-broadcast": "0.11.1",
74
- "@voltro/plugin-mail": "0.11.1",
75
- "@voltro/plugin-storage": "0.11.1",
76
- "@voltro/plugin-webhooks": "0.11.1",
77
- "@voltro/protocol": "0.11.1",
78
- "@voltro/runtime": "0.11.1",
79
- "@voltro/serverless": "0.11.1",
80
- "@voltro/workflow": "0.11.1",
65
+ "@voltro/ai": "0.11.3",
66
+ "@voltro/cache": "0.11.3",
67
+ "@voltro/data-transfer": "0.11.3",
68
+ "@voltro/database": "0.11.3",
69
+ "@voltro/env": "0.11.3",
70
+ "@voltro/kv": "0.11.3",
71
+ "@voltro/logger": "0.11.3",
72
+ "@voltro/plugin-auth": "0.11.3",
73
+ "@voltro/plugin-broadcast": "0.11.3",
74
+ "@voltro/plugin-mail": "0.11.3",
75
+ "@voltro/plugin-storage": "0.11.3",
76
+ "@voltro/plugin-webhooks": "0.11.3",
77
+ "@voltro/protocol": "0.11.3",
78
+ "@voltro/runtime": "0.11.3",
79
+ "@voltro/serverless": "0.11.3",
80
+ "@voltro/workflow": "0.11.3",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -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": 15
56
+ "files": 16
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -723,6 +723,28 @@ The strategy above only *verifies* — your browser still has to *send* the toke
723
723
 
724
724
  This is **required when the api is a separate origin** (the common case — `api.example.com` vs your web origin): there is no shared cookie and no upgrade header, so without `headers` every subscription connects **anonymous** (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.
725
725
 
726
+ ### Declaratively — `apis.<name>.authHeaders` in `app.config.ts`
727
+
728
+ Rather than hand-writing a `mount()` entry just to inject the thunk, declare it on the api in `app.config.ts`. The framework owns the client mount, the SSR-null case (the resolver runs browser-only — it never fires on the server), and the re-resolve on every reconnect; you supply only the token function:
729
+
730
+ ```ts
731
+ // app.config.ts
732
+ export default {
733
+ type: 'web' as const,
734
+ apis: {
735
+ api: {
736
+ package: '@app/api',
737
+ // Resolved fresh on every (re)connect — a rotating token is pulled anew:
738
+ authHeaders: async () => ({
739
+ authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
740
+ }),
741
+ },
742
+ },
743
+ }
744
+ ```
745
+
746
+ `authHeaders` is a **function**, so it is imported from `app.config.ts` into the client bundle rather than serialized — the file must stay browser-safe (no `node:*` / server-only value imports; the env schema and other pure config are fine). It supersedes a static `headers` on the same api. This is the preferred form; reach for a hand-written `mount()` only when you need to wrap the tree in your own provider as well.
747
+
726
748
  ## WorkOS
727
749
 
728
750
  ```ts
@@ -315,6 +315,8 @@ export default defineExecutor(notesSummary, async (_input, ctx) => {
315
315
 
316
316
  It's a runtime identity (returns the handler unchanged) — the whole value is the compile check. The Effect error and requirement channels stay inferred; only the success value is constrained. A **descriptor-return** (reactive) executor is allowed through unchecked: the store produces the rows, so a value-level return type can't express the row-vs-`output` check. `defineExecutor` works the same for `defineMutation` / `defineAction` handlers.
317
317
 
318
+ > **Composing one executor inside another (e.g. a workflow `step`).** The value `defineExecutor` returns is typed as the `ExecutorReturn` UNION (`Output | Promise | Effect | descriptor-return`), so it has no `.pipe` — you can't feed the wrapped default export straight into another Effect. Export the handler's raw `Effect` separately (a named `export const execute = …`, or import the un-wrapped function) and compose THAT; keep the `defineExecutor`-wrapped default only as the procedure's entry point. The union return is deliberate — it's what lets one helper type every executor shape — so this is a "import the raw effect for composition" convention, not a gap to route around.
319
+
318
320
  ## Auto-optimistic source
319
321
 
320
322
  `source` also connects query caches to mutation `target` metadata:
@@ -1947,6 +1949,72 @@ When no analytics sink is configured the framework provides the no-op sink: the
1947
1949
 
1948
1950
 
1949
1951
 
1952
+ ---
1953
+
1954
+ <!-- source: en/data/crud.md -->
1955
+ ## CRUD helpers
1956
+
1957
+ _crud.* secure-default handler helpers — tenant-scoped reads, column redaction, and null-not-throw getById, so the CRUD tail of a handler is one honest line._
1958
+
1959
+ Most of a plain list / get / create / update / delete handler is the same five lines every time — and getting those lines subtly wrong is how data leaks. The `crud.*` helpers from `@voltro/runtime` give you the **executor** with the secure defaults baked in; you still write the descriptor (schemas + `guards`), which is where the browser-safe wire contract and the authorization live.
1960
+
1961
+ ```ts
1962
+ // accounts.list.query.server.ts
1963
+ import { crud } from '@voltro/runtime'
1964
+
1965
+ export default crud.list('accounts', { redact: ['apiSecret'] })
1966
+ ```
1967
+
1968
+ ```ts
1969
+ // accounts.list.query.ts — the descriptor stays hand-written + browser-safe
1970
+ import { defineQuery } from '@voltro/protocol'
1971
+ import { Schema } from 'effect'
1972
+
1973
+ export default defineQuery({
1974
+ name: 'accounts.list',
1975
+ input: Schema.Struct({}),
1976
+ // note: the wire output OMITS apiSecret, so it never reaches the client
1977
+ output: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })),
1978
+ })
1979
+ ```
1980
+
1981
+ ## What the defaults bake in
1982
+
1983
+ - **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
1984
+ - **Redaction.** `redact` names columns stripped from every returned row — a credential, a token hash, a salary that a generated read must never ship. It applies to reads and to the row a `create` / `update` echoes back. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
1985
+ - **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
1986
+
1987
+ ## The helpers
1988
+
1989
+ | Helper | Executor it returns |
1990
+ |---|---|
1991
+ | `crud.list(table, { redact? })` | tenant-scoped list of every row, redacted |
1992
+ | `crud.getById(table, { redact? })` | one row by `input.id`, or `null` — redacted |
1993
+ | `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
1994
+ | `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
1995
+ | `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
1996
+
1997
+ `redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD but still needs to redact declaratively.
1998
+
1999
+ ## What they deliberately don't do — authorization
2000
+
2001
+ A guard runs *before* the executor, so gating lives on the **descriptor**, not the handler — an executor cannot gate itself. Keep every write descriptor guarded:
2002
+
2003
+ ```ts
2004
+ export default defineMutation({
2005
+ name: 'accounts.create',
2006
+ input: AccountInput,
2007
+ output: Account,
2008
+ guards: [requireScope('accounts:write')], // ← the gate; crud.create does not add one
2009
+ })
2010
+ ```
2011
+
2012
+ ## Scope — why the schema is still hand-written
2013
+
2014
+ These helpers give you the secure **handler**, not schema derivation. Deriving the descriptor's `input`/`output` from the table automatically would need the table VALUE inside the descriptor file — and a descriptor is loaded value-level by the browser client, so importing a table there drags the store and driver into the browser bundle (the boundary guard aborts the boot; `rowSchema` is server-only for exactly this reason). Full schema-derivation, and a boot audit that fails when a `tenant()` table's list reads unscoped or a write goes ungated, are a separate planned pass — the handler defaults above are the part that ships browser-safe today and closes the leak class.
2015
+
2016
+
2017
+
1950
2018
  ---
1951
2019
 
1952
2020
  <!-- source: en/data/subscribers.md -->
@@ -208,6 +208,13 @@ const rows = await ctx.store.query(database.people.descriptor)
208
208
  rows[0]?.fullName // → 'Mario Lima'
209
209
  ```
210
210
 
211
+ **A generated column is never caller-supplied.** It's omitted from the typed insert
212
+ payload — `insertRow(store, people, { firstName, lastName })` compiles with no
213
+ `fullName`, and its type is optional even when the column is NOT nullable and has no
214
+ default (the DB owns the value). If a loose `store.insert` passes one anyway, the
215
+ framework strips it before the INSERT — MariaDB and Postgres both reject an explicit
216
+ value for a generated column, so this keeps that error from reaching the dialect.
217
+
211
218
  ## STORED vs VIRTUAL
212
219
 
213
220
  `{ stored: true }` (the rare-but-useful case):
@@ -1044,6 +1044,22 @@ The framework deliberately avoids the Prisma / TypeORM "auto-generated junction"
1044
1044
 
1045
1045
  3. **Schema is explicit**. Looking at your `database/` directory tells you exactly which tables exist. No hidden auto-generated tables to chase down at migration time.
1046
1046
 
1047
+ ## Writing links — `store.links`
1048
+
1049
+ Reconciling the set of links from one row (a post's tags, a user's orgs) by hand — read the current rows, work out which to insert and which to delete — is fiddly and easy to get wrong. A drop-all-then-reinsert shortcut loses data when two requests overlap and makes a reactive subscription on the junction churn *every* row even when nothing changed. `ctx.store.links(junctionTable, anchor)` does the diff for you:
1050
+
1051
+ ```ts
1052
+ // anchor names the source column + id; the target column is auto-detected
1053
+ await ctx.store.links('org_memberships', { userId: user.id }).set(orgIds) // reconcile to exactly orgIds
1054
+ await ctx.store.links('org_memberships', { userId: user.id }).add([orgId]) // idempotent — no-op if already linked
1055
+ await ctx.store.links('org_memberships', { userId: user.id }).remove([orgId])
1056
+ const orgIds = await ctx.store.links('org_memberships', { userId: user.id }).list()
1057
+ ```
1058
+
1059
+ `set(targetIds)` writes only the **difference**: the missing links are inserted, the surplus deleted, and links that are already correct are left untouched — so a reactive consumer sees a change only for what actually changed, and it returns `{ added, removed }`. `add` and `remove` read first and act only on the genuine delta, so both are idempotent.
1060
+
1061
+ The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual. A junction with extra business columns (a membership `role`, a tag `order`) needs those set per row — `links` only manages the two FK columns, so insert those rows directly.
1062
+
1047
1063
  ## SQL shape
1048
1064
 
1049
1065
  The framework emits an INNER JOIN through the junction:
@@ -460,9 +460,11 @@ Building an API app produces the bundle, and `voltro serve` boots from it. In **
460
460
 
461
461
  Because production never transpiles, the serve image needs none of the build toolchain. The framework declares `tsx`, `esbuild`, `vite`, and Tailwind as **optional** dependencies of `@voltro/cli`, and the production Dockerfiles isolate the app with `pnpm --prod --no-optional deploy` — which drops that whole tree (and its native binaries) from the image. A serve image ships only what it runs at runtime: your app, the framework, and the one SQL driver you declared.
462
462
 
463
- **Web apps get the same treatment.** `voltro build` also precompiles the `voltro start` runtime into a **start bundle** (`.framework/dist-web/startBundle/`, framework inlined), and `voltro start` boots from it automatically so a cold web boot loads one artefact instead of resolving the whole framework graph. This collapses the web `modules` phase the same way the serve bundle does (~17× on a small app in practice), which matters most exactly where it hurts: a scale-from-zero container on a fraction of a vCPU. It is transparent no config, no separate command, and it travels in `.framework` with everything else the deploy already copies. If the bundle ever fails to build or load, `voltro start` falls back to the ordinary per-module boot: slower, never broken.
463
+ And it shrinks further, the same way a web image does. The serve bundle is directly executable, so the production API container runs it with `node .framework/dist-api/serveBundle/serveEntry.js` not `pnpm voltro serve` with no pnpm process and no `@voltro/cli` bin to resolve at boot. That lets `voltro prune-runtime` drop `@voltro/cli` and the whole inlined framework tree from the runtime image too; what stays is the native SQL driver your app actually declared (traced + kept automatically) a memory-store api's `node_modules` fell from ~146 MB to ~11 MB in a fixture. The serve entry chdir's to the app root before its app-module registry keys are computed, so the pruned, relocated tree still resolves every module.
464
464
 
465
- **And the image itself shrinks.** Because the SSR bundle, the start bundle, and the precompiled config all inline + tree-shake the framework, a booted web app needs from `node_modules` only the runtime-external **native** leaves it actually reaches (a SQL driver an ISR or config path touches) everything else is already compiled into the bundles. The standalone web Dockerfiles trace the real reachable set (`@vercel/nft`, the same tool behind Next.js `output: standalone`) and drop the rest. It's automatic a native driver you use is traced and kept, one you don't is dropped, no per-app allow-listand for a static/SSR marketing site with no native runtime dependency, `node_modules` collapses to nothing: the image is just the base plus `.framework`. On a real marketing app the shipped app tree fell from ~210 MB to ~65 MB. A smaller image is a faster cold pull on a fresh node the other half of the scale-from-zero latency the boot bundle can't touch.
465
+ **Web apps get the same treatment.** `voltro build` also precompiles the `voltro start` runtime into a **start bundle** (`.framework/dist-web/startBundle/startEntry.js`, framework inlined) so a cold web boot loads one artefact instead of resolving the whole framework graph. This collapses the web `modules` phase the same way the serve bundle does (~17× on a small app in practice), which matters most exactly where it hurts: a scale-from-zero container on a fraction of a vCPU. In production the container runs that bundle **directly** `node .framework/dist-web/startBundle/startEntry.js`, not `pnpm voltro start`so there is no pnpm process and no `@voltro/cli` bin to resolve at boot; the bundle is a self-contained, relocation-safe entrypoint (its main-guard chdir's to the app root so cwd-based resolution holds anywhere). In development `voltro start` imports the same bundle; if it ever fails to build or load, that path falls back to the ordinary per-module boot slower, never broken.
466
+
467
+ **And the image itself shrinks — to almost nothing.** Because the SSR bundle, the start bundle, and the precompiled config all inline + tree-shake the framework, *and the production entrypoint is the bundle itself rather than the CLI*, a booted web app needs from `node_modules` only the runtime-external **native** leaves it actually reaches (a SQL driver an ISR or config path touches) — everything else is already compiled into the bundles. `voltro prune-runtime` traces the real reachable set from that entrypoint (`@vercel/nft`, the same tool behind Next.js `output: standalone`) and drops the rest — including `@voltro/cli` and the whole inlined effect/React tree, which nothing at runtime imports any more. It's automatic — a native driver you use is traced and kept, one you don't is dropped, no per-app allow-list — and for a static/SSR marketing site with no native runtime dependency, `node_modules` collapses to **zero** (measured on a fixture: 151 MB → ~0 B): the image is just the node base plus `.framework`. A build-time **boot smoke** then starts the *pruned* tree with the real `node …/startEntry.js` entrypoint and fails the build unless it reaches `start: ready`, so a slimmed image that can't boot never ships. A smaller image is a faster cold pull on a fresh node — the other half of the scale-from-zero latency the boot bundle can't touch.
466
468
 
467
469
  ## Tiers (Voltro Cloud — coming soon)
468
470