@voltro/cli 0.23.0 → 0.25.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 (99) hide show
  1. package/CHANGELOG.md +537 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/bin/voltro.mjs +39 -4
  4. package/dist/{apiBuild-JQtIhZPy.js → apiBuild-BqhCSytw.js} +46 -24
  5. package/dist/apiBuild-N1R4V792.js +2 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/{commands-Bsu9Buln.js → commands-7EmYJ9Xg.js} +2796 -2478
  8. package/dist/dbCommand-CIrdFLp9.js +2 -0
  9. package/dist/{dbCommand-GI7-BVZt.js → dbCommand-FUU12FvD.js} +285 -246
  10. package/dist/{dev-DRxF_qOB.js → dev-BvHT7WZa.js} +1 -1
  11. package/dist/{dev-jM07kq_D.js → dev-MacSQ1Ll.js} +2042 -1631
  12. package/dist/fileConventions-3bffWssN.js +30 -0
  13. package/dist/frameworkTableAssembly-BsnCKzQ6.js +2 -0
  14. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-Cw5zJz6n.js} +2 -2
  15. package/dist/index.js +1 -1
  16. package/dist/inspect-C9gjHwBk.js +2 -0
  17. package/dist/{inspect-_ldwsAwH.js → inspect-DuLUrZp9.js} +14 -1
  18. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-EQwH7BI4.js} +15 -15
  19. package/dist/manifestBuild-BVwS1Z_6.js +2 -0
  20. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-Dneq4_Jx.js} +1 -1
  21. package/dist/serveCommand-5ZFiNO1R.js +1241 -0
  22. package/dist/serveEntry.js +2 -2
  23. package/dist/{start-C_4VUDT8.js → start-C-ZWSDpg.js} +2 -2
  24. package/dist/startEntry.js +2 -2
  25. package/package.json +17 -17
  26. package/templates/AGENTS.core.md +12 -5
  27. package/templates/AGENTS.md +14 -7
  28. package/templates/agent-docs/_index.md +2 -2
  29. package/templates/agent-docs/_manifest.json +3 -3
  30. package/templates/agent-docs/authentication.md +21 -0
  31. package/templates/agent-docs/cli.md +32 -0
  32. package/templates/agent-docs/data.md +491 -167
  33. package/templates/agent-docs/database/migrations.md +132 -63
  34. package/templates/agent-docs/database/schema.md +80 -0
  35. package/templates/agent-docs/database/seedsdialects.md +12 -0
  36. package/templates/agent-docs/deployment.md +75 -1
  37. package/templates/agent-docs/plugins.md +1 -1
  38. package/templates/agent-docs/reference.md +2 -2
  39. package/templates/agent-docs/security.md +18 -0
  40. package/templates/agent-docs/templates/apibackends.md +5 -5
  41. package/templates/agent-docs/templates/overview.md +1 -1
  42. package/templates/agent-docs/whats-new.md +263 -169
  43. package/templates/agent-docs/workflows.md +74 -20
  44. package/templates/apps/api-ai/package.json +8 -7
  45. package/templates/apps/api-auth/package.json +9 -8
  46. package/templates/apps/api-backend/package.json +8 -7
  47. package/templates/apps/api-backend-deactivation/package.json +8 -7
  48. package/templates/apps/api-backend-mail/package.json +9 -8
  49. package/templates/apps/api-backend-mariadb/package.json +10 -9
  50. package/templates/apps/api-backend-storage/package.json +9 -8
  51. package/templates/apps/api-data-advanced/package.json +9 -8
  52. package/templates/apps/api-durable/README.md +2 -1
  53. package/templates/apps/api-durable/events/orders.event.ts +18 -0
  54. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
  55. package/templates/apps/api-durable/package.json +9 -8
  56. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
  57. package/templates/apps/api-feature-flags/package.json +10 -9
  58. package/templates/apps/api-governance/package.json +9 -8
  59. package/templates/apps/api-kv/package.json +9 -8
  60. package/templates/apps/api-moderation/package.json +9 -8
  61. package/templates/apps/api-observability/package.json +9 -8
  62. package/templates/apps/api-ratelimit/package.json +9 -8
  63. package/templates/apps/api-rbac/package.json +9 -8
  64. package/templates/apps/api-rest/package.json +8 -7
  65. package/templates/apps/api-saas/package.json +12 -11
  66. package/templates/apps/api-search/package.json +9 -8
  67. package/templates/apps/api-versioning/package.json +9 -8
  68. package/templates/apps/api-webhooks/README.md +2 -2
  69. package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
  70. package/templates/apps/api-webhooks/package.json +10 -9
  71. package/templates/apps/api-webhooks/template.json +1 -1
  72. package/templates/apps/changelog/package.json +7 -6
  73. package/templates/apps/edge-functions/package.json +3 -2
  74. package/templates/apps/frontend-admin/package.json +9 -8
  75. package/templates/apps/frontend-app/package.json +9 -8
  76. package/templates/apps/frontend-blank/package.json +8 -7
  77. package/templates/apps/frontend-contact/package.json +8 -7
  78. package/templates/apps/frontend-contact/src/globals.d.ts +6 -0
  79. package/templates/apps/frontend-dashboard/package.json +8 -7
  80. package/templates/apps/frontend-docs/package.json +8 -7
  81. package/templates/apps/frontend-docs/src/globals.d.ts +6 -0
  82. package/templates/apps/frontend-i18n/package.json +7 -6
  83. package/templates/apps/frontend-landing/package.json +8 -7
  84. package/templates/apps/frontend-landing/src/globals.d.ts +6 -0
  85. package/templates/apps/frontend-spa/package.json +8 -7
  86. package/templates/apps/frontend-spa/src/globals.d.ts +6 -0
  87. package/templates/apps/frontend-ssr/package.json +8 -7
  88. package/templates/apps/frontend-ssr/src/globals.d.ts +6 -0
  89. package/templates/apps/frontend-ssr-api/package.json +9 -8
  90. package/templates/apps/frontend-static-blog/package.json +7 -6
  91. package/templates/apps/frontend-static-blog/src/globals.d.ts +6 -0
  92. package/dist/apiBuild-CD-4JLLA.js +0 -2
  93. package/dist/dbCommand-CSguuUQn.js +0 -2
  94. package/dist/fileConventions-CBNPZVOV.js +0 -29
  95. package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
  96. package/dist/inspect-BA67TF6v.js +0 -2
  97. package/dist/manifestBuild-i-fRHg_H.js +0 -2
  98. package/dist/serveCommand-Cl8sicD5.js +0 -1162
  99. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
@@ -4,69 +4,6 @@
4
4
 
5
5
 
6
6
 
7
- ---
8
-
9
- <!-- source: en/database/migrations/adopt.md -->
10
- ## Adopting a table into a plugin's
11
-
12
- _voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last._
13
-
14
- An app that did not start on a green field already has a table for half the plugins it installs. `voltro db adopt` is the way **into** the plugin's table, so using the plugin does not mean running a second source of truth beside it.
15
-
16
- ```bash
17
- voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
18
- voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply
19
- ```
20
-
21
- **Dry run unless you pass `--apply`.** The interesting failure here is irreversible and the interesting output is the refusal, so nothing is written until you say so. A refused plan prints no steps at all, rather than a preview of something that will not happen.
22
-
23
- ## The map file is yours
24
-
25
- ```ts
26
- // ai-flows.map.ts
27
- export default {
28
- map: {
29
- name: 'name',
30
- mode: { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
31
- costMicroUsd: { expr: '"totalCostCents" * 10000' },
32
- },
33
- leaveUnset: ['id'],
34
- }
35
- ```
36
-
37
- Read `target: source` — fill the plugin's column FROM this expression of mine, which is the direction the SQL runs. A string is a source column; `{ expr }` is raw SQL against the source row, for the unit conversions and merged fields no tool can infer. Those are domain knowledge, and a tool inventing them silently corrupts data.
38
-
39
- `leaveUnset` is how "the target's own default fills this" stops looking like "I forgot it".
40
-
41
- ## What it refuses
42
-
43
- - **a NOT NULL target column nobody maps to** — the alternative is a silent zero that reads as real data forever after;
44
- - **a target table that already holds rows** — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
45
- - **a typo on either side of the map.**
46
-
47
- A source column nobody carries across is reported but not fatal: dropping a dead column is deliberate often enough, and "I forgot this" and "I decided" look identical in a map file.
48
-
49
- ## The order, and why the drop is last
50
-
51
- 1. **snapshot** — `<table>__adopt_snapshot`, a real table in the same database, so restoring is a statement rather than an operational procedure at 2am. It keeps the columns the adopt left behind.
52
- 2. **copy**
53
- 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows.
54
- 4. **drop the source** — last, and only if the counts match.
55
-
56
- On a mismatch **both tables stay** and the command says so. The snapshot is never removed after a failed verify — it exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
57
-
58
- ## Ids, if the typeid prefixes differ
59
-
60
- The dry run says so before anything runs, because discovering it after the copy is discovering it too late: every row gets a new id, so every reference to the old table has to be rewritten — **including ids embedded in JSON columns**.
61
-
62
- Rewriting them is **not** automatic. Those ids live in your columns and inside your JSON, and only you know where. The translation table is what the command owes you; the rewrite is what you owe yourself. Doing it automatically is the one place here where being wrong would be silent.
63
-
64
- ## After the move
65
-
66
- Your rows are now in a table whose shape the framework evolves — and nothing special happens to them. They migrate exactly like every other row, through the same declarative differ. A narrowing can fail on your data, loudly, the same way it would on anybody's.
67
-
68
-
69
-
70
7
  ---
71
8
 
72
9
  <!-- source: en/database/migrations/index.md -->
@@ -1619,6 +1556,46 @@ no-ops out.
1619
1556
 
1620
1557
  The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
1621
1558
 
1559
+ ## Rehearsing a migration against real data
1560
+
1561
+ The strongest check on this pipeline is not that each command exits 0 — it is that **no row moved that you did not ask to move**. A consumer running a MariaDB cutover built this loop and it caught three defects the framework's own suite did not; the method is theirs, written up here because the next person should not have to invent it.
1562
+
1563
+ 1. **Restore a backup into a throwaway database.**
1564
+
1565
+ ```sh
1566
+ voltro data backup ./rehearsal .
1567
+ DB_URL=$SCRATCH_URL voltro data restore ./rehearsal
1568
+ ```
1569
+
1570
+ Use `data backup` / `data restore` — the NATIVE path — not the logical `data export`. The logical exporter re-shapes rows through the **current** declared schema, and the state a rehearsal exists to migrate *from* is precisely the one that does not match it.
1571
+
1572
+ 2. **Take an exact census, before.**
1573
+
1574
+ ```sql
1575
+ SELECT table_name, COUNT(*) FROM ... -- one COUNT(*) per table
1576
+ ```
1577
+
1578
+ It must be `COUNT(*)`. `information_schema.TABLE_ROWS` is an **estimate** on InnoDB — routinely off by thousands, and it is what a fast version of this check would reach for. The slowness is the point.
1579
+
1580
+ 3. **Run the exact production command sequence** — the same one your deploy Job runs, in the same order:
1581
+
1582
+ ```sh
1583
+ voltro db files .
1584
+ voltro db plan --json > plan.json
1585
+ voltro db apply --plan plan.json
1586
+ ```
1587
+
1588
+ 4. **Take the census again, and require the difference to be explainable.**
1589
+
1590
+ A healthy run moves one row: the `_voltro_migration_plans` ledger entry. Anything else is a question, not a result.
1591
+
1592
+ **Two things the census must get right**, both learned by using it:
1593
+
1594
+ - **A soft drop is not a loss.** With `VOLTRO_SOFT_DROP=1` a dropped table reappears as `<name>__dropped_<YYYYMMDDHHMMSS>` with its rows intact. Reporting that as a vanished table trains people to ignore the check; reporting it as clean hides a real drop. Give it its own category.
1595
+ - **A new table is not a discrepancy.** A migration that adds one produces a table with no "before" count. Say so explicitly rather than letting a zero read as data loss.
1596
+
1597
+ To rehearse a schema several months old — the realistic case — craft the backup deliberately: a table a later migration added, a column a later one narrowed, a column the schema no longer declares. The planner is state-based, so it diffs live against declared and never replays a history; a six-month-old dump costs exactly one diff.
1598
+
1622
1599
  ## File-based migrations in this pipeline
1623
1600
 
1624
1601
  `voltro db apply` runs pending `migrations/*.ts` **first**, then diffs — the same order the boot path uses.
@@ -3295,6 +3272,35 @@ Fix:
3295
3272
  resolves the root from the first non-flag argument, defaulting to
3296
3273
  the current working directory
3297
3274
 
3275
+ ## "N reactive table(s) have NO change trigger in the database" (postgres)
3276
+
3277
+ ```text
3278
+ auto-migrate: 500 reactive table(s) have NO change trigger in the database —
3279
+ ab_test_results, ab_test_variants, ab_tests, … (+492). Writes to them will not
3280
+ reach another instance's subscribers; a single instance is unaffected, which is
3281
+ why this stays invisible until you scale out. Run `voltro db apply` to install them.
3282
+ ```
3283
+
3284
+ On postgres, reactivity is carried by DDL: a per-table `framework_changes_<table>` trigger that `NOTIFY`s the CDC channel. The declared schema and the database can disagree about which tables have one.
3285
+
3286
+ **Run `voltro db apply`.** It converges the triggers as its own step, and it does so **even when the schema diff is empty** — the usual case here, because a missing trigger is not a shape difference and `db plan` will correctly report `0 operations`:
3287
+
3288
+ ```text
3289
+ $ voltro db apply
3290
+ schema diff: 0 operations, 0 blocked
3291
+ (schema is up to date)
3292
+ db apply: installing change triggers on 500 table(s)
3293
+ db apply: change triggers converged (1501 statement(s))
3294
+ ```
3295
+
3296
+ `db apply --plan` converges them too, so the pre-deploy Job pattern needs no extra step.
3297
+
3298
+ **Why a table ends up without one.** The trigger DDL is emitted by the full-schema path — a fresh database — so any table that arrived while your app was already running, or during a release that installed none, has no trigger. A restored dump can do it too (triggers travel with a full dump, but not with a schema-only or `--no-triggers` one), as can a hand-run `DROP TRIGGER` during an incident.
3299
+
3300
+ **Why it stays invisible.** A single instance's own writes reach its own subscribers through the in-process path. The trigger is what carries a write to the *other* instances, so the symptom only appears when you scale out — subscriptions that quietly stop updating, with nothing in the logs.
3301
+
3302
+ The mirror case is reported the same way: a `.nonReactive()` table that still carries a trigger keeps paying `REPLICA IDENTITY FULL` and a `NOTIFY` on every write for a subscription nobody receives. `db apply` removes both.
3303
+
3298
3304
  ## When the fix hint doesn't match reality
3299
3305
 
3300
3306
  The fix hints come from the planner's classification logic — they should always be actionable. If you see one that doesn't make sense given your code:
@@ -3313,3 +3319,66 @@ Hint mismatches are bugs in the planner's classification — they're rare but al
3313
3319
  - [Drift](./drift.md) — when the live DB diverged
3314
3320
  - [Multi-dialect strategy](./multi-dialect.md) — why MySQL + forward-roll
3315
3321
  - [Prod pipeline](./prod-pipeline.md) — the deploy-step apply pattern
3322
+
3323
+
3324
+
3325
+ ---
3326
+
3327
+ <!-- source: en/database/migrations/adopt.md -->
3328
+ ## Adopting a table into a plugin's
3329
+
3330
+ _voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last._
3331
+
3332
+ An app that did not start on a green field already has a table for half the plugins it installs. `voltro db adopt` is the way **into** the plugin's table, so using the plugin does not mean running a second source of truth beside it.
3333
+
3334
+ ```bash
3335
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
3336
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply
3337
+ ```
3338
+
3339
+ **Dry run unless you pass `--apply`.** The interesting failure here is irreversible and the interesting output is the refusal, so nothing is written until you say so. A refused plan prints no steps at all, rather than a preview of something that will not happen.
3340
+
3341
+ ## The map file is yours
3342
+
3343
+ ```ts
3344
+ // ai-flows.map.ts
3345
+ export default {
3346
+ map: {
3347
+ name: 'name',
3348
+ mode: { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
3349
+ costMicroUsd: { expr: '"totalCostCents" * 10000' },
3350
+ },
3351
+ leaveUnset: ['id'],
3352
+ }
3353
+ ```
3354
+
3355
+ Read `target: source` — fill the plugin's column FROM this expression of mine, which is the direction the SQL runs. A string is a source column; `{ expr }` is raw SQL against the source row, for the unit conversions and merged fields no tool can infer. Those are domain knowledge, and a tool inventing them silently corrupts data.
3356
+
3357
+ `leaveUnset` is how "the target's own default fills this" stops looking like "I forgot it".
3358
+
3359
+ ## What it refuses
3360
+
3361
+ - **a NOT NULL target column nobody maps to** — the alternative is a silent zero that reads as real data forever after;
3362
+ - **a target table that already holds rows** — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
3363
+ - **a typo on either side of the map.**
3364
+
3365
+ A source column nobody carries across is reported but not fatal: dropping a dead column is deliberate often enough, and "I forgot this" and "I decided" look identical in a map file.
3366
+
3367
+ ## The order, and why the drop is last
3368
+
3369
+ 1. **snapshot** — `<table>__adopt_snapshot`, a real table in the same database, so restoring is a statement rather than an operational procedure at 2am. It keeps the columns the adopt left behind.
3370
+ 2. **copy**
3371
+ 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows.
3372
+ 4. **drop the source** — last, and only if the counts match.
3373
+
3374
+ On a mismatch **both tables stay** and the command says so. The snapshot is never removed after a failed verify — it exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
3375
+
3376
+ ## Ids, if the typeid prefixes differ
3377
+
3378
+ The dry run says so before anything runs, because discovering it after the copy is discovering it too late: every row gets a new id, so every reference to the old table has to be rewritten — **including ids embedded in JSON columns**.
3379
+
3380
+ Rewriting them is **not** automatic. Those ids live in your columns and inside your JSON, and only you know where. The translation table is what the command owes you; the rewrite is what you owe yourself. Doing it automatically is the one place here where being wrong would be silent.
3381
+
3382
+ ## After the move
3383
+
3384
+ Your rows are now in a table whose shape the framework evolves — and nothing special happens to them. They migrate exactly like every other row, through the same declarative differ. A narrowing can fail on your data, loudly, the same way it would on anybody's.
@@ -629,6 +629,86 @@ A mixin can also declare `requires: [otherMixin()]` (resolved depth-first) and `
629
629
 
630
630
 
631
631
 
632
+ ---
633
+
634
+ <!-- source: en/database/concurrency-and-expiry.md -->
635
+ ## Concurrency + expiry
636
+
637
+ _.version() for optimistic locking and expires() for time-limited rows — what each guarantees, on which dialect._
638
+
639
+ Two column-level tools for questions a schema cannot otherwise answer: **which write is newest**, and **when does this row stop counting**.
640
+
641
+ ## `.version()` — optimistic locking
642
+
643
+ Two clients read the same row and both write it. Without a version the second silently wins, and the first user's change is gone with no trace. That is the shape of every *"my edit disappeared"* report.
644
+
645
+ ```ts
646
+ export const documents = table('documents', {
647
+ id: id(),
648
+ title: text(),
649
+ version: integer().version(),
650
+ })
651
+ ```
652
+
653
+ From then on the store **increments** `version` on every update, and an update that carries an expectation fails when the row has moved on:
654
+
655
+ ```ts
656
+ // The client sends the version it read.
657
+ yield* ctx.store.update('documents', input.id, { title: input.title, version: input.version })
658
+ // → VersionConflict { expected: 3, actual: 7 } when four writes landed in between
659
+ ```
660
+
661
+ `VersionConflict` is a typed error, so it reaches the client typed and a UI can offer *reload and re-apply* instead of showing a crash. It carries **both** numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is.
662
+
663
+ **Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
664
+
665
+ **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-versioning`](/docs/plugins/versioning) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
666
+
667
+ **Three details worth knowing:**
668
+
669
+ - The version a caller sends is an **expectation, never a write**. It is stripped from the patch, so a client cannot pin its own version and win every race.
670
+ - An update with **no** expectation is still last-write-wins — the default does not change — but the version **still advances**. A version that moved only for careful writers would be worse than none: it would sit still while a careless write changed the row.
671
+ - A row **deleted** underneath you is a conflict too, with `actual: null`. That is how you tell "deleted" from "changed".
672
+
673
+ ## `expires()` — a row with an end date
674
+
675
+ ```ts
676
+ export const inviteLinks = table('inviteLinks', {
677
+ id: id(),
678
+ email: text(),
679
+ }).with(expires())
680
+
681
+ await ctx.store.insert('inviteLinks', {
682
+ email,
683
+ expiresAt: new Date(Date.now() + 24 * 3_600_000),
684
+ })
685
+ ```
686
+
687
+ After that instant the row is **not returned by reads**. `expiresAt` is nullable and null means *never*, so adding the mixin to an existing table does not make its rows vanish.
688
+
689
+ Opt out for a deliberate read — an admin view, a grace-period check:
690
+
691
+ ```ts
692
+ ctx.store.select('inviteLinks').includeExpired()
693
+ ```
694
+
695
+ ### Read this before you rely on it
696
+
697
+ **Visibility and storage are two different guarantees, and only one of them holds everywhere.**
698
+
699
+ | | where | when |
700
+ | --- | --- | --- |
701
+ | **Invisible to reads** | every dialect | immediately, the instant it passes |
702
+ | **Physically deleted** | postgres only | eventually, by the retention sweep |
703
+
704
+ So an expired row is **invisible everywhere and still present in the database on four of five dialects**. That is the right trade — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB — but it is a surprising one, and it matters: *do not treat an expired row as unreachable*. If the value must actually be gone, delete it, or do not store it in a row at all.
705
+
706
+ ## See also
707
+
708
+ [Column types](/docs/database/columns) · [Mutations](/docs/data/mutations) · [Soft delete](/docs/plugins/soft-delete)
709
+
710
+
711
+
632
712
  ---
633
713
 
634
714
  <!-- source: en/database/ids/index.md -->
@@ -225,6 +225,18 @@ DB_URL=file:./db.sqlite # or `:memory:` for ephemeral
225
225
 
226
226
  The `app.config.ts` `store:` field stays as the dev-friendly shortcut (`store: 'postgres'`, `store: 'memory'`) — env always wins.
227
227
 
228
+ ### Query timeout — bound a runaway query
229
+
230
+ ```sh
231
+ DB_STATEMENT_TIMEOUT_MS=30000 # cancel any single query after 30s
232
+ ```
233
+
234
+ A missing index or an accidental cartesian join can run for minutes, and while it does it **pins a pooled connection**. Enough of them under load and the pool is exhausted — every other request now waits on a connection that will never free, and the whole app stalls. `DB_STATEMENT_TIMEOUT_MS` puts a ceiling on it: a query that outlasts the deadline is cancelled, its connection returns to the pool, and the caller gets a normal error instead of a hang.
235
+
236
+ It applies to the **runtime query path only**. Migrations (`voltro db apply`) run legitimately long statements — backfills, index builds — and are **never** cancelled by it. (Caveat: `voltro dev`'s boot auto-migrate shares the app connection, so a very slow dev migration under a low timeout would trip it — raise the value, or run `voltro db apply` first.)
237
+
238
+ **Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real, server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the variable but currently ignore it, and the reasons are honest rather than incidental: `@effect/sql-mssql` exposes only a connection-establishment timeout, not a per-request one; MySQL/MariaDB's `max_execution_time` bounds `SELECT`s only (writes stay unbounded), which would be a misleading half-guarantee; and SQLite is in-process with a single connection, so there is no pool to protect. Unset (or any non-postgres dialect) = no timeout.
239
+
228
240
  ## Local development — bring up all five
229
241
 
230
242
  The framework ships a docker-compose at `voltro/test/docker-compose.yml` that brings up postgres + mysql + mariadb + mssql on distinct ports so per-dialect tests can run side-by-side and the dev fixture never clashes with your starter postgres on `:5432`:
@@ -918,6 +918,53 @@ under `NODE_ENV=production` (it refuses unless both fingerprints still match the
918
918
  reviewed plan). A Job runs **once per release** vs an `initContainer`'s once per
919
919
  replica, so it's the better fit for a multi-replica rollout.
920
920
 
921
+ ### Expand/contract — the migration that's safe while old pods still serve
922
+
923
+ The pre-deploy Job applies the schema **before the new pods roll** — so during a
924
+ rolling update, old pods (old code) run against the already-migrated schema for
925
+ the length of the rollout. A migration that DROPS or RENAMES a column, NARROWS a
926
+ type, or ADDS a constraint breaks those old pods mid-rollout: they 500 reading a
927
+ column that's gone, or their writes are rejected by the new constraint. The
928
+ migration "succeeded" and the app served errors anyway.
929
+
930
+ `voltro db plan` flags these — the operations unsafe under a rolling deploy are
931
+ listed with a `⚠`, separately from the data-safety (lossy / blocked) gate, since
932
+ the two are orthogonal: a `dropped()` column is blessed for data loss and *still*
933
+ breaks an old reader.
934
+
935
+ ```text
936
+ ⚠ 1 operation(s) UNSAFE under a rolling deploy
937
+ (old + new instances overlap → old code breaks against the new schema):
938
+ • drop-column: old instances still SELECT/INSERT "orders"."legacy_total"; …
939
+ → stop reading the column in code and deploy that first; drop it in a LATER deploy
940
+ ```
941
+
942
+ Two ways to handle it:
943
+
944
+ 1. **No overlap window** — a maintenance-window or **scale-to-zero** deploy (old
945
+ pods gone before new ones start) has no simultaneous old code, so a single-step
946
+ drop/rename is fine. The advisory doesn't apply; ignore it.
947
+ 2. **Zero-downtime rollout** — split the breaking change into two releases, each
948
+ of which keeps *both* code versions working (**expand/contract**):
949
+ - **Expand** (release N): add the new shape — a nullable column, a new table,
950
+ a backfill, dual-write from the new code. Old code ignores it.
951
+ - **Cut over**: the new code reads/writes the new shape; deploy it.
952
+ - **Contract** (release N+1): once no pod runs the old code, drop/rename/narrow
953
+ the now-unused old shape. This step's `db plan` is clean.
954
+
955
+ Renaming `orders.total` → `orders.amount` under zero downtime is: add `amount`
956
+ (expand) → backfill + dual-write → cut reads over → drop `total` (contract) — three
957
+ releases, never one, so no in-flight pod ever references a column that isn't there.
958
+
959
+ **Make it a hard gate if you always rolling-deploy — `VOLTRO_ROLLING_DEPLOY=1`.**
960
+ The `db plan` ⚠ is advisory by default, because a maintenance-window / scale-to-zero
961
+ deploy has no overlap window and the framework can't tell which you run. If your
962
+ pipeline is *always* a rolling update, set `VOLTRO_ROLLING_DEPLOY=1` in the migration
963
+ Job's env: `voltro db apply` then **refuses** (exit 2) a plan containing a
964
+ rolling-unsafe operation instead of warning, so an un-split breaking change fails the
965
+ deploy rather than breaking pods at runtime. Override a specific apply with `--force`.
966
+ Unset (the default) leaves today's advisory behaviour untouched.
967
+
921
968
  ### If you DO run `voltro dev` in a cluster (dev / staging only)
922
969
 
923
970
  `voltro dev` binds a small **boot-health surface** on its own port so a probe can
@@ -1059,6 +1106,33 @@ that route to a terminating pod before k8s finishes removing it from the Service
1059
1106
  endpoints. With it, that window is drained. `terminationGracePeriodSeconds` must
1060
1107
  be larger than the sleep plus the app's own teardown, or k8s SIGKILLs mid-drain.
1061
1108
 
1109
+ **Bound the app's own teardown with `VOLTRO_SHUTDOWN_GRACE_MS`.** After
1110
+ `SIGTERM`, the runtime runs its finalizers (pool close, plugin `onDeactivate`,
1111
+ analytics flush, trace persist) and then exits — but installing the signal
1112
+ handler removes node's default kill, so a finalizer that *never* completes (a
1113
+ pool drain against a database that is already gone, a wedged `onDeactivate`)
1114
+ would otherwise hang the process forever. A hard deadline caps that: teardown
1115
+ gets until the deadline, then the process exits regardless. It defaults to
1116
+ **10s**; set `VOLTRO_SHUTDOWN_GRACE_MS` (clamped to 1s–5min) to sit JUST UNDER
1117
+ your `terminationGracePeriodSeconds` minus the preStop sleep — so the app drains
1118
+ and exits *cleanly on its own* before k8s SIGKILLs it mid-drain:
1119
+
1120
+ ```yaml
1121
+ spec:
1122
+ terminationGracePeriodSeconds: 30
1123
+ containers:
1124
+ - name: api
1125
+ env:
1126
+ # preStop sleep (5s) + app teardown (≤22s) < 30s grace, with headroom.
1127
+ - name: VOLTRO_SHUTDOWN_GRACE_MS
1128
+ value: "22000"
1129
+ ```
1130
+
1131
+ The close is clean on the client side too: on shutdown each live WebSocket is
1132
+ closed with a proper close frame (not an abrupt socket drop), and the web
1133
+ client's supervisor reconnects on any close — so an open dashboard re-attaches
1134
+ to a healthy replica across a rolling deploy without a page reload.
1135
+
1062
1136
  ## 8. Multiple replicas
1063
1137
 
1064
1138
  Cache and KV default to **in-process** (per-replica). For a shared backend across replicas:
@@ -1087,7 +1161,7 @@ Schedules and aggregates auto-coordinate via an advisory lock on SQL stores —
1087
1161
  - [ ] `auth.anonymousTenantRequired: true` (unless the app serves anonymous public data)
1088
1162
  - [ ] `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_SERVICE_NAME` pointed at your collector
1089
1163
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
1090
- - [ ] `terminationGracePeriodSeconds` generous for graceful drain
1164
+ - [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
1091
1165
  - [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
1092
1166
 
1093
1167
 
@@ -70,7 +70,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
70
70
  | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
71
71
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
72
72
  | `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
73
- | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineOutgoingEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
73
+ | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
74
74
  | `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
75
75
  | `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics#voltroplugin-analytics-postgres) |
76
76
  | `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics#voltroplugin-duckdb) |
@@ -361,7 +361,7 @@ Events include lifecycle changes, timers, signals, and updates recorded by the w
361
361
 
362
362
  ## `useWorkflowDomainEvents(apiName, filters?, options?)`
363
363
 
364
- Subscribes to the **domain events** an app emitted through `ctx.events.emit(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.
364
+ Subscribes to the **domain events** an app emitted through `ctx.events.publish(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.
365
365
 
366
366
  ```tsx
367
367
  const { events } = useWorkflowDomainEvents('app', { name: 'order.paid', limit: 50 })
@@ -1131,7 +1131,7 @@ Templates come in **three kinds**, matching the three things you deploy:
1131
1131
  | [`api-rest`](/docs/templates/api-rest) | Public REST API — `defineRestRoute` (query/path/body, scope guards, Idempotency-Key) + `@voltro/plugin-openapi` (OpenAPI 3.1 spec + Swagger UI at `/docs`). Zero-infra boot. |
1132
1132
  | [`api-saas`](/docs/templates/api-saas) | The SaaS plugin bundle — billing entitlements + notifications + analytics + presence, wired turnkey; one `projects.create` exercises three together. Zero-infra boot. |
1133
1133
  | [`api-observability`](/docs/templates/api-observability) | Production-readiness — Prometheus `/metrics` + a custom counter, Sentry (inert without a DSN), tracing, and a `@voltro/testing` unit test (`voltro test`). Zero-infra boot. |
1134
- | [`api-webhooks`](/docs/templates/api-webhooks) | First-class webhooks both ways — a signature-verified incoming `*.webhook.tsx` receiver + an outgoing `defineOutgoingEvent` emitted via a durable signed delivery workflow. Zero-infra boot. |
1134
+ | [`api-webhooks`](/docs/templates/api-webhooks) | First-class webhooks both ways — a signature-verified incoming `*.webhook.tsx` receiver + an outgoing `defineEvent` emitted via a durable signed delivery workflow. Zero-infra boot. |
1135
1135
 
1136
1136
  ## Web frontends (`kind: web`)
1137
1137
 
@@ -39,6 +39,24 @@ This matters because a caller-supplied URL is ordinary product surface — a
39
39
  scraper, a webhook-registration form, an importer, a "test this connection"
40
40
  button. Those reach `HttpClient` with whatever the user typed.
41
41
 
42
+ ### Handling a refusal
43
+
44
+ A blocked request fails on the **error channel**, as an `HttpClientError.RequestError` whose `cause` is the `SsrfBlockedError`. So the handler that supplied the URL can decide what it means:
45
+
46
+ ```ts
47
+ yield* enrich(userSuppliedUrl).pipe(
48
+ Effect.catchTag('RequestError', (e) =>
49
+ e.cause instanceof SsrfBlockedError
50
+ ? Effect.succeed(none) // an optional enrichment: skip it
51
+ : Effect.fail(e)), // a real transport failure: keep failing
52
+ )
53
+ ```
54
+
55
+ Two things worth knowing if you are upgrading from a version before this was a failure:
56
+
57
+ - **It used to be a defect** (`Effect.die`), so code that handled it did so with `Effect.catchAllDefect`. That arm no longer fires. If you also have an `Effect.mapError` above it, the rejection now reaches `mapError` FIRST — and a delivery that classified an SSRF block as *terminal* in the defect arm will be reclassified as whatever `mapError` returns. A consumer hit exactly this: a permanent condition became retryable and burned the full backoff budget re-attempting a request that can never succeed. Nothing in the type system can see that, because the error channel already carried `HttpClientError`.
58
+ - **`description` names the policy** (`SSRF policy: blocked private/reserved address …`), which is what you want in a log line; the `cause` is what you want in a branch.
59
+
42
60
  ### Allowing a target on purpose
43
61
 
44
62
  ```ts
@@ -663,7 +663,7 @@ const execute = async (
663
663
  export default execute
664
664
  ```
665
665
 
666
- `TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.emit(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
666
+ `TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.publish(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
667
667
 
668
668
  ## Event trigger — start a workflow from a domain event
669
669
 
@@ -1019,7 +1019,7 @@ Watch the run in the dashboard's **Workflows** tab (the `awaitSignal` gate also
1019
1019
 
1020
1020
  ## Anti-patterns
1021
1021
 
1022
- - **Emitting the domain event before the write commits.** `orders.place` emits `order.placed` *after* `ctx.store.insert` returns `ctx.events.emit` is post-commit safe so a rolled-back transaction never starts a workflow for an order that doesn't exist. Don't emit eagerly inside the same expression as the insert.
1022
+ - **Worrying about publishing before the write commits.** You no longer have to: inside a mutation `ctx.events.publish` fires on COMMIT and not at all on rollback, so a rolled-back transaction cannot start a workflow for an order that does not exist and cannot tell a client it does either. Publish wherever it reads best.
1023
1023
  - **Doing external I/O inside a mutation.** The place mutation only inserts + emits. HTTP calls, payments, and other side effects belong in a workflow `step()` (journaled + replayed) or an action — a mutation runs in a transaction and can't roll back an HTTP side effect.
1024
1024
  - **Tracking the workflow run handle to send a signal.** `orders.approve` re-derives the executionId from the payload via the deterministic `idempotencyKey` instead. Storing the handle from the place mutation is unnecessary and breaks across restarts.
1025
1025
  - **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — and `TenantMismatch` must be imported from `@voltro/plugin-multitenancy/guard` in the descriptor, never the package root (the root leaks `@voltro/database` into the browser bundle).
@@ -2212,7 +2212,7 @@ The package also ships `MockClock` / `MockEmail` / `mockAi` / `makeWorkflowRunne
2212
2212
  <!-- source: en/templates/api-webhooks.md -->
2213
2213
  ## API · Webhooks
2214
2214
 
2215
- _First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineOutgoingEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
2215
+ _First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
2216
2216
 
2217
2217
  Webhooks **both ways**, wired turnkey: a **signature-verified incoming receiver** that rejects forged traffic before your code runs, and an **outgoing event** a mutation emits to subscribed targets through a durable, signed, retried delivery workflow. Webhooks are **file-convention** (drop a `*.webhook.tsx`, it's auto-discovered) — not a `plugins:[]` entry. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-webhooks`**.
2218
2218
 
@@ -2231,7 +2231,7 @@ apps/acme/api/
2231
2231
  ├── .env # VOLTRO_WEBHOOK_SECRET_ORDERS (dev signing secret)
2232
2232
  ├── database/schema.ts # orders + webhookTables() bookkeeping
2233
2233
  ├── webhooks/orders.webhook.tsx # INCOMING — signature-verified receiver
2234
- ├── events/order.completed.webhook.tsx # OUTGOING — defineOutgoingEvent
2234
+ ├── events/order.completed.webhook.tsx # OUTGOING — defineEvent
2235
2235
  ├── mutations/orders.fulfill.mutation.ts # create order + emit (+ .server.ts)
2236
2236
  └── queries/orders.list.query.ts # reactive list (+ .server.ts)
2237
2237
  ```
@@ -2281,7 +2281,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/
2281
2281
 
2282
2282
  ```ts
2283
2283
  // events/order.completed.webhook.tsx
2284
- export default defineOutgoingEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
2284
+ export default defineEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
2285
2285
 
2286
2286
  // mutations/orders.fulfill.mutation.server.ts — after the row commits:
2287
2287
  const { eventId, deliveries } = await useWebhooks(ctx).emit('order.completed', { orderId, tenantId, /* … */ })
@@ -43,7 +43,7 @@ The four `api-backend*` shapes share the same minimal `notes` domain and differ
43
43
  | [`api-rest`](/docs/templates/api-rest) | **Public REST + OpenAPI** — `defineRestRoute` endpoints (query/path/body, scope guards, `Idempotency-Key`) + `@voltro/plugin-openapi` (Swagger UI at `/docs`, generated from the descriptors). Zero-infra boot. |
44
44
  | [`api-saas`](/docs/templates/api-saas) | **The SaaS plugin bundle** — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One `projects.create` exercises billing + analytics + notifications together. |
45
45
  | [`api-observability`](/docs/templates/api-observability) | **Metrics + errors + tests** — `@voltro/plugin-prometheus` (`GET /metrics` + a custom counter), `@voltro/plugin-sentry` (inert without a DSN), tracing, and a `@voltro/testing` unit test. Zero-infra boot. |
46
- | [`api-webhooks`](/docs/templates/api-webhooks) | **First-class webhooks** — a signature-verified incoming `*.webhook.tsx` receiver (rejects forged traffic before your handler) + an outgoing `defineOutgoingEvent` a mutation emits via a durable signed delivery workflow. Zero-infra boot. |
46
+ | [`api-webhooks`](/docs/templates/api-webhooks) | **First-class webhooks** — a signature-verified incoming `*.webhook.tsx` receiver (rejects forged traffic before your handler) + an outgoing `defineEvent` a mutation emits via a durable signed delivery workflow. Zero-infra boot. |
47
47
  | [`api-search`](/docs/templates/api-search) | **Full-text search in sync** — `@voltro/plugin-search` mirrors every table write into an index via the post-commit change tap; the synthesized `search.query` rpc returns tenant-scoped hits. Memory backend (zero infra); one line to Typesense/Meilisearch/Algolia. |
48
48
  | [`api-feature-flags`](/docs/templates/api-feature-flags) | **Feature flags as code** — `@voltro/plugin-flags`: kill-switch / %-rollout / targeting, a declarative `gatedBy` map + an in-handler `requireFlag` guard (both fail typed `FlagDisabled`), `useFlag()` on the web. Memory store. |
49
49
  | [`api-ratelimit`](/docs/templates/api-ratelimit) | **Per-endpoint rate limiting** — `@voltro/plugin-ratelimit` intercepts rpc: a default + rules (sliding-window / token-bucket, by subject/tenant/…), typed `RateLimited` auto-merged into the error union. Memory store. |