@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.
- package/CHANGELOG.md +395 -0
- package/dist/apiBuild-CDE4NXNi.js +2 -0
- package/dist/{apiBuild-Bdaetr37.js → apiBuild-CkpsCqIu.js} +2 -2
- package/dist/bin.js +1 -1
- package/dist/build-51qBWfYY.js +981 -0
- package/dist/{checkCommand-D0QV_zM_.js → checkCommand-C1EbNmwl.js} +1 -1
- package/dist/{checkCommand-CAwFXrxA.js → checkCommand-C30J2F18.js} +4 -4
- package/dist/{codegenCommand-CidbQzbv.js → codegenCommand-kS3Twuc9.js} +12 -12
- package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-BKjCwGiN.js} +1127 -895
- package/dist/{commands-CWjfThXv.js → commands-DowWkYtd.js} +25 -25
- package/dist/{dashboardCommand-BekcY5Ls.js → dashboardCommand-gG1y4PFG.js} +3 -3
- package/dist/{dataCommand-2pccgbIy.js → dataCommand-hNOGKz4K.js} +7 -7
- package/dist/{dbCommand-DpK_vQET.js → dbCommand-CMAIz-Bf.js} +3 -3
- package/dist/dbCommand-DHi_RuDl.js +2 -0
- package/dist/{dev-Dw263KPu.js → dev-Ce2gEziv.js} +2411 -2391
- package/dist/dev-Dff_pdik.js +3 -0
- package/dist/{doctorCommand-aR_bFmIi.js → doctorCommand-5ULMpL10.js} +652 -289
- package/dist/doctorCommand-B8H2MYCE.js +2 -0
- package/dist/{dormancyCommand-eXTQMbHU.js → dormancyCommand-DYgI0Kf-.js} +3 -3
- package/dist/{embeddingsCommand-CTmiQvwa.js → embeddingsCommand-yJivQSrI.js} +2 -2
- package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
- package/dist/{envCommand-BDUgV7EM.js → envCommand-ORJUf8r_.js} +2 -2
- package/dist/{evolveCommand-YV8qW1LU.js → evolveCommand-DTawtTxf.js} +2 -2
- package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
- package/dist/{frameworkTableAssembly-DNOFXfEQ.js → frameworkTableAssembly-vfkzuzEo.js} +104 -85
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BjVXpMlP.js → infoCommand-BFksHunl.js} +1 -1
- package/dist/inspect-CZvsmp8t.js +2 -0
- package/dist/{inspect-CNYvNXPU.js → inspect-PcC5J-Dl.js} +74 -59
- package/dist/{logsCmd-BU8uCdys.js → logsCmd-C5y9HyrG.js} +58 -53
- package/dist/manifestBuild-AUh31bFU.js +2 -0
- package/dist/{manifestBuild-CEkjfpwc.js → manifestBuild-BwiS4iYm.js} +1 -1
- package/dist/{migrate-SICulyz1.js → migrate-DtC3lu7H.js} +4 -4
- package/dist/{probeCommand-6HxEkNDG.js → probeCommand-BUYzc0CT.js} +1 -1
- package/dist/{runtimeTrace-DgYMc09E.js → runtimeTrace-BUk5zMXY.js} +1 -1
- package/dist/{sdkgen-PY-umd6O.js → sdkgen-CAhmmbjx.js} +1 -1
- package/dist/serveCommand-BUXUtSF5.js +2 -0
- package/dist/serveCommand-DhtC7WBq.js +2545 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-BFQQkL1i.js → start-C75w6ftA.js} +1 -1
- package/dist/{start-B9NGB8gn.js → start-DNtIlBcn.js} +527 -502
- package/dist/startEntry.js +1 -1
- package/dist/{tracesCmd-BWYDqMy6.js → tracesCmd-C1qKa94g.js} +68 -63
- package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-CtuD5r1t.js} +1 -1
- package/dist/updateCommand-eVKVHj3N.js +2 -0
- package/dist/webDev-Bzb8ZFus.js +2 -0
- package/dist/{webDev-C53hJdcL.js → webDev-CBN9KkyQ.js} +949 -834
- package/dist/{webhooksCommand-uuPu8qQX.js → webhooksCommand-BPJQOGTe.js} +2 -2
- package/package.json +61 -19
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/ai.md +175 -1
- package/templates/agent-docs/authentication.md +68 -19
- package/templates/agent-docs/cli.md +48 -0
- package/templates/agent-docs/configuration.md +29 -4
- package/templates/agent-docs/data.md +30 -0
- package/templates/agent-docs/database/scaling.md +10 -1
- package/templates/agent-docs/database/transactions.md +48 -0
- package/templates/agent-docs/deployment.md +22 -0
- package/templates/agent-docs/observability.md +13 -2
- package/templates/agent-docs/plugins/audit.md +21 -5
- package/templates/agent-docs/plugins/auth-supabase.md +21 -0
- package/templates/agent-docs/plugins/billing.md +2 -0
- package/templates/agent-docs/plugins/sentry.md +91 -8
- package/templates/agent-docs/reference.md +1 -0
- package/templates/agent-docs/scheduling.md +23 -0
- package/templates/agent-docs/schema-driven-ui.md +110 -0
- package/templates/agent-docs/whats-new.md +103 -399
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -7
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +7 -7
- package/templates/apps/frontend-blank/package.json +6 -6
- package/templates/apps/frontend-cms/package.json +8 -8
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +6 -6
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +7 -7
- package/templates/apps/frontend-saas/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +6 -6
- package/templates/apps/frontend-ssr/package.json +6 -6
- package/templates/apps/frontend-ssr-api/package.json +7 -7
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-status/package.json +7 -7
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/apiBuild-Vw1figjO.js +0 -2
- package/dist/build-CI36wL4R.js +0 -868
- package/dist/dbCommand-DrycGWWt.js +0 -2
- package/dist/dev-B9Gz0k85.js +0 -3
- package/dist/doctorCommand-BMWs6aVm.js +0 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +0 -2
- package/dist/inspect-S2rWy1Ys.js +0 -2
- package/dist/manifestBuild-DIa_s6u0.js +0 -2
- package/dist/serveCommand-BITS8Hpj.js +0 -2
- package/dist/serveCommand-DIJ3ma76.js +0 -2485
- package/dist/updateCommand-DsXEAHbd.js +0 -2
- 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
|
-
|
|
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
|
|
373
|
-
credential it carries nothing — every token of a given kind is the
|
|
374
|
-
For a human-chosen password it is a weak hint.
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
122
|
-
|
|
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
|
-
|
|
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 -->
|