@voltro/cli 0.34.0 → 0.35.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 +167 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-DZF_f0_I.js → apiBuild-DYD_ONLD.js} +2 -2
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-CE7Ca9gm.js → build-CD8K4XOr.js} +7 -7
- package/dist/{checkCommand-Dv8r9tGO.js → checkCommand-Ct9xkTrS.js} +8 -7
- package/dist/{checkCommand-YZDmkAGd.js → checkCommand-DKpDLlqu.js} +1 -1
- package/dist/{clusterCmd-CNXKlcvD.js → clusterCmd-D5wsCmA_.js} +1 -1
- package/dist/{codegenCommand-DC6w2tNZ.js → codegenCommand-C4YoQIc2.js} +3 -3
- package/dist/{codemodRunner-DRRqXR74.js → codemodRunner-BnFq3Fgu.js} +175 -34
- package/dist/{commands-gutsz-Ac.js → commands-BE8E7zF3.js} +37 -29
- package/dist/{dashboardCommand-3YG8p-UA.js → dashboardCommand-D7SgZGaN.js} +3 -3
- package/dist/{dataCommand-qL0r7fPO.js → dataCommand-BhYwDgg-.js} +56 -54
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-B6X0FZbq.js → dbCommand-O8HA63s2.js} +6 -6
- package/dist/{dev-CerMd0mW.js → dev-C7sFZq3m.js} +2696 -2188
- package/dist/{dev-CoG-ZPx8.js → dev-D2BikO7a.js} +1 -1
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{doctorCommand-CnDXQxfa.js → doctorCommand-DnimF5IM.js} +76 -73
- package/dist/{dormancyCommand-Dszo57d6.js → dormancyCommand-QewYug_s.js} +1 -1
- package/dist/{embeddingsCommand-C0sKVRo1.js → embeddingsCommand-BfiLS_QI.js} +1 -1
- package/dist/{envCommand-DPgdV1Bq.js → envCommand-CCGPRQY1.js} +2 -2
- package/dist/{evolveCommand-DHpkgjgH.js → evolveCommand-CHsLCtDf.js} +2 -2
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/{generateCommand-oibemh97.js → generateCommand-DbgcUpGw.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BJw9nLUR.js → infoCommand-DwOgK1t6.js} +1 -1
- package/dist/{inspect-CBqFtAKA.js → inspect-CjTYzAs_.js} +32 -2
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/{inspectCmd-Bppy-GGw.js → inspectCmd-EHFZ9yYu.js} +1 -1
- package/dist/{inspectFetch-Cm8_wVvp.js → inspectFetch-EMuhTG_9.js} +1 -1
- package/dist/{logsCmd-CCca3KRZ.js → logsCmd-D36xK7Zu.js} +1 -1
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-sxpwdKY1.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/{migrate-CBwOt_iV.js → migrate-BV7I-ZHZ.js} +3 -3
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/{runtimeTrace-CRxalXTs.js → runtimeTrace-CH3eUiMw.js} +1 -1
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/{sdkgen-CYJscZC7.js → sdkgen-BLkvGRfX.js} +1 -1
- package/dist/{seedRunner-TFHHiToI.js → seedRunner-ZmLSqNe2.js} +74 -70
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-B-9Nsp-S.js → start-BgN62boB.js} +1 -1
- package/dist/{start-9LiUOfES.js → start-T4VesWiM.js} +2 -2
- package/dist/startEntry.js +1 -1
- package/dist/{tracesCmd-CkEZQrtt.js → tracesCmd-DStmCJPi.js} +1 -1
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/{updateCommand-Bkptutss.js → updateCommand-x0pI_x-B.js} +1 -1
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{webDev-CBYvPqQr.js → webDev-Dybxew86.js} +14 -2
- package/dist/{webhooksCommand-CID96Rga.js → webhooksCommand-DlAgS2Iw.js} +1 -1
- package/dist/{workflowsCmd-D1VTmLMY.js → workflowsCmd-BGF-mRZ5.js} +1 -1
- package/package.json +47 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/authentication.md +55 -7
- package/templates/agent-docs/cli.md +59 -0
- package/templates/agent-docs/data.md +59 -2
- package/templates/agent-docs/database/migrations.md +50 -1
- package/templates/agent-docs/deployment.md +214 -21
- package/templates/agent-docs/introduction.md +1 -1
- package/templates/agent-docs/multi-tenancy.md +93 -22
- package/templates/agent-docs/plugins/ai-flows.md +86 -0
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/clickhouse.md +31 -0
- package/templates/agent-docs/plugins/search.md +6 -3
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins.md +3 -1
- package/templates/agent-docs/scheduling.md +17 -0
- package/templates/agent-docs/security.md +2 -2
- package/templates/agent-docs/whats-new.md +95 -1729
- package/templates/agent-docs/workflows.md +101 -0
- 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-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- 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 +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- 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-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- 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/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +3 -3
- package/dist/apiBuild-BICVGnEC.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/dbCommand-CpdKLeQq.js +0 -2
- package/dist/doctorCommand-BvqGBwNG.js +0 -2
- package/dist/frameworkTableAssembly-BGHmck-x.js +0 -2
- package/dist/frameworkTableAssembly-DkkP6BgC.js +0 -674
- package/dist/inspect-C_T_WGvl.js +0 -2
- package/dist/manifestBuild-ChsKAhmn.js +0 -2
- package/dist/scheduleCmd--jksTrf6.js +0 -69
- package/dist/serveCommand-B_isw7q4.js +0 -1647
- package/dist/serveCommand-DOvbgRnQ.js +0 -2
- package/dist/updateCommand-us1_hdIC.js +0 -2
- package/dist/webDev-Cg-fFiyd2.js +0 -2
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
_Voltro Cloud (coming soon) — the managed runtime for your Voltro project. Today the Free control plane registers + observes your self-hosted apps._
|
|
13
13
|
|
|
14
|
-
Voltro Cloud is the managed tier of the same
|
|
14
|
+
Voltro Cloud is the managed tier of the same runtime you run yourself. It's not a fork — `voltro start` is what powers your production instances, just with custom domains, multi-region routing, provisioned Postgres / Redis / object storage, and a control plane that handles deploys.
|
|
15
15
|
|
|
16
16
|
**Status:** managed cloud **deploy + provisioning is coming soon** and is not yet available. What's live today is the **Free control plane**: sign up and use it to **register and observe your self-hosted apps** (hosted observability, quotas, governance, teams). The managed-tier features described below are the planned shape of Voltro Cloud — see the per-section notes.
|
|
17
17
|
|
|
@@ -117,7 +117,7 @@ voltro cloud import # scaffold voltro.cloud.toml from the app's app.config.ts
|
|
|
117
117
|
|
|
118
118
|
_Run Voltro on your own infra — Docker compose, env vars, reverse proxy, scaling._
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
The runtime that powers Voltro Cloud is the same runtime you run yourself with `voltro start` — one codebase, one license. Self-hosting is fully supported — no separate "lite" runtime, no feature gates around the core API.
|
|
121
121
|
|
|
122
122
|
## The shape of a self-hosted deploy
|
|
123
123
|
|
|
@@ -1129,11 +1129,16 @@ Durable in-DB traces are **off in production by default** — traces belong in y
|
|
|
1129
1129
|
|
|
1130
1130
|
## 7. Graceful shutdown
|
|
1131
1131
|
|
|
1132
|
-
On `SIGTERM` / `SIGINT`, `voltro serve` shuts down cleanly (exit 0)
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1132
|
+
On `SIGTERM` / `SIGINT`, `voltro serve` shuts down cleanly (exit 0), in this
|
|
1133
|
+
order: it stops accepting new connections and lets **in-flight requests finish
|
|
1134
|
+
against a fully-alive app** (bounded — the request drain gets 60% of the
|
|
1135
|
+
shutdown grace, so the teardown behind it always fits inside the deadline),
|
|
1136
|
+
then stops schedulers, detaches subscribers / reactions / aggregates, drains
|
|
1137
|
+
the analytics sink and mirror, ends any remaining WebSocket, and closes the
|
|
1138
|
+
SQL connection pool **last** (waiting for in-flight transactions). Verified
|
|
1139
|
+
against a real serve under signal: a request in flight when `SIGTERM` lands
|
|
1140
|
+
completes with a full response before the process exits — no preStop hook, no
|
|
1141
|
+
orchestrator. A bare `voltro serve` under docker compose drains itself.
|
|
1137
1142
|
|
|
1138
1143
|
The **transactional-outbox worker** is part of that sequence: its poll timer and
|
|
1139
1144
|
its change subscription are released, and a delivery already in flight is
|
|
@@ -1141,11 +1146,12 @@ awaited, before the pool closes. An outbox row that was still pending is not
|
|
|
1141
1146
|
lost — it is durable, and the next process's first pass picks it up, which is
|
|
1142
1147
|
one of the three reasons that poll exists.
|
|
1143
1148
|
|
|
1144
|
-
**
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
+
**What an orchestrator still adds: routing.** The app finishes every request
|
|
1150
|
+
it has *accepted* — but a request that *arrives* after `SIGTERM` is refused
|
|
1151
|
+
(the listener closes immediately, on purpose), and only the layer that routes
|
|
1152
|
+
traffic can stop sending it. Failing readiness from inside the process doesn't
|
|
1153
|
+
help: the listener is already closed. Under k8s, close that window with a
|
|
1154
|
+
**preStop hook**:
|
|
1149
1155
|
|
|
1150
1156
|
```yaml
|
|
1151
1157
|
spec:
|
|
@@ -1163,10 +1169,15 @@ spec:
|
|
|
1163
1169
|
command: ["sh", "-c", "sleep 5"]
|
|
1164
1170
|
```
|
|
1165
1171
|
|
|
1166
|
-
Without the preStop hook, a rolling update
|
|
1167
|
-
that route to a terminating pod before k8s finishes removing it from the
|
|
1168
|
-
endpoints
|
|
1169
|
-
|
|
1172
|
+
Without the preStop hook, a rolling update refuses the small window of requests
|
|
1173
|
+
that still route to a terminating pod before k8s finishes removing it from the
|
|
1174
|
+
Service endpoints — refused with a connection error, not truncated mid-response
|
|
1175
|
+
(everything already accepted completes either way). With it, that window is
|
|
1176
|
+
served too. `terminationGracePeriodSeconds` must be larger than the sleep plus
|
|
1177
|
+
the app's own teardown, or k8s SIGKILLs mid-drain. Environments with no
|
|
1178
|
+
endpoint removal at all — docker compose above all — need nothing: there is no
|
|
1179
|
+
routing layer to lag behind the shutdown, so the built-in drain is the whole
|
|
1180
|
+
story.
|
|
1170
1181
|
|
|
1171
1182
|
**Bound the app's own teardown with `VOLTRO_SHUTDOWN_GRACE_MS`.** After
|
|
1172
1183
|
`SIGTERM`, the runtime runs its finalizers (pool close, plugin `onDeactivate`,
|
|
@@ -1190,10 +1201,10 @@ spec:
|
|
|
1190
1201
|
value: "22000"
|
|
1191
1202
|
```
|
|
1192
1203
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1204
|
+
Live WebSockets are ENDED promptly at shutdown — the drain never lets an open
|
|
1205
|
+
socket hold the process to the deadline — and the web client's supervisor
|
|
1206
|
+
treats any close as a reconnect signal, so an open dashboard re-attaches to a
|
|
1207
|
+
healthy replica across a rolling deploy without a page reload.
|
|
1197
1208
|
|
|
1198
1209
|
## 8. Multiple replicas
|
|
1199
1210
|
|
|
@@ -1367,7 +1378,7 @@ VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT=5 # lease-refresh cadence (default 10; kee
|
|
|
1367
1378
|
|
|
1368
1379
|
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.
|
|
1369
1380
|
|
|
1370
|
-
(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`
|
|
1381
|
+
(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, the change stream does the same job wherever remote changes reach the spine** — a Postgres-only multi-replica fleet (LISTEN/NOTIFY CDC) is the common case: a remote replica's signal, start context, or run transition arrives as a change event and triggers an immediate, coalesced re-poll, so signal/step latency stops being poll-bounded there too. Only with *neither* a broker *nor* CDC does the poll interval remain the bound — tune `VOLTRO_WORKFLOW_POLL_INTERVAL=2` then. Unrelated to the failover path above.)
|
|
1371
1382
|
|
|
1372
1383
|
## Checklist
|
|
1373
1384
|
|
|
@@ -1561,3 +1572,185 @@ Air-gapped and enterprise deployments attest seats contractually instead; no tel
|
|
|
1561
1572
|
|
|
1562
1573
|
- [Voltro Cloud](./voltro-cloud.md) — the control plane these features live in
|
|
1563
1574
|
- [Self-hosting](./self-hosting.md) — running Voltro yourself
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
---
|
|
1579
|
+
|
|
1580
|
+
<!-- source: en/deployment/platforms.md -->
|
|
1581
|
+
## Platform recipes
|
|
1582
|
+
|
|
1583
|
+
_Deploy the same container to Fly.io, Railway, Render, or a Hetzner VM — one image, four wrappers._
|
|
1584
|
+
|
|
1585
|
+
Every recipe on this page deploys the **same artifact**: the production image
|
|
1586
|
+
from the compose baseline's `docker/api.Dockerfile` (`voltro baseline set
|
|
1587
|
+
compose` writes it into your project). The platforms differ only in the wrapper
|
|
1588
|
+
— how they build it, which env vars they inject, and how they health-check it.
|
|
1589
|
+
|
|
1590
|
+
**What is verified, stated exactly.** The container itself is the tested part:
|
|
1591
|
+
it builds from a clean context, boots `voltro serve` from the precompiled serve
|
|
1592
|
+
bundle, survives the `pnpm deploy` relocation, and answers
|
|
1593
|
+
`/internal/readiness` with 200 — that loop runs in this repo's own validation.
|
|
1594
|
+
The platform wrapper files below are written against each platform's current
|
|
1595
|
+
config format and have **not** been executed against a live account of that
|
|
1596
|
+
platform; if one drifts from what the platform ships today, the container is
|
|
1597
|
+
still right and the fix is in the wrapper.
|
|
1598
|
+
|
|
1599
|
+
Three properties of the image every platform relies on:
|
|
1600
|
+
|
|
1601
|
+
- **`PORT` wins.** The port precedence is `PORT` > `--port` > `app.config.ts` —
|
|
1602
|
+
deliberately, because platforms assign through `PORT`. You never configure a
|
|
1603
|
+
port in the wrapper beyond telling the platform which one the app answers on.
|
|
1604
|
+
- **A missing secret refuses to boot.** `VOLTRO_SESSION_SECRET` unset is a
|
|
1605
|
+
clean, named boot refusal — not a server that signs with a default. Set
|
|
1606
|
+
secrets in the platform's secret store before the first deploy, or read the
|
|
1607
|
+
refusal message; both are correct outcomes.
|
|
1608
|
+
- **`/internal/readiness` flips to 200 only after the whole boot.** Use it as
|
|
1609
|
+
the health check everywhere; routing traffic on process-up instead of
|
|
1610
|
+
readiness is how a deploy serves 502s for the first seconds.
|
|
1611
|
+
|
|
1612
|
+
## Fly.io
|
|
1613
|
+
|
|
1614
|
+
```toml
|
|
1615
|
+
# fly.toml
|
|
1616
|
+
app = "my-voltro-api"
|
|
1617
|
+
primary_region = "fra"
|
|
1618
|
+
|
|
1619
|
+
[build]
|
|
1620
|
+
dockerfile = "docker/api.Dockerfile"
|
|
1621
|
+
build-args = { APP_PATH = "apps/my-app/api" }
|
|
1622
|
+
|
|
1623
|
+
[env]
|
|
1624
|
+
DB_DIALECT = "postgres"
|
|
1625
|
+
|
|
1626
|
+
[http_service]
|
|
1627
|
+
internal_port = 4000
|
|
1628
|
+
force_https = true
|
|
1629
|
+
auto_stop_machines = "stop"
|
|
1630
|
+
auto_start_machines = true
|
|
1631
|
+
min_machines_running = 0
|
|
1632
|
+
|
|
1633
|
+
[[http_service.checks]]
|
|
1634
|
+
path = "/internal/readiness"
|
|
1635
|
+
interval = "10s"
|
|
1636
|
+
timeout = "2s"
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
```sh
|
|
1640
|
+
fly secrets set VOLTRO_SESSION_SECRET=$(openssl rand -base64 32) DB_URL=<from fly postgres attach>
|
|
1641
|
+
fly deploy
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
The one Fly-specific decision: `auto_stop_machines` gives you scale-to-zero,
|
|
1645
|
+
and a cold start pays the container boot. The serve bundle exists for exactly
|
|
1646
|
+
this — the framework-boot slice of a cold start is ~180–210 ms instead of ~1 s.
|
|
1647
|
+
Read [Scale to zero](/docs/deployment/scale-to-zero) before choosing
|
|
1648
|
+
`min_machines_running = 0` for an api that owns schedules: a machine that is
|
|
1649
|
+
never awake fires no cron.
|
|
1650
|
+
|
|
1651
|
+
## Railway
|
|
1652
|
+
|
|
1653
|
+
Railway detects the Dockerfile; point it at the right one and set the build
|
|
1654
|
+
context to the repo root (the Dockerfile copies the whole workspace for
|
|
1655
|
+
`pnpm install`).
|
|
1656
|
+
|
|
1657
|
+
```json
|
|
1658
|
+
// railway.json
|
|
1659
|
+
{
|
|
1660
|
+
"build": {
|
|
1661
|
+
"builder": "DOCKERFILE",
|
|
1662
|
+
"dockerfilePath": "docker/api.Dockerfile"
|
|
1663
|
+
},
|
|
1664
|
+
"deploy": {
|
|
1665
|
+
"healthcheckPath": "/internal/readiness",
|
|
1666
|
+
"restartPolicyType": "ON_FAILURE"
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
Set `VOLTRO_SESSION_SECRET` and `DB_URL` as service variables; Railway injects
|
|
1672
|
+
`PORT` and the image binds to it — no port config anywhere. The
|
|
1673
|
+
`APP_PATH` build arg goes into the service's build settings.
|
|
1674
|
+
|
|
1675
|
+
## Render
|
|
1676
|
+
|
|
1677
|
+
```yaml
|
|
1678
|
+
# render.yaml
|
|
1679
|
+
services:
|
|
1680
|
+
- type: web
|
|
1681
|
+
name: my-voltro-api
|
|
1682
|
+
runtime: docker
|
|
1683
|
+
dockerfilePath: ./docker/api.Dockerfile
|
|
1684
|
+
dockerContext: .
|
|
1685
|
+
healthCheckPath: /internal/readiness
|
|
1686
|
+
envVars:
|
|
1687
|
+
- key: DB_DIALECT
|
|
1688
|
+
value: postgres
|
|
1689
|
+
- key: VOLTRO_SESSION_SECRET
|
|
1690
|
+
sync: false
|
|
1691
|
+
- key: DB_URL
|
|
1692
|
+
fromDatabase:
|
|
1693
|
+
name: my-voltro-db
|
|
1694
|
+
property: connectionString
|
|
1695
|
+
|
|
1696
|
+
databases:
|
|
1697
|
+
- name: my-voltro-db
|
|
1698
|
+
plan: basic-1gb
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
`sync: false` makes the secret a dashboard-entered value that never lands in
|
|
1702
|
+
the blueprint file — the same "we ship no secret values" rule the framework
|
|
1703
|
+
enforces on its own templates applies to yours.
|
|
1704
|
+
|
|
1705
|
+
## Hetzner (or any bare VM)
|
|
1706
|
+
|
|
1707
|
+
A VM is the compose baseline with a process manager on top — this is the one
|
|
1708
|
+
recipe whose whole stack is the already-validated path from
|
|
1709
|
+
[Self-hosting](/docs/deployment/self-hosting).
|
|
1710
|
+
|
|
1711
|
+
```sh
|
|
1712
|
+
# once, on the VM
|
|
1713
|
+
apt-get install -y docker.io docker-compose-plugin
|
|
1714
|
+
git clone <your-repo> /srv/app && cd /srv/app
|
|
1715
|
+
cp .env.example .env # then fill in real values — nothing boots without them
|
|
1716
|
+
docker compose up -d --build
|
|
1717
|
+
```
|
|
1718
|
+
|
|
1719
|
+
```ini
|
|
1720
|
+
# /etc/systemd/system/voltro.service — survive reboots
|
|
1721
|
+
[Unit]
|
|
1722
|
+
Description=voltro stack
|
|
1723
|
+
Requires=docker.service
|
|
1724
|
+
After=docker.service
|
|
1725
|
+
|
|
1726
|
+
[Service]
|
|
1727
|
+
Type=oneshot
|
|
1728
|
+
RemainAfterExit=true
|
|
1729
|
+
WorkingDirectory=/srv/app
|
|
1730
|
+
ExecStart=/usr/bin/docker compose up -d
|
|
1731
|
+
ExecStop=/usr/bin/docker compose down
|
|
1732
|
+
|
|
1733
|
+
[Install]
|
|
1734
|
+
WantedBy=multi-user.target
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
Caddy (in the baseline compose) terminates TLS with an automatic Let's Encrypt
|
|
1738
|
+
certificate — point the domain's A record at the VM and the certificate is
|
|
1739
|
+
provisioned on first request. What a VM does NOT give you: rolling deploys
|
|
1740
|
+
(compose restarts in place — expect seconds of downtime per deploy, or put two
|
|
1741
|
+
VMs behind a load balancer), and managed Postgres backups —
|
|
1742
|
+
[`voltro data backup`](/docs/cli/data) plus the restore drill is your baseline,
|
|
1743
|
+
and the drill is the part people skip.
|
|
1744
|
+
|
|
1745
|
+
## Which one
|
|
1746
|
+
|
|
1747
|
+
| | scale-to-zero | managed DB | rolling deploys | cost floor |
|
|
1748
|
+
|---|---|---|---|---|
|
|
1749
|
+
| Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
|
|
1750
|
+
| Railway | usage-based sleep | built-in | yes | ~0 idle |
|
|
1751
|
+
| Render | paid plans only | built-in | yes | fixed/instance |
|
|
1752
|
+
| Hetzner VM | no | bring your own | no (single VM) | fixed, cheapest at steady load |
|
|
1753
|
+
|
|
1754
|
+
An api that owns cron schedules should not scale to zero. An api with bursty
|
|
1755
|
+
traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
|
|
1756
|
+
the boring answer — one always-on instance — is also the cheapest to operate.
|
|
@@ -127,7 +127,7 @@ Voltro is deliberately opinionated about boring things (HTTP, state, transport,
|
|
|
127
127
|
|
|
128
128
|
## What we won't do
|
|
129
129
|
|
|
130
|
-
- **Lock you in.** Self-hosting is fully supported. Cloud is the premium tier of the same
|
|
130
|
+
- **Lock you in.** Self-hosting is fully supported. Cloud is the premium tier of the same runtime, not a fork.
|
|
131
131
|
- **Multi-runtime grab-bag.** Effect-TS end-to-end — no Restate, no Temporal, no Inngest, not even opt-in.
|
|
132
132
|
- **Magic.** Every file convention is documented; every generated file lives in `.framework/` and you can read it.
|
|
133
133
|
- **Codegen you have to remember to run.** Schema flows from your tables to your React components automatically via Vite's module graph.
|
|
@@ -158,6 +158,20 @@ export default {
|
|
|
158
158
|
|
|
159
159
|
Or via env — `VOLTRO_TENANT_ISOLATION=namespace` — which overrides the config field. The same flag is read by `voltro dev` and `voltro serve`, so the topology can't drift between dev and prod.
|
|
160
160
|
|
|
161
|
+
### Provisioning — a new tenant's first request creates its namespace
|
|
162
|
+
|
|
163
|
+
The namespace is provisioned **lazily, on first use**: a tenant nobody has seen
|
|
164
|
+
before gets its schema and tables created — and its
|
|
165
|
+
[`lifecycle: 'onTenantCreate'` seeds](/docs/data/queries) fired — the first time
|
|
166
|
+
a request touches its store, memoised per process afterwards. There is nothing
|
|
167
|
+
to pre-register, and a failed provision is retried on the next request rather
|
|
168
|
+
than cached.
|
|
169
|
+
|
|
170
|
+
Eager provisioning is **your** move, because only your app knows its tenants:
|
|
171
|
+
call `provisionTenantNamespace(tables, namespace, sqlLayer, dialect)` from a
|
|
172
|
+
seed or startup file over your own tenant table when you want the DDL paid at
|
|
173
|
+
deploy time instead of on a tenant's first request.
|
|
174
|
+
|
|
161
175
|
### One mechanism, per-dialect mapping
|
|
162
176
|
|
|
163
177
|
Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sanitised-id>`, derived from `subject.tenantId` — mapped to each dialect's native physical container:
|
|
@@ -657,43 +671,100 @@ tenant's data.
|
|
|
657
671
|
> `TenantResidencyUnresolved`. Home region not served here → typed
|
|
658
672
|
> `TenantRegionUnavailable`. Both fail closed; that's the whole point.
|
|
659
673
|
|
|
660
|
-
##
|
|
674
|
+
## Declare it
|
|
675
|
+
|
|
676
|
+
Residency is declared in `app.config.ts` and wired by both boot paths — `voltro
|
|
677
|
+
dev` and `voltro serve` open one store per servable region and route every
|
|
678
|
+
request through it. There is nothing to call from your own code.
|
|
661
679
|
|
|
662
680
|
```ts
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
681
|
+
export default defineApiApp({
|
|
682
|
+
tenancy: {
|
|
683
|
+
isolation: 'namespace',
|
|
684
|
+
residency: {
|
|
685
|
+
// The regions THIS deployment holds stores for.
|
|
686
|
+
servableRegions: ['eu-west'],
|
|
687
|
+
// region → the NAME of the env var holding that region's database URL.
|
|
688
|
+
regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
|
|
689
|
+
// tenant → home region. An array, or a function resolving one at boot.
|
|
690
|
+
homes: [
|
|
691
|
+
{ tenantId: 'acme', region: 'eu-west' },
|
|
692
|
+
{ tenantId: 'globex', region: 'us-east' },
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
},
|
|
673
696
|
})
|
|
674
697
|
```
|
|
675
698
|
|
|
676
|
-
|
|
677
|
-
|
|
699
|
+
`regionUrlEnv` takes a variable NAME, not a URL: a connection string is a
|
|
700
|
+
secret and `app.config.ts` is committed. Everything else about a region's store
|
|
701
|
+
— pool bounds, TLS, `search_path`, statement timeouts — is inherited from the
|
|
702
|
+
primary connection, so a region cannot silently run with different limits than
|
|
703
|
+
the deployment it belongs to.
|
|
704
|
+
|
|
705
|
+
`homes` may be a function (`() => Promise<TenantHome[]>`) if the mapping lives
|
|
706
|
+
in your own table or control plane. It is resolved **once, at boot**: adding a
|
|
707
|
+
tenant home needs a restart. That is deliberate — the alternative is a cache
|
|
708
|
+
with a staleness window on a decision whose entire value is that it is never
|
|
709
|
+
wrong.
|
|
710
|
+
|
|
711
|
+
### It requires namespace isolation, and refuses to boot without it
|
|
712
|
+
|
|
713
|
+
`residency` without `isolation: 'namespace'` is a **boot refusal**, not a
|
|
714
|
+
warning. The region keeps regions apart; the namespace keeps tenants apart
|
|
715
|
+
inside a region. With only the first, every tenant in a region would share one
|
|
716
|
+
set of tables — the region boundary held and the tenant boundary dropped, which
|
|
717
|
+
looks like residency and is not.
|
|
718
|
+
|
|
719
|
+
The other boot refusals, all for the same reason (a deployment that looks like
|
|
720
|
+
it enforces residency and does not is worse than one that will not start):
|
|
678
721
|
|
|
679
|
-
|
|
722
|
+
- a servable region with no `regionUrlEnv` entry;
|
|
723
|
+
- a servable region whose env var is unset;
|
|
724
|
+
- an empty `servableRegions`;
|
|
725
|
+
- a tenant mapped to two different regions.
|
|
680
726
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
727
|
+
## What a request gets
|
|
728
|
+
|
|
729
|
+
Every request resolves its subject's tenant → home region → **that region's
|
|
730
|
+
store**, and only then binds the tenant's namespace inside it. Residency picks
|
|
731
|
+
which database; the namespace picks which tenant's tables in it.
|
|
732
|
+
|
|
733
|
+
Three refusals, none of which falls back:
|
|
734
|
+
|
|
735
|
+
| Situation | Result |
|
|
736
|
+
|---|---|
|
|
737
|
+
| Subject has no resolvable tenant | `TenantResidencyUnresolved` |
|
|
738
|
+
| Tenant has no home mapped | `TenantResidencyUnresolved` |
|
|
739
|
+
| Tenant homed in a region this deployment does not serve | `TenantRegionUnavailable`, naming the region so a gateway can route it |
|
|
740
|
+
|
|
741
|
+
`ctx.storeForTenant(id)` resolves residency for **that** tenant, not the
|
|
742
|
+
caller's — so a handler acting on another tenant either reaches that tenant's
|
|
743
|
+
region or is refused. It is the seam background work must use: a schedule or a
|
|
744
|
+
workflow runs with no request and, under the system subject, no tenant, so
|
|
745
|
+
`ctx.store` on those paths is the primary store. A job that touches one tenant's
|
|
746
|
+
rows has to say which tenant, and `storeForTenant` is how it says so.
|
|
747
|
+
|
|
748
|
+
A transaction is never re-routed. When a mutation hands its transaction-scoped
|
|
749
|
+
store to a nested call, that store is used as given — it already went through
|
|
750
|
+
residency to exist, and moving writes off the connection holding the lock would
|
|
751
|
+
be a worse failure than the one residency prevents.
|
|
752
|
+
|
|
753
|
+
## Driving it yourself
|
|
754
|
+
|
|
755
|
+
The resolvers are exported for a control plane that owns the mapping itself —
|
|
756
|
+
`setResidencyConfig`, `residentPlacement`, `bindResidentStore`. The declaration
|
|
757
|
+
above calls the first two for you; reach for them directly only if you are
|
|
758
|
+
building the region topology outside the framework.
|
|
684
759
|
|
|
685
760
|
```ts
|
|
686
761
|
import { bindResidentStore } from '@voltro/database'
|
|
687
762
|
|
|
688
|
-
// stores: ReadonlyMap<region, StoreHandle>
|
|
763
|
+
// stores: ReadonlyMap<region, StoreHandle>
|
|
689
764
|
const { store, placement } = bindResidentStore(subject, config, stores)
|
|
690
765
|
// placement = { region, namespace, connectionKey? }
|
|
691
|
-
// throws TenantRegionUnavailable for an EU-homed tenant on a US deployment.
|
|
692
766
|
```
|
|
693
767
|
|
|
694
|
-
`residentPlacement(subject, config)` gives the placement alone (region +
|
|
695
|
-
the tenant's namespace within that region's store) without needing the handles.
|
|
696
|
-
|
|
697
768
|
## Provision a new resident tenant
|
|
698
769
|
|
|
699
770
|
`provisionResidentTenant` runs an injected provisioner against the tenant's HOME
|
|
@@ -165,10 +165,96 @@ A flow authored as DATA carries the same setting in `_voltro_ai_flows.humanTimeo
|
|
|
165
165
|
When the bound does expire, the run row is written `status: 'failed'` naming the
|
|
166
166
|
bound that elapsed — it no longer dies with the row still reading `waiting`.
|
|
167
167
|
|
|
168
|
+
## Conditional steps and fan-out
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
defineFlow({
|
|
172
|
+
name: 'campaign',
|
|
173
|
+
brief: [{ key: 'mode', label: 'Mode', type: 'text' }],
|
|
174
|
+
steps: [
|
|
175
|
+
flowStep.text({ prompt: 'Draft the copy', outputKey: 'copy' }),
|
|
176
|
+
|
|
177
|
+
// Runs only when the brief asked for the full treatment.
|
|
178
|
+
flowStep.text({
|
|
179
|
+
prompt: 'Write a long-form variant of {{copy}}',
|
|
180
|
+
outputKey: 'longform',
|
|
181
|
+
when: { ref: 'mode', op: 'eq', value: 'full' },
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
// These two run CONCURRENTLY — same group, consecutive.
|
|
185
|
+
flowStep.media('image', { prompt: 'Hero image for {{copy}}', outputKey: 'hero', group: 'assets' }),
|
|
186
|
+
flowStep.media('image', { prompt: 'Square crop for {{copy}}', outputKey: 'square', group: 'assets' }),
|
|
187
|
+
|
|
188
|
+
// Both group outputs are available again after the group completes.
|
|
189
|
+
flowStep.note({ prompt: 'Ship {{hero}} and {{square}}', outputKey: 'summary' }),
|
|
190
|
+
],
|
|
191
|
+
})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `when:` — run a step only sometimes
|
|
195
|
+
|
|
196
|
+
A step with a `when:` runs only if the condition holds against the run context
|
|
197
|
+
(brief fields ⊕ earlier `outputKey`s). A step whose condition is false is
|
|
198
|
+
**skipped**, not failed: it produces no output, so anything referencing it sees
|
|
199
|
+
an absent value — which is what makes `when` compose with the dependency guard
|
|
200
|
+
instead of fighting it. The run timeline shows the step as `skipped` with the
|
|
201
|
+
rendered reason (`{{mode}} equals "full"`), so a step that vanished is never
|
|
202
|
+
indistinguishable from a step nobody declared.
|
|
203
|
+
|
|
204
|
+
| `op` | True when |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `truthy` / `falsy` | the value is present and not `false` / an empty array |
|
|
207
|
+
| `eq` / `neq` | the value equals the literal (structural for objects and arrays) |
|
|
208
|
+
| `contains` | the array contains the value, or the string contains the substring |
|
|
209
|
+
|
|
210
|
+
Two deliberate choices, because both look like bugs until you know why:
|
|
211
|
+
|
|
212
|
+
- **A condition is structured data, not an expression string.** A flow can be
|
|
213
|
+
authored as a stored row a user edits in a browser, and an expression there
|
|
214
|
+
would be an evaluator running user-authored source on your server. It also
|
|
215
|
+
lets the boot-time validator refuse a condition on a key nothing produces —
|
|
216
|
+
the alternative silently skips its step on every run, forever.
|
|
217
|
+
- **`0` and `''` are TRUTHY here.** A step gated on a generated count or string
|
|
218
|
+
means "did the producer run", not "is it non-zero". The second is `{ op:
|
|
219
|
+
'neq', value: 0 }`, which you can say when you mean it.
|
|
220
|
+
|
|
221
|
+
A compound condition needs two steps, or a `structured` step that computes the
|
|
222
|
+
boolean. That is a real limit of a parser-free design.
|
|
223
|
+
|
|
224
|
+
### `group:` — run steps concurrently
|
|
225
|
+
|
|
226
|
+
**Consecutive** steps sharing a `group` name run at the same time. Each keeps its
|
|
227
|
+
own durable step, so a replay resolves every branch from the journal exactly as
|
|
228
|
+
it would sequentially — the concurrency is in the execution, not in the
|
|
229
|
+
durability.
|
|
230
|
+
|
|
231
|
+
Three rules, all enforced at registration rather than at run time:
|
|
232
|
+
|
|
233
|
+
- Steps in one group **cannot read each other's outputs** — they have no order
|
|
234
|
+
between them. Their outputs become available to everything *after* the group.
|
|
235
|
+
- A group must be **contiguous**. A group name that stops and resumes would
|
|
236
|
+
execute as two sequential fan-outs, which is the opposite of what it reads as.
|
|
237
|
+
- A **`human` review cannot join a group** — it suspends the whole run, which
|
|
238
|
+
one branch of a fan-out cannot do.
|
|
239
|
+
|
|
240
|
+
One failing branch fails the run; the siblings that succeeded keep their
|
|
241
|
+
journaled results, so a retry does not re-pay for them.
|
|
242
|
+
|
|
168
243
|
## Chaining & cadence
|
|
169
244
|
|
|
170
245
|
- **`chainTo`** launches a follow-up flow on success (result ⊕ input → the child's
|
|
171
246
|
brief); `requireConfirmation` stages it for the user to confirm.
|
|
247
|
+
- **A chain is bounded.** `chainTo` used to carry one guard — a flow could not
|
|
248
|
+
chain to itself — so `A → B → A`, or a chain that simply ran deep, was
|
|
249
|
+
unbounded: every hop starts a child run with a fresh idempotency key, and
|
|
250
|
+
nothing was counting. A run now carries the chain that led to it, and a chain
|
|
251
|
+
is refused when the target is already in that path (a cycle) or when the depth
|
|
252
|
+
reaches `maxChainDepth` (default 5; `aiFlowsPlugin({ maxChainDepth })` or
|
|
253
|
+
`VOLTRO_AI_FLOW_MAX_CHAIN_DEPTH`). The refusal lands on the run row's
|
|
254
|
+
`chainRefusal` naming the path — a chain that silently does not fire is
|
|
255
|
+
indistinguishable from one nobody declared. The parent run still **succeeds**:
|
|
256
|
+
a refused follow-up is a configuration problem, not a reason to destroy a
|
|
257
|
+
completed result.
|
|
172
258
|
- **`cadence`** schedules runs — `weekly` (with `intervalWeeks` + `anchorDate`) or
|
|
173
259
|
`monthly` (`weekOfMonth`, incl. `'last'`). `cadenceMatches` / `nextRuns` are
|
|
174
260
|
exported so the editor's "next 3 runs" preview and the scheduler agree exactly.
|
|
@@ -46,7 +46,7 @@ What it provides:
|
|
|
46
46
|
- The `BillingService` Context.Tag — yield it in any handler.
|
|
47
47
|
- The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
|
|
48
48
|
- A webhook receiver at `POST /billing/webhook`.
|
|
49
|
-
- The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.entitlementStatus`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
|
|
49
|
+
- The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.entitlementStatus`, `billing.reportUsage`, `billing.previewChange`, `billing.invoices`, `billing.changePlan`, `billing.changeSeats`. Every route resolves the tenant from the caller's subject, and each declares its access decision: `billing.subscription` + `billing.entitlementStatus` are `openAccess` (the tenant-scoped reads every member's account UI renders), while the routes that change what the tenant pays — `startCheckout`, `portalUrl`, `previewChange`, `changePlan`, `changeSeats` — and the invoice history (`invoices`) require the **`billing:manage`** scope, and `reportUsage` requires **`billing:report`** (a metering credential's scope — an unguarded usage report would let any session inflate its tenant's counters). Grant the scopes via an rbac role or your auth strategy's `resolveScopes`; `admin:full` passes, as always.
|
|
50
50
|
- **Seat-based billing**; proration and failed-payment retries are Stripe's.
|
|
51
51
|
- **Dunning** — a past-due notification sequence, a grace period and a lockout, composed on Stripe's outcomes.
|
|
52
52
|
- The typed `BillingError`, `EntitlementExceeded` + `SubscriptionLocked` errors, merged into every procedure's wire error union.
|
|
@@ -63,6 +63,37 @@ on graceful shutdown.
|
|
|
63
63
|
| `table` | `string` | `'events'` | Override the events-table name. |
|
|
64
64
|
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (a `ReplacingMergeTree(version)` over `{ id, data, version, is_deleted }`) so analytical queries JOIN events against live user data. The version is the framework's commit-order stamp, so a change that arrives late loses the collapse. |
|
|
65
65
|
| `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
|
|
66
|
+
| `batch` | `{ maxSize?, flushIntervalMs? } \| false` | **on** — `{ maxSize: 20, flushIntervalMs: 5000 }` | Client-side batching of `track()` inserts. `false` opts out (one immediate, confirmed insert per event). |
|
|
67
|
+
|
|
68
|
+
## Batching is on by default
|
|
69
|
+
|
|
70
|
+
One HTTP insert per event is how a MergeTree gets **part-exploded** under load —
|
|
71
|
+
every single-row insert creates a data part the server must merge away. So
|
|
72
|
+
`track()` **batches by default**: rows buffer in the process and flush in ONE
|
|
73
|
+
multi-row insert once 20 events are pending, 5 seconds after the first buffered
|
|
74
|
+
event (whichever comes first), and on graceful shutdown (the `dispose` hook
|
|
75
|
+
drains the buffer before the client closes).
|
|
76
|
+
|
|
77
|
+
What changes observably with batching on:
|
|
78
|
+
|
|
79
|
+
- A successful `track()` means **"buffered"**, not "ClickHouse accepted the
|
|
80
|
+
row" — the insert happens later, off the call path.
|
|
81
|
+
- Events become visible to reads up to `flushIntervalMs` later than they were
|
|
82
|
+
tracked.
|
|
83
|
+
- A flush failure is logged and that batch **dropped** (best-effort analytics —
|
|
84
|
+
re-queuing a partially-applied insert would risk duplicates). A hard crash
|
|
85
|
+
loses whatever is still buffered; a graceful shutdown loses nothing.
|
|
86
|
+
|
|
87
|
+
If you need per-event delivery confirmation, opt out explicitly:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
analytics: clickhouseAnalytics({
|
|
91
|
+
url: process.env.CLICKHOUSE_URL!,
|
|
92
|
+
batch: false, // one immediate insert per track(); success = row accepted
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
…or tune the window: `batch: { maxSize: 500, flushIntervalMs: 2000 }`.
|
|
66
97
|
|
|
67
98
|
`database`, `table`, and each `mirrorTables` entry are validated as SQL
|
|
68
99
|
identifiers at boot (they're interpolated into DDL) — a bad name fails loudly at
|
|
@@ -46,7 +46,7 @@ export default {
|
|
|
46
46
|
|
|
47
47
|
The plugin declares `onChangeEvent`. On every committed write to a configured table, `applyChange` maps the row via `map(row)` and upserts (insert/update) or removes (delete) the index document. No `*.subscribe.ts`, no manual indexing calls — the tap is the single sync path, and it runs under both `voltro dev` and `voltro serve`.
|
|
48
48
|
|
|
49
|
-
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you.
|
|
49
|
+
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you. The endpoint **streams** the table (keyset-paginated) and upserts one bounded page at a time — `sync.reindexBatchSize`, default 1000 rows — so memory stays flat no matter how large the table is. `backfillIndex` itself stays a plain array API for small explicit seeds.
|
|
50
50
|
|
|
51
51
|
## When the engine is down
|
|
52
52
|
|
|
@@ -69,6 +69,9 @@ searchPlugin({
|
|
|
69
69
|
retryMaxDelayMs: 10_000, // ceiling for any single wait
|
|
70
70
|
resyncIntervalMs: 60_000, // repair-sweep interval. 0 turns the sweep off (POST /resync still repairs on demand)
|
|
71
71
|
resyncBatchSize: 200, // max ledger entries repaired per sweep
|
|
72
|
+
reindexBatchSize: 1000, // rows per page for POST /reindex (streamed keyset walk — memory stays flat)
|
|
73
|
+
statsFlushIntervalMs: 5_000, // buffered sync-counter flush cadence. 0 = one durable write per event
|
|
74
|
+
statsFlushMaxBuffered: 1000, // flush early once this many counts are pending
|
|
72
75
|
},
|
|
73
76
|
})
|
|
74
77
|
```
|
|
@@ -258,9 +261,9 @@ Two things to be clear about before you reach for it:
|
|
|
258
261
|
|
|
259
262
|
## Dashboard panel
|
|
260
263
|
|
|
261
|
-
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed, last reindex) + the resolved backend,
|
|
264
|
+
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed/dropped, last reindex) + the resolved backend, per-index **drift badges** (`pendingDrift` / `drifted` / last drift time), the **repair queue itself** (the drift-ledger rows, oldest first, with attempt counts and the engine's last error), and two actions — **Reindex** per index (streams the table's current rows back in) and **Resync now** (repairs the drifted rows on demand). Reindex gates on the `canReindexSearch` capability, resync on `canResyncSearch`. Backed by `/_voltro/inspect/plugins/search/{indexes,drift,reindex,resync}`.
|
|
262
265
|
|
|
263
|
-
The sync stats behind this panel are **durable and aggregated across replicas
|
|
266
|
+
The sync stats behind this panel are **durable and aggregated across replicas** — and counted **in memory first**: each replica buffers its per-event counts and flushes them to the stats table once per window (`sync.statsFlushIntervalMs`, default 5 s; early once `statsFlushMaxBuffered` counts are pending), so an indexed-table write never pays a per-write read+CAS against your primary. A graceful shutdown flushes the tail; a hard crash loses at most the current window of *counters* (never a change — the drift ledger, not these counters, is the durable record of what did not reach the engine). `GET /indexes` drains the buffer before reading, so the panel is always current. `statsFlushIntervalMs: 0` restores one durable write per event. They live in a framework-owned `_voltro_search_stats` table (contributed via `extendSchema.tables`; the plugin declares `store:write`), one row per `(index, replica)`, each bumped with an atomic compare-and-set. `/indexes` aggregates every replica's row — summed under `local` change scope, maxed under `fleet` (see [Under multiple replicas](#under-multiple-replicas)) — and takes the most-recent reindex, so the counts are truthful under multiple instances and survive a restart. Zero-infra dev/tests use an in-process stats store; `bindDataStore` swaps in the durable one at boot, along with the drift ledger.
|
|
264
267
|
|
|
265
268
|
## Permissions
|
|
266
269
|
|
|
@@ -417,6 +417,17 @@ procedure's wire-error union so they decode typed on the client:
|
|
|
417
417
|
`storage.revoke`, `storage.listGrants`, `storage.listRefs`. (The upload hook picks
|
|
418
418
|
the right ones per `prefer`; you rarely call them directly.)
|
|
419
419
|
|
|
420
|
+
Every route declares its access decision. The self-service upload/read flow is
|
|
421
|
+
`openAccess` — what bounds it is structural (mints bind the caller's subject +
|
|
422
|
+
tenant into a signed ticket, finalize/complete refuse another subject's token,
|
|
423
|
+
reads go through the per-object access policy + grants, the grant RPCs enforce
|
|
424
|
+
owner-or-admin in-handler). Three routes require a scope instead: **`storage.mintUploadUrl`**
|
|
425
|
+
(the raw bring-your-own-key PUT, which bypasses limits + scan) and
|
|
426
|
+
**`storage.ingestUrl`** (the server fetches a caller-supplied URL — an SSRF
|
|
427
|
+
surface) need `storage:manage`; **`storage.listRefs`** (the tenant-WIDE media
|
|
428
|
+
library index) needs `storage:browse`. Grant them via an rbac role or
|
|
429
|
+
`resolveScopes`; `admin:full` passes.
|
|
430
|
+
|
|
420
431
|
### The grant RPCs are owner-only
|
|
421
432
|
|
|
422
433
|
`storage.share`, `storage.revoke` and `storage.listGrants` require that the
|