@voltro/cli 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/dist/apiBuild-CDE4NXNi.js +2 -0
  3. package/dist/{apiBuild-Bdaetr37.js → apiBuild-CkpsCqIu.js} +2 -2
  4. package/dist/bin.js +1 -1
  5. package/dist/build-51qBWfYY.js +981 -0
  6. package/dist/{checkCommand-D0QV_zM_.js → checkCommand-C1EbNmwl.js} +1 -1
  7. package/dist/{checkCommand-CAwFXrxA.js → checkCommand-C30J2F18.js} +4 -4
  8. package/dist/{codegenCommand-CidbQzbv.js → codegenCommand-kS3Twuc9.js} +12 -12
  9. package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-BKjCwGiN.js} +1127 -895
  10. package/dist/{commands-CWjfThXv.js → commands-DowWkYtd.js} +25 -25
  11. package/dist/{dashboardCommand-BekcY5Ls.js → dashboardCommand-gG1y4PFG.js} +3 -3
  12. package/dist/{dataCommand-2pccgbIy.js → dataCommand-hNOGKz4K.js} +7 -7
  13. package/dist/{dbCommand-DpK_vQET.js → dbCommand-CMAIz-Bf.js} +3 -3
  14. package/dist/dbCommand-DHi_RuDl.js +2 -0
  15. package/dist/{dev-Dw263KPu.js → dev-Ce2gEziv.js} +2411 -2391
  16. package/dist/dev-Dff_pdik.js +3 -0
  17. package/dist/{doctorCommand-aR_bFmIi.js → doctorCommand-5ULMpL10.js} +652 -289
  18. package/dist/doctorCommand-B8H2MYCE.js +2 -0
  19. package/dist/{dormancyCommand-eXTQMbHU.js → dormancyCommand-DYgI0Kf-.js} +3 -3
  20. package/dist/{embeddingsCommand-CTmiQvwa.js → embeddingsCommand-yJivQSrI.js} +2 -2
  21. package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
  22. package/dist/{envCommand-BDUgV7EM.js → envCommand-ORJUf8r_.js} +2 -2
  23. package/dist/{evolveCommand-YV8qW1LU.js → evolveCommand-DTawtTxf.js} +2 -2
  24. package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
  25. package/dist/{frameworkTableAssembly-DNOFXfEQ.js → frameworkTableAssembly-vfkzuzEo.js} +104 -85
  26. package/dist/index.js +1 -1
  27. package/dist/{infoCommand-BjVXpMlP.js → infoCommand-BFksHunl.js} +1 -1
  28. package/dist/inspect-CZvsmp8t.js +2 -0
  29. package/dist/{inspect-CNYvNXPU.js → inspect-PcC5J-Dl.js} +74 -59
  30. package/dist/{logsCmd-BU8uCdys.js → logsCmd-C5y9HyrG.js} +58 -53
  31. package/dist/manifestBuild-AUh31bFU.js +2 -0
  32. package/dist/{manifestBuild-CEkjfpwc.js → manifestBuild-BwiS4iYm.js} +1 -1
  33. package/dist/{migrate-SICulyz1.js → migrate-DtC3lu7H.js} +4 -4
  34. package/dist/{probeCommand-6HxEkNDG.js → probeCommand-BUYzc0CT.js} +1 -1
  35. package/dist/{runtimeTrace-DgYMc09E.js → runtimeTrace-BUk5zMXY.js} +1 -1
  36. package/dist/{sdkgen-PY-umd6O.js → sdkgen-CAhmmbjx.js} +1 -1
  37. package/dist/serveCommand-BUXUtSF5.js +2 -0
  38. package/dist/serveCommand-DhtC7WBq.js +2545 -0
  39. package/dist/serveEntry.js +1 -1
  40. package/dist/{start-BFQQkL1i.js → start-C75w6ftA.js} +1 -1
  41. package/dist/{start-B9NGB8gn.js → start-DNtIlBcn.js} +527 -502
  42. package/dist/startEntry.js +1 -1
  43. package/dist/{tracesCmd-BWYDqMy6.js → tracesCmd-C1qKa94g.js} +68 -63
  44. package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-CtuD5r1t.js} +1 -1
  45. package/dist/updateCommand-eVKVHj3N.js +2 -0
  46. package/dist/webDev-Bzb8ZFus.js +2 -0
  47. package/dist/{webDev-C53hJdcL.js → webDev-CBN9KkyQ.js} +949 -834
  48. package/dist/{webhooksCommand-uuPu8qQX.js → webhooksCommand-BPJQOGTe.js} +2 -2
  49. package/package.json +61 -19
  50. package/templates/AGENTS.md +1 -1
  51. package/templates/agent-docs/_index.md +1 -1
  52. package/templates/agent-docs/_manifest.json +2 -2
  53. package/templates/agent-docs/ai.md +175 -1
  54. package/templates/agent-docs/authentication.md +68 -19
  55. package/templates/agent-docs/cli.md +48 -0
  56. package/templates/agent-docs/configuration.md +29 -4
  57. package/templates/agent-docs/data.md +30 -0
  58. package/templates/agent-docs/database/scaling.md +10 -1
  59. package/templates/agent-docs/database/transactions.md +48 -0
  60. package/templates/agent-docs/deployment.md +22 -0
  61. package/templates/agent-docs/observability.md +13 -2
  62. package/templates/agent-docs/plugins/audit.md +21 -5
  63. package/templates/agent-docs/plugins/auth-supabase.md +21 -0
  64. package/templates/agent-docs/plugins/billing.md +2 -0
  65. package/templates/agent-docs/plugins/sentry.md +91 -8
  66. package/templates/agent-docs/reference.md +1 -0
  67. package/templates/agent-docs/scheduling.md +23 -0
  68. package/templates/agent-docs/schema-driven-ui.md +110 -0
  69. package/templates/agent-docs/whats-new.md +103 -399
  70. package/templates/apps/api-ai/package.json +6 -6
  71. package/templates/apps/api-auth/package.json +8 -8
  72. package/templates/apps/api-backend/package.json +7 -7
  73. package/templates/apps/api-backend-deactivation/package.json +7 -7
  74. package/templates/apps/api-backend-mail/package.json +8 -8
  75. package/templates/apps/api-backend-mariadb/package.json +9 -9
  76. package/templates/apps/api-backend-sqlite/package.json +8 -8
  77. package/templates/apps/api-backend-storage/package.json +8 -8
  78. package/templates/apps/api-cms/package.json +9 -9
  79. package/templates/apps/api-collab/package.json +8 -8
  80. package/templates/apps/api-data-advanced/package.json +8 -8
  81. package/templates/apps/api-durable/package.json +8 -8
  82. package/templates/apps/api-feature-flags/package.json +9 -9
  83. package/templates/apps/api-governance/package.json +8 -8
  84. package/templates/apps/api-kv/package.json +8 -8
  85. package/templates/apps/api-moderation/package.json +8 -8
  86. package/templates/apps/api-observability/package.json +8 -8
  87. package/templates/apps/api-ratelimit/package.json +8 -8
  88. package/templates/apps/api-rbac/package.json +8 -8
  89. package/templates/apps/api-rest/package.json +7 -7
  90. package/templates/apps/api-row-history/package.json +8 -8
  91. package/templates/apps/api-saas/package.json +11 -11
  92. package/templates/apps/api-saas-starter/package.json +10 -10
  93. package/templates/apps/api-search/package.json +8 -8
  94. package/templates/apps/api-status/package.json +8 -8
  95. package/templates/apps/api-webhooks/package.json +9 -9
  96. package/templates/apps/changelog/package.json +7 -7
  97. package/templates/apps/edge-functions/package.json +2 -2
  98. package/templates/apps/frontend-admin/package.json +7 -7
  99. package/templates/apps/frontend-app/package.json +8 -8
  100. package/templates/apps/frontend-auth/package.json +7 -7
  101. package/templates/apps/frontend-blank/package.json +6 -6
  102. package/templates/apps/frontend-cms/package.json +8 -8
  103. package/templates/apps/frontend-collab/package.json +9 -9
  104. package/templates/apps/frontend-contact/package.json +7 -7
  105. package/templates/apps/frontend-dashboard/package.json +6 -6
  106. package/templates/apps/frontend-docs/package.json +8 -8
  107. package/templates/apps/frontend-i18n/package.json +6 -6
  108. package/templates/apps/frontend-landing/package.json +7 -7
  109. package/templates/apps/frontend-portal/package.json +7 -7
  110. package/templates/apps/frontend-saas/package.json +7 -7
  111. package/templates/apps/frontend-spa/package.json +6 -6
  112. package/templates/apps/frontend-ssr/package.json +6 -6
  113. package/templates/apps/frontend-ssr-api/package.json +7 -7
  114. package/templates/apps/frontend-static-blog/package.json +8 -8
  115. package/templates/apps/frontend-status/package.json +7 -7
  116. package/templates/apps/mobile-app/package.json +4 -4
  117. package/dist/apiBuild-Vw1figjO.js +0 -2
  118. package/dist/build-CI36wL4R.js +0 -868
  119. package/dist/dbCommand-DrycGWWt.js +0 -2
  120. package/dist/dev-B9Gz0k85.js +0 -3
  121. package/dist/doctorCommand-BMWs6aVm.js +0 -2
  122. package/dist/frameworkTableAssembly-CGNC0qr7.js +0 -2
  123. package/dist/inspect-S2rWy1Ys.js +0 -2
  124. package/dist/manifestBuild-DIa_s6u0.js +0 -2
  125. package/dist/serveCommand-BITS8Hpj.js +0 -2
  126. package/dist/serveCommand-DIJ3ma76.js +0 -2485
  127. package/dist/updateCommand-DsXEAHbd.js +0 -2
  128. package/dist/webDev-C2dRz9s5.js +0 -2
@@ -263,6 +263,54 @@ await ctx.store.upsert('orgSlugs', {
263
263
  Requires a composite UNIQUE constraint on the table — declare it via
264
264
  `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
265
265
 
266
+ ### The row you get back is the row that was there
267
+
268
+ When an upsert on a non-`id` key MATCHES, it updates the existing row — and that
269
+ row keeps its own `id`. The `id` you passed is the one that would have been used
270
+ had it inserted, so a fresh id on every call is the normal shape:
271
+
272
+ ```ts
273
+ await ctx.store.upsert('projectHours', {
274
+ teamId: 't-1', year: 2026, hours: 111,
275
+ }, {
276
+ conflictColumns: ['teamId', 'year'],
277
+ update: ['hours'],
278
+ })
279
+ ```
280
+
281
+ The returned row carries the id of whichever row now holds those values. Compare
282
+ values, not ids, if you need to know whether you inserted or updated.
283
+
284
+ On MySQL and MariaDB this costs one extra guarantee, because the dialect cannot
285
+ express the question. Postgres names its target — `ON CONFLICT (teamId, year)`
286
+ fires on that index and nothing else — while `ON DUPLICATE KEY UPDATE` fires on
287
+ whichever unique key the incoming row violates, which may not be the one you
288
+ named. The store therefore checks afterwards that the row it reached carries your
289
+ conflict values, and refuses the write if it does not: that row would have
290
+ absorbed your values while your row was never written. The refusal names the
291
+ differing column; the values go to the server log rather than into a sentence
292
+ that may reach a user.
293
+
294
+ A conflict column the database GENERATES cannot be checked this way — its value
295
+ is never in the row you sent. The remaining columns are still compared; if a
296
+ conflict key consists ENTIRELY of generated columns there is nothing to compare,
297
+ and the upsert refuses rather than guessing.
298
+
299
+ ### Generated columns in a conflict key
300
+
301
+ A partial-unique index on MySQL/MariaDB is usually a STORED generated column plus
302
+ NULL-distinct semantics — the key constrains only the rows the expression marks.
303
+ An upsert can conflict on such a key, but only on the path where the DATABASE
304
+ evaluates the expression: the single-statement form, reached on MariaDB when the
305
+ row is a complete INSERT row and `update` is a column list.
306
+
307
+ The other path looks the row up by value first — it has to, because a function
308
+ `update` needs the existing row to compute its patch — and the value of a
309
+ generated column is not in the row you passed. There is no substitute for it, so
310
+ that combination refuses and names the column rather than guessing at one.
311
+ Postgres has no such split: `ON CONFLICT` names its index and the server
312
+ evaluates the column.
313
+
266
314
  ## `insertIgnore` — keep existing on conflict
267
315
 
268
316
  For idempotent write patterns where you want to ensure a row exists
@@ -907,6 +907,28 @@ readinessProbe:
907
907
  failureThreshold: 3
908
908
  ```
909
909
 
910
+ ### Configuring them — `health` in `app.config.ts`
911
+
912
+ The paths and the answers are declarable, for **both** app types:
913
+
914
+ ```ts
915
+ export default {
916
+ type: 'web' as const,
917
+ health: {
918
+ // Default `/internal`; move it when your app owns a route there.
919
+ path: '/api/health',
920
+ liveness: () => true,
921
+ readiness: async () => catalogLoaded(),
922
+ },
923
+ }
924
+ ```
925
+
926
+ **`readiness` is the one worth writing**, because "ready" genuinely differs. For an api it is the dependency ping above. For a screen on a wall it is the opposite — keep showing the last rendered frame while the api wobbles, and count as ready precisely then. Only the app knows.
927
+
928
+ **`liveness` should stay cheap and dependency-free.** A liveness probe that consults a database restarts pods when the database is slow, which is the one thing that makes an outage worse.
929
+
930
+ The probes are answered **before routing** on every boot path, so no route and no guard can claim them. That is not a detail: on a web app `voltro dev` used to have no probe surface, and `/internal/*` fell into the page router — an SPA shell answered `200` with HTML, a page whose loader redirects answered `303`, an auth guard answered `303` to `/login`. A kubelet reads all three as PASS, which is the one property a health probe must not have. Worse, one of those pages had a loader that calls the api, so the **web** pod's readiness hung on the **api**'s reachability, once per probe interval.
931
+
910
932
  > **Run `voltro serve` in serving pods — not `voltro dev`.** `voltro dev` is the
911
933
  > local-iteration supervisor: file-watch, respawn, codegen, and a boot-time
912
934
  > auto-migrate that introspects the whole schema. It does **not** expose the
@@ -513,7 +513,7 @@ others. The answer is `fleet`-scoped and says what it is missing:
513
513
  }
514
514
  ```
515
515
 
516
- Three properties are deliberate:
516
+ Four properties are deliberate:
517
517
 
518
518
  - **A replica that has written nothing is `missing`, not absent.** Dropping it
519
519
  would make a partial answer look complete — the same unlabelled sample, one
@@ -521,9 +521,20 @@ Three properties are deliberate:
521
521
  - **A stale row is reported with its `ageMs`, not filtered out.** Removing it
522
522
  hides that the answer is partial; keeping it unmarked presents fiction as
523
523
  current.
524
+ - **A row from a replica that is GONE is `departed`, not stale.** These are
525
+ different facts with different next steps: a stale row belongs to a replica
526
+ membership still lists, so it is running and has stopped refreshing — a fault,
527
+ and a reason for `complete: false`. A departed row belongs to a process that
528
+ exited; that is what a scale-down looks like, and it is not a reason for
529
+ anything. Conflating them made `complete` unreachable after any replica had
530
+ ever exited. They are still listed, with their age, because a reader who
531
+ cannot see them cannot tell a fleet that scaled down from a table being
532
+ written by something nobody is tracking.
524
533
  - **Mixed versions are named** in `completeness.versions` when the responders
525
534
  disagree. A rolling deploy spans two shapes, and averaging them silently is
526
- wrong in a way nothing downstream can detect.
535
+ wrong in a way nothing downstream can detect. Departed replicas are excluded
536
+ from the tally — a process that has been dead for hours reporting a "version
537
+ split" is a line somebody acts on.
527
538
 
528
539
  **If there is no shared store, the request is refused with `501`** and a reason
529
540
  — never answered with this replica's own numbers. Handing back a sample to
@@ -356,7 +356,7 @@ What it discloses, and what it never does:
356
356
 
357
357
  | | |
358
358
  | --- | --- |
359
- | a string | its LENGTH — `string(113)`. Never a prefix, never a hash |
359
+ | a string | its LENGTH — `string(113)`. Never a prefix, never a hash. Below a floor, a bucket: `string(<16)` |
360
360
  | a number, a boolean, a date | its TYPE only. A number can BE the secret |
361
361
  | a declared field name | survives — `jiraToken` |
362
362
  | a key that is not an identifier | described, not reproduced — `<key:string(36)>` |
@@ -369,10 +369,26 @@ Two of those are worth a sentence each.
369
369
  it looks like a declared field: a plain short identifier. A legitimate key that
370
370
  is not one (`content-type`) loses its name and keeps its shape.
371
371
 
372
- **A string's length is a real disclosure, and a small one.** For a fixed-format
373
- credential it carries nothing — every token of a given kind is the same length.
374
- For a human-chosen password it is a weak hint. If that matters in your threat
375
- model, `'all'` is the default and stays available.
372
+ **A string's length is a real disclosure, and a small one above a floor.** For
373
+ a fixed-format credential it carries nothing — every token of a given kind is the
374
+ same length. For a human-chosen password it is a weak hint.
375
+
376
+ Below a floor it stops being small: a six-digit TOTP reported as `string(6)`, or
377
+ a four-digit PIN as `string(4)`, tells a reader with log access exactly what
378
+ shape to try. Nothing this mode is FOR lives under 16 characters — the case that
379
+ motivated it is 113 against an expected 44 — so a string shorter than that
380
+ reports `string(<16)`: shorter than the floor, not how much shorter.
381
+
382
+ ```ts
383
+ auditPlugin({ redactOutcome: 'shape', redactionLengthFloor: 0 })
384
+ ```
385
+
386
+ `0` turns the floor off, for a deployment whose audited payloads are ids and
387
+ tokens and every character of length is worth having; raise it for one holding
388
+ short human-entered secrets. An EMPTY string stays exact (`string(0)`) — "the
389
+ field arrived empty" is a real diagnosis and an empty string is not a secret. The
390
+ floor applies to described keys too, for the reason above them: a key that is not
391
+ a declared field is a key carrying data.
376
392
 
377
393
  `redactSubject` spells its variant `'metadata-shape'`, because its default
378
394
  (`'metadata'`) already names the field it acts on.
@@ -87,6 +87,27 @@ const resolve = composeAuthStrategies([
87
87
  | `defaultTenantId` | (none) | Fallback for single-tenant deployments. |
88
88
  | `scopesFromClaims` | (none) | Map claims (Supabase `role`, or an `app_metadata` permissions array) → `Subject.scopes`. |
89
89
 
90
+ ### `jwtSecret` may be a function — and should be, if you build an image
91
+
92
+ `voltro build` imports `app.config.ts`, so anything the config CONSTRUCTS runs at
93
+ build time. `jwtSecret: process.env.SUPABASE_JWT_SECRET` therefore makes an image
94
+ build need a production credential for a bundle that will not contain one, and a
95
+ placeholder is not an option — the framework ships no secret values, and an
96
+ `ARG SUPABASE_JWT_SECRET` in a Dockerfile is exactly that.
97
+
98
+ Pass a function instead:
99
+
100
+ ```ts
101
+ supabaseStrategy({ jwtSecret: () => process.env.SUPABASE_JWT_SECRET })
102
+ ```
103
+
104
+ The build never calls it. The BOOT does, once, through the strategy pass both
105
+ `voltro dev` and `voltro serve` share — so an unset variable still refuses to
106
+ start, with the strategy's id in the message. Deferring the read does not defer
107
+ the failure to the first request: an auth strategy that cannot verify a token
108
+ accepts nobody, and a process that started anyway serves a login that can never
109
+ succeed.
110
+
90
111
  Algorithms follow the mode — JWKS pins `['RS256', 'ES256']`, `jwtSecret` pins `['HS256']`; the two are mutually exclusive, so there is no alg-confusion downgrade surface (an RS256 setup can't be tricked into accepting an HS256-forged token). `none` is never accepted.
91
112
 
92
113
  ## Environment variables
@@ -40,6 +40,8 @@ export default {
40
40
 
41
41
  `plans` is the single source of tier→limit truth — it lives in code, not the DB, so limits change by redeploy, not migration. Entitlement values are `number | 'unlimited'`. With no provider configured (and no `STRIPE_SECRET_KEY`), the plugin uses an in-memory **mock provider** — zero-config for dev and tests.
42
42
 
43
+ > **Under `NODE_ENV=production`, a mock chosen by ABSENCE warns.** `STRIPE_SECRET_KEY` is a deployment variable, and one that silently stops being set — a rotated secret, a typo in a values file, a CI variable nobody created — is routine. Without the line, an app that had been charging customers keeps answering every billing call successfully, reaches nobody, and leaves nothing to find afterwards. Set the key, or write `provider: 'mock'` so the mock is a decision on the page rather than an absence. An explicitly declared mock stays silent.
44
+
43
45
  What it provides:
44
46
 
45
47
  - The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) via `extendSchema.tables`.
@@ -111,21 +111,104 @@ What it does:
111
111
  > ```
112
112
  > It's a no-op on the server (SSR) and without a DSN.
113
113
 
114
- The two halves are independent: the api plugin alone gives you server traces + errors; add the web half for the browser hop. For the FULL frontend→backend waterfall in Sentry, run **both** (`sentryPlugin({ traces:true })` on the api + `sentry: { dsn }` on the web app), with matching `release`.
114
+ ### Values the bundle cannot know
115
+
116
+ Everything in that `sentry:` block is a literal frozen into the bundle. That is
117
+ right for a DSN and a release, and wrong for `environment` as soon as one image
118
+ serves more than one environment — one build job and several environment-bound
119
+ deploy jobs is an ordinary pipeline, and a baked `environment` is then true for
120
+ at most one of them. Leaving it out does not help: Sentry defaults a missing
121
+ environment to `production`, so a wrong tag is what you get either way, and a
122
+ wrong tag is worse than none because somebody acts on it.
123
+
124
+ There is no runtime channel to read instead — public values are baked at build
125
+ time by construction — so the value has to be computed where it is known. The
126
+ entry is generated anyway, so it can call something:
127
+
128
+ ```ts
129
+ // app.config.ts
130
+ sentry: { dsn: '…', optionsFrom: 'src/sentryOptions.ts' }
131
+ ```
132
+
133
+ ```ts
134
+ // src/sentryOptions.ts
135
+ export default () => ({
136
+ environment: location.hostname.startsWith('stage-') ? 'staging' : 'production',
137
+ })
138
+ ```
139
+
140
+ The module's default export is called by the generated entry before
141
+ `initSentryBrowser`, may be async, and its result is spread LAST — so it
142
+ overrides the literals above it, which is the whole reason to reach for it.
143
+
144
+ ### Or from the public env
145
+
146
+ Without an explicit option, the browser half reads
147
+ `VOLTRO_PUBLIC_SENTRY_DSN`, `VOLTRO_PUBLIC_SENTRY_ENVIRONMENT` and
148
+ `VOLTRO_PUBLIC_SENTRY_RELEASE` — the same shape the api half already has, where
149
+ `sentryPlugin()` takes no arguments and resolves `SENTRY_*` from the environment.
150
+ Declare them in your `defineEnv` contract and an app is finished without touching
151
+ `app.config.ts`.
152
+
153
+ **A public value must exist when the bundle is BUILT.** `voltro build` freezes the
154
+ declared public subset into the bundle — that is what makes it readable in a
155
+ browser — so these belong in the build (a Docker build-arg, a CI build step), not
156
+ in deployment env. Setting one as a deployment variable deploys cleanly and does
157
+ nothing; `voltro start` warns when it finds one, naming whether the bundle
158
+ carries that key at all.
159
+
160
+ The two halves are independent: the api plugin alone gives you server traces + errors; add the web half for the browser hop. For the full frontend→backend waterfall, run **both** — and both are on by default, so an app with the plugin on the api and `@voltro/plugin-sentry` as a dependency of the web app gets the whole trace from one `SENTRY_DSN` plus one `VOLTRO_PUBLIC_SENTRY_DSN`. Give both halves the same `release`.
161
+
162
+ **Traces default to on, at `tracesSampleRate: 1.0`.** That is the coherent default rather than a generous one: the browser half already sampled every page load, navigation and rpc call at 1.0, so with the api half off each of those spans was emitted, paid for, and belonged to a trace with no server side — a browser hop hanging off nothing. The rate is named on the boot line (`sentry active traces=true tracesSampleRate=1`) so a first boot shows what it is about to send; lower it with `tracesSampleRate`, or set `traces: false` to keep errors and breadcrumbs only.
115
163
 
116
164
  ## Source maps (browser stack traces)
117
165
 
118
- The web bundle is minified, so browser errors arrive in Sentry with **minified** stack traces unless you upload source maps. The plugin does **not** upload them for you — it captures + correlates errors, but wiring the build to publish maps is a per-app build step (it needs your build output plus a Sentry auth token, neither of which the runtime plugin owns). Wire it once with Sentry's official tooling, keyed to the **same `release`** the plugin uses so the maps match the uploaded events:
166
+ The web bundle is minified, so browser errors arrive in Sentry with **minified** stack traces unless the maps are uploaded. `voltro build` does it:
167
+
168
+ ```ts
169
+ // app.config.ts
170
+ web: {
171
+ sourcemaps: {
172
+ mode: 'hidden',
173
+ upload: { org: 'acme', project: 'web' },
174
+ },
175
+ }
176
+ ```
119
177
 
120
178
  ```sh
121
- # after the web build, from the web app dir — uploads maps for one release
122
- pnpm add -D @sentry/cli
123
- SENTRY_AUTH_TOKEN=… npx sentry-cli sourcemaps upload \
124
- --org <org> --project <project> \
125
- --release "$SENTRY_RELEASE" ./dist
179
+ # the build needs both; the deployment needs neither
180
+ SENTRY_AUTH_TOKEN=sntrys_… SENTRY_RELEASE=1.4.2 voltro build
126
181
  ```
127
182
 
128
- Or add `@sentry/vite-plugin` to the web app's Vite config so the upload runs automatically on every production build. Either way, set `release` to the same value (e.g. the git SHA) on BOTH the api `sentryPlugin({ release })` and the web `sentry` field the upload's `--release` must match or Sentry won't resolve the frames. Server stack traces are un-minified already (the api ships readable JS), so this is a browser-only concern.
183
+ That emits the maps, uploads them through `@sentry/cli`, and then **removes them from the output**. Three things in that sentence are the reason it lives in the build rather than in your deploy script:
184
+
185
+ - **The moment.** There is no seam in a Dockerfile between "the bundle exists" and "the image is built". There is one here.
186
+ - **The release.** Sentry matches an artifact to an event by release, and the event's comes from `SENTRY_RELEASE` via `sentryPlugin`. Upload under a different value and no frame resolves — silently, because an upload that matched nothing looks exactly like one that worked. A build with an upload configured and no release **refuses** rather than uploading under nothing.
187
+ - **The deletion.** A `.map` left in `dist` is your source, downloadable by anyone. "We delete it in the deploy step" is a promise a failing build breaks, so the removal is a `finally`: the maps go even when the upload fails, and a configured upload that did not happen fails the build.
188
+
189
+ ### Turning it on selectively
190
+
191
+ `app.config.ts` is TypeScript, so the ordinary branch is the answer:
192
+
193
+ ```ts
194
+ web: {
195
+ sourcemaps: process.env.CI
196
+ ? { mode: 'hidden', upload: { org: 'acme', project: 'web' } }
197
+ : 'hidden',
198
+ }
199
+ ```
200
+
201
+ `'hidden'` alone emits the maps and **keeps** them — right for reading a stack trace locally, and a leak in an image. Omit the field entirely and nothing is emitted, which is the default.
202
+
203
+ `keep` defaults to "keep them only if nothing consumed them": `false` when `upload` is set, `true` when it is not. Set `keep: true` beside an upload when you want both.
204
+
205
+ ### The auth token is not a config field
206
+
207
+ It is read from `SENTRY_AUTH_TOKEN` and there is deliberately no `authToken` option: `app.config.ts` is a committed file, and a token with `project:releases` scope can write to every project in the org. Give it to the **build** (a CI secret, a Docker build secret) — not to the deployment, since the upload happens while the bundle is being built.
208
+
209
+ **Install `@sentry/cli` yourself: `pnpm add -D @sentry/cli` in the web app.** The framework depends on it nowhere, and that is a licence decision rather than a packaging one — it is FSL-1.1-MIT, which restricts competing commercial use, so it must not sit in the dependency graph of a package we publish. A build with an upload configured and the package missing **refuses and names the command**, rather than skipping the upload and leaving you to find out months later that no frame resolves.
210
+
211
+ Server stack traces are un-minified already (the api ships readable JS), so this is a browser-only concern.
129
212
 
130
213
  ## Catching errors — what's automatic, what's manual
131
214
 
@@ -89,6 +89,7 @@ these before hand-rolling a form, a table, or a picker** — full guide in
89
89
  | [`usePreview`](/docs/ui/client-utilities/use-preview) | Mutation dry-run — run the real handler in a rolled-back transaction. |
90
90
  | [`useProvenance`](/docs/ui/client-utilities/use-provenance) | "Why is this value here?" — lineage lookup for a field. |
91
91
  | [`useOnRpcError`](/docs/ui/client-utilities/use-on-rpc-error) / `reportClientError` | Subscribe to the rpc error bus; report a client error to the server. |
92
+ | [`useSubscriptionHealth`](/docs/ui/client-utilities/use-subscription-health) | Which of an api's calls are currently REFUSED — a refused subscription never retries and reads as `loading`. |
92
93
  | [`useTracking`](/docs/ui/client-utilities/use-tracking) | Fire mount/unmount + interaction tracking events. |
93
94
  | [`useCapabilityManifest`](/docs/ui/client-utilities/use-capability-manifest) | The api's capability manifest (procedures + tables + schemas), fetched once. |
94
95
  | [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions) | Force-refresh live subscriptions (e.g. after an out-of-band change). |
@@ -539,6 +539,29 @@ defineSchedule({
539
539
 
540
540
  > The watchdog stops *waiting* and records the timeout; it cannot truly abort a Promise's in-flight side effects (JavaScript has no thread-kill). Make long handlers cooperative — check a deadline, or do the heavy lifting in a [workflow](/docs/workflows/overview) with its own step-level durability.
541
541
 
542
+ ## Across replicas — the heartbeat
543
+
544
+ `onOverlap: 'skip'` is a **cluster-wide** rule, not a per-process one: a firing is suppressed when an occurrence of the same schedule is running on *any* replica. That check reads `_voltro_schedule_runs`, so it needs a way to tell a run that is still working from one whose process died mid-run and left its row on `running` forever.
545
+
546
+ That is `heartbeatAt`: a run in flight bumps it every **30 seconds**, and a row silent for three beats is read as dead rather than as a live occurrence. Without it the only evidence a row carried was `firedAt`, and a window generous enough never to cut off a long run is also long enough to suppress a half-hourly cron for hours after one restart.
547
+
548
+ ```ts
549
+ // app.config.ts
550
+ export default defineAppConfig({
551
+ scheduling: {
552
+ scheduleHeartbeatMs: 30_000, // default; VOLTRO_SCHEDULE_HEARTBEAT_MS overrides it
553
+ },
554
+ })
555
+ ```
556
+
557
+ Raise it in a deployment with many long-running schedules where the writes matter more than the detection latency; a run shorter than one interval writes no beat at all and costs nothing either way. It is **not a lease** — a stale beat only stops a corpse from holding the schedule shut; nothing takes ownership of the run.
558
+
559
+ The skip is logged with what it measured, so the two cases are legible from the log alone:
560
+
561
+ ```
562
+ schedule: overlap skip name=reindex scope=cluster heldBy=pod-7f4c lastSeenMs=1840
563
+ ```
564
+
542
565
  ## Backfill — `backfill`
543
566
 
544
567
  When the process was down across one or more firing instants, what should happen on boot? Computed from the last `_voltro_schedule_runs` row for the schedule.
@@ -370,6 +370,36 @@ widget })` rides an annotation, `description` becomes help text, and
370
370
  underneath is an implementation detail — no engine type appears in the public
371
371
  API, and production builds stub its devtools channel automatically.
372
372
 
373
+ ### When the FORM decides which mutation it is
374
+
375
+ Some forms only learn their target from what the user does: a calendar entry
376
+ becomes a recurring **series** the moment "repeats" is ticked, and the series
377
+ mutation takes eleven more fields than the single one.
378
+
379
+ Pass a function of the current values instead of a tag:
380
+
381
+ ```tsx
382
+ const form = useFormBinding<CalendarInput>(
383
+ 'app',
384
+ (v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'),
385
+ { defaults: { title: '', repeats: false } },
386
+ )
387
+ ```
388
+
389
+ The schema in force follows the tag, so `form.fields` grows and shrinks with it
390
+ and validation always matches what will actually be submitted. **The values do
391
+ not reset** — the engine is constructed once and never rebuilt, so everything
392
+ typed before the switch survives it. That is the whole reason this exists:
393
+ deriving the tag outside the binding is not available (the values belong to the
394
+ binding and do not exist before it), and re-mounting with a different tag throws
395
+ the user's input away.
396
+
397
+ Two details worth knowing. On the first render there are no values yet, so the
398
+ function is called with your `defaults`. And the accessibility ids are pinned to
399
+ the first tag and stay there — they are DOM ids, and letting them move on the
400
+ keystroke that flips the branch would remount every field, taking the focus and
401
+ the caret with it.
402
+
373
403
  ### Reference fields, uploads, and testing the form
374
404
 
375
405
  **Reference fields.** Mark a schema field as a table reference and it renders
@@ -813,6 +843,23 @@ chat.regenerate() // re-run the last user prompt
813
843
  | `regenerate()` | Re-sends the last user prompt as a fresh turn; `undefined` if there is none. |
814
844
  | `sending` | `true` while a `send` is in flight. |
815
845
 
846
+ **You do not need `@ai-sdk/react`.** `useAgentChat` covers the three things it is
847
+ usually kept for:
848
+
849
+ - **Tool calls** — each message carries `parts`, and a `tool` part has
850
+ `toolName`, `state`, `input` and `output`. Render from `parts` rather than
851
+ `content` and a tool call looks the same live and after a reload.
852
+ - **Persistence** — the thread is a DB-backed reactive query, not client state.
853
+ Nothing is lost when the tab closes.
854
+ - **Resumption** — for the same reason. Reopen the tab mid-turn and the
855
+ in-flight assistant message re-renders from the row that is still being
856
+ written; there is no transport to reconnect and no replay to arrange.
857
+
858
+ So an app can drop `@ai-sdk/react` and `DefaultChatTransport` entirely. What
859
+ `useAgentChat` deliberately does not do is manage a transport of its own — it
860
+ composes the agent's synthesized `messages` subscription and `send` action, which
861
+ is what makes the reload behaviour free rather than implemented.
862
+
816
863
  `threadId` is required — mint one per chat (a `useRef`'d uuid) and keep it stable
817
864
  across renders, since it is the subscription key for the whole thread. Render
818
865
  from `message.parts` (text / reasoning / tool / source / file) rather than
@@ -1547,6 +1594,18 @@ changes, so define it with `useCallback` or at module scope unless you want that
1547
1594
  And the bus is per api runtime — an app talking to several apis subscribes once
1548
1595
  per api name.
1549
1596
 
1597
+ **You do not need this hook to REPORT rpc failures.** Every event this bus emits
1598
+ is also published on the client error bus, so a reporter — the Sentry browser
1599
+ integration, or anything wired with `subscribeClientErrors` — already sees it,
1600
+ under `source: 'rpc.mutation' | 'rpc.action' | 'rpc.subscription'` with the rpc
1601
+ tag and `traceId` in `context`. This hook is for cross-cutting *policy* (redirect
1602
+ on `Unauthenticated`, toast on a network failure), not for telemetry; bridging
1603
+ the two by hand now reports twice.
1604
+
1605
+ Everything is published, including a reconnect storm. If that is too loud for
1606
+ your reporter, gate it in the subscriber — which failures are worth an event is
1607
+ your policy, and the bus cannot hold one for every subscriber.
1608
+
1550
1609
  This is the read side of failures that already happened on the wire. To push a
1551
1610
  client-side error the server never saw, call `reportClientError(error, context)`
1552
1611
  instead. Note also that a subscription failure *after* data arrived reaches only
@@ -1597,6 +1656,57 @@ identity* changed underneath them.
1597
1656
 
1598
1657
 
1599
1658
 
1659
+ ---
1660
+
1661
+ <!-- source: en/ui/client-utilities/use-subscription-health.md -->
1662
+ ## useSubscriptionHealth
1663
+
1664
+ _Which of an api's calls are currently refused — the state a screen cannot derive from its own hook._
1665
+
1666
+ A refused subscription is **terminal for its transport**. It does not retry, so
1667
+ there is no second error event to react to — and `SubscriptionFailed` presents
1668
+ `data: undefined`, which is the value most reading layers derive `loading` from.
1669
+ A component that branches on `data === undefined` therefore shows a spinner from
1670
+ then on, indefinitely. The state union models this correctly (`failed: true`,
1671
+ `loading: false`); what loses it is the wrapper hook in between, and a wrapper
1672
+ that passes `{ data, loading }` through is the natural shape to write.
1673
+
1674
+ `useSubscriptionHealth` answers the question no single hook can: **is anything on
1675
+ this api currently refused, and what.**
1676
+
1677
+ ```tsx
1678
+ import { useSubscriptionHealth } from '@voltro/client'
1679
+
1680
+ const SessionBar = () => {
1681
+ const { healthy, failed } = useSubscriptionHealth('app')
1682
+ if (healthy) return null
1683
+ return <Banner>Not loading: {failed.map((f) => f.tag).join(', ')}</Banner>
1684
+ }
1685
+ ```
1686
+
1687
+ A tag enters `failed` when a call for it fails and leaves when a call for the
1688
+ **same tag** succeeds. It is keyed by tag rather than counted, because the
1689
+ question a screen asks is "is this working now" and two failures of one call are
1690
+ one broken thing. `healthy` is the positive form of the same fact, so a component
1691
+ reads `if (!healthy)` instead of `if (failed.length > 0)`.
1692
+
1693
+ Each entry is `{ tag, source, error, at }` — `at` is the timestamp of the
1694
+ failure, so a bar can say *since 12:04* rather than only *broken*.
1695
+
1696
+ ## What it does not do
1697
+
1698
+ It is not a retry mechanism and not an error boundary. Recovery is the
1699
+ transport's job: an `Unauthenticated` failure makes the framework re-resolve the
1700
+ credential and rebuild the connection, and a rebuilt client re-subscribes on its
1701
+ own — a failed entry is never carried across the swap, so it is re-attempted
1702
+ rather than restored as broken. Nothing in your app wires that.
1703
+
1704
+ The register is scoped to one api's runtime and is reset when that runtime is
1705
+ rebuilt, deliberately: carrying a failure across a transport swap would report a
1706
+ call as broken that has not been tried since.
1707
+
1708
+
1709
+
1600
1710
  ---
1601
1711
 
1602
1712
  <!-- source: en/ui/client-utilities/use-tracking.md -->