@voltro/cli 0.35.0 → 0.36.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 +68 -0
- package/dist/{apiBuild-DYD_ONLD.js → apiBuild-DiE9zcj6.js} +1 -1
- package/dist/apiBuild-ZEZnCXn2.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-CD8K4XOr.js → build-DBe6c7qC.js} +6 -6
- package/dist/{checkCommand-DKpDLlqu.js → checkCommand-BTA6P3Yr.js} +1 -1
- package/dist/{checkCommand-Ct9xkTrS.js → checkCommand-BhG0gmUD.js} +2 -2
- package/dist/{codegenCommand-C4YoQIc2.js → codegenCommand-DH-8ZXzR.js} +1 -1
- package/dist/{codemodRunner-BnFq3Fgu.js → codemodRunner-DjQGrdmg.js} +64 -47
- package/dist/{commands-BE8E7zF3.js → commands-Bp7utV-7.js} +18 -18
- package/dist/{dashboardCommand-D7SgZGaN.js → dashboardCommand-D5Jwjmr1.js} +1 -1
- package/dist/{dataCommand-BhYwDgg-.js → dataCommand-DnCm0B2C.js} +1 -1
- package/dist/{dev-D2BikO7a.js → dev-BSFTgCQ5.js} +1 -1
- package/dist/{dev-C7sFZq3m.js → dev-Dc4cpmRl.js} +2000 -2000
- package/dist/doctorCommand-BYffQc0s.js +2 -0
- package/dist/{doctorCommand-DnimF5IM.js → doctorCommand-CNFtt3nR.js} +9 -9
- package/dist/{dormancyCommand-QewYug_s.js → dormancyCommand-6F6NsJKf.js} +1 -1
- package/dist/{embeddingsCommand-BfiLS_QI.js → embeddingsCommand-DRRdneJD.js} +1 -1
- package/dist/{envCommand-CCGPRQY1.js → envCommand-TZzcyqZ-.js} +1 -1
- package/dist/{evolveCommand-CHsLCtDf.js → evolveCommand-iU35TPjT.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DwOgK1t6.js → infoCommand-unqisJGp.js} +1 -1
- package/dist/{runtimeTrace-CH3eUiMw.js → runtimeTrace-D5LJ_N55.js} +1 -1
- package/dist/{sdkgen-BLkvGRfX.js → sdkgen-82xVuvbt.js} +1 -1
- package/dist/serveCommand-CEBVP36A.js +2 -0
- package/dist/{serveCommand-iwlUBNS1.js → serveCommand-VVGcIMIw.js} +487 -487
- package/dist/serveEntry.js +1 -1
- package/dist/updateCommand-CSBXVsYq.js +2 -0
- package/dist/{updateCommand-x0pI_x-B.js → updateCommand-CwnUK_mf.js} +1 -1
- package/dist/{webhooksCommand-DlAgS2Iw.js → webhooksCommand-di1CJVzu.js} +1 -1
- package/package.json +23 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/cli.md +22 -0
- package/templates/agent-docs/database/misc.md +33 -0
- package/templates/agent-docs/whats-new.md +31 -130
- 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-B8aoJvuw.js +0 -2
- package/dist/doctorCommand-CM4Ch9C7.js +0 -2
- package/dist/serveCommand-CbDHU6l-.js +0 -2
- package/dist/updateCommand-BlMZhWgO.js +0 -2
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as e } from "./loadEnv-D9nEOClM.js";
|
|
2
2
|
import { r as t } from "./appModuleLoader-C9r9mxZt.js";
|
|
3
3
|
import { i as n } from "./dialectDriver-CgXnDfec.js";
|
|
4
|
-
import { t as r } from "./serveCommand-
|
|
4
|
+
import { t as r } from "./serveCommand-VVGcIMIw.js";
|
|
5
5
|
export { e as loadDotEnv, t as registerAppModules, n as registerDriver, r as runServe };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as e, r as t, t as n } from "./codemodRunner-
|
|
1
|
+
import { n as e, r as t, t as n } from "./codemodRunner-DjQGrdmg.js";
|
|
2
2
|
import { basename as r, dirname as i, join as a, relative as o, resolve as s } from "node:path";
|
|
3
3
|
import { existsSync as c, readFileSync as l, readdirSync as u, statSync as d, writeFileSync as f } from "node:fs";
|
|
4
4
|
import { totalmem as p } from "node:os";
|
|
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
|
|
|
222
222
|
...t === void 0 ? {} : { payload: t }
|
|
223
223
|
};
|
|
224
224
|
}, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
|
|
225
|
-
let { walk: t, loadDiscovered: n } = await import("./dev-
|
|
225
|
+
let { walk: t, loadDiscovered: n } = await import("./dev-BSFTgCQ5.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-CrGAfhIG.js");
|
|
226
226
|
return r((await n(await t(e))).events.map((e) => ({
|
|
227
227
|
file: e.file,
|
|
228
228
|
descriptor: e.descriptor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -568,6 +568,12 @@
|
|
|
568
568
|
"title": "The declared schema no longer reads `CDC` / `VOLTRO_UNDO` / `VOLTRO_TRACING_PERSIST`",
|
|
569
569
|
"kind": "manual"
|
|
570
570
|
},
|
|
571
|
+
{
|
|
572
|
+
"version": "0.35.1",
|
|
573
|
+
"id": "0.35.1/01_unauthorized-replaces-session-expired",
|
|
574
|
+
"title": "A 401 is `code: 'unauthorized'`; the vault's own failure is `'credential_unusable'`",
|
|
575
|
+
"kind": "transform"
|
|
576
|
+
},
|
|
571
577
|
{
|
|
572
578
|
"version": "0.4.0",
|
|
573
579
|
"id": "0.4.0/01_rbac-forbidden-to-scopeerror",
|
|
@@ -667,22 +673,22 @@
|
|
|
667
673
|
"@effect/platform-node": "^0.108.0",
|
|
668
674
|
"@effect/sql": "^0.52.0",
|
|
669
675
|
"@effect/workflow": "^0.19.0",
|
|
670
|
-
"@voltro/ai": "0.
|
|
671
|
-
"@voltro/cache": "0.
|
|
672
|
-
"@voltro/data-transfer": "0.
|
|
673
|
-
"@voltro/database": "0.
|
|
674
|
-
"@voltro/env": "0.
|
|
675
|
-
"@voltro/kv": "0.
|
|
676
|
-
"@voltro/logger": "0.
|
|
677
|
-
"@voltro/plugin-auth": "0.
|
|
678
|
-
"@voltro/plugin-broadcast": "0.
|
|
679
|
-
"@voltro/plugin-mail": "0.
|
|
680
|
-
"@voltro/plugin-storage": "0.
|
|
681
|
-
"@voltro/plugin-webhooks": "0.
|
|
682
|
-
"@voltro/protocol": "0.
|
|
683
|
-
"@voltro/runtime": "0.
|
|
684
|
-
"@voltro/serverless": "0.
|
|
685
|
-
"@voltro/workflow": "0.
|
|
676
|
+
"@voltro/ai": "0.36.0",
|
|
677
|
+
"@voltro/cache": "0.36.0",
|
|
678
|
+
"@voltro/data-transfer": "0.36.0",
|
|
679
|
+
"@voltro/database": "0.36.0",
|
|
680
|
+
"@voltro/env": "0.36.0",
|
|
681
|
+
"@voltro/kv": "0.36.0",
|
|
682
|
+
"@voltro/logger": "0.36.0",
|
|
683
|
+
"@voltro/plugin-auth": "0.36.0",
|
|
684
|
+
"@voltro/plugin-broadcast": "0.36.0",
|
|
685
|
+
"@voltro/plugin-mail": "0.36.0",
|
|
686
|
+
"@voltro/plugin-storage": "0.36.0",
|
|
687
|
+
"@voltro/plugin-webhooks": "0.36.0",
|
|
688
|
+
"@voltro/protocol": "0.36.0",
|
|
689
|
+
"@voltro/runtime": "0.36.0",
|
|
690
|
+
"@voltro/serverless": "0.36.0",
|
|
691
|
+
"@voltro/workflow": "0.36.0",
|
|
686
692
|
"chokidar": "^5.0.0",
|
|
687
693
|
"ioredis": "^5.11.1",
|
|
688
694
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -707,7 +707,7 @@ each plugin's own README.
|
|
|
707
707
|
|
|
708
708
|
| Topic | Open | Summary |
|
|
709
709
|
|---|---|---|
|
|
710
|
-
| **What's new in 0.
|
|
710
|
+
| **What's new in 0.36.0** | `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. |
|
|
711
711
|
| 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. |
|
|
712
712
|
| 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. |
|
|
713
713
|
| 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.36.0** | `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. |
|
|
@@ -3677,6 +3677,28 @@ Codemods that span multiple versions run in order (e.g. upgrading `0.2.0 → 0.4
|
|
|
3677
3677
|
|
|
3678
3678
|
Use `voltro db apply` (the declarative diff), not `voltro db migrate` (the imperative file-runner) — only the former reconciles framework tables. If your app also ships `migrations/*.migration.ts`, `voltro db migrate .` runs both halves and is what refusal 4 above asks your pre-deploy job for.
|
|
3679
3679
|
|
|
3680
|
+
## Restart every process — a running one keeps the OLD modules
|
|
3681
|
+
|
|
3682
|
+
`voltro update` changes what is on disk. A process that was already running when
|
|
3683
|
+
you ran it keeps the module graph it loaded at boot, so it goes on executing the
|
|
3684
|
+
previous version indefinitely — and against a `.framework` directory that has
|
|
3685
|
+
since been rewritten.
|
|
3686
|
+
|
|
3687
|
+
That mix is worse than either version alone. A consumer lost half an hour to a
|
|
3688
|
+
pod whose api had started before the upgrade: it served requests, reported
|
|
3689
|
+
healthy, and returned no SSR at all, because the running process held the old
|
|
3690
|
+
modules while the build output on disk was new.
|
|
3691
|
+
|
|
3692
|
+
Restart every process after an update, including ones you did not deploy:
|
|
3693
|
+
|
|
3694
|
+
```sh
|
|
3695
|
+
kubectl rollout restart deploy/api deploy/web # or: docker compose up -d --force-recreate
|
|
3696
|
+
```
|
|
3697
|
+
|
|
3698
|
+
`voltro dev` reloads itself, so a development machine is not affected. Anything
|
|
3699
|
+
long-running is — a `voltro serve` / `voltro start` container, a worker, a
|
|
3700
|
+
process a supervisor kept alive across the upgrade.
|
|
3701
|
+
|
|
3680
3702
|
## After the update — the checklist
|
|
3681
3703
|
|
|
3682
3704
|
```bash
|
|
@@ -330,6 +330,39 @@ Precedence, exactly:
|
|
|
330
330
|
3. **`.safe()`** on an encrypted column suppresses the implied secret and copies
|
|
331
331
|
it verbatim — say this only when you have genuinely reviewed the plaintext.
|
|
332
332
|
|
|
333
|
+
## Which stores decrypt — every one the framework hands you
|
|
334
|
+
|
|
335
|
+
The codec is applied by the store, so "which store" is the whole question. All
|
|
336
|
+
three of these decrypt on read and encrypt on write:
|
|
337
|
+
|
|
338
|
+
| store | where you get it |
|
|
339
|
+
| --- | --- |
|
|
340
|
+
| `ctx.store` | inside a handler |
|
|
341
|
+
| the boot store | an auth strategy, a plugin HTTP route, `bindDataStore` |
|
|
342
|
+
| a transaction view | inside `store.transactional(...)` |
|
|
343
|
+
|
|
344
|
+
The boot store is deliberately NOT the request-scoped one — it has no resolved
|
|
345
|
+
Subject, so it carries no tenant scope, no soft-delete filter, no audit stamping
|
|
346
|
+
and no row filter. It does carry the **storage codec**, because that needs no
|
|
347
|
+
Subject: it is how a declared column is spelled on disk versus in JS.
|
|
348
|
+
|
|
349
|
+
That split is worth knowing because getting it wrong is silent. Ciphertext is a
|
|
350
|
+
string. It compares, concatenates, renders and logs without complaint, so a
|
|
351
|
+
value read through a store that skipped the codec fails somewhere else entirely
|
|
352
|
+
— a team sent `enc:v1:…` upstream as a bearer token, got a 401, and spent a day
|
|
353
|
+
inside their auth code. If a value that should be plaintext arrives as
|
|
354
|
+
`enc:v1:…`, the question is which store produced it, not whether the column is
|
|
355
|
+
declared correctly.
|
|
356
|
+
|
|
357
|
+
A store you construct yourself from a driver has no codec. If you need one —
|
|
358
|
+
a migration script, a maintenance task — wrap it:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { wrapStoreWithBootCodec } from '@voltro/runtime'
|
|
362
|
+
|
|
363
|
+
const store = wrapStoreWithBootCodec(rawDriverStore, 'postgres')
|
|
364
|
+
```
|
|
365
|
+
|
|
333
366
|
## Encrypting a column that already has rows
|
|
334
367
|
|
|
335
368
|
`.encrypted()` encrypts on **write**. Adding it to a populated column converts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# What's new in 0.
|
|
1
|
+
# What's new in 0.36.0
|
|
2
2
|
|
|
3
3
|
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
4
|
It is the cheapest way to notice that the framework grew the thing you were
|
|
@@ -9,163 +9,64 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
|
9
9
|
|
|
10
10
|
### ⚠ BREAKING
|
|
11
11
|
|
|
12
|
-
- **@voltro/
|
|
13
|
-
- **@voltro/cli** — The declared framework table set no longer reads a runtime flag. `CDC`, `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` each moved it before this release; `app.config.ts` gained `schema: { traces?, undo? }` to declare the two that still need a decision.
|
|
12
|
+
- **@voltro/integration-http, @voltro/plugin-atlassian** — A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`. The connection vault's own failure — where we DO know the credential is unusable — becomes `code: 'credential_unusable'`.
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
`session_expired` asserted a cause the status cannot support. A 401 says the credential was not accepted and says nothing about why: expired, revoked, insufficient scope and MALFORMED all produce it. A consumer's plugin sent ciphertext as a bearer token (a separate defect, fixed in the same release), the upstream answered 401, this name called it an expired session, and their health check acted on the name and deleted a valid session. Login loop, with every symptom pointing at a revoked credential.
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
Names get acted on, which is the whole reason to split them:
|
|
18
17
|
|
|
19
|
-
`
|
|
18
|
+
- `'unauthorized'` — the upstream refused. Non-transient, so still never retried; `status` rides along so a caller that knows more about its own upstream can decide for itself. Deciding for them is what this gives up. - `'credential_unusable'` — the connection vault could not produce a credential (no grant, revoked grant, refresh failed). Here the claim is ours to make, because the failure is ours rather than the far end's.
|
|
20
19
|
|
|
21
|
-
The
|
|
20
|
+
The 401 message stopped saying "session expired" too. It now says the credential was refused and that the reason is not in the response — which is the honest sentence and the one that would have saved the day this cost.
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
Its test asserts the CLAIM rather than banning the word: the first version forbade `/expired/i` and went red against the corrected message, which lists expiry as one of several things a 401 can mean. That distinction is the point of the change, so the assertion had to be about `session expired` specifically.
|
|
24
23
|
|
|
25
|
-
**`voltro update` carries you across this** — codemod `0.35.
|
|
26
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — A declared event must decide who may listen — the boot gate now covers `defineEvent`, closing SEC-1's sibling. `defineEvent`'s `guards:` was optional and `bindEvent` skipped an empty list, so under `security.defaultDeny` an event with NO access declaration was silently subscribable by anyone who could open the socket, while the identical shape was already refused for every procedure.
|
|
27
|
-
|
|
28
|
-
`defineEvent` now accepts `openAccess: '<reason>'` — mutually exclusive with `guards:`, reason string required — exactly as the four procedure definers do. The erased `{ open }` decision rides the same `guards` array every enforcement path reads; `bindEvent` treats it as "no check" (an open event pays what an unguarded one pays: nothing), and `eventToRpc` no longer unions `ScopeError` into the wire contract for an event that cannot produce a denial.
|
|
29
|
-
|
|
30
|
-
**Breaking for `security.defaultDeny` apps (the default):** an app with a `*.event.ts` declaring neither `guards:` nor `openAccess:` now refuses to boot under `voltro dev` and `voltro serve`, naming every undecided event — the same message, from the same gate, procedures get. `voltro doctor` lists the same set. Migration: give each event a decision (`guards: [{ scope: '…' }]` or `openAccess: '<why anyone may listen>'`); an app that wants the old default-allow declares `security: { defaultDeny: false }` once, in `app.config.ts`. Plugin-declared events are not judged — the gate reads the app's own discovered files only.
|
|
31
|
-
|
|
32
|
-
The events inspect snapshot (and the devtools Events panel) now counts only ENFORCEABLE guards and carries the `openAccess` reason, so a deliberately open event renders as "open access" instead of as "1 guard" over an event anyone may subscribe to.
|
|
33
|
-
|
|
34
|
-
**`voltro update` carries you across this** — codemod `0.35.0/01_event-access-decision`.
|
|
35
|
-
- **@voltro/plugin-ai-flows** — **The breaking half, first:** `RunStepStatus` gained `'skipped'`. A sixth member means an exhaustive switch over it stops compiling and a status-keyed lookup has a hole — so a manual codemod fires on any app that names the type or its literals. Everything else here is additive (optional fields, new exports, one nullable column that rides the declarative differ).
|
|
36
|
-
|
|
37
|
-
Flows can now BRANCH, FAN OUT, and no longer chain without a bound. Three additions, and the third is a defect fix wearing a feature's clothes.
|
|
38
|
-
|
|
39
|
-
**`when:` — a conditional step.** A step runs only if its condition holds against the run context; a false condition SKIPS the step rather than failing it, so it produces no output and anything referencing it sees an absent value. The run timeline carries the rendered reason (`{{mode}} equals "full"`), because a step that silently vanished is indistinguishable from a step nobody declared.
|
|
40
|
-
|
|
41
|
-
The condition is STRUCTURED data (`{ ref, op, value }`), not an expression string, and that is three decisions in one. A flow can be authored as a stored row a user edits in a browser — an expression there is an evaluator running user-authored source on the server. The visual editor can offer a dropdown over a structure and cannot over a string it would have to parse. And `validateFlow` already walks every reference, so a structured `ref` joins that check for free: a typo'd condition would otherwise evaluate absent, take the false branch, and skip its step on every run, forever, with nothing logged.
|
|
42
|
-
|
|
43
|
-
Truthiness here deliberately differs from JavaScript's: `0` and `''` are TRUTHY. A step gated on a generated count or string means "did the producer run", not "is it non-zero" — the second is `{ op: 'neq', value: 0 }`, sayable when meant.
|
|
44
|
-
|
|
45
|
-
**`group:` — concurrent steps.** Consecutive steps sharing a group name run at the same time, each keeping its own durable step, so a replay resolves every branch from the journal exactly as it would sequentially. Kept as a flat field rather than a nested `parallel([...])` because the durable step name, the run timeline and a `human` step's signal name are all INDEX-keyed — nesting would re-index every flow already running.
|
|
46
|
-
|
|
47
|
-
Three rules, all enforced at registration: grouped steps cannot read each other's outputs (they have no order between them), a group must be contiguous (a name that stops and resumes would run as two sequential fan-outs), and a `human` review cannot join a group (it suspends the whole run). Context writes are applied after the whole segment in AUTHORED order — applying them as branches land would make the run context depend on scheduling, which a durable replay must never do.
|
|
48
|
-
|
|
49
|
-
**A chain is bounded — this half is a fix.** `chainTo` carried exactly one guard, a flow could not chain to itself, so `A → B → A` and any deep chain were unbounded: each hop starts a child run with a fresh idempotency key, so nothing collapsed it and nothing was counting the hops. A run now carries the chain that led to it ON THE PAYLOAD — deliberately not reconstructed from the run rows, because a guard whose evidence comes from a query is a guard that permits the loop whenever the query fails. A chain is refused on a cycle, or at `maxChainDepth` (default 5; `aiFlowsPlugin({ maxChainDepth })` or `VOLTRO_AI_FLOW_MAX_CHAIN_DEPTH`), and the refusal lands on the run row's new `chainRefusal` column naming the path. The parent run still SUCCEEDS: a refused follow-up is a configuration problem, not a reason to destroy a completed result.
|
|
50
|
-
|
|
51
|
-
All three are driven through the REAL durable executor in tests, not just their pure helpers — a primitive that is correct and reaches nothing is the defect class this package's own segmentation module exists to prevent.
|
|
52
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/ai, @voltro/plugin-billing, @voltro/plugin-flags, @voltro/plugin-governance, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-storage** — Every first-party plugin rpc route now declares an access decision (`guards:` or `openAccess: '<reason>'`), and `security.defaultDeny` is enforced in the DISPATCH spine as defense in depth behind the boot gate: a descriptor that reaches the wire with no decision (a third-party plugin route, an embedder's hand-bound descriptor) is refused per-request with a typed `ScopeError` before the transaction / external I/O. Twelve previously-open routes now require a scope: `billing.startCheckout` / `portalUrl` / `previewChange` / `changePlan` / `changeSeats` / `invoices` → `billing:manage`; `billing.reportUsage` → `billing:report`; `governance.export` / `erase` → `admin:full` (already enforced in-handler, now declared); `storage.mintUploadUrl` / `ingestUrl` → `storage:manage`; `storage.listRefs` → `storage:browse`. Migration: grant the scope to the role/subjects that legitimately hold each capability (rbac role, `resolveScopes`, api-key scopes) — the codemod lists every route and the open-by-design surfaces that did NOT change. `PluginRpcRoute` gains `guards`/`openAccess` fields, carried through the route lift into the enforced descriptor; the synthesized agent/undo/connections built-ins declare `openAccess` so they keep serving under default-deny.
|
|
24
|
+
**`voltro update` carries you across this** — codemod `0.35.1/01_unauthorized-replaces-session-expired`.
|
|
53
25
|
|
|
54
26
|
### Added
|
|
55
27
|
|
|
56
|
-
- **@voltro/
|
|
57
|
-
- **@voltro/data-transfer, @voltro/sql-postgres, @voltro/cli** — The logical importer bulk-loads postgres targets via `COPY … FROM STDIN` (PERF-13). `voltro data import` engages it automatically wherever plain-INSERT semantics provably hold — `--mode replace`, or the default `upsert` into a table that is empty at import time (the fresh-target shape of every cross-dialect migration) — and never under `--atomic`. A refused COPY batch is atomic (nothing landed), so the importer replays exactly that batch through the per-row path with held-row / deferred-FK semantics intact. MEASURED on a 7-column table (text/int/bool/jsonb/timestamptz), 50 000 rows, local postgres: row-by-row 12.8 s (~3.9 k rows/s) vs COPY 0.59 s (~84.6 k rows/s) — **21.7×**. New seams: `ImportOptions.copyLoader` / `copyBatchSize` (default 5000) in `@voltro/data-transfer`, and `makePgCopySession` / `encodeCopyRow` in `@voltro/sql-postgres` (a submittable CopyIn query over the existing `pg` driver — no new dependency). Other dialects keep the per-row writes.
|
|
58
|
-
- **@voltro/cli** — `voltro probe access` asks a RUNNING app whether its declared access is actually enforced — the question none of the existing checks ask.
|
|
59
|
-
|
|
60
|
-
`voltro check`, the boot access gate and `security.defaultDeny` all verify that a decision was DECLARED. None of them verifies that the declaration REFUSES anyone. That distinction is not hypothetical here: the dispatch spine and the boot gate were separate for several releases, a procedure filtered out of the rpc group while still bound in the handler map served silently on one path and crashed the other, and `check` itself counted a decided-open route as guarded. Every one was the declaration and the behaviour disagreeing, found by reading rather than by asking.
|
|
61
|
-
|
|
62
|
-
It calls every guarded procedure with NO credentials and reports three verdicts: `refused` (enforcement works), `admitted` (the finding), and `inconclusive` — the call failed for a reason that is not an access refusal, usually payload validation running before the guard. `inconclusive` is never counted as a pass; `--strict` fails on it, which is what CI wants.
|
|
28
|
+
- **@voltro/web** — **`apiSurface: compatible` — why the three altered golden lines cannot break a caller.** `LoaderContext` and `LoaderFn` each gained a type parameter WITH a default, so an unparameterised reference still resolves. The one that needed proving is `query?`, which went from a written-out signature to `LoaderQuery<Procedures>` — and `LoaderQuery` is a conditional whose false branch is character-for-character the previous signature. `unknown` does not extend `ProcedureTypeMap`, so the defaulted instantiation takes that branch.
|
|
63
29
|
|
|
64
|
-
|
|
30
|
+
Proved with `tsc` rather than by reading it: a probe asserting mutual assignability between `LoaderQuery<unknown>` and the old signature compiles, and inverting the probe fails — with tsc printing the resolved type as `<T = unknown>(tag: string, input?: Record<string, unknown> | undefined) => Promise<T>`, which is the old signature verbatim.
|
|
65
31
|
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
**Validated against a live app, and it took two corrections to get there.** The first version sent a readable request id, which the transport converts with `BigInt(id)` — so every probe came back as a Defect before any guard ran, and every app looked broken. The second read a top-level `_tag` off an object while `POST /rpc` answers an ARRAY of envelopes, so a correctly-refused call scored as `admitted`. Both versions had a green unit suite, because the fixtures asserted the shape the code assumed. The fixtures are now copied from a real transcript.
|
|
69
|
-
- **@voltro/cli, @voltro/runtime** — `app.config.ts` gained `reactive: { deliveryConcurrency, rawReadTrackingLimit }` — the delivery-loop tunables were env-only, which left a number the framework picks on the project's behalf undeclarable in the one file that carries every other tunable.
|
|
70
|
-
|
|
71
|
-
Resolution stays inside the Dispatcher constructor (`resolveReactiveConfig`), so neither boot path can drift, and the env vars still win over the declared value: an operator acting on a running deployment outranks the project file. The threading itself is source-pinned across all three files (`dev.ts`, `serveCommand.ts`, `serveApi.ts`) because the serve side is a two-file relay and the union is where an option goes missing invisibly.
|
|
72
|
-
- **@voltro/cli, @voltro/database** — Data residency is DECLARABLE and wired. `tenancy.residency` in `app.config.ts` opens one store per servable region on both boot paths and routes every request to its tenant's home region — or refuses it.
|
|
73
|
-
|
|
74
|
-
The primitives have existed for two rounds (`setResidencyConfig`, `residentPlacement`, `bindResidentStore`), exported and tested, with **zero callers**. A user could reach them, but nothing in the framework did: there was no way to declare residency and no request ever consulted it. That gap was pinned by a test walking every workspace source, which went red on this change and asked for the module header to be corrected — it now names its consumers instead of asserting it has none, so a SECOND unreviewed caller still fails.
|
|
32
|
+
`LoaderContext` takes the app's procedure map, so a loader's `query` infers its input and output from the descriptor instead of returning `unknown`.
|
|
75
33
|
|
|
76
34
|
```ts
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
homes: [{ tenantId: 'acme', region: 'eu-west' }],
|
|
83
|
-
},
|
|
35
|
+
import type { AppProcedures } from '<your-api>/rpcGroup'
|
|
36
|
+
|
|
37
|
+
export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
|
|
38
|
+
const rows = await query?.('bookmarks.list', { limit: 100 })
|
|
39
|
+
// ^ inferred; an unknown tag or a wrong input shape is a compile error
|
|
84
40
|
}
|
|
85
41
|
```
|
|
86
42
|
|
|
87
|
-
`
|
|
88
|
-
|
|
89
|
-
**Every failure is a refusal, never a fallback**, because a residency system that degrades to a default store violates residency at exactly the moment something is misconfigured. Unresolvable tenant, unmapped home, or a home region this deployment does not serve are all typed refusals; the last one names the region so a gateway can route it.
|
|
43
|
+
`AppProcedures` is generated already and has been for a while — it was wired to `createHooks` on the CLIENT and to nothing on the server, so every loader call site spelled its own output type by hand and a typo in a tag compiled. A consumer reported it twice.
|
|
90
44
|
|
|
91
|
-
|
|
45
|
+
The extraction reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than re-deriving them: a second answer to "what does this tag return" drifts the first time a descriptor field is renamed, and both answers look right in isolation.
|
|
92
46
|
|
|
93
|
-
|
|
47
|
+
Opt-in, and non-breaking: with no map named, the signature is the previous `<T = unknown>(tag: string, …)`. The framework cannot import an app's generated file, which is the same reason `createHooks<AppProcedures>` takes it explicitly.
|
|
94
48
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Homes resolve once at boot (an array, or a function reading your own table), so adding a tenant home needs a restart — chosen over a cache with a staleness window on a decision whose whole value is that it is never wrong.
|
|
98
|
-
- **@voltro/runtime, @voltro/cli** — `voltro schedule backfill <name> --from <iso> --to <iso> [--yes] [--limit N]` and `POST /_voltro/inspect/schedules/:name/backfill` (WF-14) — fire every cron occurrence of a schedule over an explicit range, sequentially, each recorded against its own cron-derived `scheduledAt` with `trigger: 'manual'`. Fills the gap boot backfill (walks from the last recorded run only) and cluster-cron catch-up (capped at one day) leave open. Bounded and confirmable: above 25 occurrences it refuses without `--yes` (printing the count), above the per-request cap (default 1 000, `--limit` up to a hard ceiling of 10 000) it refuses outright, firing nothing — never a silent prefix. Wired on both boot paths through one shared hook.
|
|
99
|
-
- **@voltro/devtools-ui, @voltro/plugin-search** — The Search dashboard panel now RENDERS the drift surface REL-1 shipped server-side and no dashboard showed (the additive-JSON silent-drift shape the 4-layer rule exists for): per-index `dropped` / `pendingDrift` / `drifted` / last-drift badges, the repair queue itself (`GET /drift` — oldest first, with attempt counts and the engine's last error), and a **Resync now** action (`POST /resync`) gated on the new `canResyncSearch` capability (its own flag — a resync re-reads only the drifted rows; a reindex re-reads the whole table). Landed across all four layers in one change set: shared `SearchPage` + wire types + capability + EN/DE strings here; HTTP fetchers + page wiring in voltro-devtools; tenant-scoped `apps.inspectSearchDrift` / `apps.inspectSearchResync` proxies + hooks + page wiring in voltro-cloud (the indexes proxy schema carries the new fields as OPTIONAL, so a customer app from before the drift ledger still decodes). `search.query` also now carries an explicit access decision (`openAccess`, with the tenant-scoping rationale in source) instead of the undecided SEC-1 shape.
|
|
100
|
-
- **@voltro/cli** — Workflow wakes over the change stream (WF-8): on a fleet where remote changes reach the change spine (Postgres LISTEN/NOTIFY CDC — the common broker-less multi-replica deployment), a remote replica's `signal-sent` event, start context, or run transition now triggers an immediate, coalesced `pollStorage` on every replica, so cross-replica signal/step latency stops being bounded by the 10 s storage poll. Honest subset by design: the cluster engine has no per-run wake seam, so the change event wakes the poll early rather than replacing it — the poll tick stays the safety net. Local-origin changes never wake (a replica waking on its own recorder rows would be a poll storm). Wired by the same `makeWorkflowWake` builder on both boot paths.
|
|
101
|
-
- **@voltro/runtime, @voltro/workflow, @voltro/cli** — `ctx.workflows.start(name, payload, { at: Date })` — delayed one-off starts (WF-13). The start is parked as a durable `_voltro_workflow_pending` row (`mode: 'delayed'`) and fired by the coordinated drainer when `at` arrives, so it survives restarts and fires on whichever replica drains. At `at` it becomes an ordinary ARRIVAL: declared flow control (debounce, singleton, rateLimit, …) judges it as of that moment — `at` never bypasses a control. The handle reports `status: 'queued'` with `deferral: { mode: 'delayed', dueAt }`. An `at` in the past starts immediately; `{ at, wait: true }` is refused. `at` is an absolute instant by design (no `delay` spelling): a delay is ambiguous about its epoch and every queue system answers it differently, while an instant composes with the schedule/backfill surfaces.
|
|
102
|
-
- **@voltro/workflow, @voltro/cli** — `workflows: { recording: 'coarse' }` in `app.config.ts` (env override `VOLTRO_WORKFLOW_RECORDING`) — turns off the two fire-and-forget per-step writes to `_voltro_workflow_run_steps` (WF-10) for hot high-step workflows. Run rows, run events (signals/timers/cancels/stall reports) and the cluster engine's durable journal are unaffected — replay and redrive work exactly as before; the cost is an empty step timeline for runs recorded under coarse. Measured before it was built (`packages/cli/scripts/admission-throughput.mjs`): the recorder costs exactly 2 store writes per step, off the step's critical path — which is why the knob is a skip, not a batcher.
|
|
103
|
-
- **@voltro/workflow, @voltro/cli** — `workflow({ schedule })` — the workflow-side cron declaration (WF-12), with Temporal Schedules' overlap vocabulary about the RUN: `onOverlap: 'skip' | 'buffer' | 'cancelOther'`. Pure sugar over the shipped scheduler: at boot it lowers into a real schedule named `workflow:<name>` (same coordinated claims, run rows, Schedules panel, `voltro schedule` verbs). The synthesised firing awaits the workflow run to completion, which is what makes skip/buffer bind on the run's duration; `cancelOther` cancels only the still-running run this schedule itself started. The firing watchdog (`schedule.maxRuntime`) defaults to 24 h here. Cron and timezone are validated at definition time.
|
|
104
|
-
|
|
105
|
-
### Changed
|
|
106
|
-
|
|
107
|
-
- **@voltro/plugin-clickhouse** — `clickhouseAnalytics` now BATCHES `track()` inserts by default (PERF-11) — 20 events / 5 s, plugin-posthog's conservative numbers — instead of one HTTP insert (and one MergeTree part) per event. What changes observably for an app that never set `batch`: a successful `track()` now means "buffered", not "ClickHouse accepted the row"; events become readable up to 5 s after they were tracked; a flush failure drops that batch with a warning (a hard crash loses whatever is still buffered — graceful shutdown drains via `dispose`). Opt OUT with `batch: false` to restore one immediate, confirmed insert per event; `batch: { maxSize, flushIntervalMs }` tunes the window. No compile break — `batch` widened to `ClickhouseBatchOptions | false`, and the previous opt-in spelling keeps working (its defaults are now 20/5000 rather than 1000/5000).
|
|
108
|
-
- **@voltro/database, @voltro/cli, @voltro/workflow** — The migration advisory lock is now scoped to the configured schema (`DB_SCHEMA`) instead of one framework-wide constant. A postgres advisory lock is database-scoped and MySQL `GET_LOCK` is server-wide, so two apps sharing one database in different schemas used to serialize each other's migrations and defer each other's boot-time trigger repair — with a log line blaming "another instance". Now: postgres derives a stable 64-bit key from the schema name (FNV-1a 64 of `voltro_migration_lock:<schema>`, sign bit cleared; collisions across schemas are possible and only reintroduce serialization, never a race); mysql/mariadb/mssql suffix the lock NAME with the schema (hashed past MySQL's 64-char `GET_LOCK` cap). Every taker moved together in this change — the declarative applier, the file-based runner, the boot auto-migrate, the CLI's reactive-trigger boot repair, and the workflow cluster first-boot gate (its own distinct key, same derivation). On mysql/mariadb, where `GET_LOCK` is server-wide and `DB_SCHEMA` is not a connection pin, setting `DB_SCHEMA` to your database name is how two apps on one server un-share the lock.
|
|
109
|
-
|
|
110
|
-
**Rolling-deploy story.** An app WITHOUT `DB_SCHEMA` (or with `DB_SCHEMA=public`) keeps the EXACT pre-change lock key and name — old and new replicas contend on the same lock throughout the rollout; nothing to do. An app WITH a non-default `DB_SCHEMA` changes its lock key when it lands this version: during that one rollout window, old-generation and new-generation replicas do not mutually exclude their DDL. The boot auto-migrate DDL is idempotent (`IF NOT EXISTS`-shaped), so the practical exposure is the known postgres `CREATE TABLE IF NOT EXISTS` catalog race — worst case one replica's boot fails and restarts. Avoid running `voltro db apply` concurrently with THAT rollout; after it, everything contends on the schema-scoped key.
|
|
111
|
-
- **@voltro/plugin-search** — `POST /reindex` now STREAMS the source table (keyset-paginated `streamTable`) and upserts one bounded page at a time instead of loading the whole table into memory — the old shape was an OOM on exactly the tables big enough to need a reindex (PERF-12). The page size is a new tunable, `searchPlugin({ sync: { reindexBatchSize } })` (default 1000), and the `/indexes` panel reports it as part of the policy in force. `backfillIndex` keeps its plain-array signature for small explicit seeds. Additive surface only: a new optional `SearchSyncOptions` knob + a new `SYNC_DEFAULTS` key — no existing call site changes meaning.
|
|
112
|
-
- **@voltro/plugin-search** — Sync-stat counters no longer pay a read+CAS against the OLTP primary on EVERY indexed-table write (PERF-14). Counts buffer in memory and flush per window — `searchPlugin({ sync: { statsFlushIntervalMs } })` (default 5000 ms; `0` restores the per-event durable write) with an early flush at `statsFlushMaxBuffered` (default 1000) pending counts. Mirrors the runtime's api-key usage buffer, SHUTDOWN included: plugin deactivate drains the tail on both boot paths, so a graceful deploy loses nothing; a hard crash loses at most the current window of counters (never a change — the drift ledger stays the durable record). `GET /indexes` drains the buffer before reading, so the panel stays truthful mid-window. `StatsStore` gained a delta-applying `add` (the flush target); both shipped impls carry it and nothing consumes user-provided `StatsStore` implementations.
|
|
113
|
-
- **@voltro/database, @voltro/plugin-webhooks, @voltro/testing, @voltro/voltro, @voltro/workflow** — Golden churn from this round's signature WIDENINGS, classified per package:
|
|
114
|
-
|
|
115
|
-
- **@voltro/database** — every migration entry point (`applySchema`, `runMigrate`, `runFrameworkBootstrap`, `applyNamespacedSchema`, `provisionTenantNamespace`, the lock functions) gained a trailing OPTIONAL parameter (`SchemaApplyOptions` / `MigrationLockScope`) for the schema-scoped lock and the dialect retry predicate. Every existing call compiles unchanged; omitting the parameter is exactly the old behavior. - **@voltro/workflow / @voltro/testing / @voltro/voltro** — the same widenings re-exported through the aggregates, plus `PresenceWrite`-adjacent type surface already classified in this release's presence entry. - **@voltro/plugin-webhooks** — `deliverWebhookWorkflow`'s payload type inference had COLLAPSED to `AnyStructSchema | Struct<Fields>`, which made `execute`'s requirements `any` for every consumer: there was no type contract in force to break, only one that silently did not exist. It now infers the real payload struct. The export's only callers are the framework's own boot paths (it exists for cluster-runner registration); an app that passed a wrong-shaped payload under `any` now gets the compile error it should always have had — which is the fix, not collateral.
|
|
49
|
+
Covered by a `.test-d.ts`, because the failure mode is "it compiles when it should not" and no runtime assertion can observe that. Two of its cases exist because the first version was vacuous: an `interface` fixture does not satisfy the map constraint (no implicit index signature — the codegen emits an alias for exactly this reason), so the typed branch fell back silently and every `@ts-expect-error` came back unused.
|
|
116
50
|
|
|
117
51
|
### Fixed
|
|
118
52
|
|
|
119
|
-
- **@voltro/cli
|
|
120
|
-
|
|
121
|
-
A consumer with 565 undecided procedures set `security: { defaultDeny: false }` across their app, and their reasoning was correct at every step from what they were shown. Their authority is per-team — a viewer in one team, an admin in another — so a subject-global `guards: [{ scope }]` would state a check they do not perform, and the boot refusal warns against exactly that ("reaching for a scope every caller already holds satisfies the gate, reads as protection, and enforces nothing"). `openAccess:` would be untrue. Both offered forms were rightly rejected, so they turned the gate off and kept enforcing in handlers.
|
|
122
|
-
|
|
123
|
-
The form that fits them — `guards: [{ action, resourceType, resource }]`, backed by `defineResourcePolicy` and a tuple source registered over their own tables — has shipped for several releases, is wired on both boot paths, fails closed without a resolver, and is documented under Authentication → Authorization. They looked: they read `GuardSpec.resource`, whose doc comment described the resolver as "a future ReBAC / `accessPolicy()` resolver". That sentence was written before the ReBAC path shipped and never updated, and it is the only thing a reader of that type has. A doc comment that says "future" about something built is not a small inaccuracy — it argued a careful team out of a security gate.
|
|
124
|
-
|
|
125
|
-
An enumeration inside a refusal is read as exhaustive, and the more careful the reader, the more thoroughly they act on it. `accessDecisionForms.test.ts` pins all three forms in all three places, including the `defaultDeny: false` branch — an app that has already given up is precisely the audience that needs to learn there was a third option.
|
|
126
|
-
- **@voltro/protocol, @voltro/database, @voltro/runtime, @voltro/plugin-broadcast, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — A reactivity-channel publish now says where it came from — `origin: 'inline'`, because it happened in THIS process — instead of borrowing the `'injected'` stamp its transport seam applies by default. Two defects came out of that one mislabel, both silent:
|
|
127
|
-
|
|
128
|
-
- **A channel published synchronously from inside a change listener never left the replica.** `plugin-broadcast` suppresses re-publishes while it is injecting, and the bracket was coarse: it dropped EVERY emission made in that window, not just the event it had injected. So `onChange` → `publishReactivity` woke the local node and no peer ever heard it — no error, no log. The one plugin doing cross-replica fan-out (presence) escaped only because it re-publishes from its own transport callback. The guard now suppresses by provenance, so a local publish made inside the bracket travels like any other. - **A replica could not tell its own channel publish from a peer's.** Both arrived `'injected'`, so a listener fanning a channel onward had nothing to key on and needed a boolean per channel to avoid an echo. `origin` answers it now.
|
|
129
|
-
|
|
130
|
-
Two supporting changes, each with its own failure mode:
|
|
131
|
-
|
|
132
|
-
- `origin` no longer survives the wire. It describes how an event reached THIS process, so the receiving replica strips what the sender serialised and stamps its own. Without this the guard fails OPEN — measured, an arrival still claiming `'inline'` amplified one publish into 163 events and killed the test worker. - The transport-origin stamp has one definition (`externalChangeEvent`, `@voltro/database`) instead of five hand copies across the memory store and the four dialect stores. `injectOriginParity.test.ts` fails if any store grows its own again — a store that hand-stamps would override a caller's stated origin, and the visible result is a channel that stops crossing replicas on that dialect only.
|
|
133
|
-
|
|
134
|
-
`DataStore.injectExternalChange` keeps its shape: an event that states no origin is still stamped `'injected'`. A store passed to `plugin-broadcast` that does not stamp at all (the interface is structural) is detected by identity and falls back to the old coarse suppression rather than amplifying.
|
|
135
|
-
- **@voltro/cli, @voltro/devtools-ui** — `voltro check` no longer mistakes a deliberate `openAccess:` mutation for an unguarded one — and no longer mistakes it for a guarded one either. The manifest serialises the decision as a `kind: 'open'` guard entry carrying the reason string; `toInput` now translates it into `openAccess` on the graph procedure with `hasGuards: false` (nothing IS checked), and the `rbac/unguarded-mutation` rule skips a procedure whose author already "confirmed it is intentionally public" — the rule's own fix text. Previously the open entry was counted as a guard, so the finding disappeared for the wrong reason: the open mutation read as protected.
|
|
136
|
-
|
|
137
|
-
`@voltro/devtools-ui` gains the hand copy of the `SerialisedGuard` wire union (it is deliberately dependency-free, so it cannot import the CLI's), renders an access badge on the RPC page — guard count, or "open access" with the reason in the tooltip — and the copy is pinned from the owning side by `serialisedGuardParity.test.ts`, in the style of `migrationOpKindParity`.
|
|
138
|
-
- **@voltro/cli** — `ctx.query` in a loader rejects an `error` event instead of returning it as the query's rows.
|
|
139
|
-
|
|
140
|
-
A consumer put a guard with an unheld scope on a query and called it over HTTP with a valid user JWT. The batch came back 200 with an `error` chunk carrying a `ScopeError` and an `Exit: Success` after it. Both are correct — the transport worked and the guard worked — but `buildLoaderQuery` unwrapped exactly ONE member of the three-member `subscriptionEvent` union and passed the other two through as data. The loader returned the error OBJECT, it went into the SSR seed, and the component called `.map()` on it: `TypeError: kept.map is not a function`, on 135 pages, for every user.
|
|
141
|
-
|
|
142
|
-
The `.catch(() => null)` in our own documented loader pattern could not fire, because nothing was thrown. Neither could `seedPagePreloads`' `onError`, for the same reason. The same rejection over the live socket sets `error` and leaves data empty, so the two transports were saying different things about one event.
|
|
143
|
-
|
|
144
|
-
The unwrap is exhaustive now: `snapshot` yields its rows, `error` rejects with the typed error attached (as `cause` and `voltroError`, so an app can still branch on `ScopeError` rather than parse a string), and a first-event `delta` — structurally impossible today — throws naming itself a framework bug rather than handing a loader an id-keyed patch. `buildLoaderQuery` has one implementation shared by `voltro dev` and `voltro start`, so the fix covers the production path the report did not measure.
|
|
145
|
-
|
|
146
|
-
The HTTP 200 is unchanged, and the reporter is right that it should be: the batch transport did succeed.
|
|
147
|
-
- **@voltro/database, @voltro/cli** — The schema apply (`applySchema` / boot auto-migrate / framework bootstrap) now consults the dialect's own transient-failure predicate instead of dying on the first `SQLITE_BUSY`. The gap was located in `@voltro/sql-turso`: `busy_timeout` cannot retry the deferred-upgrade lock class, the store path already honoured the dialect's `retryFilter`, and the migration applier never consulted it — so a DDL statement that met the schema lock failed on the first attempt while every equivalent DML statement would have been retried. The CLI threads each loaded dialect's `retryFilter` through the new `SchemaApplyOptions`; retries are bounded (`VOLTRO_MIGRATION_DDL_RETRIES`, default 4, exponential backoff with jitter) and only ever re-run statements that are safe to re-run: per-statement on the per-operation dialects (sqlite/turso/mysql/mariadb, `IF NOT EXISTS`-shaped or covered by the duplicate-index tolerance), whole-transaction on postgres/mssql, whose retryable classes (deadlock victim, serialization failure) roll the transaction back cleanly. No `retryFilter` threaded means no retry — exactly the previous behavior. The declarative plan applier (`applyPlan`) is deliberately unchanged: its operations are not uniformly idempotent, and partial failure there is owned by the resume ledger.
|
|
148
|
-
- **@voltro/web, @voltro/client** — The web first load no longer ships `msgpackr` — 190.5 → 180.6 KB gz (−9.9 KB, 5.1% of the whole first load) for every app, measured on the zero-procedure fixture and re-pinned into `bundle-budget.json`.
|
|
149
|
-
|
|
150
|
-
It shipped because `@effect/rpc`'s RpcSerialization module top-level-imports msgpackr while every Voltro path selects `layerJson`, and msgpackr declares no `sideEffects` flag — so no bundler was allowed to drop it. The fix is a dependency patch adding `sideEffects: false`, declared at BOTH workspace roots (the @effect/cluster patch rule: a meta-root `pnpm install` must apply it too, or the two installs fight over node_modules). An app that genuinely calls `makeMsgPack` keeps the library — the flag only permits dropping it when unused.
|
|
53
|
+
- **@voltro/cli** — The store a plugin receives through `bindDataStore` now carries the storage codec, so an `.encrypted()` column read through it decrypts.
|
|
151
54
|
|
|
152
|
-
|
|
153
|
-
- **@voltro/cli, @voltro/database** — Under `tenantIsolation: 'namespace'`, a tenant's namespace is now PROVISIONED on its first use — schema DDL plus the `onTenantCreate` seed lifecycle, memoised per namespace per process.
|
|
55
|
+
A consumer measured both stores inside one request: `ctx.store` gave a 44-character plaintext PAT, and the store their plugin's `credentialsResolver` received gave 113 characters of `enc:v1:…`. Ciphertext is a syntactically valid bearer token, so nothing threw. Jira answered 401, `@voltro/integration-http` named that `session_expired`, their PAT health check did the reasonable thing with that name and deleted the session, and the user got login → dashboard → login forever. A configuration error in the costume of an authentication refusal, where every symptom pointed at the one explanation that was wrong.
|
|
154
56
|
|
|
155
|
-
|
|
57
|
+
The part worth recording is that `bootStoreCodec.ts` was written for exactly this, after it happened at two other seams, and its header predicts this consumer's symptom verbatim: "a route reading an `.encrypted()` column got the literal string `enc:v1:…` back … the failure reads as 'wrong credential'". The fix was applied per-seam. `bindDataStore` was not one of the seams anybody listed, so it happened a third time — and a per-seam test stayed green throughout, because it covered the two seams somebody remembered.
|
|
156
58
|
|
|
157
|
-
|
|
59
|
+
`bootStoreHandouts.test.ts` asserts the rule instead: no boot path hands a plugin the raw driver, on either boot path, with the wrapper applied before the handout. The codec needs no Subject — it is how a column is spelled on disk versus in JS — so there was never anything a boot-level store could not carry.
|
|
158
60
|
|
|
159
|
-
|
|
160
|
-
- **@voltro/runtime, @voltro/cli
|
|
61
|
+
Also relevant to anyone who followed the 0.28.0 codemod: that codemod told apps to stop carrying a credential on the Subject and look it up in the resolver instead. Doing exactly that is what put an app on this seam, so the instruction and `.encrypted()` were not simultaneously satisfiable through it.
|
|
62
|
+
- **@voltro/runtime, @voltro/cli** — The rpc/WebSocket query and stream arms now resolve row visibility before the executor sees a context. Fixes a 0.35.0 regression that made every read throw for an app with a registered row filter, and the older leak underneath it.
|
|
161
63
|
|
|
162
|
-
|
|
64
|
+
0.35.0 shipped two things for the row filter: the registration moved to `globalThis` (so a duplicate `@voltro/runtime` instance cannot hide it), and a scoped store built without a resolved scope started throwing instead of silently serving unfiltered rows. The first was a real fix for a real hazard. The second was correct in principle and immediately fatal in practice, because the framework itself had a path that did exactly what it now refuses.
|
|
163
65
|
|
|
164
|
-
|
|
66
|
+
The consumer who reported the original leak ran the two-line check we asked for and `getRowFilter()` was visible from their request path — so the instance split was NOT their cause, and our hypothesis was wrong. Their measurement is what found the real one: the refusal fired, meaning the registration was FOUND and `ctx.rowFilter` was still undefined at the store. Nothing was missing; a step was.
|
|
165
67
|
|
|
166
|
-
|
|
68
|
+
Four arms reach a request context. `makeOneShotQueryRunner` (REST) and `makeQuerySubscriber` (SSE) both `await withRowFilter(...)` and say so in a comment. The rpc query handler and the stream handler — each hand-copied into both boot paths — handed the raw request straight through. So a user's executor received a context whose `ctx.store` applied no row filter, on the two arms that carry the most traffic. It survived because subscriptions are refiltered per DELIVERY, which made a descriptor-returning query look correct end to end while the executor's own reads were not.
|
|
167
69
|
|
|
168
|
-
|
|
70
|
+
`withScopedRequest` is the seam that fixes it once: a request that already carries a scope passes through untouched (resolving twice would run the app's `load` twice per request), an app with NO filter stays fully synchronous, and an app with one gets an Effect — which every one of these call sites already accepts. A boot-path parity test pins both stream arms and the shared producer.
|
|
169
71
|
|
|
170
|
-
The
|
|
171
|
-
- **@voltro/cli** — A bare `voltro serve` under docker compose now drains on SIGTERM — in-flight requests complete against a fully-alive app, the listener refuses new work, live WebSockets are ended cleanly, and the process exits on its own, well inside `VOLTRO_SHUTDOWN_GRACE_MS`. No preStop hook or endpoint removal required. Two real defects closed (both measured against a live server): a single connected WebSocket wedged `nodeServer.close()` — node's `closeAllConnections()`/`closeIdleConnections()` cannot end an upgraded socket while `close()` still waits on it — so EVERY shutdown with a connected web client ran to the 10s deadline cut and the steps queued behind the close (the store's connection-pool close included) silently never ran; and the shutdown hook deactivated plugins and drained the analytics mirror BEFORE the request drain, so a request finishing during shutdown hit dead services and its writes were never mirrored. The drain is bounded: in-flight requests get 60% of the shutdown grace (floor 500ms), stragglers are then destroyed, and idle keep-alive sockets are swept continuously so a finished response never delays exit. The stale `serveApi` comment claiming `NodeRuntime.runMain` owns SIGTERM (and pointing at a k8s preStop hook as the fix) is rewritten to describe the drain that actually runs. Verify against a real serve with `node scripts/serve-drain-check.mjs`.
|
|
72
|
+
The refusal also stopped firing for a SYSTEM subject. That is not a softening: `resolveRowFilterScopeFor` returns `NO_ROW_FILTER` for a system subject, so the only correct value was already determined, and several legitimate paths (schedules, resumed workflows, the webhook trigger context) build a context directly with no scope. Demanding a decision there is what took the api down.
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.97.0",
|
|
14
14
|
"@effect/rpc": "^0.76.0",
|
|
15
|
-
"@voltro/ai": "0.
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/ai": "0.36.0",
|
|
16
|
+
"@voltro/cli": "0.36.0",
|
|
17
|
+
"@voltro/database": "0.36.0",
|
|
18
|
+
"@voltro/env": "0.36.0",
|
|
19
|
+
"@voltro/protocol": "0.36.0",
|
|
20
|
+
"@voltro/runtime": "0.36.0",
|
|
21
21
|
"effect": "^3.22.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.36.0",
|
|
25
25
|
"typescript": "^6.0.3",
|
|
26
26
|
"@vitest/coverage-v8": "^4.1.10",
|
|
27
27
|
"vitest": "^4.1.10"
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@effect/platform": "^0.97.0",
|
|
15
15
|
"@effect/rpc": "^0.76.0",
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/plugin-auth": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.36.0",
|
|
17
|
+
"@voltro/database": "0.36.0",
|
|
18
|
+
"@voltro/env": "0.36.0",
|
|
19
|
+
"@voltro/plugin-auth": "0.36.0",
|
|
20
|
+
"@voltro/protocol": "0.36.0",
|
|
21
|
+
"@voltro/runtime": "0.36.0",
|
|
22
|
+
"@voltro/sql-postgres": "0.36.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.36.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
28
|
"@vitest/coverage-v8": "^4.1.10",
|
|
29
29
|
"vitest": "^4.1.10"
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@effect/platform": "^0.97.0",
|
|
18
18
|
"@effect/rpc": "^0.76.0",
|
|
19
|
-
"@voltro/cli": "0.
|
|
20
|
-
"@voltro/database": "0.
|
|
21
|
-
"@voltro/env": "0.
|
|
22
|
-
"@voltro/plugin-multitenancy": "0.
|
|
23
|
-
"@voltro/protocol": "0.
|
|
24
|
-
"@voltro/runtime": "0.
|
|
19
|
+
"@voltro/cli": "0.36.0",
|
|
20
|
+
"@voltro/database": "0.36.0",
|
|
21
|
+
"@voltro/env": "0.36.0",
|
|
22
|
+
"@voltro/plugin-multitenancy": "0.36.0",
|
|
23
|
+
"@voltro/protocol": "0.36.0",
|
|
24
|
+
"@voltro/runtime": "0.36.0",
|
|
25
25
|
"effect": "^3.22.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@voltro/testing": "0.
|
|
28
|
+
"@voltro/testing": "0.36.0",
|
|
29
29
|
"typescript": "^6.0.3",
|
|
30
30
|
"@vitest/coverage-v8": "^4.1.10",
|
|
31
31
|
"vitest": "^4.1.10"
|