@voltro/runtime 0.23.0 → 0.24.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 +111 -0
- package/dist/index.d.ts +103 -3
- package/dist/index.js +1967 -1908
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,117 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.24.0] — 2026-08-02
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/ai, @voltro/cli** — **`agent_threads` and `agent_messages` are `_voltro_agent_threads` and `_voltro_agent_messages`.** The last two framework-owned tables sitting in the user's namespace; the other ten moved in 0.22.0 and these were not in that set.
|
|
47
|
+
|
|
48
|
+
The collision it ends is the obvious half. The half that cost a consumer something is `versioningPlugin`: its "framework and plugin tables are out of the default" keys on the `_voltro_` prefix, so `agent_messages` was IN the default versioned set — and `runAssistant` patches the streaming assistant row about every 100 ms while it types. Under `timing: 'in-transaction'` that is a row-history write per throttle tick, on the hottest path in the app. They found it while adopting the versioning inversion and excluded both tables by hand; that `exclude:` entry can go now.
|
|
49
|
+
|
|
50
|
+
**The rows move themselves.** `.renamedFrom()` on both, so the next `db apply` or auto-migrate boot emits a catalog-only `ALTER TABLE … RENAME TO` on every dialect — no copy, no row rewrite. `AGENT_THREADS_TABLE` / `AGENT_MESSAGES_TABLE` are exported and carry the new names, and the synthesized `<agent>.messages` query moved with them, so typed code is unaffected.
|
|
51
|
+
|
|
52
|
+
The codemod is `manual` for the same reason the 0.22.0 one was: what a transform cannot see is raw SQL written by hand against those names.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **@voltro/plugin-auth** — Brute-force account lockout. After 5 failed credential attempts (wrong password OR wrong MFA code) within 15 minutes, sign-in for that email is refused with a `429 account_locked` for 15 minutes; a completed login clears the counter. The counter is keyed by email — an unknown address locks exactly like a real one, so the lock can't be used to probe which accounts exist. **On by default** (a security default); tune or disable via `authRoutesPlugin({ lockout: { maxAttempts, windowSeconds, lockSeconds } })`. Apps that spread `authTables` get the new `loginAttempts` table automatically on the next `voltro db apply` / `voltro dev` boot — it rides the declarative differ, no codemod.
|
|
57
|
+
- **@voltro/cli** — Backup provenance stamp. `voltro data backup` now writes a `voltro-backup-stamp.json` sidecar next to the native dump recording the dialect, the authoritative live-schema fingerprint, the `@voltro/cli` version, and the timestamp — a native `pg_dump`/`mariadb-dump` artifact is otherwise opaque about what it is. `voltro data restore` reads the stamp BEFORE touching the DB and acts on two failures that are silent until they corrupt: a CROSS-DIALECT restore (postgres dump into a mysql DB) is REFUSED (override with `--force`), and a SCHEMA/CODE fingerprint skew WARNS to run `voltro db apply` after the restore. A backup with no stamp (older/hand-made) restores with a caution, not a hard stop. Docs additionally clarify that point-in-time recovery (PITR) is a database/provider concern (WAL/binlog archiving) the framework deliberately does not reimplement, and that a backup you have never restored is a hypothesis. `codemod: none` — new CLI output + a restore-time guard; no user-authored code is affected.
|
|
58
|
+
- **@voltro/database, @voltro/sql-postgres, @voltro/runtime, @voltro/cli** — Per-statement query timeout via `DB_STATEMENT_TIMEOUT_MS` (or `ConnectionConfig.statementTimeoutMs`). A runaway query — a missing index, an accidental cartesian join — no longer pins a pooled connection indefinitely: it is cancelled once it outlasts the deadline, its connection returns to the pool, and the caller gets a normal error instead of a hang that, under load, exhausts the pool and stalls the whole app. Applies to the **runtime query path only** — migrations (`voltro db apply`) run legitimately long statements and are never cancelled by it. **Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the field but currently ignore it (mssql's driver exposes no per-request timeout, MySQL/MariaDB's `max_execution_time` bounds SELECTs only, SQLite has no pool to protect). New `isQueryTimeout` classifier in `@voltro/runtime` recognises a timeout cancel across dialects. Off by default (unset = no timeout — unchanged behaviour). `codemod: none` — a new opt-in env var / config field; no user-authored code is affected.
|
|
59
|
+
- **@voltro/database, @voltro/cli** — Rolling-deploy safety classifier + `voltro db plan` advisory. A migration can be fully data-safe (every op auto-applies) and still break a zero-downtime rollout: during the overlap window old pods run the previous code against the already-migrated schema, so a dropped/renamed column, a narrowed type, or an added constraint makes those old pods 500 on reads or have their writes rejected. This is an axis ORTHOGONAL to the lossy/blocked data-safety gate — a `dropped()` column is blessed for data loss and still breaks an old reader.
|
|
60
|
+
|
|
61
|
+
`classifyRollingDeploySafety(op)` (a pure function in `@voltro/database`) returns a per-operation verdict with a reason + an expand/contract remedy; `voltro db plan` now lists the unsafe operations under a `⚠`, separately from the lossy/blocked summary. Advisory, NOT a refusal — the framework can't know the deploy strategy, and a maintenance-window / scale-to-zero deploy has no overlap window. The classifier is consumed by both the self-hosted advisory and (later) the cloud managed-hosting migration wall. Docs bless the expand/contract pattern. `codemod: none` — new API + CLI output only; no user-authored code is affected.
|
|
62
|
+
- **@voltro/runtime** — Configurable graceful-shutdown deadline via `VOLTRO_SHUTDOWN_GRACE_MS` (milliseconds, clamped to 1s–5min, default 10s). After `SIGTERM`/`SIGINT` the runtime runs its finalizers (connection-pool close, plugin `onDeactivate`, analytics flush, trace persist) and then exits — but installing the signal handler removes node's default kill, so a finalizer that never completes would otherwise hang the process forever; the deadline caps that. Operators set it to sit just under their orchestrator's hard kill (k8s `terminationGracePeriodSeconds` minus the preStop sleep, ECS `stopTimeout`) so the app drains and exits cleanly on its own before SIGKILL truncates it mid-drain. A non-numeric / non-positive value falls back to the 10s default (never a `NaN` deadline that fires immediately). `codemod: none` — a new opt-in env var; no user-authored code is affected.
|
|
63
|
+
- **@voltro/client, @voltro/runtime, @voltro/cli, @voltro/protocol** — WS-rpc mutation idempotency. A retried mutation carrying the same `idempotency-key` is deduplicated at the server: the first result is replayed and the handler does NOT run twice — so a network-blip retry, a reconnect resend, or (with a stable key) a double-click can't create a duplicate order / double charge. It reuses the same engine + `_voltro_idempotency` table as the REST path, so setting `idempotency` in `app.config.ts` now protects BOTH surfaces. `useMutation` / `useAction` mint a per-call key automatically and attach it to the rpc frame (over `RpcClient.currentHeaders`, merged with the auth headers) — pass `mutate(input, { idempotencyKey })` with a stable key for higher-level dedup. The key is scoped by `(tenant, subject, mutation)` so one subject's key can never replay for another, and the stored output is round-tripped through the mutation's output Schema so a `Date`-bearing replay reproduces the original exactly. Off by default (no `idempotency` config → no dedup). `@voltro/client` now peer-depends on `@effect/rpc` + `@effect/platform` (already transitive via `effect`).
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **@voltro/runtime, @voltro/cli** — **A `*.schedule.ts` or `*.subscribe.ts` body written as an Effect silently did nothing.** Not "was rejected" — ran, recorded a success, and never executed.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export const handler: ScheduleHandler = () =>
|
|
71
|
+
Effect.gen(function* () { yield* reconcileInvoices() }) // never ran
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Both call sites accepted the value and dropped it. `scheduler.ts` did `await def.handler(ctx)`, and an Effect is not a thenable, so `await` returned it unchanged. `subscriberRunner.ts` tested `result instanceof Promise`, which an Effect is not, so the branch was skipped. Neither raised anything. In an Effect-first framework the natural thing to write was the thing that quietly did nothing — worse than a type error, because a type error is visible.
|
|
75
|
+
|
|
76
|
+
Both handler types now accept sync, Promise **and** Effect forms, and one shared `settleHandlerBody` decides what a body IS, so the two call sites can no longer disagree about it. They keep their different DISPOSAL, deliberately: a schedule AWAITS its body (a firing that failed must not record as a success), a subscriber does not (a slow body must not back-pressure the change stream).
|
|
77
|
+
|
|
78
|
+
Reported four releases ago. It sat because it was listed as "open" at the bottom of a feedback round and never entered the backlog — the register that now holds that tail is `plans/open/framework/consumer-reported-tail.md`.
|
|
79
|
+
- **@voltro/plugin-auth** — **Brute-force lockout could be entirely inert, on by default, with nothing in the log to say so.** The postgres store fails OPEN on a store error — correct, a DB hiccup must not lock every user out of an app — but it failed open in silence: `recordLoginFailure` swallowed its write error, `isLockedOut` then read "not locked", and the security control that the release notes describe as **on by default** counted nothing at all.
|
|
80
|
+
|
|
81
|
+
The reachable case is not a hiccup. An app that enumerates its tables by hand instead of spreading `authTables` never migrates `loginAttempts`, so every write fails with `relation "loginAttempts" does not exist` — permanently, invisibly.
|
|
82
|
+
|
|
83
|
+
Behaviour is unchanged: still open, still no throw into the login flow. What is new is that each failure logs `[auth] lockout … failed — brute-force protection is not counting`, which also separates the two cases by hand: a transient error logs once, a missing table logs on every failed sign-in.
|
|
84
|
+
|
|
85
|
+
Found by the release gate, and the finding is uncomfortable in a useful way — the contract suite that runs against a LIVE postgres had been extended for lockout, and its hand-written fixture DDL was never given the new table. The fail-open then converted "relation does not exist" into a plain assertion failure, which is the only reason it was visible at all. The fixture now asserts that it covers every table the plugin declares, in both directions, and that assertion runs without postgres so the drift cannot be introduced on a machine where the pg half skips.
|
|
86
|
+
- **@voltro/cli, @voltro/database** — **`voltro db apply` now installs the change triggers the boot diagnostic tells you to install.** It did not, and said it did.
|
|
87
|
+
|
|
88
|
+
0.23.0 added a check that compares declared reactivity against the triggers actually in the database, and it works — a consumer's first boot on 0.23.0 reported 500 of their 525 tables as having no change trigger. The remedy it named was `voltro db apply`, and `db apply` answered:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
schema diff: 0 operations, 0 blocked
|
|
92
|
+
(schema is up to date)
|
|
93
|
+
db apply: schema is up to date — nothing to apply
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both were telling the truth. Reactive triggers are emitted only by the two FULL-schema emitters — the CREATE-everything path for a fresh database, and the framework bootstrap — so every table an existing app has added through the PLANNER since it was created never got one. The planner has no trigger dimension to notice with, so `db plan` correctly reports zero operations while 500 tables sit untriggered. Their database had 27 triggers, all 27 on framework tables.
|
|
97
|
+
|
|
98
|
+
The consequence is the one the diagnostic describes: a single instance is unaffected (its own writes reach its own subscribers in-process), so this stays invisible until you scale out, and then subscriptions quietly stop seeing other instances' writes.
|
|
99
|
+
|
|
100
|
+
`db apply` and `db apply --plan` now converge triggers as an explicit, reported step — **including when the schema diff is empty**, which is not an edge case here but the reported one. It is deliberately NOT a planner operation: a trigger carries no data, its DDL is idempotent, and it is derived entirely from `isReactive`, so it converges rather than diffs.
|
|
101
|
+
|
|
102
|
+
**A second defect found while fixing the first: a custom `cdcChannel` made the check report every reactive table as missing.** The detector derived the trigger name itself (`framework_changes_<table>`) while the emitter puts the channel in the name on any non-default channel — two derivations of one name, disagreeing exactly where nobody looks. They read one function now.
|
|
103
|
+
|
|
104
|
+
Also: **`_voltro_schedule_claims` had no retention.** One row per (schedule, minute-bucket), append-only, and the only table of its family without a sweep — `_voltro_schedule_runs` (the OUTCOME of a firing) had one; its coordination twin (the RACE for the same firing) did not. Measured by the same consumer at 35,128 rows in 14 days across 31 schedules. Now pruned at 30 days by default (`VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS`), which is safe because a claim only ever answers a question about one minute bucket and the scheduler asks about the current one.
|
|
105
|
+
- **@voltro/cli, @voltro/database** — **`voltro serve` could not boot from its own bundle on any app with binlog CDC enabled**, and reported it as a build problem.
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
[voltro] serve bundle failed to load: n7 is not a constructor
|
|
109
|
+
[voltro] FATAL: production `voltro serve` requires a precompiled serve bundle …
|
|
110
|
+
Run `voltro build` before serving
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The bundle was neither missing nor unloadable. `@vlasky/zongji` — the binlog reader — sat on `NATIVE_RUNTIME_LEAVES`, the list of packages routed through a runtime CJS shim instead of being inlined, under a comment describing it as a leaf with "a compiled `.node` binding". It has none: it is a pure-JS ESM package. Through the shim its consumer broke, from the opposite direction to the `pg` regression the same list already documents — the shim's `module.exports` IS the ESM namespace `{ default: ctor }`, esbuild's `__toESM` wraps it again, and `(await import('@vlasky/zongji')).default` came back as the namespace object. Measured both ways:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
shimmed: typeof mod.default === 'object' → `is not a constructor`
|
|
117
|
+
inlined: typeof mod.default === 'function' → constructs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Inlining it removes the shim's interop from the path. A new guard asserts the RULE rather than the list: every entry on `NATIVE_RUNTIME_LEAVES` must actually carry a compiled binding, or be one of the two documented dynamic-import cases.
|
|
121
|
+
|
|
122
|
+
**And the message that hid it is fixed.** The launcher wrapped the bundle's IMPORT and its RUN in one `try`, so every runtime fault the app's boot could raise came out as "serve bundle failed to load … Run `voltro build`", with the stack discarded. The reporter had just run it. The two are separated now: an import that throws is still a build problem, and an import that succeeds followed by a `runServe` throw is reported as itself, with its stack, and does not fall through to a second execution path.
|
|
123
|
+
|
|
124
|
+
**`voltro db files` ran a migration and could not record it, on MariaDB.** The file-migration writer passed an ISO-8601 string into `_voltro_migration_plans.appliedAt`, a `DATETIME`, which MariaDB rejects (`ER_TRUNCATED_WRONG_VALUE`). The INSERT runs AFTER `up()`, so the side effects landed and the bookkeeping did not: a probe migration inserting one row grew 1 → 2 → 3 → 4 across four invocations with zero `source='file'` ledger entries, and the deploy could never complete — `db apply` refuses while a file migration is pending and nothing could ever record it.
|
|
125
|
+
|
|
126
|
+
The PLANNER's writer had the conversion, inline, with a comment describing this exact rejection. The file writer was a second copy without it, so the common path worked and the escape hatch was broken exactly where it is reached for. One `appliedAtValue` now, shared, covered against a live MariaDB. A ledger write that fails after a successful `up()` also gets its own error type: the recovery is the opposite of the ordinary one — do NOT re-run — and it used to surface as a generic `Failed to execute statement`.
|
|
127
|
+
|
|
128
|
+
**`voltro build` never removed output from earlier builds.** Content-hashed chunks mean every build writes new names and nothing overwrites the old ones; nothing reads them either, so they accumulate and ship in the image. Measured by a consumer at 11,048 files where a clean build produces 2,131. Now pruned after the build, keyed on mtime — deliberately not a wipe before it, which opens a window in which the bundle does not exist.
|
|
129
|
+
|
|
130
|
+
**A completed drain now says so** (`drained in 80ms`), and one cut at the deadline says that instead. Previously the only trace of either was whatever a shutdown hook happened to log, so "drained in 80 ms" and "was cut at 10 s" looked identical.
|
|
131
|
+
- **@voltro/runtime** — **A request the SSRF policy blocks is now a catchable failure instead of an uncatchable defect.**
|
|
132
|
+
|
|
133
|
+
It was `Effect.die(new SsrfBlockedError(...))`. A blocked outbound request is a decision the policy made about a URL the CALLER supplied — and as a defect the caller could not do anything about it: the fiber collapsed, it surfaced as an untagged 500, and a handler that wanted to fall back to a queue, return a typed error to the client, or skip an optional enrichment had no way to.
|
|
134
|
+
|
|
135
|
+
It rides inside `HttpClientError.RequestError` rather than being raised on its own, because `HttpClient.HttpClient`'s error channel IS `HttpClientError` — failing with a foreign type would not typecheck for any consumer. So `catchTag('RequestError')` and `catchAll` both see it, `description` names the policy, and the `SsrfBlockedError` survives as `cause` for a caller that wants the specific reason.
|
|
136
|
+
|
|
137
|
+
Not breaking: the error channel already carried `HttpClientError`. What changed is that the failure now arrives on it.
|
|
138
|
+
|
|
139
|
+
Reported three releases ago. It sat because it was listed as open at the bottom of a feedback round and never entered the backlog — see `plans/open/framework/consumer-reported-tail.md`.
|
|
140
|
+
|
|
141
|
+
### Internal (no consumer-facing effect)
|
|
142
|
+
|
|
143
|
+
- **@voltro/database** — `VOLTRO_SOFT_DROP=1` convergence is now proven against a live postgres, not argued.
|
|
144
|
+
|
|
145
|
+
The planner-side fix — treating `<name>__dropped_<YYYYMMDDHHMMSS>` as framework-managed so an undeclared snapshot is never re-proposed for dropping — shipped some releases ago. What never existed was the assertion, and a consumer had told us so: *"still not testable from a host without the DB"*. That was their constraint, read as ours. The docker stack is exactly what it needed.
|
|
146
|
+
|
|
147
|
+
The test soft-drops a COLUMN and a TABLE for real, asserts the data survives under the snapshot name, and asserts the **re-plan is EMPTY** — the property, not the statements. Verified red by disabling the planner's snapshot awareness: both cases then die in `applyPlan`'s convergence check, which is the original defect.
|
|
148
|
+
|
|
149
|
+
Two harness mistakes are recorded in the file because each produced a failure that reads exactly like the framework defect under test: an unscoped re-plan against a SHARED database proposes dropping every table in it, and a file-wide scope puts the first test's table into the second test's residue.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
42
153
|
## [0.23.0] — 2026-08-02
|
|
43
154
|
|
|
44
155
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -1117,7 +1117,7 @@ export declare interface BeginOAuthResult {
|
|
|
1117
1117
|
*/
|
|
1118
1118
|
export declare const bindConnectionSubject: (clientId: number, subject: Subject) => void;
|
|
1119
1119
|
|
|
1120
|
-
export declare const bindMutation: <Input, Output, E = never>(execute: (input: Input, context: RuntimeContext) => Output | Promise<Output> | Effect.Effect<Output, E, never>, input: Input, spanName?: string) => Effect.Effect<Output, E, SubjectService | ConnectionInfo>;
|
|
1120
|
+
export declare const bindMutation: <Input, Output, E = never>(execute: (input: Input, context: RuntimeContext) => Output | Promise<Output> | Effect.Effect<Output, E, never>, input: Input, spanName?: string, codec?: MutationOutputCodec<unknown>, idempotency?: IdempotencyBinding) => Effect.Effect<Output, E, SubjectService | ConnectionInfo>;
|
|
1121
1121
|
|
|
1122
1122
|
/**
|
|
1123
1123
|
* Bind a non-reactive server→client stream (A3). The executor builds a
|
|
@@ -2170,6 +2170,13 @@ export declare const drainForShutdown: (options: {
|
|
|
2170
2170
|
readonly hooks: ReadonlyArray<() => void | Promise<void>>;
|
|
2171
2171
|
readonly deadlineMs: number;
|
|
2172
2172
|
readonly exit: () => void;
|
|
2173
|
+
/** How the drain ended, for the caller to report. A completed drain and one
|
|
2174
|
+
* CUT at the deadline are different incidents and were indistinguishable in
|
|
2175
|
+
* the log — asked for by a consumer who could see neither. */
|
|
2176
|
+
readonly onOutcome?: (outcome: {
|
|
2177
|
+
readonly reason: "drained" | "deadline";
|
|
2178
|
+
readonly ms: number;
|
|
2179
|
+
}) => void;
|
|
2173
2180
|
}) => void;
|
|
2174
2181
|
|
|
2175
2182
|
/**
|
|
@@ -2504,6 +2511,9 @@ export declare interface GroupState {
|
|
|
2504
2511
|
readonly extreme?: number;
|
|
2505
2512
|
}
|
|
2506
2513
|
|
|
2514
|
+
/** What a user handler may return. */
|
|
2515
|
+
export declare type HandlerBody = void | Promise<unknown> | Effect.Effect<unknown, unknown, never>;
|
|
2516
|
+
|
|
2507
2517
|
/** Define a histogram. `boundaries` default to the framework duration buckets. */
|
|
2508
2518
|
export declare const histogramMetric: (name: string, boundaries?: MetricBoundaries.MetricBoundaries, description?: string) => Metric.Metric.Histogram<number>;
|
|
2509
2519
|
|
|
@@ -2530,6 +2540,21 @@ export declare interface HttpSecretsOptions {
|
|
|
2530
2540
|
readonly ttlMs?: number;
|
|
2531
2541
|
}
|
|
2532
2542
|
|
|
2543
|
+
/**
|
|
2544
|
+
* WS-rpc idempotency store + TTL, passed to `bindMutation` as a closure param by
|
|
2545
|
+
* the boot path (`serveApi`/`dev`) — NOT a service. @effect/rpc runs a handler in
|
|
2546
|
+
* a NARROWED context (only the middleware `provides` + declared deps), so a plain
|
|
2547
|
+
* merged service is invisible to `Effect.serviceOption` inside the handler; the
|
|
2548
|
+
* store+ttl are boot-time constants the bind site already holds, so a closure is
|
|
2549
|
+
* both correct and simpler. Omitted → WS mutation dedup is off (the default).
|
|
2550
|
+
* Reuses the SAME `dataStoreIdempotencyStore` + `_voltro_idempotency` table as
|
|
2551
|
+
* the REST path — enable once, protect both.
|
|
2552
|
+
*/
|
|
2553
|
+
export declare interface IdempotencyBinding {
|
|
2554
|
+
readonly store: IdempotencyStore;
|
|
2555
|
+
readonly ttlMs: number;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2533
2558
|
export declare interface IdleCheckOptions {
|
|
2534
2559
|
readonly store: DataStore;
|
|
2535
2560
|
readonly signals: ActivitySignals;
|
|
@@ -2775,6 +2800,15 @@ export declare const isOrglessUserSubject: (subject: {
|
|
|
2775
2800
|
readonly tenantId?: string | null;
|
|
2776
2801
|
} | null | undefined) => boolean;
|
|
2777
2802
|
|
|
2803
|
+
/** A query cancelled for exceeding the `statementTimeoutMs` deadline, across
|
|
2804
|
+
* dialects: pg `57014` (query_canceled — what `statement_timeout` raises),
|
|
2805
|
+
* MySQL `3024` (ER_QUERY_TIMEOUT), MariaDB `1969` (ER_STATEMENT_TIMEOUT), mssql
|
|
2806
|
+
* `ETIMEOUT` (tedious request timeout), sqlite `SQLITE_INTERRUPT`. NOT a
|
|
2807
|
+
* transient error — a re-run just repeats the runaway, so it must not retry
|
|
2808
|
+
* (`servePipeline`'s transient set deliberately excludes it). Lets a consumer /
|
|
2809
|
+
* observability layer name the failure instead of reading an opaque SqlError. */
|
|
2810
|
+
export declare const isQueryTimeout: (dbCause: Record<string, unknown>) => boolean;
|
|
2811
|
+
|
|
2778
2812
|
/**
|
|
2779
2813
|
* Optimistic-concurrency guard: an undo is safe only when the row still looks
|
|
2780
2814
|
* like what the forward mutation left (`next`). If another writer changed it
|
|
@@ -3143,6 +3177,14 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
|
|
|
3143
3177
|
/** `__voltro.connections.list` — every declared connection, for the caller. */
|
|
3144
3178
|
export declare const makeListConnectionsExecutor: (deps: ConnectionBuiltinDeps) => (_input: Record<string, never>, ctx: ConnectionExecutorCtx) => Promise<ReadonlyArray<ConnectionState>>;
|
|
3145
3179
|
|
|
3180
|
+
/**
|
|
3181
|
+
* Build the replay codec from a mutation's `descriptor.output` Schema (call it at
|
|
3182
|
+
* the bind site). Both directions fall back to the raw value if the Schema can't
|
|
3183
|
+
* round-trip it — a guard for exotic schemas; for a value the handler actually
|
|
3184
|
+
* produced, `encodeUnknownSync` never throws. `undefined` schema → no codec.
|
|
3185
|
+
*/
|
|
3186
|
+
export declare const makeMutationOutputCodec: (outputSchema: Schema.Schema.AnyNoContext | undefined) => MutationOutputCodec<unknown> | undefined;
|
|
3187
|
+
|
|
3146
3188
|
/**
|
|
3147
3189
|
* Build the shared mutation runner. Used by BOTH the rpc WS handler and
|
|
3148
3190
|
* the `/_voltro/inspect/invoke` endpoint (and the prod entrypoint) so they
|
|
@@ -3438,6 +3480,20 @@ export declare interface MutationLike {
|
|
|
3438
3480
|
executor(input: unknown, ctx: unknown): unknown;
|
|
3439
3481
|
}
|
|
3440
3482
|
|
|
3483
|
+
/**
|
|
3484
|
+
* Encode/decode a mutation's output for idempotent replay. On a FRESH call the
|
|
3485
|
+
* WIRE form (`encode`) is stored, so a later replay `decode`s it back to the exact
|
|
3486
|
+
* Output the handler would have returned and the rpc layer re-encodes it
|
|
3487
|
+
* identically. Storing the DECODED Output directly would corrupt `Date`/etc.
|
|
3488
|
+
* fields through the store's JSON round-trip (encode there expects a `Date`, not
|
|
3489
|
+
* the ISO string a round-trip produced). Built from `descriptor.output` at the
|
|
3490
|
+
* call site; both fns fall back to the raw value if the Schema can't round-trip.
|
|
3491
|
+
*/
|
|
3492
|
+
export declare interface MutationOutputCodec<Output> {
|
|
3493
|
+
readonly encode: (output: Output) => unknown;
|
|
3494
|
+
readonly decode: (stored: unknown) => Output;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3441
3497
|
export declare interface MutationRunnerDeps {
|
|
3442
3498
|
readonly store: TransactionalStore;
|
|
3443
3499
|
/** Build the per-call `AppContext` bound to the transactional `tx`. */
|
|
@@ -5340,7 +5396,20 @@ export declare interface ScheduleFireContext {
|
|
|
5340
5396
|
*/
|
|
5341
5397
|
export declare type ScheduleFireInterceptor = (next: () => Promise<void>, ctx: ScheduleFireContext) => Promise<void>;
|
|
5342
5398
|
|
|
5343
|
-
|
|
5399
|
+
/**
|
|
5400
|
+
* A schedule body. Promise-form or Effect-form; both run.
|
|
5401
|
+
*
|
|
5402
|
+
* The Effect arm is not sugar. Before it existed the call site was
|
|
5403
|
+
* `await def.handler(ctx)`, and an Effect is not a thenable — so `await`
|
|
5404
|
+
* returned it unchanged, the body never executed, and the firing was recorded
|
|
5405
|
+
* as a success. In an Effect-first framework the natural thing to write was the
|
|
5406
|
+
* thing that silently did nothing. Same bridge a file-based migration's `up`
|
|
5407
|
+
* has carried since it shipped.
|
|
5408
|
+
*
|
|
5409
|
+
* `R = never`: the effect must carry its own requirements. Everything a
|
|
5410
|
+
* schedule needs is on `ctx.app`.
|
|
5411
|
+
*/
|
|
5412
|
+
export declare type ScheduleHandler = (ctx: ScheduleContext) => void | Promise<void> | Effect.Effect<unknown, unknown, never>;
|
|
5344
5413
|
|
|
5345
5414
|
/** What happens when a firing arrives while the previous run of the
|
|
5346
5415
|
* same schedule is still in flight. */
|
|
@@ -5698,6 +5767,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
|
|
|
5698
5767
|
/** Test seam — swap (or reset with `undefined`) the process recorder. */
|
|
5699
5768
|
export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
|
|
5700
5769
|
|
|
5770
|
+
/**
|
|
5771
|
+
* Normalise a handler's return value to "a promise, or nothing to wait for".
|
|
5772
|
+
*
|
|
5773
|
+
* `undefined` means the body was synchronous and has already run. Anything else
|
|
5774
|
+
* is a promise the caller disposes of as its context requires.
|
|
5775
|
+
*/
|
|
5776
|
+
export declare const settleHandlerBody: (body: HandlerBody) => Promise<unknown> | undefined;
|
|
5777
|
+
|
|
5701
5778
|
/**
|
|
5702
5779
|
* Register (or clear) the process-global tuple source. Last write wins.
|
|
5703
5780
|
*
|
|
@@ -5716,6 +5793,18 @@ export declare type ShapeClassification = {
|
|
|
5716
5793
|
readonly reason: string;
|
|
5717
5794
|
};
|
|
5718
5795
|
|
|
5796
|
+
/**
|
|
5797
|
+
* The teardown deadline, from `VOLTRO_SHUTDOWN_GRACE_MS` (milliseconds), clamped
|
|
5798
|
+
* to `[1s, 5min]`. Operators set it to sit JUST UNDER their orchestrator's hard
|
|
5799
|
+
* kill — k8s `terminationGracePeriodSeconds`, ECS `stopTimeout` — so the process
|
|
5800
|
+
* drains in-flight work and exits cleanly on its own BEFORE SIGKILL truncates it
|
|
5801
|
+
* mid-drain (which would strand exactly the finalizers this path exists to run:
|
|
5802
|
+
* connection-pool close, plugin `onDeactivate`, analytics flush, trace persist).
|
|
5803
|
+
* A non-numeric / non-positive value falls back to the 10s default rather than
|
|
5804
|
+
* producing a `setTimeout(…, NaN)` that fires immediately and defeats the drain.
|
|
5805
|
+
*/
|
|
5806
|
+
export declare const shutdownGraceMsFromEnv: () => number;
|
|
5807
|
+
|
|
5719
5808
|
/** No-coordination gate for single-instance deployments (PM2,
|
|
5720
5809
|
* single pod, dev). */
|
|
5721
5810
|
export declare const singleCoordinator: Coordinator;
|
|
@@ -5973,7 +6062,18 @@ export declare interface SubscribeContext {
|
|
|
5973
6062
|
* framework's CDC `ChangeEvent` re-exported for ergonomics. */
|
|
5974
6063
|
export declare type SubscribeEvent = ChangeEvent;
|
|
5975
6064
|
|
|
5976
|
-
|
|
6065
|
+
/**
|
|
6066
|
+
* A subscriber body. Promise-form or Effect-form; both run.
|
|
6067
|
+
*
|
|
6068
|
+
* The Effect arm is not sugar — see `ScheduleHandler`. The runner tested
|
|
6069
|
+
* `result instanceof Promise`, an Effect is not one, so the branch was skipped
|
|
6070
|
+
* and the body never executed. No error, no log line, the change event handled
|
|
6071
|
+
* "successfully".
|
|
6072
|
+
*
|
|
6073
|
+
* `R = never`: the effect must carry its own requirements. Everything a
|
|
6074
|
+
* subscriber needs is on `ctx`.
|
|
6075
|
+
*/
|
|
6076
|
+
export declare type SubscribeHandler = (event: SubscribeEvent, ctx: SubscribeContext) => void | Promise<void> | Effect.Effect<unknown, unknown, never>;
|
|
5977
6077
|
|
|
5978
6078
|
/** Which row op(s) the subscriber listens on. `'any'` matches every
|
|
5979
6079
|
* op; an array enumerates the concrete ops. */
|