@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.
- package/CHANGELOG.md +537 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/bin/voltro.mjs +39 -4
- package/dist/{apiBuild-JQtIhZPy.js → apiBuild-BqhCSytw.js} +46 -24
- package/dist/apiBuild-N1R4V792.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-Bsu9Buln.js → commands-7EmYJ9Xg.js} +2796 -2478
- package/dist/dbCommand-CIrdFLp9.js +2 -0
- package/dist/{dbCommand-GI7-BVZt.js → dbCommand-FUU12FvD.js} +285 -246
- package/dist/{dev-DRxF_qOB.js → dev-BvHT7WZa.js} +1 -1
- package/dist/{dev-jM07kq_D.js → dev-MacSQ1Ll.js} +2042 -1631
- package/dist/fileConventions-3bffWssN.js +30 -0
- package/dist/frameworkTableAssembly-BsnCKzQ6.js +2 -0
- package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-Cw5zJz6n.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/inspect-C9gjHwBk.js +2 -0
- package/dist/{inspect-_ldwsAwH.js → inspect-DuLUrZp9.js} +14 -1
- package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-EQwH7BI4.js} +15 -15
- package/dist/manifestBuild-BVwS1Z_6.js +2 -0
- package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-Dneq4_Jx.js} +1 -1
- package/dist/serveCommand-5ZFiNO1R.js +1241 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-C_4VUDT8.js → start-C-ZWSDpg.js} +2 -2
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +12 -5
- package/templates/AGENTS.md +14 -7
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/authentication.md +21 -0
- package/templates/agent-docs/cli.md +32 -0
- package/templates/agent-docs/data.md +491 -167
- package/templates/agent-docs/database/migrations.md +132 -63
- package/templates/agent-docs/database/schema.md +80 -0
- package/templates/agent-docs/database/seedsdialects.md +12 -0
- package/templates/agent-docs/deployment.md +75 -1
- package/templates/agent-docs/plugins.md +1 -1
- package/templates/agent-docs/reference.md +2 -2
- package/templates/agent-docs/security.md +18 -0
- package/templates/agent-docs/templates/apibackends.md +5 -5
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +263 -169
- package/templates/agent-docs/workflows.md +74 -20
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/package.json +8 -7
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-durable/README.md +2 -1
- package/templates/apps/api-durable/events/orders.event.ts +18 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/README.md +2 -2
- package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/template.json +1 -1
- package/templates/apps/changelog/package.json +7 -6
- package/templates/apps/edge-functions/package.json +3 -2
- package/templates/apps/frontend-admin/package.json +9 -8
- package/templates/apps/frontend-app/package.json +9 -8
- package/templates/apps/frontend-blank/package.json +8 -7
- package/templates/apps/frontend-contact/package.json +8 -7
- package/templates/apps/frontend-contact/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/package.json +8 -7
- package/templates/apps/frontend-docs/package.json +8 -7
- package/templates/apps/frontend-docs/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/package.json +7 -6
- package/templates/apps/frontend-landing/package.json +8 -7
- package/templates/apps/frontend-landing/src/globals.d.ts +6 -0
- package/templates/apps/frontend-spa/package.json +8 -7
- package/templates/apps/frontend-spa/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr/package.json +8 -7
- package/templates/apps/frontend-ssr/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/package.json +9 -8
- package/templates/apps/frontend-static-blog/package.json +7 -6
- package/templates/apps/frontend-static-blog/src/globals.d.ts +6 -0
- package/dist/apiBuild-CD-4JLLA.js +0 -2
- package/dist/dbCommand-CSguuUQn.js +0 -2
- package/dist/fileConventions-CBNPZVOV.js +0 -29
- package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
- package/dist/inspect-BA67TF6v.js +0 -2
- package/dist/manifestBuild-i-fRHg_H.js +0 -2
- package/dist/serveCommand-Cl8sicD5.js +0 -1162
- 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 `
|
|
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.
|
|
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 `
|
|
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.
|
|
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
|
-
- **
|
|
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
|
|
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 —
|
|
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
|
|
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 `
|
|
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. |
|