@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -29
  3. package/dist/apiBuild-C-x9YacA.js +2 -0
  4. package/dist/{apiBuild-C4uEbs3s.js → apiBuild-h9VHtnlw.js} +2 -2
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-DRovTKza.js +2 -0
  7. package/dist/{checkCommand-dm7OHtPt.js → checkCommand-xGhRFFg2.js} +15 -15
  8. package/dist/{commands-C0nEePif.js → commands-CJfepbm4.js} +97 -13
  9. package/dist/{dbCommand-ifOMafuG.js → dbCommand-DTLKAfbA.js} +401 -241
  10. package/dist/dbCommand-b1gum4td.js +2 -0
  11. package/dist/{dev-5ficNnvF.js → dev-C_P8FLSx.js} +1539 -1487
  12. package/dist/{dev-rc3fwPSZ.js → dev-iiMtlkfs.js} +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-BqO4E9G0.js → inspectMetrics-1xzTKAFx.js} +573 -550
  15. package/dist/{sdkgen-B_5mHQS2.js → sdkgen-C81QIkiL.js} +1 -1
  16. package/dist/serveCommand-DdaM4Hup.js +1608 -0
  17. package/dist/serveEntry.js +2 -2
  18. package/dist/{start-D1-8eKrO.js → start-C0koT0UO.js} +1 -1
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +31 -18
  21. package/templates/AGENTS.md +1 -1
  22. package/templates/agent-docs/_index.md +1 -1
  23. package/templates/agent-docs/database/misc.md +37 -0
  24. package/templates/agent-docs/database/schema.md +4 -2
  25. package/templates/agent-docs/deployment.md +87 -1
  26. package/templates/agent-docs/plugins.md +50 -0
  27. package/templates/agent-docs/scheduling.md +4 -1
  28. package/templates/agent-docs/security.md +23 -2
  29. package/templates/agent-docs/whats-new.md +128 -86
  30. package/templates/apps/api-ai/package.json +7 -7
  31. package/templates/apps/api-auth/package.json +8 -8
  32. package/templates/apps/api-backend/package.json +7 -7
  33. package/templates/apps/api-backend-deactivation/package.json +7 -7
  34. package/templates/apps/api-backend-mail/package.json +8 -8
  35. package/templates/apps/api-backend-mariadb/package.json +9 -9
  36. package/templates/apps/api-backend-sqlite/package.json +8 -8
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-cms/package.json +10 -10
  39. package/templates/apps/api-collab/package.json +8 -8
  40. package/templates/apps/api-data-advanced/package.json +8 -8
  41. package/templates/apps/api-durable/package.json +8 -8
  42. package/templates/apps/api-feature-flags/package.json +9 -9
  43. package/templates/apps/api-governance/package.json +8 -8
  44. package/templates/apps/api-kv/package.json +8 -8
  45. package/templates/apps/api-moderation/package.json +8 -8
  46. package/templates/apps/api-observability/package.json +8 -8
  47. package/templates/apps/api-ratelimit/package.json +8 -8
  48. package/templates/apps/api-rbac/package.json +8 -8
  49. package/templates/apps/api-rest/package.json +7 -7
  50. package/templates/apps/api-saas/package.json +11 -11
  51. package/templates/apps/api-saas-starter/package.json +10 -10
  52. package/templates/apps/api-search/package.json +8 -8
  53. package/templates/apps/api-status/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +9 -9
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-auth/package.json +8 -8
  61. package/templates/apps/frontend-blank/package.json +7 -7
  62. package/templates/apps/frontend-cms/package.json +9 -9
  63. package/templates/apps/frontend-collab/package.json +9 -9
  64. package/templates/apps/frontend-contact/package.json +7 -7
  65. package/templates/apps/frontend-dashboard/package.json +7 -7
  66. package/templates/apps/frontend-docs/package.json +7 -7
  67. package/templates/apps/frontend-i18n/package.json +6 -6
  68. package/templates/apps/frontend-landing/package.json +7 -7
  69. package/templates/apps/frontend-portal/package.json +8 -8
  70. package/templates/apps/frontend-saas/package.json +8 -8
  71. package/templates/apps/frontend-spa/package.json +7 -7
  72. package/templates/apps/frontend-ssr/package.json +7 -7
  73. package/templates/apps/frontend-ssr-api/package.json +8 -8
  74. package/templates/apps/frontend-static-blog/package.json +6 -6
  75. package/templates/apps/frontend-status/package.json +8 -8
  76. package/templates/apps/mobile-app/package.json +2 -2
  77. package/dist/apiBuild-OJEjtwcn.js +0 -2
  78. package/dist/checkCommand-CwMrzAgV.js +0 -2
  79. package/dist/dbCommand-By__Ev0R.js +0 -2
  80. package/dist/serveCommand-PxMmn96o.js +0 -1578
package/CHANGELOG.md CHANGED
@@ -39,6 +39,211 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.33.0] — 2026-08-11
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/runtime, @voltro/voltro** — `CoordinatedScheduleHandle` gained `wake()`, `currentIntervalMs()` and `isArmed()`.
47
+
48
+ The type change that carries the poller work in this release (see *A coordinated tick is a FLOOR*). Two of the three shapes it touches are NOT breaking and are listed here so the classification is checkable rather than asserted:
49
+
50
+ - the effect parameter was **widened** — it may now return a tick outcome, and an existing `() => Promise<void>` still satisfies it; - `Coordinator.tryClaim` gained an **optional** third parameter (the caller's bucket width), so an existing implementation still conforms.
51
+
52
+ (The plugin-facing `scheduleCoordinated` also gained an OPTIONAL fourth argument, `{ disarmWhenIdle }` — additive, and how a plugin opts its own task out of polling entirely.)
53
+
54
+ What breaks is code that **constructs** a handle rather than receiving one: a hand-written test double of `PluginBindContext`, which is the ordinary way to unit-test a plugin's `bindDataStore`. Four of the framework's own suites carried one, and three of those compiled only because the stub was cast — which is also why the two new members must be REQUIRED rather than optional. An optional `wake()` would let a caller subscribe a change channel to a handle that silently has none, and a poller that never wakes is the failure this release exists to remove, arriving quietly.
55
+
56
+ The codemod is `manual`: the object literal needing the two fields carries no importable symbol and usually sits behind an `as never`, so no transform can tell it apart from an unrelated literal in the same test file. It is gated on the app mentioning `scheduleCoordinated` at all.
57
+ - **@voltro/runtime** — `@effect/opentelemetry` is now an **optional peer** of `@voltro/runtime` instead of a dependency. **If you export traces or metrics, install it:**
58
+
59
+ ```sh
60
+ pnpm add @effect/opentelemetry
61
+ ```
62
+
63
+ If you do not (no `FRAMEWORK_TRACING`, no `FRAMEWORK_METRICS`, no `OTEL_EXPORTER_OTLP_*`), nothing changes and your install gets 24 lines quieter.
64
+
65
+ It is reached from one dynamic `import()`, only when tracing is on, and it declares seven non-optional OpenTelemetry peers of which we supply five. So every `pnpm install` of every consumer ended with an unmet-peer block describing a condition that broke nothing. Declaring the two missing peers as real dependencies was the wrong direction — one of them is `@opentelemetry/sdk-trace-web`, the BROWSER tracer — and 0.64.0 is the current stable, so there is no upstream release marking them optional to wait for.
66
+
67
+ The reporting consumer's argument is what decided it: *"a check that is loud on every upgrade teaches people to skip the output, and the next warning in that block is the one that matters. We read past this one for four releases."*
68
+
69
+ A boot with tracing enabled and the package absent fails with a message naming this install line — a startup failure, not a silent loss of telemetry.
70
+
71
+ **`voltro update` carries you across this** — codemod `0.33.0/01_opentelemetry-optional-peer`.
72
+
73
+ ### Added
74
+
75
+ - **@voltro/cli** — `voltro agents-md` now reports which `@voltro/cli` it seeded from, and warns when that is not the one the project installs.
76
+
77
+ ```
78
+ seeded from @voltro/cli 0.31.0 (project has 0.32.0; whats-new describes 0.31.0;
79
+ modules COPIED into ./agent-docs)
80
+ WARN the `voltro` binary that ran is 0.31.0, but this project installs 0.32.0 —
81
+ everything just written describes the OLDER version.
82
+ ```
83
+
84
+ A consumer reported a freshly-seeded `agent-docs/whats-new.md` one release behind their installed version, twice. The published packages are correct (verified with `npm pack`), so the content came from a different `@voltro/cli` than the one they installed — the command reads its templates relative to the RUNNING binary, and a globally-installed `voltro`, a stale `dist`, or a parent workspace's copy all produce exactly that, with output that looked identical either way.
85
+
86
+ It does not refuse and does not pick a cli for you: running the workspace binary against a checkout is legitimate and common.
87
+ - **@voltro/cli** — `voltro db encrypt-column <table>.<column>` — the data migration `.encrypted()` always needed.
88
+
89
+ `.encrypted()` encrypts on WRITE, so adding it to a populated column converts nothing that is already there, and there was no supported way to convert it. A consumer carried three plaintext credential columns for months with no next step: *"`.encrypted()` braucht einen Cipher UND eine Datenmigration der bestehenden Zeilen; gemeldet, nicht behoben."*
90
+
91
+ ```sh
92
+ voltro db encrypt-column integrations.webhookSecret --dry-run
93
+ voltro db encrypt-column integrations.webhookSecret employees.meilisearchKey --yes
94
+ ```
95
+
96
+ Five guards, each for a way a naive version succeeds and destroys data:
97
+
98
+ - **Idempotent** — an already-ciphertext value is skipped, so an interrupted run is resumed by running it again. Double encryption is unrecoverable without the key history. - **Round-trip verified before the write** — every value is decrypted back in-process first, so a broken cipher fails with nothing written. - **Key checked against what the column already holds** — a *different* key round-trips fine, so the check above cannot see it. Resuming with the wrong key would leave a column readable with neither key alone. - **Width pre-flight** — ciphertext is `49 + 4×ceil(bytes/3)` characters, so a 64-char key needs 137 and a `varchar(100)` fails partway. Refuses with both numbers and the `.maxLength()` to set. Measured in BYTES: `'ä'.repeat(10)` is 10 characters and 20 bytes. - **`--yes` required**, `--dry-run` shows the counts, and no value — plaintext or ciphertext — is ever printed.
99
+
100
+ Verified against a real postgres: the conversion, the re-run no-op, both refusals writing nothing, and a decrypt back to the original including multi-byte content.
101
+
102
+ ### Changed
103
+
104
+ - **@voltro/runtime** — `_voltro_schedule_claims` swaps its `(scheduleName, bucket)` index for `(scheduleName, claimedAt)`.
105
+
106
+ A consumer read `pg_stat_user_indexes` on their live table and measured, over its whole lifetime:
107
+
108
+ ```
109
+ _voltro_schedule_claims_pkey 348 978 scans
110
+ _voltro_schedule_claims_claimedAt_idx 3 949
111
+ _voltro_schedule_claims_scheduleName_bucket_idx 4
112
+ ```
113
+
114
+ Four. It was declared "for the case where you would rather ask by field", and nothing ever asks by field — every read of this table goes through the primary key, which *is* `<scheduleName>@<bucket>`. An index nothing uses is not free: it is written on every INSERT, into a table written once per tick per schedule.
115
+
116
+ `(scheduleName, claimedAt)` is the shape of a query that now exists — the per-schedule prune a winning claim runs (`WHERE scheduleName = ? AND claimedAt < ?`). The `claimedAt` index stays: the retention sweep's cutoff spans every schedule and needs it leading, which the composite cannot provide.
117
+
118
+ No codemod: a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
119
+
120
+ The same measurement corrected something the reporter had said in an earlier round and we had repeated back to them — that both indexes went unused. The primary key is used constantly. That makes the finding sharper rather than weaker: the ability to answer this question in one lookup is not merely available, it is demonstrably in use on the same table, and the one read path that needed it was the one not taking it.
121
+
122
+ ### Fixed
123
+
124
+ - **@voltro/database** — A column ADDED with a `reference()` now gets its foreign key in the same plan.
125
+
126
+ `ADD COLUMN` emits no `REFERENCES` clause on any dialect, and the planner's FK branch lived only in the path for a column present on both sides — so adding a `reference()` column to an existing table planned an `add-column` and nothing else. The constraint appeared on the SECOND `voltro db apply`, when the column was live and the diff finally saw a live column with no FK.
127
+
128
+ Two applies converged, so the state was reachable, which is why this survived as a low-priority note for a long time. It is worse under `voltro dev`: the boot diff refuses to record a fingerprint while the re-plan is non-empty, so an app whose only pending change was such a column re-planned on every boot and never converged.
129
+
130
+ Both callers share one `addForeignKeyOps` builder now, and the existing dependency tiering already orders `add-column` before `add-foreign-key`.
131
+ - **@voltro/runtime, @voltro/protocol, @voltro/workflow, @voltro/cli** — A coordinated tick is a FLOOR now, and a claim no longer outlives its bucket.
132
+
133
+ A consumer's `_voltro_schedule_claims` reached **86 214 rows / 33 MB** on two days of uptime and took their deployment down: ten of a fifteen-slot pooler pinned on the claim read, an SSR render measured at **300 490 ms** behind them, every page in three frontends unusable, and a `rollout restart` that could not complete because the surge pod could not get a connection. Two hours of their own measurement produced the diagnosis, and both halves of it were right.
134
+
135
+ **Where the rows came from.** They declare one workflow, have never started it, use no flow control and no offloaded inference. Over one hour, with two replicas:
136
+
137
+ ```
138
+ voltro.ai.inference 1 259 rows/h (250 ms ticks) framework
139
+ voltro.workflow.admission 1 247 rows/h (1 s ticks) framework
140
+ their own eight schedules 18 rows/h
141
+ ```
142
+
143
+ 99.3 % of the ledger was the framework polling two structurally empty queues. A fixed interval has no way to learn that, so:
144
+
145
+ - **`scheduleCoordinated`'s effect may now REPORT its tick.** Return `{ idle: true }` and the runner backs off toward a ceiling; return `{ idle: true, nextDueInMs }` and it arms for that instant instead — which is what keeps a `debounce` window from being slept through. Returning nothing keeps the fixed interval, so every existing plugin task ticks exactly as before. - **Where an arrival is guaranteed to wake it, an idle task STOPS ENTIRELY** (`{ disarmWhenIdle: true }`). Both framework tasks do, on any deployment where a peer replica's write is visible locally — Postgres LISTEN/NOTIFY, or a broadcast broker. Measured against a real Postgres on a deployment that uses neither queue: **2 claim rows in five minutes**, one per task, both at boot. Where that guarantee does not hold, the ceiling (`VOLTRO_POLL_CEILING_MS`, default 30 s) is the correct behaviour and is what they get. - **`handle.wake()` runs a tick now.** Both framework queues are tables with the framework's own CDC triggers on them, so an enqueue already produces a change event on every replica; both dispatchers subscribe to it. The idle case gets ~120× cheaper and the busy case gets FASTER — work starts on the INSERT rather than up to a tick later. - The claim bucket stays floored by the BASE interval. Replicas do not share a backoff state, and two replicas computing different keys for one moment would both win.
146
+
147
+ **The cadence is declarable.** `scheduling: { admissionDrainMs, inferenceTickMs, cancelSweepMs, pollCeilingMs }` in `app.config.ts`, each with a matching `VOLTRO_*` env var that overrides it — the same ordering as `VOLTRO_TENANT_ISOLATION` over `tenancy.isolation`. They were internal constants, and a number the framework picks on a user's behalf belongs somewhere they can read it without reading our source. One resolver, called by both boot paths, so there is no second default to drift.
148
+
149
+ **Why the rows never left.** A claim answers one question about one bucket and was already answered the moment the bucket passed. A winning claim now deletes that schedule's own predecessors, so the table's size is a small multiple of the number of schedules rather than a function of uptime. How far back it prunes scales with the caller's bucket width — a cron keeps ~68 minutes of them (its firings carry their own instant, so a stalled one can re-present an old bucket), a 250 ms task ~1 minute (it recomputes its bucket at tick time, so an old one is unreachable). Deleting too early is a double fire; that grace is the whole safety argument. The 24-hour retention sweep stays as the backstop for a schedule that was renamed or deleted, which the per-schedule prune can never revisit.
150
+
151
+ Where reactivity is absent — a non-Postgres dialect with no broadcast broker — a remote replica's enqueue produces no local event and the ceiling is the whole latency budget. `VOLTRO_POLL_CEILING_MS` is there for that case and documented as such.
152
+ - **@voltro/logger** — The pretty log format now prints a nested `Error`'s `message`. It did not, and the JSON format did.
153
+
154
+ `Error.prototype.message` is non-enumerable, so `JSON.stringify(err)` emits the metadata and drops the message. `expandCauseForJson` has existed for a long time to solve exactly that — and it was wired into `jsonFormat` only. The section heading above it said "(JSON path)", which was literally accurate.
155
+
156
+ `voltro dev` prints the pretty format. What a consumer saw when their boot died on a saturated pooler:
157
+
158
+ ```
159
+ auto-migrate failed — aborting boot
160
+ err={"failure":{"cause":{"length":117,…,"code":"XX000"},"message":"PgClient: Failed to connect"}}
161
+ ```
162
+
163
+ `length: 117` is the length of a message that is not there. Recovered by hand, it was `(EMAXCONNSESSION) max clients reached in session mode - max clients are limited to pool_size: 15` — the whole diagnosis in one sentence, naming the fix.
164
+
165
+ Both the field tail and the plain-object cause branch expand now. The fix is in the formatter, not at the reporting call site: every `log.error('…', { err })` anywhere had the same hole.
166
+ - **@voltro/cli** — Every `@voltro/*` package now exports its own `package.json`, so `require('@voltro/cli/package.json').version` works.
167
+
168
+ It threw. Node has enforced this since 12: a package with an `exports` field exposes only what that field lists, and none of the 77 packages listed `"./package.json"`.
169
+
170
+ Reported by a consumer for whom it was the instruction WE gave for settling whether a security command had been running on stale code — so the verification step for a security question could not run at all. Both the workspace `exports` and the shipped `publishConfig.exports` are fixed, and a guard sweeps every package so a new one cannot ship without it.
171
+ - **@voltro/cli, @voltro/sql-postgres** — The `db pool:` boot line now counts the connections this process holds OUTSIDE the pool, and names them.
172
+
173
+ It reported `max × replicas` and called that the connection count. A consumer sizing a per-pod budget against a pooler measured the gap:
174
+
175
+ > `LISTEN` läuft außerhalb von `dbMaxConnections` (eine pro Pod, gemessen sogar > 3). Der echte Bedarf ist `dbMaxConnections + 1`.
176
+
177
+ Their measurement was right and their conclusion was one short. The framework opens a standalone connection in three places, and a full deployment holds all three:
178
+
179
+ | Process | Connection | When | |---|---|---| | api `voltro serve` | CDC `LISTEN` consumer | `changeStrategy: 'cdc'` | | web `voltro start` | ISR invalidator `LISTEN` | a page declares `cacheInvalidatesOn` | | web `voltro start` | postgres ISR cache client | `SSR_CACHE=postgres` |
180
+
181
+ The third is not a `LISTEN`, which is why counting `LISTEN` rows in `pg_stat_activity` undercounts, and why `+1` could not have been documented as a constant: the count is per PROCESS and only the process knows what it armed.
182
+
183
+ The line says `No connections outside the pool in this process` when there are none — silence about it is what made "counted, zero" indistinguishable from "not counted". The `maxConnections` docstring, which promised `+1` as if it were the deployment's number, is corrected. Production-hardening docs (both languages) gain the table plus the `maxSurge` arithmetic a rolling update needs.
184
+ - **@voltro/cli** — The retention sweep is registered on every dialect — it was postgres-only, and silently.
185
+
186
+ `wireRetentionSweep` opened with `if (dialect !== 'postgres') return`, so on mariadb, mysql, mssql and sqlite **none of its seven policies was registered, nothing was ever deleted, and the boot printed no armed-policies line** — so there was nothing to notice either. A consumer on MariaDB 11.8.8 measured it by reading the published bundle rather than their logs:
187
+
188
+ ```
189
+ _voltro_schedule_claims 109 520 rows 32.8 MB over 20 days
190
+ _voltro_schedule_runs 17 507 rows 7.4 MB
191
+ ```
192
+
193
+ Their seven `VOLTRO_*_TTL_HOURS` variables were inert — read only inside the branch that never ran — and two of them were already set in their Helm chart.
194
+
195
+ **The gate was aimed at the right thing and applied to the wrong scope.** What is postgres-specific is the fast DELETE (`"camelCase"` quoting, `DELETE … RETURNING`), which is one branch of one function that has always had a portable fallback beside it. Gating the REGISTRATION on it turned a performance choice into a feature that does not exist. The dialect check now sits on the branch it describes.
196
+
197
+ Two things came out with it:
198
+
199
+ - **The fallback deleted row by row.** Acceptable while the path was unreachable; against the reporter's backlog it is 20 000 round trips per sweep pass. It reads a bounded batch of ids and issues ONE set-based delete for them — still bounded, so the DELETE never grows to lock the whole backlog. - **Two tests asserted the defect as intended behaviour**, with reasoning that was internally consistent and rested on the premise that was itself the bug (*"the sweep is postgres-only, and announcing a delete that will not happen is the mirror image of the defect"*). Both are inverted now and run across all five dialects.
200
+
201
+ This is the third turn of the same screw, and the reporter's framing is the one to keep: we fixed *a standing delete that never introduces itself*, then shipped *one that introduces itself and does not run* — and beside both of those sat one that silently did not exist.
202
+ - **@voltro/cli** — `voltro db scan-credentials` no longer reports the framework's own redaction markers as credentials, and no longer claims a match was a *key*.
203
+
204
+ A consumer with correctly-redacting plugins got:
205
+
206
+ ```
207
+ ✗ _voltro_row_history.data — 69 of 149 row(s) match a credential-shaped key
208
+ matched (rows per needle, may overlap): token (69)
209
+ ```
210
+
211
+ All 69 rows were `"_omitted": ["token"]` — `@voltro/plugin-versioning`'s record that a `.serverOnly().sensitive('secret')` column was deliberately left OUT of the snapshot. The scan matched the proof that nothing is stored there, called it a credential, and printed *purge them AND rotate the credentials* underneath.
212
+
213
+ Two changes. The headline says what the predicate does — it is a substring match over the whole serialized column, so it finds a credential-shaped **name** anywhere in the value, which it always did. And each hit is now EXPLAINED: a bounded second pass (500 matched rows per target) reads them back in-process and separates a JSON **key** from a **redaction marker** (`_omitted`, `__redacted`). Values are never printed and never logged.
214
+
215
+ A target whose every matched row is a marker reports as explained and exits `0`. The bar is deliberately high — every matched row examined, every one a marker and nothing else. A capped read-back, one real key, or one row that will not parse as JSON keeps the target a finding and still exits `1`.
216
+
217
+ 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 turned.
218
+ - **@voltro/runtime, @voltro/cli** — `advisoryLock` scheduling no longer reads the whole `_voltro_schedule_claims` table to answer whether one claim row exists, and that table is now swept on the scale it fills.
219
+
220
+ The existence check ran `SELECT "id" FROM "_voltro_schedule_claims"` with no `WHERE` and no `LIMIT`, then filtered in JavaScript — twice per claim attempt (the fast path, and the re-read that separates "lost the race" from "the claims table is broken"), on every replica, for every schedule firing. A consumer measured ten concurrent copies of that scan holding every connection of a 15-slot pooler, with an SSR render behind them at **300 490 ms**. It is a primary-key lookup bounded to one row now (`id` *is* the claim key).
221
+
222
+ The pool-acquire bound added in 0.32.0 turns that from a hang into an error; it does not stop the scan from filling the pool. Both are needed.
223
+
224
+ `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS` also defaults to **24 hours** instead of 30 days. The 30-day default was copied from the framework's history tables (`_voltro_schedule_runs` and friends), and a claim row is a lock ledger — it answers a question about one firing instant and nothing reads yesterday's. At the 1 557 rows/hour that consumer measured, a 30-day window reaches ~1.1 million rows before the first one ages out. Raise it deliberately if you need to; 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.
225
+
226
+ The boot announcement can now express an age under a day (`older than 1h`); it previously rounded every TTL to whole days, so an operator setting one hour read their own policy back as `older than 0d`.
227
+ - **@voltro/runtime** — A coordinated periodic task armed below one second now runs at the interval it was given.
228
+
229
+ `scheduleCoordinated` floors the wall clock to its `intervalMs` and races on that instant; the claim key truncated it to second precision. A task at 250 ms therefore produced four bucket instants per second that collapsed to one key — the first tick won and the other three were dropped as "lost the claim". Measured: 1 of 4.
230
+
231
+ `voltro.ai.inference` is armed at 250 ms and was dispatching once per second, on every multi-replica deployment, with nothing above `warn` to say so.
232
+
233
+ This is the defect the coordinator's own comment describes at minute precision (6-field crons firing once a minute), one decimal place down; that comment was written before `scheduleCoordinated` existed, and `scheduleCoordinated` is the caller that goes below a second.
234
+
235
+ Milliseconds join the claim key only when non-zero, so every cron key is byte-identical to before — load-bearing during a rolling deploy, where old and new replicas computing different keys for one firing would both win and double-fire.
236
+
237
+ Note the consequence for table size: a sub-second task now writes claim rows at its true rate. Bounded by `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS` (24 h), and `ai.tickIntervalMs` raises the interval if you want fewer.
238
+
239
+ ### Internal (no consumer-facing effect)
240
+
241
+ - **@voltro/cli** — No separate consumer-facing note on purpose: this refines the per-needle breakdown described in the UNRELEASED 0.32.0 section, and that section — which is what a reader will actually see — carries the correction. Documenting it twice would describe one change as two.
242
+
243
+ The refinement: the per-needle counts OVERLAP and do not sum to the hit count (a row holding both a token and a secret is counted by both). The output line says so now, because two numbers printed under a total invite being added up, and a reader who adds them and gets more than the total loses confidence in the whole report.
244
+
245
+ ---
246
+
42
247
  ## [0.32.0] — 2026-08-10
43
248
 
44
249
  ### ⚠ BREAKING
@@ -5,7 +5,7 @@ property of its respective copyright holders and is used under the terms of
5
5
  its license. This file is provided for attribution; it grants no rights in
6
6
  @voltro/cli itself, which is proprietary (see LICENSE).
7
7
 
8
- Generated from the resolved runtime dependency closure (229 packages).
8
+ Generated from the resolved runtime dependency closure (228 packages).
9
9
 
10
10
  ---
11
11
 
@@ -4014,34 +4014,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
4014
4014
  SOFTWARE.
4015
4015
  ```
4016
4016
 
4017
- ## @effect/opentelemetry@0.64.0
4018
-
4019
- License: MIT
4020
-
4021
- ```
4022
- MIT License
4023
-
4024
- Copyright (c) 2020-present The Contributors
4025
-
4026
- Permission is hereby granted, free of charge, to any person obtaining a copy
4027
- of this software and associated documentation files (the "Software"), to deal
4028
- in the Software without restriction, including without limitation the rights
4029
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
4030
- copies of the Software, and to permit persons to whom the Software is
4031
- furnished to do so, subject to the following conditions:
4032
-
4033
- The above copyright notice and this permission notice shall be included in all
4034
- copies or substantial portions of the Software.
4035
-
4036
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
4037
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
4038
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
4039
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
4040
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
4041
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
4042
- SOFTWARE.
4043
- ```
4044
-
4045
4017
  ## @effect/platform@0.97.0
4046
4018
 
4047
4019
  License: MIT
@@ -0,0 +1,2 @@
1
+ import { c as e, l as t } from "./apiBuild-h9VHtnlw.js";
2
+ export { e as runApiBuild, t as runServeBundleBuild };
@@ -1,6 +1,6 @@
1
- import { bt as e } from "./inspectMetrics-BqO4E9G0.js";
1
+ import { bt as e } from "./inspectMetrics-1xzTKAFx.js";
2
2
  import { a as t } from "./seedRunner-Bqxgp7HZ.js";
3
- import { h as n } from "./dev-5ficNnvF.js";
3
+ import { h as n } from "./dev-C_P8FLSx.js";
4
4
  import { dirname as r, isAbsolute as i, join as a, relative as o, resolve as s } from "node:path";
5
5
  import { fileURLToPath as c } from "node:url";
6
6
  import { createLogger as l } from "@voltro/logger";
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { ct as e } from "./inspectMetrics-BqO4E9G0.js";
3
- import { r as t } from "./commands-C0nEePif.js";
4
- import { l as n } from "./dbCommand-ifOMafuG.js";
2
+ import { ct as e } from "./inspectMetrics-1xzTKAFx.js";
3
+ import { r as t } from "./commands-CJfepbm4.js";
4
+ import { l as n } from "./dbCommand-DTLKAfbA.js";
5
5
  import { createLogger as r } from "@voltro/logger";
6
6
  //#region src/sqlErrorDetail.ts
7
7
  var i = (e) => typeof e == "object" && !!e, a = (e) => {
@@ -0,0 +1,2 @@
1
+ import { r as e, t } from "./checkCommand-xGhRFFg2.js";
2
+ export { t as offlineManifest, e as toInput };
@@ -1,7 +1,7 @@
1
- import { Ct as e, et as t, f as n, n as r, nt as i, o as a, rt as o, tt as s, z as c } from "./inspectMetrics-BqO4E9G0.js";
2
- import { Ht as l, Ut as u } from "./dev-5ficNnvF.js";
1
+ import { Ct as e, et as t, f as n, n as r, nt as i, o as a, rt as o, tt as s, z as c } from "./inspectMetrics-1xzTKAFx.js";
2
+ import { Gt as l, Wt as u } from "./dev-C_P8FLSx.js";
3
3
  import { C as d, E as f, O as p, T as m, d as h, h as g, i as _, l as v, m as y, r as ee, u as te } from "./fileConventions-Cof68_BL.js";
4
- import { r as ne, u as re } from "./apiBuild-C4uEbs3s.js";
4
+ import { r as ne, u as re } from "./apiBuild-h9VHtnlw.js";
5
5
  import { r as ie } from "./appGraph-gQ_6GkQQ.js";
6
6
  import { dirname as b, isAbsolute as ae, join as x, relative as S, resolve as C, sep as w } from "node:path";
7
7
  import { pathToFileURL as oe } from "node:url";
@@ -1904,7 +1904,7 @@ var le = () => {
1904
1904
  let t = Tn.get(e);
1905
1905
  if (t !== void 0) return t;
1906
1906
  let n = (async () => {
1907
- let { walk: t, loadDiscovered: n } = await import("./dev-rc3fwPSZ.js"), r = await t(e);
1907
+ let { walk: t, loadDiscovered: n } = await import("./dev-iiMtlkfs.js"), r = await t(e);
1908
1908
  return {
1909
1909
  files: r,
1910
1910
  discovered: await n(r)
@@ -2056,7 +2056,7 @@ var le = () => {
2056
2056
  }
2057
2057
  }, zn = async (e) => {
2058
2058
  try {
2059
- let { loadApiConfig: t } = await import("./dev-rc3fwPSZ.js"), n = (await t(e))?.doctor?.scopeVocabulary;
2059
+ let { loadApiConfig: t } = await import("./dev-iiMtlkfs.js"), n = (await t(e))?.doctor?.scopeVocabulary;
2060
2060
  if (typeof n != "string") return [];
2061
2061
  let [r, i] = n.split("#");
2062
2062
  if (r === void 0 || i === void 0) return [];
@@ -2072,7 +2072,7 @@ var le = () => {
2072
2072
  findings: []
2073
2073
  };
2074
2074
  try {
2075
- let { offlineManifest: n, toInput: r } = await import("./checkCommand-CwMrzAgV.js"), { buildAppGraph: i, checkAppGraph: a } = await import("./appGraph-CvQCte0z.js"), o = await n(e), s = r(o.manifest, o.tables), c = await zn(e), l = [.../* @__PURE__ */ new Set([...s.scopes ?? [], ...c])];
2075
+ let { offlineManifest: n, toInput: r } = await import("./checkCommand-DRovTKza.js"), { buildAppGraph: i, checkAppGraph: a } = await import("./appGraph-CvQCte0z.js"), o = await n(e), s = r(o.manifest, o.tables), c = await zn(e), l = [.../* @__PURE__ */ new Set([...s.scopes ?? [], ...c])];
2076
2076
  if (l.length === 0) return t;
2077
2077
  let u = i({
2078
2078
  ...s,
@@ -2114,7 +2114,7 @@ var le = () => {
2114
2114
  if (n === void 0 || r.length === 0) return;
2115
2115
  let i = [];
2116
2116
  try {
2117
- let { loadApiConfig: t } = await import("./dev-rc3fwPSZ.js");
2117
+ let { loadApiConfig: t } = await import("./dev-iiMtlkfs.js");
2118
2118
  i = (await t(e))?.locales ?? [];
2119
2119
  } catch {
2120
2120
  return;
@@ -2130,7 +2130,7 @@ var le = () => {
2130
2130
  }, Un = async (e) => {
2131
2131
  let { discovered: t } = await X(e), n = [];
2132
2132
  try {
2133
- let { loadApiConfig: t } = await import("./dev-rc3fwPSZ.js");
2133
+ let { loadApiConfig: t } = await import("./dev-iiMtlkfs.js");
2134
2134
  n = ((await t(e))?.plugins ?? []).map((e) => {
2135
2135
  let t = (e.extendSchema?.tables ?? []).map((e) => {
2136
2136
  let t = e;
@@ -2375,7 +2375,7 @@ var le = () => {
2375
2375
  } catch {
2376
2376
  continue;
2377
2377
  }
2378
- a.isDirectory() ? $n(r, t, n + 1) : a.isFile() && l.test(i) && t.push(r);
2378
+ a.isDirectory() ? $n(r, t, n + 1) : a.isFile() && u.test(i) && t.push(r);
2379
2379
  }
2380
2380
  return t;
2381
2381
  }, er = /* @__PURE__ */ new Set(["actors", "tenants"]), tr = async (e) => {
@@ -2409,7 +2409,7 @@ var le = () => {
2409
2409
  let t = e.severity === "error" ? "✗ error" : "⚠ warn", n = e.file ? ` (${e.file})` : "";
2410
2410
  return `${t} ${e.rule} ${e.node}${n}\n ${e.breaks.join("; ")}\n fix: ${e.fix}`;
2411
2411
  }).join("\n"), ar = async (e) => {
2412
- let { walk: t, loadDiscovered: n, loadApiConfig: r } = await import("./dev-rc3fwPSZ.js"), { buildRpcEntry: i } = await import("./manifestBuild-Dj8Jjoto.js"), { buildCapabilityManifest: a } = await import("./inspect-mmBuRXmy.js"), { relative: o } = await import("node:path"), s = await n(await t(e)), c = (t, n, r) => i(t, n, o(e, r)), l = [
2412
+ let { walk: t, loadDiscovered: n, loadApiConfig: r } = await import("./dev-iiMtlkfs.js"), { buildRpcEntry: i } = await import("./manifestBuild-Dj8Jjoto.js"), { buildCapabilityManifest: a } = await import("./inspect-mmBuRXmy.js"), { relative: o } = await import("node:path"), s = await n(await t(e)), c = (t, n, r) => i(t, n, o(e, r)), l = [
2413
2413
  ...s.queries.map((e) => c(e.descriptor, "query", e.file)),
2414
2414
  ...s.mutations.map((e) => c(e.descriptor, "mutation", e.file)),
2415
2415
  ...s.actions.map((e) => c(e.descriptor, "action", e.file)),
@@ -2445,21 +2445,21 @@ var le = () => {
2445
2445
  };
2446
2446
  else return console.error("voltro check --diff: expected removeTable:<name> | removeProcedure:<tag> | removeScope:<name>"), 1;
2447
2447
  }
2448
- let o = i.find((e) => !e.startsWith("-")) ?? process.cwd(), s = i.includes("--offline"), c = s ? [] : await me(n, r), l, d;
2448
+ let o = i.find((e) => !e.startsWith("-")) ?? process.cwd(), s = i.includes("--offline"), c = s ? [] : await me(n, r), u, d;
2449
2449
  if (c.length === 0) {
2450
2450
  if (!s && r !== void 0) return console.error("voltro check: --url given but no api reachable there."), 1;
2451
2451
  try {
2452
2452
  let e = await ar(C(o));
2453
- l = e.manifest, d = e.tables;
2453
+ u = e.manifest, d = e.tables;
2454
2454
  } catch (e) {
2455
2455
  return console.error(`voltro check: could not assemble the graph from source: ${e.message}`), console.error(" Start `voltro dev` to check against a running api instead."), 1;
2456
2456
  }
2457
2457
  } else {
2458
2458
  let e = c[0], [t, n] = await Promise.all([N(e, "/_voltro/inspect/manifest"), N(e, "/_voltro/inspect/data/tables")]);
2459
2459
  if (t.kind === "err") return console.error(`voltro check: manifest fetch failed: ${t.error}`), 1;
2460
- l = t.data, d = n.kind === "ok" ? n.data : { tables: [] };
2460
+ u = t.data, d = n.kind === "ok" ? n.data : { tables: [] };
2461
2461
  }
2462
- let f = rr(l, d), p = ie(f, a), m = [...V(o).findings.map((e) => ({
2462
+ let f = rr(u, d), p = ie(f, a), m = [...V(o).findings.map((e) => ({
2463
2463
  rule: e.rule,
2464
2464
  node: e.file,
2465
2465
  file: e.file,
@@ -2473,7 +2473,7 @@ var le = () => {
2473
2473
  breaks: [e.kind === "suffixed-outside-pages" ? "a page.tsx outside src/pages/ never routes" : "no test beside it"],
2474
2474
  fix: e.kind === "suffixed-outside-pages" ? "move it under src/pages/ into the directory whose route it serves, or rename it — page.tsx is reserved" : "write the test with the file — the name is derivable",
2475
2475
  severity: e.kind === "suffixed-outside-pages" ? "error" : "warn"
2476
- }))], h = (await u(o, $n(o))).map((e) => ({
2476
+ }))], h = (await l(o, $n(o))).map((e) => ({
2477
2477
  rule: "client/not-browser-safe",
2478
2478
  node: e.file,
2479
2479
  file: e.file,
@@ -1,18 +1,18 @@
1
1
  import { t as e } from "./cliRuntime-Oh517vCV.js";
2
- import { A as t, Ct as n, D as r, Dt as i, Et as a, F as o, I as s, M as c, N as l, O as u, P as d, R as f, S as p, X as m, Y as h, Z as g, _t as _, a as v, b as y, bt as b, c as ee, d as te, f as ne, gt as re, h as ie, ht as ae, j as oe, m as se, o as ce, p as le, s as ue, u as de, vt as fe, w as pe, x as me, xt as he, yt as ge } from "./inspectMetrics-BqO4E9G0.js";
3
- import { _ as _e, a as ve, c as ye, d as x, f as be, g as xe, h as Se, i as Ce, l as we, m as Te, n as Ee, o as De, p as Oe, s as ke, t as Ae, u as je, v as S, y as Me } from "./checkCommand-dm7OHtPt.js";
2
+ import { A as t, Ct as n, D as r, Dt as i, Et as a, F as o, I as s, M as c, N as l, O as u, P as d, R as f, S as p, X as m, Y as h, Z as g, _t as _, a as v, b as y, bt as b, c as ee, d as te, f as ne, gt as re, h as ie, ht as ae, j as oe, m as se, o as ce, p as le, s as ue, u as de, vt as fe, w as pe, x as me, xt as he, yt as ge } from "./inspectMetrics-1xzTKAFx.js";
3
+ import { _ as _e, a as ve, c as ye, d as x, f as be, g as xe, h as Se, i as Ce, l as we, m as Te, n as Ee, o as De, p as Oe, s as ke, t as Ae, u as je, v as S, y as Me } from "./checkCommand-xGhRFFg2.js";
4
4
  import { s as Ne } from "./seedRunner-Bqxgp7HZ.js";
5
5
  import { n as Pe } from "./appModuleLoader-C9r9mxZt.js";
6
6
  import { a as C, c as Fe, i as Ie, o as Le, s as Re } from "./devActivity-BhIu6ncs.js";
7
- import { Gt as ze, It as Be, Vt as Ve, Wt as He, c as Ue, f as We, i as Ge, l as Ke, o as qe, rn as Je, z as Ye } from "./dev-5ficNnvF.js";
7
+ import { Kt as ze, Rt as Be, Ut as Ve, an as He, c as Ue, f as We, i as Ge, l as Ke, o as qe, qt as Je, z as Ye } from "./dev-C_P8FLSx.js";
8
8
  import { C as Xe, i as Ze, o as Qe } from "./frameworkTableAssembly-BwIrO5nv.js";
9
9
  import { D as $e, E as et, N as tt, O as nt, T as rt, a as it, d as at, f as ot, h as st, i as ct, j as lt, k as ut, l as dt, m as ft, r as pt, u as mt } from "./fileConventions-Cof68_BL.js";
10
- import { i as ht, n as gt, o as _t, s as vt } from "./apiBuild-C4uEbs3s.js";
10
+ import { i as ht, n as gt, o as _t, s as vt } from "./apiBuild-h9VHtnlw.js";
11
11
  import { r as yt } from "./appGraph-gQ_6GkQQ.js";
12
- import { t as bt } from "./start-D1-8eKrO.js";
12
+ import { t as bt } from "./start-C0koT0UO.js";
13
13
  import { a as xt, i as St, n as Ct, r as wt, t as Tt } from "./cliArgs-qdZSElM3.js";
14
- import { n as Et, r as Dt } from "./serveCommand-PxMmn96o.js";
15
- import { c as Ot, r as kt, s as At } from "./dbCommand-ifOMafuG.js";
14
+ import { n as Et, r as Dt } from "./serveCommand-DdaM4Hup.js";
15
+ import { c as Ot, r as kt, s as At } from "./dbCommand-DTLKAfbA.js";
16
16
  import { basename as w, dirname as T, isAbsolute as jt, join as E, relative as D, resolve as O, sep as Mt } from "node:path";
17
17
  import { fileURLToPath as Nt, pathToFileURL as Pt } from "node:url";
18
18
  import { FileSystem as Ft } from "@effect/platform";
@@ -870,10 +870,10 @@ never mint — inspect stays closed until an operator opens it.`);
870
870
  }, Kr = async (e) => {
871
871
  let t = await ue(e);
872
872
  if (!t) {
873
- let { loadApiConfig: t } = await import("./dev-rc3fwPSZ.js"), n = await t(e);
873
+ let { loadApiConfig: t } = await import("./dev-iiMtlkfs.js"), n = await t(e);
874
874
  if (n) {
875
875
  V.info("building api app", { app: n.name ?? "(unnamed)" });
876
- let { runApiBuild: t, runServeBundleBuild: r } = await import("./apiBuild-OJEjtwcn.js");
876
+ let { runApiBuild: t, runServeBundleBuild: r } = await import("./apiBuild-C-x9YacA.js");
877
877
  await t(e);
878
878
  try {
879
879
  await r(e);
@@ -1092,10 +1092,10 @@ never mint — inspect stays closed until an operator opens it.`);
1092
1092
  return t.ran ? t.exitCode : (yield* k.logError("no web app found at this path", { hint: "looked for app.config.ts with `type: 'web'`" }), 1);
1093
1093
  }), Jr = async (t) => {
1094
1094
  if (t.includes("--target")) {
1095
- let { runSdkBuildCommand: e } = await import("./sdkgen-B_5mHQS2.js");
1095
+ let { runSdkBuildCommand: e } = await import("./sdkgen-C81QIkiL.js");
1096
1096
  return e(t);
1097
1097
  }
1098
- let n = t.find((e) => !e.startsWith("-")) ?? process.cwd(), r = await e(qr(n), { scope: "voltro:build" }), i = Je(n);
1098
+ let n = t.find((e) => !e.startsWith("-")) ?? process.cwd(), r = await e(qr(n), { scope: "voltro:build" }), i = He(n);
1099
1099
  return i.warning !== void 0 && V.warn(i.warning), r;
1100
1100
  }, Yr = 3, Xr = 50, Zr = 256 * 1024, Qr = (e) => e.reduce((e, t) => e + Buffer.byteLength(t.content, "utf8"), 0), $r = async (e, t) => {
1101
1101
  let n = Math.max(1, t.maxAttempts ?? Yr), r = t.maxArtifacts ?? Xr, i = t.maxTotalBytes ?? Zr, a = [];
@@ -4489,6 +4489,90 @@ never mint — inspect stays closed until an operator opens it.`);
4489
4489
  "purge the rows AND rotate the credentials — assume anything written to a",
4490
4490
  "log table has been read."
4491
4491
  ].join("\n")
4492
+ }),
4493
+ H({
4494
+ version: "0.33.0",
4495
+ id: "0.33.0/01_opentelemetry-optional-peer",
4496
+ title: "`@effect/opentelemetry` is now an optional peer — install it if you use tracing",
4497
+ kind: "manual",
4498
+ appliesTo: () => !0,
4499
+ note: [
4500
+ "SKIP THIS if you do not export traces or metrics (no `FRAMEWORK_TRACING`,",
4501
+ "no `FRAMEWORK_METRICS`, no `OTEL_EXPORTER_OTLP_*` endpoint). Nothing changes",
4502
+ "for you, and your `pnpm install` just got 24 lines quieter.",
4503
+ "",
4504
+ "IF YOU DO: `@effect/opentelemetry` is no longer installed for you. Add it:",
4505
+ "",
4506
+ " pnpm add @effect/opentelemetry",
4507
+ "",
4508
+ "WHY: it was a hard dependency of `@voltro/runtime` and is reached from one",
4509
+ "dynamic import, only when tracing is on. It declares seven non-optional",
4510
+ "OpenTelemetry peers; we supply five. The two we do not are",
4511
+ "`@opentelemetry/sdk-logs` and `@opentelemetry/sdk-trace-web` — and the",
4512
+ "second is the BROWSER tracer, so adding it to a server package to silence",
4513
+ "the warning would have been the wrong direction. So every consumer saw an",
4514
+ "unmet-peer block on every install, for a condition that broke nothing.",
4515
+ "",
4516
+ "The team who reported it read past that block for four releases. A warning",
4517
+ "that is always there is a warning nobody reads, and the next one in the",
4518
+ "block is the one that matters.",
4519
+ "",
4520
+ "IF YOU FORGET: a boot with tracing enabled fails with a message naming this",
4521
+ "exact install line, rather than a bare module-not-found for a package you",
4522
+ "never wrote down. It is a startup failure, not a silent loss of traces —",
4523
+ "losing telemetry quietly would be the worse outcome."
4524
+ ].join("\n")
4525
+ }),
4526
+ H({
4527
+ version: "0.33.0",
4528
+ id: "0.33.0/02_coordinated-handle-gained-wake",
4529
+ title: "`CoordinatedScheduleHandle` gained `wake()`, `currentIntervalMs()` and `isArmed()` — update hand-written test doubles",
4530
+ kind: "manual",
4531
+ appliesTo: ({ project: e }) => e.getSourceFiles().some((e) => e.getFullText().includes("scheduleCoordinated")),
4532
+ note: [
4533
+ "SKIP THIS unless you STUB `ctx.scheduleCoordinated` in a test. Calling it is",
4534
+ "unchanged — the framework builds the handle, and the effect parameter only",
4535
+ "got wider (it may now return a tick outcome; returning nothing still works",
4536
+ "and still means \"assume there was work\").",
4537
+ "",
4538
+ "IF YOU STUB IT: the handle now has three more members. A minimal double:",
4539
+ "",
4540
+ " scheduleCoordinated: (name, intervalMs, effect) => {",
4541
+ " const timer = setInterval(() => { void effect() }, intervalMs)",
4542
+ " return {",
4543
+ " name,",
4544
+ " stop: () => clearInterval(timer),",
4545
+ " wake: () => { void effect() },",
4546
+ " currentIntervalMs: () => intervalMs,",
4547
+ " isArmed: () => true,",
4548
+ " }",
4549
+ " }",
4550
+ "",
4551
+ "WHY: a coordinated tick used to run on a fixed interval and write a row into",
4552
+ "`_voltro_schedule_claims` every time it won — whether or not there was any",
4553
+ "work. On a two-replica deployment that had never enqueued anything, two",
4554
+ "framework tasks accounted for 99.3 % of that table: 2 506 rows an hour,",
4555
+ "against 18 from the app's own eight schedules. The table reached 86 214 rows",
4556
+ "and pinned ten connections of a fifteen-slot pooler.",
4557
+ "",
4558
+ "So the interval is a FLOOR now. Return `{ idle: true }` from a tick that",
4559
+ "found nothing and the runner backs off toward `VOLTRO_POLL_CEILING_MS`",
4560
+ "(30 s); add `nextDueInMs` when you already know something is coming, and it",
4561
+ "arms for that instant instead. `wake()` is the other half — wire it to",
4562
+ "whatever announces work (a `store.onChange` on your queue table) and the",
4563
+ "work starts on arrival rather than up to one interval later.",
4564
+ "",
4565
+ "AND IF YOU HAVE A CHANGE CHANNEL, you can stop polling altogether: pass",
4566
+ "`{ disarmWhenIdle: true }` as a fourth argument and an idle tick with",
4567
+ "nothing pending stops the timer — one tick at startup, then only `wake()`.",
4568
+ "Only do that where a peer replica's write reaches your process (Postgres",
4569
+ "LISTEN/NOTIFY, or a broadcast broker); without that a disarmed task sleeps",
4570
+ "through a peer's enqueue and the backoff ceiling is the right choice.",
4571
+ "",
4572
+ "IF YOU DO NOTHING: your task keeps its fixed interval, exactly as before.",
4573
+ "Reporting nothing is the conservative default on purpose — a task that",
4574
+ "cannot tell whether it was idle must not be slowed down on a guess."
4575
+ ].join("\n")
4492
4576
  })
4493
4577
  ], ls = /* @__PURE__ */ new Set([
4494
4578
  "node_modules",
@@ -5244,7 +5328,7 @@ never mint — inspect stays closed until an operator opens it.`);
5244
5328
  ]
5245
5329
  });
5246
5330
  c.push(i), await ic(o, "web"), U.info(`running ${s.length} test file(s)`, { count: s.length });
5247
- let l = 0, u = He(), d = ze(u);
5331
+ let l = 0, u = ze(), d = Je(u);
5248
5332
  d !== void 0 && U.warn(d);
5249
5333
  for (let e of s) {
5250
5334
  let t = await new Promise((t) => {
@@ -8223,7 +8307,7 @@ replicas = 1
8223
8307
  return Ne(e);
8224
8308
  }, yf = async (t) => {
8225
8309
  if (t.includes("--create-only")) return e(gf(O(t.find((e) => !e.startsWith("-")) ?? process.cwd())), { scope: "voltro:migrate" });
8226
- let { runDbCommand: n } = await import("./dbCommand-By__Ev0R.js");
8310
+ let { runDbCommand: n } = await import("./dbCommand-b1gum4td.js");
8227
8311
  return n(["apply", ...t]);
8228
8312
  }, bf = "voltro-backup-stamp.json", xf = (e) => ({
8229
8313
  stampVersion: 1,