@voltro/cli 0.24.0 → 0.26.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 +670 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BtrTyKnF.js} +15 -15
- package/dist/apiBuild-DDJ0It4j.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-CaIhTsC2.js → commands-D-9iYF2D.js} +1889 -1591
- package/dist/dbCommand-DrzXimKf.js +2 -0
- package/dist/{dbCommand-CC61CsAc.js → dbCommand-uuNCrFAb.js} +172 -163
- package/dist/{dev-DPkQVUTP.js → dev-BnWq4jeA.js} +1 -1
- package/dist/{dev-NCuEhNxs.js → dev-Dbm6SWtn.js} +2369 -1774
- package/dist/fileConventions-3bffWssN.js +30 -0
- package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-BwHU9Euq.js} +12 -8
- package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-_ldwsAwH.js → inspect-CUCCzw2I.js} +20 -4
- package/dist/inspect-gt8bq-Tz.js +2 -0
- package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-BU90mvJN.js} +15 -15
- package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-BnzAxp2O.js} +1 -1
- package/dist/manifestBuild-ifczArzr.js +2 -0
- package/dist/serveCommand-XBXuwJty.js +1294 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-C_4VUDT8.js → start-BGXIf6zT.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/cli.md +15 -0
- package/templates/agent-docs/data.md +339 -2
- package/templates/agent-docs/database/migrations.md +40 -0
- package/templates/agent-docs/database/schema.md +127 -0
- package/templates/agent-docs/deployment.md +28 -0
- package/templates/agent-docs/plugins.md +26 -4
- 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 +278 -168
- package/templates/agent-docs/workflows.md +160 -42
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -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 +8 -8
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -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 +9 -9
- package/templates/apps/api-webhooks/template.json +1 -1
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-UEM3QBke.js +0 -2
- package/dist/dbCommand-Cr__4ATv.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-C4gzDZzc.js +0 -1175
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
|
@@ -1556,6 +1556,46 @@ no-ops out.
|
|
|
1556
1556
|
|
|
1557
1557
|
The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
|
|
1558
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
|
+
|
|
1559
1599
|
## File-based migrations in this pipeline
|
|
1560
1600
|
|
|
1561
1601
|
`voltro db apply` runs pending `migrations/*.ts` **first**, then diffs — the same order the boot path uses.
|
|
@@ -423,6 +423,53 @@ the framework ships richer types for specific use cases:
|
|
|
423
423
|
- **[JSON](/docs/database/json)** — `json<T>()` for arbitrary nested
|
|
424
424
|
data; native JSONB on postgres.
|
|
425
425
|
|
|
426
|
+
## Pointing at a plugin-owned row — `pluginRef`
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
import { pluginRef } from '@voltro/database'
|
|
430
|
+
import { aiFlowsTable } from '@voltro/plugin-ai-flows'
|
|
431
|
+
|
|
432
|
+
export const favourites = table('favourites', {
|
|
433
|
+
id: id(),
|
|
434
|
+
flowId: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' }),
|
|
435
|
+
sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable(),
|
|
436
|
+
})
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
A plain typed id column with **no foreign key**, plus a declared orphan rule the
|
|
440
|
+
framework runs on the post-commit change channel.
|
|
441
|
+
|
|
442
|
+
**Reach for `reference()` first.** A real foreign key across a plugin boundary
|
|
443
|
+
works and survives the plugin renaming its table, because `reference()` takes
|
|
444
|
+
the table as a VALUE — see
|
|
445
|
+
[plugins/overview](/docs/plugins/overview#pointing-your-table-at-a-plugins-row).
|
|
446
|
+
`pluginRef` is for the case where you have deliberately chosen NOT to have a
|
|
447
|
+
key: it enforces nothing at the database level.
|
|
448
|
+
|
|
449
|
+
What it restores is the piece that goes missing with that choice — not the
|
|
450
|
+
constraint but the RULE, which otherwise becomes a hand-written subscriber per
|
|
451
|
+
app that nobody notices the absence of.
|
|
452
|
+
|
|
453
|
+
| policy | on the target's delete |
|
|
454
|
+
| --- | --- |
|
|
455
|
+
| `'delete'` | delete the referencing row — for one that only exists to point (a favourite, a pin, a share) |
|
|
456
|
+
| `'null'` | clear the column, keep the row. Requires `.nullable()` |
|
|
457
|
+
| `'keep'` (default) | nothing — the explicit "I handle it myself" |
|
|
458
|
+
|
|
459
|
+
Four things worth knowing before you rely on it:
|
|
460
|
+
|
|
461
|
+
- **The tenant boundary fails closed.** A referencing row whose tenant differs
|
|
462
|
+
from the deleted row's — or which has none — is left alone. Deleting across a
|
|
463
|
+
tenant boundary because a scope was missing is worse than leaving an orphan.
|
|
464
|
+
- **Soft deletes are opt-in** (`onSoftDelete: true`). A soft delete is a state
|
|
465
|
+
the target can undo, so cascading on it destroys rows a restore cannot bring
|
|
466
|
+
back — and plugin tables are inconsistent about carrying `deletedAt` at all.
|
|
467
|
+
- **The target is a table VALUE.** A rename carries the rule with it, which is
|
|
468
|
+
the whole reason the FK was refused; a string would put the coupling back.
|
|
469
|
+
- **A missing plugin refuses at boot**, naming both sides. A declared rule
|
|
470
|
+
against a table nothing registers would sit there looking enforced.
|
|
471
|
+
|
|
472
|
+
|
|
426
473
|
## Anti-patterns
|
|
427
474
|
|
|
428
475
|
- **`serial`/`bigserial` integer ids.** Leaks row counts via `/users/12345`. Use `id()` (TypeID).
|
|
@@ -629,6 +676,86 @@ A mixin can also declare `requires: [otherMixin()]` (resolved depth-first) and `
|
|
|
629
676
|
|
|
630
677
|
|
|
631
678
|
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
<!-- source: en/database/concurrency-and-expiry.md -->
|
|
682
|
+
## Concurrency + expiry
|
|
683
|
+
|
|
684
|
+
_.version() for optimistic locking and expires() for time-limited rows — what each guarantees, on which dialect._
|
|
685
|
+
|
|
686
|
+
Two column-level tools for questions a schema cannot otherwise answer: **which write is newest**, and **when does this row stop counting**.
|
|
687
|
+
|
|
688
|
+
## `.version()` — optimistic locking
|
|
689
|
+
|
|
690
|
+
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.
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
export const documents = table('documents', {
|
|
694
|
+
id: id(),
|
|
695
|
+
title: text(),
|
|
696
|
+
version: integer().version(),
|
|
697
|
+
})
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
From then on the store **increments** `version` on every update, and an update that carries an expectation fails when the row has moved on:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
// The client sends the version it read.
|
|
704
|
+
yield* ctx.store.update('documents', input.id, { title: input.title, version: input.version })
|
|
705
|
+
// → VersionConflict { expected: 3, actual: 7 } when four writes landed in between
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
`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.
|
|
709
|
+
|
|
710
|
+
**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.)
|
|
711
|
+
|
|
712
|
+
**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.
|
|
713
|
+
|
|
714
|
+
**Three details worth knowing:**
|
|
715
|
+
|
|
716
|
+
- 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.
|
|
717
|
+
- 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.
|
|
718
|
+
- A row **deleted** underneath you is a conflict too, with `actual: null`. That is how you tell "deleted" from "changed".
|
|
719
|
+
|
|
720
|
+
## `expires()` — a row with an end date
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
export const inviteLinks = table('inviteLinks', {
|
|
724
|
+
id: id(),
|
|
725
|
+
email: text(),
|
|
726
|
+
}).with(expires())
|
|
727
|
+
|
|
728
|
+
await ctx.store.insert('inviteLinks', {
|
|
729
|
+
email,
|
|
730
|
+
expiresAt: new Date(Date.now() + 24 * 3_600_000),
|
|
731
|
+
})
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
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.
|
|
735
|
+
|
|
736
|
+
Opt out for a deliberate read — an admin view, a grace-period check:
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
ctx.store.select('inviteLinks').includeExpired()
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### Read this before you rely on it
|
|
743
|
+
|
|
744
|
+
**Visibility and storage are two different guarantees, and only one of them holds everywhere.**
|
|
745
|
+
|
|
746
|
+
| | where | when |
|
|
747
|
+
| --- | --- | --- |
|
|
748
|
+
| **Invisible to reads** | every dialect | immediately, the instant it passes |
|
|
749
|
+
| **Physically deleted** | postgres only | eventually, by the retention sweep |
|
|
750
|
+
|
|
751
|
+
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.
|
|
752
|
+
|
|
753
|
+
## See also
|
|
754
|
+
|
|
755
|
+
[Column types](/docs/database/columns) · [Mutations](/docs/data/mutations) · [Soft delete](/docs/plugins/soft-delete)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
|
|
632
759
|
---
|
|
633
760
|
|
|
634
761
|
<!-- source: en/database/ids/index.md -->
|
|
@@ -956,6 +956,15 @@ Renaming `orders.total` → `orders.amount` under zero downtime is: add `amount`
|
|
|
956
956
|
(expand) → backfill + dual-write → cut reads over → drop `total` (contract) — three
|
|
957
957
|
releases, never one, so no in-flight pod ever references a column that isn't there.
|
|
958
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
|
+
|
|
959
968
|
### If you DO run `voltro dev` in a cluster (dev / staging only)
|
|
960
969
|
|
|
961
970
|
`voltro dev` binds a small **boot-health surface** on its own port so a probe can
|
|
@@ -1141,6 +1150,24 @@ KV_BACKEND=redis
|
|
|
1141
1150
|
|
|
1142
1151
|
Schedules and aggregates auto-coordinate via an advisory lock on SQL stores — no extra config to keep them from double-firing across replicas.
|
|
1143
1152
|
|
|
1153
|
+
### Workflow failover across replicas
|
|
1154
|
+
|
|
1155
|
+
On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:
|
|
1156
|
+
|
|
1157
|
+
- **Inject `POD_IP`** (K8s downward API, `fieldRef: status.podIP`) or set `VOLTRO_WORKFLOW_RUNNER_HOST`. This is each replica's cluster **identity** — without a distinct value, every replica registers as the *same* runner and they stop distributing shards (and cross-pod resume degrades). The boot logs a warning if it sees `localhost` with SQL storage.
|
|
1158
|
+
- **Make step side effects idempotent.** Failover is *at-least-once at the step boundary*: a crash between a side effect and its journal write re-runs that step. A step's own [`retry:`](/docs/workflows/retries) does not change this — it's about the step you're inside, not the replica handoff.
|
|
1159
|
+
|
|
1160
|
+
**Is polling the bottleneck? No — reclaim is a lease, not a poll.** A crashed replica keeps its shards until its heartbeat goes stale; only then can a survivor claim them. So takeover latency is bounded by the **lease TTL (~35s by default)**, not by any message-poll interval, and a push mechanism (LISTEN/NOTIFY) does **not** move it. Two knobs tune it:
|
|
1161
|
+
|
|
1162
|
+
```sh
|
|
1163
|
+
VOLTRO_WORKFLOW_FAILOVER_LEASE=15 # seconds a dead replica's work stays locked (default 35)
|
|
1164
|
+
VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT=5 # lease-refresh cadence (default 10; keep ≈ lease/3)
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
Lower the lease for **faster failover**, at the cost of **false-positive reclaims**: if a *healthy* replica is paused longer than the lease by a GC pause or a DB-latency spike, another replica may briefly also claim its shards. Keep the heartbeat around a third of the lease so one slow refresh doesn't trip a reclaim. For crash detection that doesn't depend on the timeout at all, pair it with a K8s **liveness probe** so a dead pod is removed promptly.
|
|
1168
|
+
|
|
1169
|
+
(A separate concern is *new*-message pickup: a workflow triggered on the replica that owns its shard starts immediately, but one owned by ANOTHER replica is otherwise picked up on that replica's next storage poll — up to 10s. **If you run a broadcast broker (Redis/NATS — which a multi-replica deployment already does for cross-replica reactivity), this is automatic and near-instant**: a trigger pushes a "wake" over the bus and the shard owner re-polls at once, on any SQL dialect. Without a broker, tune `VOLTRO_WORKFLOW_POLL_INTERVAL=2` instead. Unrelated to the failover path above.)
|
|
1170
|
+
|
|
1144
1171
|
## Checklist
|
|
1145
1172
|
|
|
1146
1173
|
- [ ] `VOLTRO_SESSION_SECRET` set from `voltro secret generate session`, in a secrets manager
|
|
@@ -1154,6 +1181,7 @@ Schedules and aggregates auto-coordinate via an advisory lock on SQL stores —
|
|
|
1154
1181
|
- [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
|
|
1155
1182
|
- [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
|
|
1156
1183
|
- [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
|
|
1184
|
+
- [ ] For durable workflows on >1 replica: SQL store + `POD_IP` injected; tune `VOLTRO_WORKFLOW_FAILOVER_LEASE` if a 35s takeover is too slow; step side effects idempotent
|
|
1157
1185
|
|
|
1158
1186
|
|
|
1159
1187
|
|
|
@@ -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) |
|
|
@@ -166,9 +166,31 @@ the `_voltro_` namespace in 0.22.0, a `reference(() => table)` followed the rena
|
|
|
166
166
|
**`fk: false`-style decoupling is still available** — declare a plain `text()`
|
|
167
167
|
column instead. Choose it when you deliberately want the app schema independent
|
|
168
168
|
of the plugin's, and accept that nothing then enforces the link. What you should
|
|
169
|
-
NOT do is reach for it by default
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
NOT do is reach for it by default.
|
|
170
|
+
|
|
171
|
+
**When you DO want the decoupling, `pluginRef` gives you the rule without the
|
|
172
|
+
key.** It is a plain typed id column — no constraint, no cross-schema
|
|
173
|
+
dependency — plus a declared orphan policy the framework runs on the post-commit
|
|
174
|
+
change channel:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { pluginRef } from '@voltro/database'
|
|
178
|
+
|
|
179
|
+
flowId: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
|
|
180
|
+
sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That closes the gap named above: an unenforced id column plus a hand-written
|
|
184
|
+
`defineSubscriber` that cleans up on delete is referential integrity
|
|
185
|
+
re-implemented per app, and it is silently wrong the first time somebody forgets
|
|
186
|
+
it. The declaration is the same one line either way — the difference is that the
|
|
187
|
+
framework performs it.
|
|
188
|
+
|
|
189
|
+
**Prefer `reference()` when you want a real key.** `pluginRef` is for the case
|
|
190
|
+
where you have deliberately chosen not to have one; it does not make the
|
|
191
|
+
database enforce anything. The tenant boundary fails closed, soft deletes are
|
|
192
|
+
opt-in (`onSoftDelete`), and a `pluginRef` at a table no installed plugin
|
|
193
|
+
registers refuses at boot rather than sitting there looking enforced.
|
|
172
194
|
|
|
173
195
|
**`orphanPolicy` is not part of this.** It is migration metadata — how existing
|
|
174
196
|
orphan rows are cleaned up *before* the FK constraint is added — and has no
|
|
@@ -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. |
|