@voltro/cli 0.32.0 → 0.33.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 +205 -0
- package/THIRD-PARTY-NOTICES.md +1 -29
- package/dist/apiBuild-C-x9YacA.js +2 -0
- package/dist/{apiBuild-C4uEbs3s.js → apiBuild-h9VHtnlw.js} +2 -2
- package/dist/bin.js +3 -3
- package/dist/checkCommand-DRovTKza.js +2 -0
- package/dist/{checkCommand-dm7OHtPt.js → checkCommand-xGhRFFg2.js} +15 -15
- package/dist/{commands-C0nEePif.js → commands-CJfepbm4.js} +97 -13
- package/dist/{dbCommand-ifOMafuG.js → dbCommand-DTLKAfbA.js} +401 -241
- package/dist/dbCommand-b1gum4td.js +2 -0
- package/dist/{dev-5ficNnvF.js → dev-C_P8FLSx.js} +1539 -1487
- package/dist/{dev-rc3fwPSZ.js → dev-iiMtlkfs.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-BqO4E9G0.js → inspectMetrics-1xzTKAFx.js} +573 -550
- package/dist/{sdkgen-B_5mHQS2.js → sdkgen-C81QIkiL.js} +1 -1
- package/dist/serveCommand-DdaM4Hup.js +1608 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-D1-8eKrO.js → start-C0koT0UO.js} +1 -1
- package/dist/startEntry.js +2 -2
- package/package.json +31 -18
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/database/misc.md +37 -0
- package/templates/agent-docs/database/schema.md +4 -2
- package/templates/agent-docs/deployment.md +87 -1
- package/templates/agent-docs/plugins.md +50 -0
- package/templates/agent-docs/scheduling.md +4 -1
- package/templates/agent-docs/security.md +23 -2
- package/templates/agent-docs/whats-new.md +128 -86
- 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 +8 -8
- 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 +9 -9
- 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 +2 -2
- package/dist/apiBuild-OJEjtwcn.js +0 -2
- package/dist/checkCommand-CwMrzAgV.js +0 -2
- package/dist/dbCommand-By__Ev0R.js +0 -2
- package/dist/serveCommand-PxMmn96o.js +0 -1578
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ct as e } from "./inspectMetrics-
|
|
1
|
+
import { ct as e } from "./inspectMetrics-1xzTKAFx.js";
|
|
2
2
|
import { c as t } from "./seedRunner-Bqxgp7HZ.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-DdaM4Hup.js";
|
|
5
5
|
export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as e, C as t, Ct as n, D as r, E as i, G as a, J as o, K as s, L as c, P as l, Q as u, S as d, St as ee, Tt as f, V as p, W as m, _ as h, _t as g, a as _, b as v, bt as y, c as b, dt as te, f as x, ft as S, g as C, h as w, i as T, k as E, m as D, mt as O, o as ne, p as re, pt as k, q as ie, r as A, s as j, t as M, ut as N, v as ae, wt as oe, x as P, y as F } from "./inspectMetrics-
|
|
1
|
+
import { $ as e, C as t, Ct as n, D as r, E as i, G as a, J as o, K as s, L as c, P as l, Q as u, S as d, St as ee, Tt as f, V as p, W as m, _ as h, _t as g, a as _, b as v, bt as y, c as b, dt as te, f as x, ft as S, g as C, h as w, i as T, k as E, m as D, mt as O, o as ne, p as re, pt as k, q as ie, r as A, s as j, t as M, ut as N, v as ae, wt as oe, x as P, y as F } from "./inspectMetrics-1xzTKAFx.js";
|
|
2
2
|
import { A as se, E as I, O as ce, a as le, k as L, m as ue } from "./inspect-BUUjt773.js";
|
|
3
3
|
import { t as de } from "./bootTiming-BdyP9nYw.js";
|
|
4
4
|
import { dirname as fe, extname as pe, join as R, resolve as z } from "node:path";
|
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { ct as e } from "./inspectMetrics-
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { ct as e } from "./inspectMetrics-1xzTKAFx.js";
|
|
2
|
+
import { t } from "./start-C0koT0UO.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.33.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",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"types": "./dist/startEntry.d.ts",
|
|
51
51
|
"import": "./dist/startEntry.js",
|
|
52
52
|
"default": "./dist/startEntry.js"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"./package.json": "./package.json"
|
|
54
55
|
},
|
|
55
56
|
"main": "./dist/index.js",
|
|
56
57
|
"module": "./dist/index.js",
|
|
@@ -363,6 +364,18 @@
|
|
|
363
364
|
"title": "`auditPlugin` now redacts `outcome` by default — opt out if your trail reads it",
|
|
364
365
|
"kind": "manual"
|
|
365
366
|
},
|
|
367
|
+
{
|
|
368
|
+
"version": "0.33.0",
|
|
369
|
+
"id": "0.33.0/01_opentelemetry-optional-peer",
|
|
370
|
+
"title": "`@effect/opentelemetry` is now an optional peer — install it if you use tracing",
|
|
371
|
+
"kind": "manual"
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"version": "0.33.0",
|
|
375
|
+
"id": "0.33.0/02_coordinated-handle-gained-wake",
|
|
376
|
+
"title": "`CoordinatedScheduleHandle` gained `wake()`, `currentIntervalMs()` and `isArmed()` — update hand-written test doubles",
|
|
377
|
+
"kind": "manual"
|
|
378
|
+
},
|
|
366
379
|
{
|
|
367
380
|
"version": "0.4.0",
|
|
368
381
|
"id": "0.4.0/01_rbac-forbidden-to-scopeerror",
|
|
@@ -462,22 +475,22 @@
|
|
|
462
475
|
"@effect/platform-node": "^0.108.0",
|
|
463
476
|
"@effect/sql": "^0.52.0",
|
|
464
477
|
"@effect/workflow": "^0.19.0",
|
|
465
|
-
"@voltro/ai": "0.
|
|
466
|
-
"@voltro/cache": "0.
|
|
467
|
-
"@voltro/data-transfer": "0.
|
|
468
|
-
"@voltro/database": "0.
|
|
469
|
-
"@voltro/env": "0.
|
|
470
|
-
"@voltro/kv": "0.
|
|
471
|
-
"@voltro/logger": "0.
|
|
472
|
-
"@voltro/plugin-auth": "0.
|
|
473
|
-
"@voltro/plugin-broadcast": "0.
|
|
474
|
-
"@voltro/plugin-mail": "0.
|
|
475
|
-
"@voltro/plugin-storage": "0.
|
|
476
|
-
"@voltro/plugin-webhooks": "0.
|
|
477
|
-
"@voltro/protocol": "0.
|
|
478
|
-
"@voltro/runtime": "0.
|
|
479
|
-
"@voltro/serverless": "0.
|
|
480
|
-
"@voltro/workflow": "0.
|
|
478
|
+
"@voltro/ai": "0.33.0",
|
|
479
|
+
"@voltro/cache": "0.33.0",
|
|
480
|
+
"@voltro/data-transfer": "0.33.0",
|
|
481
|
+
"@voltro/database": "0.33.0",
|
|
482
|
+
"@voltro/env": "0.33.0",
|
|
483
|
+
"@voltro/kv": "0.33.0",
|
|
484
|
+
"@voltro/logger": "0.33.0",
|
|
485
|
+
"@voltro/plugin-auth": "0.33.0",
|
|
486
|
+
"@voltro/plugin-broadcast": "0.33.0",
|
|
487
|
+
"@voltro/plugin-mail": "0.33.0",
|
|
488
|
+
"@voltro/plugin-storage": "0.33.0",
|
|
489
|
+
"@voltro/plugin-webhooks": "0.33.0",
|
|
490
|
+
"@voltro/protocol": "0.33.0",
|
|
491
|
+
"@voltro/runtime": "0.33.0",
|
|
492
|
+
"@voltro/serverless": "0.33.0",
|
|
493
|
+
"@voltro/workflow": "0.33.0",
|
|
481
494
|
"chokidar": "^5.0.0",
|
|
482
495
|
"ioredis": "^5.11.1",
|
|
483
496
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -652,7 +652,7 @@ each plugin's own README.
|
|
|
652
652
|
|
|
653
653
|
| Topic | Open | Summary |
|
|
654
654
|
|---|---|---|
|
|
655
|
-
| **What's new in 0.
|
|
655
|
+
| **What's new in 0.33.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. |
|
|
656
656
|
| 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. |
|
|
657
657
|
| 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. |
|
|
658
658
|
| 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.33.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. |
|
|
@@ -225,6 +225,43 @@ Precedence, exactly:
|
|
|
225
225
|
3. **`.safe()`** on an encrypted column suppresses the implied secret and copies
|
|
226
226
|
it verbatim — say this only when you have genuinely reviewed the plaintext.
|
|
227
227
|
|
|
228
|
+
## Encrypting a column that already has rows
|
|
229
|
+
|
|
230
|
+
`.encrypted()` encrypts on **write**. Adding it to a populated column converts
|
|
231
|
+
nothing that is already stored — those rows stay plaintext until something
|
|
232
|
+
rewrites them, which for a credential column may be never.
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
voltro db encrypt-column integrations.webhookSecret --dry-run
|
|
236
|
+
voltro db encrypt-column integrations.webhookSecret employees.meilisearchKey --yes
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
It reads `FIELD_ENCRYPTION_KEY` (or `--key-env NAME`) and must be **the same key
|
|
240
|
+
your app runs with** — the one you pass to
|
|
241
|
+
`governancePlugin({ fieldEncryption: { key } })`.
|
|
242
|
+
|
|
243
|
+
**Order does not matter.** A read returns a non-ciphertext value unchanged, so
|
|
244
|
+
the column may hold a mix while you deploy: run the command before or after the
|
|
245
|
+
release that adds `.encrypted()`, and run it again afterwards to catch anything
|
|
246
|
+
written in between. It skips what is already encrypted, which also means an
|
|
247
|
+
interrupted run is resumed by running it again.
|
|
248
|
+
|
|
249
|
+
Five things it refuses to do, each of them a way a hand-written `UPDATE` goes
|
|
250
|
+
wrong quietly:
|
|
251
|
+
|
|
252
|
+
| It checks | Because |
|
|
253
|
+
|---|---|
|
|
254
|
+
| already-ciphertext values are skipped | double encryption cannot be undone without the key history |
|
|
255
|
+
| the value decrypts back before the write | a broken cipher otherwise fails on the first *read*, when the plaintext is gone |
|
|
256
|
+
| the key matches what the column already holds | a *different* key round-trips fine; resuming with one leaves a column readable with neither key alone |
|
|
257
|
+
| the column is wide enough | ciphertext is `49 + 4×ceil(bytes/3)` characters — a 64-char key needs 137, and a `varchar(100)` fails partway through |
|
|
258
|
+
| `--yes` is present | it rewrites a column in place |
|
|
259
|
+
|
|
260
|
+
The width figure is in **bytes**, not characters: `'ä'.repeat(10)` is 10
|
|
261
|
+
characters and 20 bytes, and encrypts to 77.
|
|
262
|
+
|
|
263
|
+
No value — plaintext or ciphertext — is ever printed. The report is counts.
|
|
264
|
+
|
|
228
265
|
## `.serverOnly()` — never to a client
|
|
229
266
|
|
|
230
267
|
A THIRD, independent axis. `.sensitive()` / `.safe()` are about **data export
|
|
@@ -748,9 +748,11 @@ ctx.store.select('inviteLinks').includeExpired()
|
|
|
748
748
|
| | where | when |
|
|
749
749
|
| --- | --- | --- |
|
|
750
750
|
| **Invisible to reads** | every dialect | immediately, the instant it passes |
|
|
751
|
-
| **Physically deleted** |
|
|
751
|
+
| **Physically deleted** | every dialect | eventually, by the retention sweep |
|
|
752
752
|
|
|
753
|
-
So an expired row is **invisible
|
|
753
|
+
So an expired row is **invisible immediately and still present in the database for a while**. That is the right trade — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB — but it matters: *do not treat an expired row as unreachable*. If the value must actually be gone, delete it, or do not store it in a row at all.
|
|
754
|
+
|
|
755
|
+
The second row of that table used to say **postgres only**, and it was accurate: the sweep was registered behind a `dialect !== 'postgres'` early return, so on mariadb, mysql, mssql and sqlite nothing was ever deleted and the boot printed no armed-policies line to say so. What is postgres-specific is the fast set-based DELETE, not the policy — the sweep falls back to a bounded read plus one set-based delete per batch elsewhere. Fixed in 0.33.0; on those dialects the first boot after upgrading will have a backlog to work through.
|
|
754
756
|
|
|
755
757
|
## See also
|
|
756
758
|
|
|
@@ -1168,13 +1168,38 @@ scaled. An operator's second pod died on `Connection timed out` for exactly
|
|
|
1168
1168
|
this. `voltro serve` now prints the number and the arithmetic at boot:
|
|
1169
1169
|
|
|
1170
1170
|
```
|
|
1171
|
-
db pool: max=10 per replica (DB_MAX_CONNECTIONS) × 4 replicas = up to
|
|
1171
|
+
db pool: max=10 per replica (DB_MAX_CONNECTIONS) + 1 = 11 × 4 replicas = up to 44 connections.
|
|
1172
|
+
Check that against your database's limit. PLUS 1 outside the pool (CDC LISTEN consumer) —
|
|
1173
|
+
those do not come out of the pool budget, they come out of the DATABASE's.
|
|
1172
1174
|
```
|
|
1173
1175
|
|
|
1174
1176
|
Set `REPLICA_COUNT` from your deployment (Helm: `{{ .Values.replicaCount }}`) and
|
|
1175
1177
|
the line does the multiplication for you; without it the line still names the
|
|
1176
1178
|
formula. `voltro dev` deliberately does not print it — one process, no replicas.
|
|
1177
1179
|
|
|
1180
|
+
**Some connections are not in the pool, and the count is per process.** A
|
|
1181
|
+
connection running `LISTEN` cannot be returned to a pool, so the driver opens a
|
|
1182
|
+
standalone one. There are three such places and a full deployment can hold all
|
|
1183
|
+
three:
|
|
1184
|
+
|
|
1185
|
+
| Process | Connection | When |
|
|
1186
|
+
|---|---|---|
|
|
1187
|
+
| api `voltro serve` | CDC `LISTEN` consumer | `changeStrategy: 'cdc'` (the default on postgres) |
|
|
1188
|
+
| web `voltro start` | ISR invalidator `LISTEN` | any page declares `cacheInvalidatesOn` |
|
|
1189
|
+
| web `voltro start` | postgres ISR cache client | `SSR_CACHE=postgres` |
|
|
1190
|
+
|
|
1191
|
+
The third is not a `LISTEN` at all, which is why counting `LISTEN` rows in
|
|
1192
|
+
`pg_stat_activity` undercounts. Each process prints its own number in the boot
|
|
1193
|
+
line above — including `No connections outside the pool in this process` when
|
|
1194
|
+
there are none, so "counted, zero" is distinguishable from "not counted".
|
|
1195
|
+
|
|
1196
|
+
**And a rolling update needs the surge pod's connections too.** A budget sized
|
|
1197
|
+
for `replicaCount` is exactly full at steady state and short during every
|
|
1198
|
+
deploy: `maxSurge` adds a pod that opens a full pool of its own. If that pod
|
|
1199
|
+
cannot connect it never becomes ready, so the rollout does not complete and the
|
|
1200
|
+
cluster stays at the higher pod count — the deploy cannot free itself. Size for
|
|
1201
|
+
`(replicaCount + maxSurge) × (DB_MAX_CONNECTIONS + out-of-pool)`.
|
|
1202
|
+
|
|
1178
1203
|
**`POD_IP` is each replica's identity, not only a workflow setting.**
|
|
1179
1204
|
|
|
1180
1205
|
```yaml
|
|
@@ -1203,6 +1228,67 @@ operator's own guidance, and better than ours was: a value taken from the
|
|
|
1203
1228
|
release namespace cannot be left out of one environment's config file, because
|
|
1204
1229
|
there is no file to forget.
|
|
1205
1230
|
|
|
1231
|
+
### The framework's own background pollers
|
|
1232
|
+
|
|
1233
|
+
Two framework tasks ride the coordinated scheduler and write a row into
|
|
1234
|
+
`_voltro_schedule_claims` on every tick they win: the workflow **admission
|
|
1235
|
+
drainer** and the offloaded-**inference dispatcher**.
|
|
1236
|
+
|
|
1237
|
+
**Where a peer replica's write is visible here, they do not poll at all.** Each
|
|
1238
|
+
runs one tick at startup — not optional; it is what finds work a previous
|
|
1239
|
+
process left behind — and then stops until something arrives. The wake comes
|
|
1240
|
+
from the change events their queue tables already emit, which is the same
|
|
1241
|
+
mechanism the rest of the framework's reactivity runs on. Measured against a
|
|
1242
|
+
real Postgres on a deployment that never uses either queue: **2 claim rows in
|
|
1243
|
+
five minutes**, one per task, both written at boot.
|
|
1244
|
+
|
|
1245
|
+
That "where" is the whole condition, and it is satisfied by Postgres
|
|
1246
|
+
LISTEN/NOTIFY or by a broadcast broker (Redis/NATS — which a multi-replica
|
|
1247
|
+
deployment already runs for cross-replica reactivity). Without either, a
|
|
1248
|
+
*remote* replica's enqueue produces no local event, so stopping would mean
|
|
1249
|
+
sleeping through it. There the tasks back off to a ceiling instead:
|
|
1250
|
+
|
|
1251
|
+
```sh
|
|
1252
|
+
VOLTRO_POLL_CEILING_MS=30000 # how long an arrival can wait when NOTHING woke the task
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
Nothing is lost in that case either — the replica that enqueued always sees its
|
|
1256
|
+
own write inline and drains it itself. What the ceiling covers is the narrower
|
|
1257
|
+
case of a *crashed* writer's lease being reclaimed by someone else.
|
|
1258
|
+
|
|
1259
|
+
A number worth knowing before you tune anything: on a two-replica deployment
|
|
1260
|
+
that had never enqueued into either queue, these two tasks accounted for **99.3 %
|
|
1261
|
+
of the claim ledger** — 2 506 rows an hour against 18 from the app's own eight
|
|
1262
|
+
schedules. A fixed interval has no way to learn a queue is empty. That is what
|
|
1263
|
+
changed; the ceiling is the fallback, not the fix.
|
|
1264
|
+
|
|
1265
|
+
### Tuning the cadence
|
|
1266
|
+
|
|
1267
|
+
The intervals are declarable, with defaults most apps never change:
|
|
1268
|
+
|
|
1269
|
+
```ts
|
|
1270
|
+
// app.config.ts
|
|
1271
|
+
export default {
|
|
1272
|
+
scheduling: {
|
|
1273
|
+
admissionDrainMs: 1000, // workflow admission drainer
|
|
1274
|
+
inferenceTickMs: 250, // offloaded-inference dispatcher
|
|
1275
|
+
cancelSweepMs: 2000, // cancelOn sweep
|
|
1276
|
+
pollCeilingMs: 30000, // idle ceiling, where nothing can wake a task
|
|
1277
|
+
},
|
|
1278
|
+
}
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
Each has a matching env var — `VOLTRO_ADMISSION_DRAIN_MS`,
|
|
1282
|
+
`VOLTRO_INFERENCE_TICK_MS`, `VOLTRO_CANCEL_SWEEP_MS`, `VOLTRO_POLL_CEILING_MS` —
|
|
1283
|
+
which **overrides** the config field, the same way `VOLTRO_TENANT_ISOLATION`
|
|
1284
|
+
overrides `tenancy.isolation`. The config is what a project declares; the env var
|
|
1285
|
+
is what an operator changes on a running deployment without a rebuild.
|
|
1286
|
+
|
|
1287
|
+
Lowering an interval does **not** make anything more responsive: an arrival
|
|
1288
|
+
already wakes the task at once. It only bounds the case where nothing announced
|
|
1289
|
+
the work. A value of `0` or a non-number is ignored rather than honoured — a
|
|
1290
|
+
zero interval would turn an idle task into a spin.
|
|
1291
|
+
|
|
1206
1292
|
### Workflow failover across replicas
|
|
1207
1293
|
|
|
1208
1294
|
On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:
|
|
@@ -967,6 +967,56 @@ framework's ALREADY-OPEN handles so it never rebuilds them:
|
|
|
967
967
|
one process needs no fan-out dedup). Returns a handle whose `stop()`
|
|
968
968
|
cancels the task; the framework also stops every armed task at shutdown.
|
|
969
969
|
|
|
970
|
+
Each tick claims one row in `_voltro_schedule_claims`, so the interval you
|
|
971
|
+
pick is also a write rate: `250` is four rows per second, per task, fleet-wide.
|
|
972
|
+
|
|
973
|
+
**The interval is a floor, not a cadence — if you report idleness.** Return
|
|
974
|
+
`{ idle: true }` from the effect on a tick that found nothing to do, and the
|
|
975
|
+
runner backs off toward a 30 s ceiling (`VOLTRO_POLL_CEILING_MS`) instead of
|
|
976
|
+
ticking at your interval forever. Return nothing and the tick keeps its fixed
|
|
977
|
+
interval, which is the safe default for a task that cannot tell.
|
|
978
|
+
|
|
979
|
+
**Or stop it entirely.** Pass `{ disarmWhenIdle: true }` as a fourth argument
|
|
980
|
+
and an idle tick with nothing pending stops the timer altogether — the task
|
|
981
|
+
runs once at startup and then only when `wake()` says so. That is the
|
|
982
|
+
difference between a cheaper poller and no poller, and it is what the
|
|
983
|
+
framework's own two background tasks do.
|
|
984
|
+
|
|
985
|
+
Only pass it when an arrival is **guaranteed** to call `wake()`. That is a
|
|
986
|
+
claim about the deployment, not the task: with Postgres LISTEN/NOTIFY or a
|
|
987
|
+
broadcast broker every replica sees every write, so it holds. Without either,
|
|
988
|
+
a peer replica's enqueue produces no local event and a disarmed task would
|
|
989
|
+
sleep through it — the backoff ceiling is the correct choice there.
|
|
990
|
+
|
|
991
|
+
Two more fields make that affordable rather than a latency tax:
|
|
992
|
+
|
|
993
|
+
- `handle.wake()` runs a tick **now**. Wire it to whatever announces work —
|
|
994
|
+
a `store.onChange` on the queue table your plugin drains — and the work
|
|
995
|
+
starts on arrival rather than up to one interval later. Calls are coalesced
|
|
996
|
+
to at most one extra tick per interval, so calling it per row is fine.
|
|
997
|
+
- `{ idle: true, nextDueInMs }` caps the backoff. Return it when the task is
|
|
998
|
+
idle right now but already knows something is coming (a window closing, a
|
|
999
|
+
lease expiring); the next tick lands on that instant instead of on the
|
|
1000
|
+
ceiling.
|
|
1001
|
+
|
|
1002
|
+
```ts
|
|
1003
|
+
const outbox = ctx?.scheduleCoordinated('vendor.outbox', 1_000, async () => {
|
|
1004
|
+
const sent = await drainOutbox()
|
|
1005
|
+
return { idle: sent === 0 }
|
|
1006
|
+
}, { disarmWhenIdle: true })
|
|
1007
|
+
|
|
1008
|
+
store.onChange((event) => {
|
|
1009
|
+
if (event.table === 'vendor_outbox' && event.op === 'insert') outbox?.wake()
|
|
1010
|
+
})
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
Why this exists: two framework tasks polled permanently-empty queues on a
|
|
1014
|
+
consumer's deployment and wrote **2 506 claim rows an hour** between them,
|
|
1015
|
+
against 18 from the app's own eight schedules. A fixed interval has no way to
|
|
1016
|
+
learn that a queue is empty. Without a `wake()` source the ceiling is your
|
|
1017
|
+
worst-case latency, so a task whose arrivals cannot announce themselves should
|
|
1018
|
+
keep reporting nothing.
|
|
1019
|
+
|
|
970
1020
|
```ts
|
|
971
1021
|
import { definePlugin } from '@voltro/protocol'
|
|
972
1022
|
|
|
@@ -346,7 +346,10 @@ replica B ─┼─ same bucket, same key ─┤
|
|
|
346
346
|
replica C ─┘ └─ PK conflict → stands down (no run row)
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
-
- **Self-expiring.** The key includes the firing instant, so a crashed winner doesn't block the next firing — the next firing instant is a new key.
|
|
349
|
+
- **Self-expiring.** The key includes the firing instant, so a crashed winner doesn't block the next firing — the next firing instant is a new key.
|
|
350
|
+
- **A claim does not outlive its bucket.** When a replica wins a claim it deletes that schedule's own older rows in the same pass, so the table's steady-state size is a small multiple of the number of schedules rather than a function of uptime. How far back it prunes scales with how far apart that caller's buckets are — a cron keeps roughly an hour of predecessors, a 250 ms coordinated task about a minute. The grace exists because deleting a claim too early is a **double fire**: a replica that is running late must still find the row that says its bucket was taken.
|
|
351
|
+
- **Swept, on the scale it fills — on every dialect.** `_voltro_schedule_claims` gets a retention policy on both boot paths: rows older than **24 hours** by `claimedAt` are deleted, tunable with `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS`. The announcement at boot names it (`retention: N policy(ies) armed`). 24 h rather than the 30 days the framework's *history* tables get, because this is a lock ledger — a claim row answers a question about one firing instant, and nothing reads yesterday's. One row per (schedule, second-bucket) adds up faster than people expect: a deployment with a handful of sub-minute schedules measured 1 557 rows/hour, which a 30-day window would have let reach a million before the first row aged out. If you raise it, the number to reason about is the longest a replica may be paused and still be trusted not to re-fire a bucket it already lost. The sweep is the **backstop**, not the main bound — it is what cleans up after a schedule you renamed or deleted, which the per-schedule prune above can never revisit. (Through 0.32.0 the whole sweep was registered behind a postgres-only gate, so on the other four dialects it never ran and the boot said nothing about it. Fixed in 0.33.0.)
|
|
352
|
+
- **Looked up by primary key.** The existence check the coordinator runs before its `INSERT` is a single-row read on `id`, which *is* the claim key — so the table's size does not enter the firing path.
|
|
350
353
|
- **Pool-safe.** Unlike a session-level `pg_advisory_lock` (tied to a connection a pool may reassign), a claims row is durable and connection-independent.
|
|
351
354
|
- **Fail-closed.** If the claims table is unreachable, the coordinator logs a warning and declines to fire rather than risk a double-fire. A missing run is recoverable via [backfill](/docs/scheduling/overlap-and-backfill); a double-fire (two charge emails) often isn't.
|
|
352
355
|
|
|
@@ -95,7 +95,7 @@ voltro db scan-credentials
|
|
|
95
95
|
voltro db scan-credentials --table my_events:actor
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
Counts rows
|
|
98
|
+
Counts rows in which a credential-shaped **name** appears anywhere in the stored value — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — plus any table you name with `--table <name>[:<column>]`. Exit code `1` on an unexplained hit, so CI can gate on it.
|
|
99
99
|
|
|
100
100
|
By default it scans every column that can physically HOLD one:
|
|
101
101
|
|
|
@@ -109,12 +109,31 @@ By default it scans every column that can physically HOLD one:
|
|
|
109
109
|
Every hit line names the needles that matched, with the number of rows each appears in:
|
|
110
110
|
|
|
111
111
|
```
|
|
112
|
-
✗ _voltro_audit_log.outcome — 69 of 149 row(s)
|
|
112
|
+
✗ _voltro_audit_log.outcome — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
|
|
113
113
|
matched (rows per needle, may overlap): token (61), secret (12)
|
|
114
|
+
examined 69 of 69 matched row(s): 61 as a JSON KEY, 8 only inside a redaction marker
|
|
114
115
|
```
|
|
115
116
|
|
|
116
117
|
**Those counts overlap and do not sum to the hit count** — a row holding both a token and a secret is counted by both needles. Without the breakdown the advice underneath (*purge them AND rotate the credentials*) is neither executable — purge what, rotate which — nor refutable: a column mentioning the word `token` in prose reads identically to one holding a live one.
|
|
117
118
|
|
|
119
|
+
### Hits are explained, not just counted
|
|
120
|
+
|
|
121
|
+
The predicate is a substring match over the whole serialized column, so a match is a match on a *name*, wherever it sits. The third line above is a bounded second pass that reads the matched rows back and says what actually matched — a JSON **key**, or only a **redaction marker**. Values are never printed and never logged.
|
|
122
|
+
|
|
123
|
+
A redaction marker is the framework's own record that a credential was deliberately *not* stored: `_omitted` (`@voltro/plugin-versioning`, the column names left out of a row snapshot) and `__redacted` (`@voltro/plugin-audit`). A target whose every matched row is one of those is reported as explained, and does **not** fail CI:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
~ _voltro_row_history.data — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
|
|
127
|
+
matched (rows per needle, may overlap): token (69)
|
|
128
|
+
examined 69 of 69 matched row(s): 0 as a JSON KEY, 69 only inside a redaction marker
|
|
129
|
+
EXPLAINED — every match is `_omitted` / `__redacted`, the framework's own
|
|
130
|
+
record that a `.sensitive()` column was left OUT. Nothing to purge, nothing to rotate.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The bar for that is deliberately high: **every** matched row examined, **every** one of them a marker and nothing else. A target where the read-back was capped (at 500 rows), or where one row is a real key, or where one row is not parseable as JSON, stays a finding and still exits `1`. A partially-explained target is still a target.
|
|
134
|
+
|
|
135
|
+
> **Why this exists.** A team with `.serverOnly().sensitive('secret')` columns and correctly-redacting plugins got 69 rows flagged, with *purge them AND rotate the credentials* underneath, against the framework's own proof that nothing was stored. The shape mattered more than the one key name: the more columns an app classifies correctly, the more markers it writes, and the redder the scan turns. A false-positive rate that rises with the care you take gets the tool muted — and a muted scan is worth less than none, because its silence still reads as evidence.
|
|
136
|
+
|
|
118
137
|
> **If you ran this on 0.30.0, 0.30.1 or 0.30.2, run it again.** Those releases scanned `subjectId` on both tables — a flat opaque id that cannot hold a credential — and none of the blob columns above. The command ran cleanly, printed a scanned count beside a hit count, and exited `0` having never looked where credentials are. It came from fixing a crash: the default column had been the literal `subject` for every table, `_voltro_row_history` has no such column, and the fix replaced the name on *both* tables instead of the one that was wrong. A clean answer from a scan that looked in the wrong place is worse than the crash it replaced. `voltro update` prints this as a manual step on the way to 0.31.0.
|
|
119
138
|
|
|
120
139
|
**It is a command and not a documented query on purpose.** The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (`subject::text ILIKE '%token%'`). On MySQL/MariaDB the natural translation is a bare `LIKE` — and against the `utf8mb4_bin` collation the migrator emits for a `json()` column, `LIKE` is case-**sensitive**. So `'%token%'` does not match `jiraToken`, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got `0`, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.
|
|
@@ -125,6 +144,8 @@ Run it on every environment. A development database is not a sample of productio
|
|
|
125
144
|
|
|
126
145
|
If it finds something: purge the rows **and** rotate the credentials — assume anything written to a log table has been read — then move the credential off the Subject entirely with `connectionCredentials(...)`, which keeps it in the framework vault.
|
|
127
146
|
|
|
147
|
+
If the finding is a credential column of your own sitting in plaintext, `.encrypted()` only protects rows written *after* you add it — [`voltro db encrypt-column`](/docs/database/sensitivity) converts the ones already there.
|
|
148
|
+
|
|
128
149
|
## Supply-chain assurance
|
|
129
150
|
|
|
130
151
|
Every release passes automated supply-chain gates in CI before a single package is published:
|