@voltro/cli 0.21.0 → 0.22.1
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 +316 -0
- package/dist/apiBuild-COyPDf3R.js +2 -0
- package/dist/{apiBuild-BHOtsQkp.js → apiBuild-DiWxVz-M.js} +2 -2
- package/dist/bin.js +3 -3
- package/dist/{commands-BZoimvvS.js → commands-BxRaIOBG.js} +1548 -1246
- package/dist/dbCommand-CO3eSAZR.js +2 -0
- package/dist/{dbCommand-CDBbZ-Ac.js → dbCommand-DVASmZj2.js} +295 -253
- package/dist/{dev-DKb92NYX.js → dev-BRiPgbKw.js} +1738 -1702
- package/dist/{dev-CZQ73Yoh.js → dev-Dd3EZzj5.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/inspect-BA67TF6v.js +2 -0
- package/dist/inspect-_ldwsAwH.js +945 -0
- package/dist/{inspectMetrics-DMjWBQif.js → inspectMetrics-9ZSuDeqD.js} +148 -115
- package/dist/{manifestBuild-P9yuCY2d.js → manifestBuild-Bs1Uw22_.js} +1 -1
- package/dist/manifestBuild-i-fRHg_H.js +2 -0
- package/dist/{serveCommand-qHJCs0cX.js → serveCommand-DXJOARkO.js} +305 -306
- package/dist/serveEntry.js +2 -2
- package/dist/{start-B1x6Z60h.js → start-CsIjcOi-.js} +3 -3
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/cli.md +118 -5
- package/templates/agent-docs/data.md +58 -0
- package/templates/agent-docs/database/migrations.md +142 -20
- package/templates/agent-docs/plugins.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +2 -2
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/whats-new.md +32 -114
- 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/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-search/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 +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-K7M6Rt0U.js +0 -2
- package/dist/dbCommand-oADAj6ez.js +0 -2
- package/dist/inspect-DcZ04OME.js +0 -2
- package/dist/inspect-Dwx0_tUj.js +0 -921
- package/dist/manifestBuild-D1MzJAiQ.js +0 -2
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { X as e } from "./inspectMetrics-
|
|
1
|
+
import { X as e } from "./inspectMetrics-9ZSuDeqD.js";
|
|
2
2
|
import { c as t } from "./seedRunner-D6eu-u5U.js";
|
|
3
3
|
import { r as n } from "./appModuleLoader-C9r9mxZt.js";
|
|
4
|
-
import { t as r } from "./serveCommand-
|
|
4
|
+
import { t as r } from "./serveCommand-DXJOARkO.js";
|
|
5
5
|
export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $ as e, C as t, E as n, G as r, H as i, J as a, P as o, Q as s, R as c, U as l, V as u, W as d, _ as f, a as p, at as m, b as ee, c as h, ct as g, dt as _, et as te, f as v, ft as y, g as ne, h as b, i as re, j as ie, m as x, nt as S, o as ae, p as C, pt as w, q as T, r as E, s as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-
|
|
2
|
-
import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-
|
|
1
|
+
import { $ as e, C as t, E as n, G as r, H as i, J as a, P as o, Q as s, R as c, U as l, V as u, W as d, _ as f, a as p, at as m, b as ee, c as h, ct as g, dt as _, et as te, f as v, ft as y, g as ne, h as b, i as re, j as ie, m as x, nt as S, o as ae, p as C, pt as w, q as T, r as E, s as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-9ZSuDeqD.js";
|
|
2
|
+
import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-_ldwsAwH.js";
|
|
3
3
|
import { t as fe } from "./bootTiming-BdyP9nYw.js";
|
|
4
4
|
import { dirname as pe, extname as P, join as F, resolve as I } from "node:path";
|
|
5
5
|
import { fileURLToPath as L, pathToFileURL as R } from "node:url";
|
|
@@ -940,7 +940,7 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
|
|
|
940
940
|
}), t.end(JSON.stringify({ error: "inspect disabled (VOLTRO_INSPECT=off)" }));
|
|
941
941
|
return;
|
|
942
942
|
}
|
|
943
|
-
let i = N(a);
|
|
943
|
+
let i = N(a, "GET");
|
|
944
944
|
if (!i.ok) {
|
|
945
945
|
t.writeHead(401, {
|
|
946
946
|
"content-type": "application/json",
|
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { X as e } from "./inspectMetrics-
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { X as e } from "./inspectMetrics-9ZSuDeqD.js";
|
|
2
|
+
import { t } from "./start-CsIjcOi-.js";
|
|
3
3
|
export { e as loadDotEnv, t as runStartCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -62,22 +62,22 @@
|
|
|
62
62
|
"@effect/platform-node": "^0.108.0",
|
|
63
63
|
"@effect/sql": "^0.52.0",
|
|
64
64
|
"@effect/workflow": "^0.19.0",
|
|
65
|
-
"@voltro/ai": "0.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "0.
|
|
65
|
+
"@voltro/ai": "0.22.1",
|
|
66
|
+
"@voltro/cache": "0.22.1",
|
|
67
|
+
"@voltro/data-transfer": "0.22.1",
|
|
68
|
+
"@voltro/database": "0.22.1",
|
|
69
|
+
"@voltro/env": "0.22.1",
|
|
70
|
+
"@voltro/kv": "0.22.1",
|
|
71
|
+
"@voltro/logger": "0.22.1",
|
|
72
|
+
"@voltro/plugin-auth": "0.22.1",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.22.1",
|
|
74
|
+
"@voltro/plugin-mail": "0.22.1",
|
|
75
|
+
"@voltro/plugin-storage": "0.22.1",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.22.1",
|
|
77
|
+
"@voltro/protocol": "0.22.1",
|
|
78
|
+
"@voltro/runtime": "0.22.1",
|
|
79
|
+
"@voltro/serverless": "0.22.1",
|
|
80
|
+
"@voltro/workflow": "0.22.1",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -595,7 +595,7 @@ each plugin's own README.
|
|
|
595
595
|
|
|
596
596
|
| Topic | Open | Summary |
|
|
597
597
|
|---|---|---|
|
|
598
|
-
| **What's new in 0.
|
|
598
|
+
| **What's new in 0.22.1** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
599
599
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
600
600
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
601
601
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -9,7 +9,7 @@ each plugin's own README.
|
|
|
9
9
|
|
|
10
10
|
| Topic | Open | Summary |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **What's new in 0.
|
|
12
|
+
| **What's new in 0.22.1** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
13
13
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
14
14
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
15
15
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -495,10 +495,11 @@ Three checks run at boot and print one line each when they have something to say
|
|
|
495
495
|
- **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
|
|
496
496
|
on and reachable by nobody; every issue request fails its guard.
|
|
497
497
|
|
|
498
|
-
`voltro doctor` adds a fourth, over your source:
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
498
|
+
`voltro doctor` adds a fourth, over your source: its **authz scan** asks of every
|
|
499
|
+
executor — queries included — whether it references an access check at all, and
|
|
500
|
+
lists the ones that reference none. It learns your own `require*` / `assert*`
|
|
501
|
+
guard names, so it does not report the call sites of guards you already wrote.
|
|
502
|
+
See [the authz scan](./build-and-start.md).
|
|
502
503
|
|
|
503
504
|
## `voltro dev <appDir>`
|
|
504
505
|
|
|
@@ -776,6 +777,32 @@ The restart is a full re-exec — there is no in-process hot-reload of a
|
|
|
776
777
|
handler body; editing a query's executor respawns the child (debounced
|
|
777
778
|
80ms, so a burst of saves collapses into one restart).
|
|
778
779
|
|
|
780
|
+
### How the old process is stopped
|
|
781
|
+
|
|
782
|
+
SIGTERM first. The child runs its teardown — plugin `onDeactivate`, the
|
|
783
|
+
CDC detach, the scheduler and workflow runtime, the connection pool, and
|
|
784
|
+
every `ctx.onShutdown(cb)` a `*.startup.ts` registered — and then exits.
|
|
785
|
+
That is normally tens of milliseconds and you never see it.
|
|
786
|
+
|
|
787
|
+
It gets **1.5 seconds**, then SIGKILL, and the escalation says so:
|
|
788
|
+
|
|
789
|
+
```text
|
|
790
|
+
child ignored SIGTERM — escalated to SIGKILL pid=41207
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Read that as "something in this app's shutdown does not complete" — a
|
|
794
|
+
pool draining against a database that is already gone, a plugin
|
|
795
|
+
`onDeactivate` waiting on a dead socket. The restart still happens; it
|
|
796
|
+
just costs the full grace window every time, and whatever teardown had
|
|
797
|
+
not finished was cut off. Worth fixing at the source rather than living
|
|
798
|
+
with, because the same hang is a slow — then failed — shutdown in
|
|
799
|
+
production.
|
|
800
|
+
|
|
801
|
+
Neither timeout is optional: a stop that can wait forever is a dev
|
|
802
|
+
server that stops restarting entirely, with the old process still
|
|
803
|
+
holding the port and your browser's websocket still attached to code you
|
|
804
|
+
edited minutes ago.
|
|
805
|
+
|
|
779
806
|
## When the dev server stops
|
|
780
807
|
|
|
781
808
|
A restart replaces the child; the supervisor keeps watching. When the dev
|
|
@@ -1015,6 +1042,65 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
|
|
|
1015
1042
|
`voltro serve .`. Drop it into your image build right after `voltro build` to
|
|
1016
1043
|
guarantee the artefact is present before the image ships.
|
|
1017
1044
|
|
|
1045
|
+
### The authz scan
|
|
1046
|
+
|
|
1047
|
+
`voltro doctor` answers one mechanical question over every executor: **does it
|
|
1048
|
+
reference an access check at all?**
|
|
1049
|
+
|
|
1050
|
+
```
|
|
1051
|
+
authz scan · 586 executor(s)
|
|
1052
|
+
✗ no access check 21
|
|
1053
|
+
⚠ inline ownership check, no named guard 24 (informational)
|
|
1054
|
+
✓ guards: on the descriptor 0
|
|
1055
|
+
✓ calls a guard from the vocabulary 320
|
|
1056
|
+
– accepted as recorded debt 221 (voltro-authz-allowlist.txt)
|
|
1057
|
+
|
|
1058
|
+
✗ teams.deleteSubTeam — deletes teams with nothing constraining WHICH row
|
|
1059
|
+
api/teams/deleteSubTeam.mutation.server.ts
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
It scans **queries and streams too**, not only writes: an executor that takes an
|
|
1063
|
+
id and returns the row is the same hole as one that writes it. The exploitable
|
|
1064
|
+
shape is "acts on a row the client named, without comparing anything on that row
|
|
1065
|
+
to the caller", and a read has it.
|
|
1066
|
+
|
|
1067
|
+
**It learns your guard names.** An exported `require*` / `assert*` from your own
|
|
1068
|
+
source counts as a guard, so `requireTeamAccess()` is recognised without any
|
|
1069
|
+
configuration. Without that the scan would report every call site of your own
|
|
1070
|
+
guards, which is the failure mode that makes a check ignorable.
|
|
1071
|
+
|
|
1072
|
+
**An inline ownership check is informational.** `row.userId !== subject.id → new
|
|
1073
|
+
AccessDeniedError({})` is correct code — it is listed so you can see where the
|
|
1074
|
+
rule lives in a handler rather than on a descriptor, and it never fails the run.
|
|
1075
|
+
|
|
1076
|
+
Findings are ordered by blast radius: a `delete` outranks an `insert`, and a
|
|
1077
|
+
target table that is `tenant()`-scoped or referenced by other tables outranks one
|
|
1078
|
+
that is neither.
|
|
1079
|
+
|
|
1080
|
+
#### The ratchet — how to adopt this on an existing app
|
|
1081
|
+
|
|
1082
|
+
A first run on a large app reports hundreds of handlers, and nobody triages
|
|
1083
|
+
hundreds of findings. So record them once and fail only on what comes after:
|
|
1084
|
+
|
|
1085
|
+
```bash
|
|
1086
|
+
voltro doctor --write-authz-allowlist # writes voltro-authz-allowlist.txt
|
|
1087
|
+
voltro doctor # exits 1 on anything NEW
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
The file is **debt, not approval** — every line is a handler nobody has confirmed
|
|
1091
|
+
is safe. It is keyed by rpc tag rather than path, so moving a file can neither
|
|
1092
|
+
re-open a hole nor hide one, and it is consulted **last**: an executor that gains
|
|
1093
|
+
a real guard is reported as guarded whether or not its line is still there. The
|
|
1094
|
+
list can only shrink unless someone adds to it deliberately.
|
|
1095
|
+
|
|
1096
|
+
#### Before you hand-roll another check
|
|
1097
|
+
|
|
1098
|
+
If your checks are imperative because a scope cannot express "may this subject
|
|
1099
|
+
act on THIS row", that is what `guards: [{ action, resourceType, resource }]` is
|
|
1100
|
+
for — and an app whose relationships live in its own tables (a `teamMembers` row,
|
|
1101
|
+
say) registers its own tuple source instead of copying data into a framework
|
|
1102
|
+
table. See [Authorization](../authentication/authorization.md).
|
|
1103
|
+
|
|
1018
1104
|
### The predicate-column check
|
|
1019
1105
|
|
|
1020
1106
|
`eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
|
|
@@ -1552,7 +1638,34 @@ voltro check --url https://api.example.com
|
|
|
1552
1638
|
|
|
1553
1639
|
## The inspect HTTP surface
|
|
1554
1640
|
|
|
1555
|
-
Every `voltro dev` / `voltro start` instance exposes
|
|
1641
|
+
Every `voltro dev` / `voltro start` instance exposes an introspection surface
|
|
1642
|
+
under `/_voltro/inspect/*`.
|
|
1643
|
+
|
|
1644
|
+
**It is not read-only.** The core endpoints are reads, but installed plugins
|
|
1645
|
+
mount their own — and some are POSTs that DO things: `plugin-governance` mounts
|
|
1646
|
+
`/erase` (an irreversible GDPR right-to-be-forgotten deletion) and `/export` (a
|
|
1647
|
+
full personal-data dump); `plugin-storage` mounts `/share` and `/revoke`.
|
|
1648
|
+
|
|
1649
|
+
**So a mutating method needs a second credential.** `VOLTRO_INSPECT_WRITE_TOKEN`,
|
|
1650
|
+
sent as the `x-voltro-inspect-write` header ON TOP of the bearer — an additional
|
|
1651
|
+
factor, not an alternative: the read token still has to be correct. GET / HEAD /
|
|
1652
|
+
OPTIONS are unaffected. Unset, those endpoints are refused.
|
|
1653
|
+
|
|
1654
|
+
```sh
|
|
1655
|
+
curl -H "authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
|
|
1656
|
+
-H "x-voltro-inspect-write: $VOLTRO_INSPECT_WRITE_TOKEN" \
|
|
1657
|
+
-X POST http://localhost:4000/_voltro/inspect/plugins/governance/erase
|
|
1658
|
+
```
|
|
1659
|
+
|
|
1660
|
+
`voltro dev` mints it per project like the read token, and the dashboard proxy
|
|
1661
|
+
injects it for loopback targets, so the dev loop is unchanged. **Nothing mints it
|
|
1662
|
+
for `serve` / `start`** — in production a destructive endpoint should take a
|
|
1663
|
+
deliberate act to enable. Use a DIFFERENT value from the read token; reusing it
|
|
1664
|
+
gives the split no meaning.
|
|
1665
|
+
|
|
1666
|
+
A plugin mounting a non-GET inspect endpoint must also declare the
|
|
1667
|
+
`inspect:write` permission, and the boot audit refuses it otherwise. That governs
|
|
1668
|
+
what a PLUGIN may mount; the write credential governs who may call it. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
|
|
1556
1669
|
|
|
1557
1670
|
```bash
|
|
1558
1671
|
PORT=4000 # from the app's app.config.ts
|
|
@@ -328,6 +328,32 @@ defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' },
|
|
|
328
328
|
|
|
329
329
|
With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
|
|
330
330
|
|
|
331
|
+
### A guard that reads a second table belongs in `source`
|
|
332
|
+
|
|
333
|
+
`source` is the reactive trigger set: the query re-runs when a listed table
|
|
334
|
+
changes, and only then. So a guard that loads a row from ANOTHER table to decide
|
|
335
|
+
access has made that table part of what the result depends on:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
export const teamBoard = defineQuery({
|
|
339
|
+
name: 'boards.forTeam',
|
|
340
|
+
input: Schema.Struct({ teamId: Schema.String }),
|
|
341
|
+
output: BoardRows,
|
|
342
|
+
// `boards` alone is wrong here — `requireTeamAccess` reads `teamMembers`.
|
|
343
|
+
source: ['boards', 'teamMembers'],
|
|
344
|
+
})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Leave `teamMembers` out and the subscription does not re-run when membership
|
|
348
|
+
changes. **That is an authorization staleness, not a cosmetic one:** revoke
|
|
349
|
+
someone's membership and their open subscription keeps serving rows they may no
|
|
350
|
+
longer see, until something else happens to invalidate it.
|
|
351
|
+
|
|
352
|
+
Nothing warns about this at runtime — a query that silently stops reacting looks
|
|
353
|
+
exactly like one with nothing to report. Reported by a team whose own invariant
|
|
354
|
+
caught it after five computed queries under-declared their `source`; the fix was
|
|
355
|
+
array sources.
|
|
356
|
+
|
|
331
357
|
## `output` is the serializer — `timestampMs`
|
|
332
358
|
|
|
333
359
|
A descriptor's `output` is not documentation of the shape. It **is** the
|
|
@@ -951,6 +977,38 @@ const message = matchError(err, {
|
|
|
951
977
|
}, () => 'Something went wrong')
|
|
952
978
|
```
|
|
953
979
|
|
|
980
|
+
## `internal: true` — off the wire entirely
|
|
981
|
+
|
|
982
|
+
Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
|
|
983
|
+
callable over the WebSocket by any authenticated browser session. `publicApi` and
|
|
984
|
+
`exposeAsTool` opt IN to wider surfaces; `internal: true` opts OUT of the default
|
|
985
|
+
one:
|
|
986
|
+
|
|
987
|
+
```ts
|
|
988
|
+
export const createFromAction = defineMutation({
|
|
989
|
+
name: 'auditLog.createFromAction',
|
|
990
|
+
input: Schema.Struct({ actorId: Schema.String, eventType: Schema.String }),
|
|
991
|
+
output: Schema.Void,
|
|
992
|
+
internal: true,
|
|
993
|
+
})
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
It is not emitted into `rpcGroup.generated.ts`, and neither `voltro dev` nor
|
|
997
|
+
`voltro serve` registers a route — the tag is unroutable over `/rpc` and the
|
|
998
|
+
WebSocket. Server code calls it by importing its executor directly.
|
|
999
|
+
|
|
1000
|
+
**A naming convention is not a boundary.** One app had grown 18 procedures named
|
|
1001
|
+
`*Internal`, meaning "only other server code calls this"; all 18 were in the
|
|
1002
|
+
client group, and one of them accepted `actorId` / `actorEmail` / `actorType`
|
|
1003
|
+
from the caller and wrote an audit row. No guard, zero callers, reachable by
|
|
1004
|
+
anyone logged in. If the only thing keeping a procedure off the wire is that
|
|
1005
|
+
nobody wrote a client call for it, it is on the wire — the same reasoning as
|
|
1006
|
+
`.serverOnly()` on a column, one level up.
|
|
1007
|
+
|
|
1008
|
+
**It is not a substitute for a guard.** An internal procedure still runs with
|
|
1009
|
+
whatever authority its caller has. This removes the wire surface, not the need to
|
|
1010
|
+
check who is asking; `voltro doctor`'s authz scan still covers it.
|
|
1011
|
+
|
|
954
1012
|
## When Not To Use A Mutation
|
|
955
1013
|
|
|
956
1014
|
- **External I/O.** Use an action or workflow.
|
|
@@ -90,6 +90,7 @@ voltro db apply # execute (dev only — refuses on NODE_ENV=pr
|
|
|
90
90
|
voltro db apply --note '...' # apply with a freeform note recorded in history
|
|
91
91
|
voltro db plans [--limit 20] # history from _voltro_migration_plans, newest first
|
|
92
92
|
voltro db drift # live-vs-baseline check — exit 0 match, 3 no baseline, 4 drift
|
|
93
|
+
voltro db drift --accept # record the CURRENT live schema as the baseline (refuses unless db plan is empty)
|
|
93
94
|
voltro db squash --before <date> # consolidate history into one snapshot
|
|
94
95
|
voltro db restore-snapshot <id> # restore VOLTRO_SOFT_DROP=1 columns from a plan
|
|
95
96
|
|
|
@@ -119,6 +120,21 @@ there is no `--json` or `--sql`, and `voltro db apply` takes only
|
|
|
119
120
|
deploy step ([prod pipeline](./prod-pipeline.md)), not a pre-serialised
|
|
120
121
|
plan file.
|
|
121
122
|
|
|
123
|
+
## Framework tables ride the same differ
|
|
124
|
+
|
|
125
|
+
The `_voltro_*` tables the framework owns are planned, classified and applied by
|
|
126
|
+
exactly the same code as yours — on every dialect. A framework release that adds
|
|
127
|
+
a table, adds a column or reshapes one lands on the boot that follows your
|
|
128
|
+
upgrade, wherever your own schema changes land. There is no separate command and
|
|
129
|
+
no dialect-specific step.
|
|
130
|
+
|
|
131
|
+
One asymmetry is deliberate and worth knowing if you ever read a plan: a
|
|
132
|
+
framework-owned table that **nobody declares** — `cluster_*` from the workflow
|
|
133
|
+
engine, a plugin's table after you removed the plugin — is never planned for a
|
|
134
|
+
drop. "Nobody declared it, so do not drop it" and "we declare it, so keep it
|
|
135
|
+
current" are different rules; collapsing them is what once made framework tables
|
|
136
|
+
evolve on postgres and nowhere else.
|
|
137
|
+
|
|
122
138
|
## Where to go next
|
|
123
139
|
|
|
124
140
|
| Topic | Page |
|
|
@@ -367,6 +383,92 @@ If the live DB doesn't have a column called `firstName`, the marker is a no-op:
|
|
|
367
383
|
|
|
368
384
|
The marker isn't validated against the live DB at schema-build time — it would have to introspect during type-checking, which is expensive. The runtime check fires at plan time.
|
|
369
385
|
|
|
386
|
+
## The indexes come with it
|
|
387
|
+
|
|
388
|
+
Renaming a column is a metadata-only operation. Its **indexes** used to not be: index names are derived (`<table>_<column>_idx`), and no dialect renames an index when the column under it is renamed — so the planner saw `users_firstName_idx` on one side and `users_givenName_idx` on the other, and planned `DROP INDEX` + `CREATE INDEX`. On a large table that is a full B-tree rebuild: minutes of IO, and without `CONCURRENTLY` a write lock, behind a rename that was supposed to be instant.
|
|
389
|
+
|
|
390
|
+
The planner now folds that into a `rename-index` operation, which is a catalog-only statement everywhere it is emitted:
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
✓ rename-column users.firstName → users.givenName # catalog-only
|
|
394
|
+
✓ rename-index users_firstName_idx → users_givenName_idx # catalog-only, no rebuild
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
You do not annotate anything for this — it follows from the column rename you already declared.
|
|
398
|
+
|
|
399
|
+
Four cases deliberately still plan as drop + create, because pairing an old index with a new one has no evidence to stand on in them:
|
|
400
|
+
|
|
401
|
+
- **sqlite** — it has no rename statement at all. The plan you read matches what runs.
|
|
402
|
+
- **UNIQUE indexes** — they are constraint objects, and the syntax to rename one diverges by dialect.
|
|
403
|
+
- **Expression / json-path indexes** — the database normalises their key text, so there is no shape to compare; only the name, which is the thing that changed.
|
|
404
|
+
- **Two same-shaped indexes renamed at once** — nothing says which became which. Rebuilding both is slower; renaming the wrong one is worse.
|
|
405
|
+
|
|
406
|
+
## Renaming a TABLE
|
|
407
|
+
|
|
408
|
+
The same problem one level up, and with more at stake: a table rename and a
|
|
409
|
+
drop+create look identical to the differ — old table gone, new table present —
|
|
410
|
+
except that guessing wrong costs every row. So it needs a marker too, and it
|
|
411
|
+
reads like its column counterpart:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
// Before:
|
|
415
|
+
export const notes = table('notes', { id: id({ prefix: 'note' }), body: text() })
|
|
416
|
+
|
|
417
|
+
// After — without the marker:
|
|
418
|
+
export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
|
|
419
|
+
// → planner sees DROP TABLE notes + CREATE TABLE archive_notes
|
|
420
|
+
// → the DROP is `lossy` and blocked; nothing happens until you acknowledge it
|
|
421
|
+
|
|
422
|
+
// With the marker:
|
|
423
|
+
export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
|
|
424
|
+
.renamedFrom('notes')
|
|
425
|
+
// → one `rename-table` op, classified `safe`
|
|
426
|
+
// → `ALTER TABLE notes RENAME TO archive_notes` — catalog-only, the rows stay put
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Unlike an index rename, every dialect has this statement — sqlite included — so
|
|
430
|
+
there is no dialect on which this falls back to a rebuild.
|
|
431
|
+
|
|
432
|
+
**Its indexes come with it.** The same derivation that bites a column rename bites
|
|
433
|
+
harder here: `notes_pkey` and `notes_<col>_idx` are named after the table, and no
|
|
434
|
+
dialect renames them when the table is renamed. The planner emits a `rename-index`
|
|
435
|
+
for each so the catalog catches up:
|
|
436
|
+
|
|
437
|
+
```
|
|
438
|
+
✓ rename-table notes → archive_notes
|
|
439
|
+
✓ rename-index notes_pkey → archive_notes_pkey
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Without that the plan would try to drop the primary-key index and re-add it as a
|
|
443
|
+
plain UNIQUE, which postgres refuses outright.
|
|
444
|
+
|
|
445
|
+
**Three cases where the planner will NOT fold the rename**, each because folding
|
|
446
|
+
it could destroy data rather than move it — and none of them is silent:
|
|
447
|
+
|
|
448
|
+
- **The old name is still declared by something.** If your schema still has a
|
|
449
|
+
`notes` table, it is yours and stays put; the new table is created empty. This
|
|
450
|
+
is a legitimate outcome (it is what lets a framework plugin reclaim a name
|
|
451
|
+
without taking yours), so the plan runs — and the `create-table` line says why
|
|
452
|
+
the marker was not applied.
|
|
453
|
+
- **The new name already exists in the database.** → **refuses to plan.** Both
|
|
454
|
+
tables exist and only you know which holds the real rows. The fix tells you to
|
|
455
|
+
move them and drop one, or drop the empty one so the rename can run. Until
|
|
456
|
+
then the old table is untouched.
|
|
457
|
+
- **Two tables both claim the same old name.** → **refuses to plan.** Nothing
|
|
458
|
+
says which should receive the rows; remove the marker from all but one.
|
|
459
|
+
|
|
460
|
+
The last two refuse rather than degrade, because the quiet outcome — an empty
|
|
461
|
+
plan reading "schema up to date" while the old table still holds every row — is
|
|
462
|
+
the one that loses data by inaction.
|
|
463
|
+
|
|
464
|
+
**Lifecycle.** Same as `.renamedFrom()` on a column: a marker whose old table is
|
|
465
|
+
not in the database is a silent no-op, so it stays in your source across a staged
|
|
466
|
+
rollout and comes out once every environment has applied it.
|
|
467
|
+
|
|
468
|
+
**One constraint worth knowing:** a table whose name starts with `_` cannot derive
|
|
469
|
+
a typeid prefix, so it needs an explicit `id({ prefix: '…' })`. You will hear about
|
|
470
|
+
it at declaration, not at runtime.
|
|
471
|
+
|
|
370
472
|
## Lifecycle — when to remove the marker
|
|
371
473
|
|
|
372
474
|
Keep the marker until the rename has been applied in EVERY env you care about (dev, staging, prod). The framework tracks applied ops in `_voltro_migration_plans`:
|
|
@@ -2067,9 +2169,24 @@ db drift: live schema DIVERGED from last applied state
|
|
|
2067
2169
|
Something changed the live schema after the last apply. This command can see
|
|
2068
2170
|
THAT it changed, not what or who — the fingerprints are hashes, not a diff.
|
|
2069
2171
|
|
|
2070
|
-
|
|
2172
|
+
Your DECLARED schema is already satisfied — `voltro db plan` reports 0 operations
|
|
2173
|
+
against this database. So the live schema is not wrong, only unrecorded: something
|
|
2174
|
+
applied a change without going through the planner (a hand-run ALTER, a DBA
|
|
2175
|
+
window, a restored dump), or it touched a table your code does not declare.
|
|
2176
|
+
|
|
2177
|
+
If that was deliberate and the schema is right, record it:
|
|
2178
|
+
|
|
2179
|
+
voltro db drift --accept
|
|
2180
|
+
|
|
2181
|
+
It updates the latest ledger row's baseline to the live schema and invents no
|
|
2182
|
+
history entry. Drift then measures from here.
|
|
2071
2183
|
```
|
|
2072
2184
|
|
|
2185
|
+
When `db plan` is NOT empty it says that instead, with the count — so the two
|
|
2186
|
+
cases are told apart by the command rather than left to you. It used to close by
|
|
2187
|
+
asserting that a zero-operation plan meant "a table your code does not declare",
|
|
2188
|
+
which is one of two possibilities and the less likely one.
|
|
2189
|
+
|
|
2073
2190
|
### Out-of-band auto-applier
|
|
2074
2191
|
|
|
2075
2192
|
Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through `_voltro_migration_plans`.
|
|
@@ -2084,31 +2201,36 @@ The drift detector ran against a read-replica that's lagging. Wait for the repli
|
|
|
2084
2201
|
|
|
2085
2202
|
## Reconciliation paths
|
|
2086
2203
|
|
|
2087
|
-
### Path 1 —
|
|
2204
|
+
### Path 1 — accept the live state
|
|
2088
2205
|
|
|
2089
|
-
When the live DB IS what you want (the manual DDL is correct, only
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
`voltro db apply` records a fresh `_voltro_migration_plans` row at the
|
|
2094
|
-
new fingerprint — re-baselining history without any DDL.
|
|
2206
|
+
When the live DB IS what you want (the manual DDL is correct, only bypassing the
|
|
2207
|
+
planner was sloppy), first make sure your declaration says so: edit the
|
|
2208
|
+
`*.entity.ts` files until `voltro db plan` diffs empty. Then record the live
|
|
2209
|
+
schema as the baseline:
|
|
2095
2210
|
|
|
2096
2211
|
```sh
|
|
2097
|
-
voltro db plan
|
|
2098
|
-
voltro db
|
|
2212
|
+
voltro db plan # must report 0 operations — declared == live
|
|
2213
|
+
voltro db drift --accept
|
|
2099
2214
|
```
|
|
2100
2215
|
|
|
2101
|
-
`
|
|
2102
|
-
|
|
2216
|
+
`--accept` writes the current live fingerprint onto the newest ledger row.
|
|
2217
|
+
Drift measures from there, and the next `voltro db drift` exits 0.
|
|
2103
2218
|
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2219
|
+
**It refuses unless `db plan` is empty**, and that guard is the whole point.
|
|
2220
|
+
Accepting a schema with operations still outstanding would record "this is what
|
|
2221
|
+
we applied" over a state nobody applied, and every later drift check would
|
|
2222
|
+
measure against that fiction. If operations are pending, run `voltro db apply` —
|
|
2223
|
+
that applies them AND writes a real baseline of its own.
|
|
2224
|
+
|
|
2225
|
+
It backfills the newest row rather than inserting one, because no migration ran
|
|
2226
|
+
and a history entry claiming otherwise would be worse than the gap it fills.
|
|
2108
2227
|
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2228
|
+
**An empty `voltro db apply` does NOT re-baseline an existing baseline.** It
|
|
2229
|
+
writes no DDL and no history row, so there is nothing for a `--note` to attach
|
|
2230
|
+
to — it will tell you the note was ignored rather than swallow it. (It does fill
|
|
2231
|
+
a baseline that is still NULL, which is a different case: a database that never
|
|
2232
|
+
had one.) `--accept` is the command whose job is to say "the live schema is
|
|
2233
|
+
right, the ledger just did not know".
|
|
2112
2234
|
|
|
2113
2235
|
### Path 2 — corrective plan against drift
|
|
2114
2236
|
|
|
@@ -2206,7 +2328,7 @@ The hardest part of drift response is figuring out **what** changed + **who** di
|
|
|
2206
2328
|
```
|
|
2207
2329
|
Detect: voltro db drift
|
|
2208
2330
|
Fix code: voltro db apply (apply corrective plan from current diff)
|
|
2209
|
-
Adopt DB: edit the *.entity.ts to match live, then voltro db
|
|
2331
|
+
Adopt DB: edit the *.entity.ts to match live until db plan is empty, then voltro db drift --accept
|
|
2210
2332
|
Backup: if data was lost, restore from your DB backup system — the framework can't help
|
|
2211
2333
|
```
|
|
2212
2334
|
|
|
@@ -92,7 +92,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
92
92
|
| `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
|
|
93
93
|
| `@voltro/plugin-versioning` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete on listed tables into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/versioning) |
|
|
94
94
|
| `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator, swept `_voltro_presence` table, cross-instance. [→ details](/docs/plugins/presence) |
|
|
95
|
-
| `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `
|
|
95
|
+
| `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `_voltro_scim_users`/`_voltro_scim_groups`. [→ details](/docs/plugins/scim) |
|
|
96
96
|
| `@voltro/plugin-sso-saml` | ✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under `/saml`; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via `@node-saml/node-saml` (optional+lazy), mints a framework session. [→ details](/docs/plugins/sso-saml) |
|
|
97
97
|
|
|
98
98
|
API keys are **first-class** (not a plugin): `apiKeys: true` in `app.config.ts` → Bearer-key auth + admin-gated `/v1/api-keys` management, hash-only storage. [→ details](/docs/configuration/api-keys)
|
|
@@ -143,7 +143,7 @@ create-vs-update: they're *different mutations with different input schemas*, so
|
|
|
143
143
|
they're naturally different forms — no "CRUD mode" switch.
|
|
144
144
|
|
|
145
145
|
```tsx
|
|
146
|
-
import { AutoForm } from '@voltro/
|
|
146
|
+
import { AutoForm } from '@voltro/ui'
|
|
147
147
|
|
|
148
148
|
// fields from the mutation's input schema; client+server share the schema;
|
|
149
149
|
// submits via the mutation with op-correct optimistic (insert prepends, etc.)
|
|
@@ -177,7 +177,7 @@ A combined create-or-edit screen is a three-line wrapper:
|
|
|
177
177
|
object → a `custom` placeholder asking for a render-prop.
|
|
178
178
|
- **Rung 1 — one custom widget** via a `<Field>` render-prop:
|
|
179
179
|
```tsx
|
|
180
|
-
import { AutoForm, Field, AsyncSelect } from '@voltro/
|
|
180
|
+
import { AutoForm, Field, AsyncSelect } from '@voltro/ui'
|
|
181
181
|
|
|
182
182
|
<AutoForm api="app" mutation="todos.create">
|
|
183
183
|
{() => (
|
|
@@ -2056,7 +2056,7 @@ curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
|
2056
2056
|
# → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, "used": 3 } }
|
|
2057
2057
|
```
|
|
2058
2058
|
|
|
2059
|
-
Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `
|
|
2059
|
+
Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `_voltro_notification_inbox` row.
|
|
2060
2060
|
|
|
2061
2061
|
## Enable durable analytics
|
|
2062
2062
|
|