@voltro/runtime 0.11.3 → 0.11.4
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 +173 -0
- package/dist/index.d.ts +229 -4
- package/dist/index.js +1158 -985
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,179 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.11.4] — 2026-07-25
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **@voltro/protocol** — **`ApiKeyRecord.metadata` — an app-defined binding carried onto the Subject.** `tenantId` is ONE level of key ownership. A product whose keys belong to a TEAM, a project or an environment needs a second, and the record had no slot for it — so the only safe way to authorize was a DB lookup on every check, on the auth hot path. Reported from an app whose `requireScope` could not see the team, so a key minted for team A authorized org-wide until they patched it with a per-check query.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
resolveKey: async (hash) => {
|
|
50
|
+
const row = await findKey(hash)
|
|
51
|
+
return row && { ...row, metadata: { keyType: row.keyType, teamId: row.teamId } }
|
|
52
|
+
}
|
|
53
|
+
// a guard then reads subject.metadata.teamId — no second query
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Merged UNDER the framework's own claims: `provider` and `userId` are the strategy's attribution of the request and are applied last, so an app's bag cannot overwrite who the framework thinks made the call (tested). A record without `metadata` behaves exactly as before.
|
|
57
|
+
- **@voltro/cli** — `voltro db generate [name]` now scaffolds a fresh, heavily-commented `migrations/<timestamp>_<name>.migration.ts` (the imperative path for data backfills / transforms the declarative differ can't express — for plain schema shape changes prefer `voltro db apply`). The name is slugified; `--name` overrides the positional, `--root` sets where `migrations/` lives. Replaces the old "not yet implemented" stub, and `db generate` is back in the command summary. `voltro db migrate --dry-run` is implemented too — it lists the pending migrations (querying the ledger for what's applied) without applying them, instead of erroring as unimplemented.
|
|
58
|
+
- **@voltro/runtime** — `crud.list(table, { columns })` — SQL column projection, so a wide column a list view never shows is **never read**, not merely dropped at the wire boundary:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
|
|
62
|
+
// the large `body` / json blob is never SELECTed, transferred, or decoded
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This is the performance half of projection. The output schema already strips undeclared columns on encode (so nothing extra ever shipped either way) — `columns` additionally saves the read, the DB→app transfer, and the decode.
|
|
66
|
+
|
|
67
|
+
`.serverOnly()` columns are removed from the projection automatically: they are stripped from the response regardless, so SELECTing them is pure waste.
|
|
68
|
+
|
|
69
|
+
Trap worth knowing: an eager `include` branch joins on a foreign key, so a projection that omits that FK column breaks the relation — keep the FK in `columns` when you also pass `include`. Additive: a new optional `columns` on `CrudListOptions`; omitting it reads the full row exactly as before.
|
|
70
|
+
- **@voltro/runtime** — `crud.list(table, options)` gains the read ergonomics a real list view needs, so a generated list isn't limited to "all rows" (the reason a rich hand-written list couldn't move to `crud.*`):
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export default crud.list('absenceRequests', {
|
|
74
|
+
filter: (input) => ({ employeeId: input.employeeId, status: input.status }), // → WHERE
|
|
75
|
+
paginate: true, // input.limit / input.offset (100 / 0)
|
|
76
|
+
sort: [{ column: 'createdAt', direction: 'desc' }], // multi-column
|
|
77
|
+
include: { employee: { with: { team: true } } }, // eager relations, nested filter/sort
|
|
78
|
+
redact: ['internalNote'],
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **`filter`** maps request input to a `WHERE` — a column→value map; an `undefined` field is ignored (so an absent filter param is a no-op). Applied through the tenant-scoped `.where`. - **`paginate`** reads `input.limit` / `input.offset` (defaults 100 / 0). - **`sort`** is a multi-column `orderBy`, applied in order. - **`include`** is the SAME spec `.with(...)` takes, so nested relations and per-branch `where` / `orderBy` / `limit` (nested filtering + sort) all work. `getById` takes `include` too.
|
|
83
|
+
|
|
84
|
+
All optional and additive: `CrudListOptions extends CrudReadOptions`, so a bare `crud.list('t')` or `crud.list('t', { redact })` is unchanged. The descriptor's `input` schema declares the filter/pagination fields, and its `output` schema stays hand-written (deriving the projection from the output schema is a codegen concern — a table value can't enter a browser-loaded descriptor).
|
|
85
|
+
- **@voltro/runtime** — `crud.list`'s `paginate` now accepts **page-based** paging beside offset-based, and `crud.count` gives a page-based UI the total it needs:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// list: ?page=3&pageSize=20 (or ?limit=20&offset=40 — both work)
|
|
89
|
+
export default crud.list('absenceRequests', { paginate: true, filter, sort })
|
|
90
|
+
// total for "page 3 of 12"
|
|
91
|
+
export default crud.count('absenceRequests', { filter })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **`page`** is **1-based** (what a UI shows) and pairs with **`pageSize`** (default 100); `offset` is computed as `(page - 1) * pageSize`. A `page` below 1 clamps to the first page rather than producing a negative OFFSET. - **`limit` / `offset`** still work unchanged. `page` wins when a caller sends both. - **`crud.count(table, { filter })`** is a real `COUNT(*)` aggregate over the same tenant-scoped, filtered set — it ignores paging fields on the input, so the total describes the whole result, not the current page. Pass it the SAME `filter` as the list (share the option object) so the count and the pages can't disagree about which rows they mean.
|
|
95
|
+
|
|
96
|
+
Additive: paging style is detected from the request input, so an existing `paginate: true` list is unchanged.
|
|
97
|
+
- **@voltro/cli** — **`voltro inspect schedules --failing`** — roll up each schedule's recent runs and report only the broken ones, exiting **1** when anything is failing.
|
|
98
|
+
|
|
99
|
+
A schedule fires unattended, so a broken one is discovered by someone going to look — and the only thing to look at was the REGISTRATION (which cron exists, when it fires next), never whether it works. That is how three nightly jobs in a downstream app stayed dead for months after a port: each was registered, each fired on time, each threw.
|
|
100
|
+
|
|
101
|
+
The exit code is the point: it makes this usable as a post-deploy gate (`voltro inspect schedules --failing || exit 1`) rather than something a human has to remember. Needed no new endpoint — `/schedules/runs` already returns the recorded firings; the roll-up is a client-side join, which keeps the cost on the diagnostic command instead of on every dashboard poll.
|
|
102
|
+
|
|
103
|
+
A trailing success ends a streak (a recovered job is not reported) and `skipped`/`missed` runs are ignored — those are coordination outcomes, not handler verdicts. Pairs with the schedule failure now logging at `error`.
|
|
104
|
+
- **@voltro/runtime** — `ctx.store.links(junction, anchor).setRows(rows)` — a diff-based reconcile for a many-to-many junction that carries PER-ROW PAYLOAD (a membership `role`, a `capacity` value), the case `set(targetIds)` couldn't model. Each row is `{ [targetColumn]: id, …payload }`; the diff is on the (source, target) pair — an added row is inserted with its payload, a removed row deleted, and a SURVIVING row whose payload actually changed is UPDATED. A row whose payload is unchanged is left untouched, so a reactive consumer sees a change only where the payload differs — the drop+reinsert replacement for a data-carrying junction. Returns `{ added, removed, updated }`. Payload is compared by strict per-column equality (scalars). Additive: a new `setRows` method on `JunctionLinks`.
|
|
105
|
+
- **@voltro/runtime, @voltro/cli** — **A query can now be projected to a public REST endpoint.** `publicApi` on a descriptor was only ever mounted for mutations and actions — a QUERY carrying it produced no route at all, silently. Both boot paths now mount queries too, so offering an API you don't consume from your own frontend is a one-line annotation:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
export default defineQuery({
|
|
109
|
+
name: 'absenceRequests.list',
|
|
110
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
|
|
111
|
+
output: Schema.Array(AbsenceRequest),
|
|
112
|
+
guards: [requireScope('absences:read')],
|
|
113
|
+
publicApi: {}, // → GET /v1/absenceRequests/list?status=open&limit=20
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A query derives **GET**, and its `input` schema binds to the **query string** (`publicApi.ts` wraps it as `{ query }` for GET) — so filter and pagination parameters work as plain URL params. Eager relations work too: `include` is resolved server-side by the executor, so the transport makes no difference. This pairs with `crud.list`'s `filter` / `paginate` / `sort` / `include`, which is what makes a REST list endpoint a single declaration.
|
|
118
|
+
|
|
119
|
+
The one-shot execution is `makeOneShotQueryRunner` (`@voltro/runtime`), built ON TOP of the existing `makeQueryDescriptorProducer` rather than beside it — so the declarative `guards:` gate, the per-request row filter and the tenant/soft-delete scoping are literally the same code the socket path runs. A second implementation would have been an authorization bypass on exactly the reads `publicApi` exposes; the tests pin that a guarded query rejects on the REST path with the executor never running and no read issued. Both handler shapes resolve: a descriptor-returning (reactive) query is finalized and executed to rows, a computed query yields its value. Wired identically in `voltro dev` and `voltro serve` (the dev/serve parity rule) from one shared deps object per path.
|
|
120
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`publicApi: { stream: 'sse' }` on a query now streams.** The field was declared but unimplemented — a query annotated with it silently served the first snapshot as JSON. It now mounts a Server-Sent-Events endpoint: the initial `snapshot`, then a `delta` per change, until the client disconnects.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
export default defineQuery({
|
|
124
|
+
name: 'orders.live',
|
|
125
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String) }),
|
|
126
|
+
output: Schema.Array(Order),
|
|
127
|
+
guards: [requireScope('orders:read')],
|
|
128
|
+
publicApi: { stream: 'sse' }, // → GET /v1/orders/live?status=open (text/event-stream)
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const es = new EventSource('/v1/orders/live?status=open')
|
|
134
|
+
es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
|
|
135
|
+
es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind rather than switch-ing on a payload field. Framing splits embedded newlines across `data:` lines (a raw `\n` would truncate the event), sends `retry: 5000`, and emits a keep-alive comment every 15s so proxies don't drop an idle stream (`cache-control: no-transform` + `x-accel-buffering: no` for the same reason).
|
|
139
|
+
|
|
140
|
+
Three things this deliberately does NOT do differently from the socket: authorization (the subscription runs through the same `makeQueryDescriptorProducer`, so the declarative `guards:`, the row filter and tenant scoping are the same code), leak handling (the client's disconnect runs the route's own unsubscribe through the response scope's finalizer, and a disconnect DURING setup tears the late-arriving subscription down), and error reporting (a guard denial arrives as one `error` EVENT — by then the response headers are on the wire, so throwing is not available).
|
|
141
|
+
|
|
142
|
+
New building blocks, useful beyond publicApi: `PluginHttpRouteResult.stream` lets ANY plugin HTTP route stream, with `sse()` / `sseFrame()` helpers in `@voltro/protocol/rest` for a hand-written `defineRestRoute`; `makeQuerySubscriber` (`@voltro/runtime`) is the shared dispatcher binding both boot paths use. Wired identically in `voltro dev` and `voltro serve`. A `stream: 'sse'` annotation on a mutation/action is ignored (nothing to subscribe to), and a query whose boot layer supplies no subscribe binding falls back to the snapshot response rather than mounting a route that never emits.
|
|
143
|
+
- **@voltro/runtime, @voltro/cli** — Boot audit for the `.serverOnly()` marker (#22): `voltro dev` now warns, naming the query + column, when a wire-reachable query's `output` schema DECLARES a `.serverOnly()` column of its `source` table. The `crud.*` read helpers strip these automatically, but a hand-written output can only be caught here — this is what would have flagged the reported dead `apiKeys.getByKeyId` query that shipped a `keyHash`.
|
|
144
|
+
|
|
145
|
+
Backed by pure, unit-tested helpers exported from `@voltro/runtime`: `serverOnlyLeaks` (a query's output vs its source's serverOnly columns) and `schemaPropertyNames` (best-effort field introspection over the output Schema AST — Struct / Array-of-Struct / NullOr / nested). Detection under-covers rather than false-positives: a shape it can't read yields no field names. The fix it points you at is to omit the column from the output schema (a runtime strip would fail the encode against a schema that still declares the field — the reason this is an audit, not a strip).
|
|
146
|
+
- **@voltro/database, @voltro/runtime** — `.serverOnly()` column marker + `serverOnlyColumns(table)` (#22) — the wire-EXPOSURE axis, declared explicitly at the schema and distinct from `.encrypted()` (storage at rest) and `.sensitive()` (export masking). A `.serverOnly()` column is read normally by server code but must never be serialized to a client:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
keyHash: text().serverOnly(), // an auth middleware verifies it; a client never sees it
|
|
150
|
+
apiToken: text().encrypted().serverOnly(), // a column can carry both axes, or either
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The [`crud.*` read helpers](#) strip `.serverOnly()` columns from every returned row AUTOMATICALLY — declare the exposure policy once at the schema and every crud read respects it, so you can't forget it on a handler (the single-source form of the per-call `redact` option, which still works for anything not worth a marker).
|
|
154
|
+
|
|
155
|
+
Why a NEW axis rather than reusing `.encrypted()`: encryption at rest says nothing about who may receive the plaintext — decrypting a private note *for its owner* is a valid case, so treating "encrypted" as "never to a client" would be wrong. Exposure is stated explicitly. Default stays exposed to both server and client; `.serverOnly()` opts a column out of the client.
|
|
156
|
+
|
|
157
|
+
Additive: a `serverOnly()` builder method + a `serverOnly?` flag on `ColumnDefinition` + the `serverOnlyColumns` helper. Enforcement beyond the `crud.*` path — a runtime strip at the rpc wire boundary and a `serverOnly: true` whole-query primitive — is a planned follow-on (see `plans/framework-serverOnly-exposure.md`).
|
|
158
|
+
|
|
159
|
+
### Fixed
|
|
160
|
+
|
|
161
|
+
- **@voltro/cli** — Machine output is now consistent across the CLI. `--json` works everywhere it should: the inspect-family commands (inspect/logs/traces/cluster/check) accept `--json` (and `--format=json`) as an alias for `--format json` — `voltro inspect app --json` silently produced pretty output before; and cloud/capabilities/doctor accept both spellings too. All `--json` payloads route through one `printJson` helper, and `bin.ts` now drains stdout before `process.exit`, so a large payload piped to a file / `| jq` is no longer truncated (only `voltro db --json` was safe before). A user-invocation mistake (no app.config here, a memory dialect where a real one is needed) now prints a clean one-line error instead of a stack trace (new `CliError`, rendered by bin.ts).
|
|
162
|
+
- **@voltro/cli** — `voltro version` now prints the real installed version (was a hardcoded `v0.0.0 (scaffold)` placeholder); usage / unknown-command / version output say "voltro" instead of "framework". `voltro <command> --help` (and `-h`) now prints the command summary and returns 0 for EVERY command instead of, in several cases, running the command — `voltro env --help` used to run the env check and could exit 1. Commands with their own richer help (inspect/logs/doctor/secret) still show it. Honest command summaries: `db` now lists its declarative-workflow subcommands (plan/apply/plans/drift/squash/restore-snapshot/files/…) and drops the never-implemented `generate`; `serverless` lists `dev`/`serve` and the `node` default target; `workflows` lists `start`. `voltro cloud deploy`/`rollback`, which are not yet implemented, now fail (non-zero, stderr) instead of printing a message and returning success, and are marked "coming soon" in help.
|
|
163
|
+
- **@voltro/cli** — Security: the inspect API's CORS now reflects the request `Origin` and allows credentials ONLY for loopback origins (localhost / 127.0.0.0/8 / ::1). Previously it echoed ANY origin with `access-control-allow-credentials: true`, so a website a developer visited could issue a credentialed cross-origin `fetch` and read the (default-open) inspect surface — DB rows, logs, schema, drift — and POST to fire schedules / start workflows. A non-loopback origin now gets no `access-control-allow-origin`, so the browser blocks the read; curl / same-origin / the local dashboard are unaffected. Also: the dashboard proxy (`/api/dashboard/proxy?target=`) now requires a loopback target (it forwards the caller's bearer, so an arbitrary target was an SSRF + bearer-harvest), and `~/.voltro/credentials.json` is written 0600 in a 0700 dir instead of world-readable.
|
|
164
|
+
- **@voltro/cli** — `voltro serverless serve`/`dev` now exits non-zero when the server fails to bind or crashes at boot (it returned 0 = success, so a CI/deploy wrapper read a dead server as up). The production `voltro serve` shutdown chain gained a terminal `.catch` + a 10s force-exit safety net, so a rejected/hung drain step (plugin deactivate, pool close) no longer strands the process until the container's SIGKILL grace timer. `voltro e2e` cleanup now escalates SIGTERM→SIGKILL for a child that ignores SIGTERM (was lingering on :4000/:5190 and EADDRINUSE-ing the next run). `voltro migrate` now warns when it falls back to the default `localhost:5432` DB with no DB_URL/DB_HOST set.
|
|
165
|
+
- **@voltro/cli** — CLI flag parsing is now shared (`src/cliArgs.ts`: `flagValue` / `takeFlag` / `hasFlag` / `positionals`) instead of each command hand-rolling its own `indexOf('--x'); args[i+1]`. Adopted across data, serve, cloud, db (14 sites), baseline, and secret — so every command accepts `--name value` AND `--name=value` uniformly. Group-command exit codes are standardized too: an unknown subcommand exits 2, a missing subcommand exits 1 (was a mix of 0/1/1-vs-1 including a dead `? 1 : 1` ternary in storage).
|
|
166
|
+
- **@voltro/runtime** — **`crud.list` now caps the page size (default 1000).** The page size is CALLER-controlled — `input.limit` / `input.pageSize` — and nothing anywhere clamped it, so `?limit=1000000000` was a one-request read of the whole table. That was already unwelcome over the WebSocket; it became a genuine exposure the moment a query could be projected to a public REST endpoint (`publicApi`), where the caller is anyone who can reach the URL.
|
|
167
|
+
|
|
168
|
+
- `limit` / `pageSize` above the cap are **clamped, not rejected** (a caller asking for too much gets the maximum page, not a 400). - `maxPageSize` raises it deliberately for an export-style endpoint. - A zero/negative `limit` clamps to 1 row, and a negative `offset` / `page < 1` clamps to 0 rather than producing a negative OFFSET (which dialects reject or treat oddly). Non-integers are floored.
|
|
169
|
+
|
|
170
|
+
Found by auditing the paging code introduced in this same series — the cap was missing from the start, so this is a fix, not a behaviour change anyone relied on.
|
|
171
|
+
- **@voltro/plugin-ai-flows** — **An AI-flow `MediaGenerator` can now require services.** Its type pinned the Effect requirement channel to `never`, so the common implementation — generate, then persist through `StorageService` — was untypeable while working perfectly at runtime. A type that forbids what the program does is a type lying about the program; the host was left carrying a documented cast.
|
|
172
|
+
|
|
173
|
+
`MediaGenerator` and its `EngineDeps` siblings (`resolveAgent`, `onEvent`, `memoryPrefix`) now declare `R = unknown`, the same shape `@voltro/ai` uses for tool bodies (`execute?: (input) => Effect<O, never, unknown>`). The engine runs inside the host's runtime, which HAS those services, and states that fact once in a `callDep` bridge rather than at every call site — mirroring `callBody` in `@voltro/ai`.
|
|
174
|
+
|
|
175
|
+
`apiSurface: compatible` — the change WIDENS the requirement channel on callbacks the app IMPLEMENTS. An existing implementation that requires nothing (`Effect<A, E, never>`) stays assignable to the widened type, so no downstream implementation breaks; the only consumer of the narrow form was the engine itself, which now bridges it. Verified by a full repo typecheck.
|
|
176
|
+
- **@voltro/cli** — **Four discovered conventions were missing from the serve bundle — a prod-only boot crash waiting for an app to use them.** `voltro build` bundles the modules matching `API_ENTRY_PATTERN`; `voltro serve` resolves every app module from that bundle, and a convention the pattern misses falls back to loading `.ts` SOURCE, which a plain-node boot cannot do. `*.outbox.ts`, `*.connection.ts`, `*.email.tsx` and `*.migration.ts` were all absent — and all four are loaded at serve time (`serveCommand` filters outbox handlers explicitly, noting that without them the transactional outbox enqueues rows in production that nothing delivers). Fixed, and the lockstep is now a TEST (`apiEntryPatternLockstep.test.ts`) that asserts a representative filename for every convention matches — it found these four the moment it was written.
|
|
177
|
+
|
|
178
|
+
**The file conventions are now single-sourced** (`fileConventions.ts`). They were declared in five modules — dev discovery, plugin codegen, framework-table assembly, migrate, the db command — and copies of a rule that IS the rule drift silently, because each copy is internally consistent. `WORKFLOW_PATTERN` had already drifted into two shapes: strict `\.workflow\.tsx$` in discovery, loose `\.workflow\.tsx?$` in table assembly. So a file named `orders.workflow.ts` got `_voltro_workflow_*` TABLES (the loose copies counted it) but was never registered as a workflow (the strict copy skipped it) — no error, no warning, just a workflow that did nothing.
|
|
179
|
+
|
|
180
|
+
The two copies were answering DIFFERENT questions, and conflating them is what made the bug invisible, so both are now named: `WORKFLOW_DESCRIPTOR_PATTERN` (strict — the executor is paired by rewriting that exact suffix, so `.ts` could never work) and `WORKFLOW_PRESENCE_PATTERN` (loose on purpose — over-provisioning a table is harmless, missing one breaks a boot). `voltro dev` now WARNS on the gap between them, naming the file and the one-character fix, instead of skipping in silence.
|
|
181
|
+
- **@voltro/database** — **A `.sensitive()` / `.safe()` marker no longer moves the schema fingerprint.** They emit no DDL and introspection never reads them back, yet they fed the hash — so adding a classification flipped the boot fast-path to "the declaration changed" and forced a full diff. A classification sweep over a few hundred tables therefore triggered a migration run, and any drift that had accumulated silently since the last real change surfaced THERE, triggered by an edit with nothing to do with it. Reported from a boot that then REFUSED and crash-looped on 31 pending ops, none of which came from the change that moved the hash.
|
|
182
|
+
|
|
183
|
+
They join `idScheme` in `stripFingerprintHints`, which already existed for exactly this class. The rule, now written down where the next field gets added: **if introspection cannot read it back, it does not belong in the fingerprint** — the hash answers "does the database match the declaration?", not "did any character of the declaration change?".
|
|
184
|
+
|
|
185
|
+
Tested including the control case (a real DDL change must still move the hash, or the test would pass against a hash that ignores everything).
|
|
186
|
+
- **@voltro/i18n** — `createTypedMessages` (#16) now collects a REAL var nested inside a plural/select branch (#21). Previously `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'` required only `count` — `discipline`, which lives inside the branches, was not extracted, so `t('key', { count })` compiled and then threw `The intl string context variable "discipline" was not provided` when it rendered. Now both `count` AND `discipline` are required at the call site.
|
|
187
|
+
|
|
188
|
+
`ICUVars` was rewritten to SCAN every `{` and classify what follows it (an arg name / a bare placeholder / a structural branch to skip) rather than match a "body up to the matching `}`", which a nested `{var}` broke. It handles arbitrary nesting, and a branch's literal text (`# days`) is still never mistaken for a var.
|
|
189
|
+
|
|
190
|
+
`apiSurface: compatible` — the `ICUVars<S>` signature is unchanged (only its body). The stricter extraction cannot break WORKING code: a call that omitted the nested var was already throwing at render (that is the bug this fixes); code that passed it — the documented workaround — keeps compiling.
|
|
191
|
+
- **@voltro/protocol, @voltro/plugin-openapi** — **A streaming route is now visible on the descriptor, and OpenAPI documents it as one.** When `publicApi: { stream: 'sse' }` shipped, the streaming-ness lived inside the handler closure — so anything that INSPECTS a route without running it couldn't tell a stream from a buffered response. The OpenAPI generator therefore emitted `content: { 'application/json': <output schema> }` for an SSE endpoint: a spec that generates clients which try to parse the whole event stream as one JSON value.
|
|
192
|
+
|
|
193
|
+
`RestRouteDescriptor` gains `streaming?: boolean`, `publicApiRoute` sets it for a `stream: 'sse'` query, and the generator emits `text/event-stream` (with no JSON response schema) for such a route.
|
|
194
|
+
|
|
195
|
+
Worth stating as a rule, since it is the second time this shape has cost something: a fact that tooling must act on belongs on the DESCRIPTOR, not in the closure that implements it. A wrong spec is worse than a missing one — clients are generated from it.
|
|
196
|
+
- **@voltro/runtime, @voltro/cli** — **A failing schedule handler is now an ERROR, not a warn.** A schedule fires unattended — there is no user watching a request fail — so the log line IS the discovery channel. It was `warn`, which `voltro logs --level error` does not show, and the only other surface is a dashboard nobody has open on staging.
|
|
197
|
+
|
|
198
|
+
The root cause was structural rather than a bad choice: `SchedulerLogger` had no error channel at all (`info` / `warn` / optional `debug`), so the level was not expressible. It now has one — optional, so an embedder passing a two-method logger still compiles, falling back to `warn` — and BOTH boot paths (`voltro dev`, `voltro serve`) wire it, since a channel nothing supplies would have changed nothing.
|
|
199
|
+
|
|
200
|
+
Reported from an app where three nightly jobs had been dead since a port — one of them an entire feature that never wrote a single row — each failing on EVERY firing, for months, at `warn`. The failure was already recorded in `_voltro_schedule_runs` (`status: 'failed'`) and published on the server-error bus; the log level was the one place that disagreed with both.
|
|
201
|
+
- **@voltro/runtime** — **`ctx.workflows.start` now validates the payload against the workflow's schema, and says so when it doesn't match.** The signature is `(workflowName: string, payload: unknown)` — the name was not checked against the registry and the payload was not checked against anything — so a caller that drifted from the workflow's schema failed DEEP inside the engine, where the message reads like the workflow itself misbehaved.
|
|
202
|
+
|
|
203
|
+
A mismatch now throws a `WorkflowPayloadError` carrying `workflowName`, `missingFields`, and the formatted parse error, and an unknown name lists the workflows that ARE registered (so a rename reads differently from a deletion). `run()` and `executionId()` validate too — `start` is not the only entry point.
|
|
204
|
+
|
|
205
|
+
Validation is on the DECODED side (`Schema.validate`, not `decodeUnknown`): a `Schema.Date` payload accepts a `Date`. Using `decodeUnknown` here would have rejected valid calls — the check meant to protect the caller breaking them.
|
|
206
|
+
|
|
207
|
+
Reported from an app where a cron fired such a start on every tick: three nightly jobs dead since a port, one of them an entire feature that never wrote a row. The payload was the bug and nothing in the failure said so. Pairs with the schedule failure now logging at `error`.
|
|
208
|
+
|
|
209
|
+
### Internal (no consumer-facing effect)
|
|
210
|
+
|
|
211
|
+
- **@voltro/cli** — Test-only: cover the migrationRunner SQL apply/rollback path (the report's top coverage gap). `migrationRunnerApply.test.ts` runs real defineMigration steps against Postgres — CREATE/DROP via the SqlClient, asserting the DDL ran, the `_voltro_migrations` ledger recorded it, re-run skips, and rollback reverts — gated on a reachable Postgres (docker-compose `postgres-test` on :55432, or DB_URL) and skipped when down, per the dialect-parity convention. (The runner's ledger upsert uses `now()` + `ON CONFLICT`, so it is genuinely not sqlite-testable.)
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
42
215
|
## [0.11.3] — 2026-07-24
|
|
43
216
|
|
|
44
217
|
### Added
|
package/dist/index.d.ts
CHANGED
|
@@ -954,6 +954,14 @@ export declare interface AttachAnalyticsMirrorOptions {
|
|
|
954
954
|
readonly run?: (effect: Effect.Effect<void, unknown>) => Promise<unknown>;
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
/** The descriptor shape the audit needs — a `defineQuery` result carries it. */
|
|
958
|
+
export declare interface AuditableQuery {
|
|
959
|
+
readonly name: string;
|
|
960
|
+
readonly output: Schema.Schema.Any;
|
|
961
|
+
/** Declared source table(s) — the tables whose serverOnly columns must not leak. */
|
|
962
|
+
readonly source?: string | ReadonlyArray<string> | undefined;
|
|
963
|
+
}
|
|
964
|
+
|
|
957
965
|
/**
|
|
958
966
|
* Resolve once a server returned by {@link startRpcServer} has actually
|
|
959
967
|
* bound its port (node `'listening'` event). REJECTS on `'error'`
|
|
@@ -1525,10 +1533,14 @@ export declare const countRunningWorkflows: (store: DataStore) => Promise<number
|
|
|
1525
1533
|
* `(input, ctx) => …` executor for a `*.server.ts` default export.
|
|
1526
1534
|
*/
|
|
1527
1535
|
export declare const crud: {
|
|
1528
|
-
/** Tenant-scoped list
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1536
|
+
/** Tenant-scoped list, redacted — with optional filter / sort / pagination from
|
|
1537
|
+
* the request input, so a real list view doesn't have to be hand-written. */
|
|
1538
|
+
list: (table: string, options?: CrudListOptions) => (input: unknown, ctx: AppContext) => Promise<ReadonlyArray<Row>>;
|
|
1539
|
+
/** One row by id, or `null` when absent — never throws. Redacted, with optional
|
|
1540
|
+
* eager-loaded relations (`include`). */
|
|
1541
|
+
getById: (table: string, options?: CrudReadOptions & {
|
|
1542
|
+
readonly include?: CrudInclude;
|
|
1543
|
+
}) => (input: {
|
|
1532
1544
|
readonly id: string;
|
|
1533
1545
|
}, ctx: AppContext) => Promise<Row | null>;
|
|
1534
1546
|
/** Insert the input as a new row (id / tenant / audit auto-stamped). The echoed
|
|
@@ -1539,6 +1551,16 @@ export declare const crud: {
|
|
|
1539
1551
|
update: (table: string, options?: CrudWriteOptions) => (input: {
|
|
1540
1552
|
readonly id: string;
|
|
1541
1553
|
} & Record<string, unknown>, ctx: AppContext) => Promise<Row | null>;
|
|
1554
|
+
/**
|
|
1555
|
+
* Tenant-scoped COUNT of the matching rows — the total a page-based UI needs to
|
|
1556
|
+
* render "page 3 of 12". Takes the SAME `filter` as `list` (share the option
|
|
1557
|
+
* object so the two can't disagree about which rows they mean) and ignores
|
|
1558
|
+
* paging: it counts the whole filtered set, not the current page. A real
|
|
1559
|
+
* `COUNT(*)` aggregate, not a fetch-and-length.
|
|
1560
|
+
*
|
|
1561
|
+
* export default crud.count('absenceRequests', { filter: (i) => ({ status: i.status }) })
|
|
1562
|
+
*/
|
|
1563
|
+
count: (table: string, options?: Pick<CrudListOptions, "filter">) => (input: unknown, ctx: AppContext) => Promise<number>;
|
|
1542
1564
|
/** Delete a row by id; returns `{ deleted }`. Guard the DESCRIPTOR. */
|
|
1543
1565
|
remove: (table: string) => (input: {
|
|
1544
1566
|
readonly id: string;
|
|
@@ -1547,6 +1569,76 @@ export declare const crud: {
|
|
|
1547
1569
|
}>;
|
|
1548
1570
|
};
|
|
1549
1571
|
|
|
1572
|
+
/** The eager-load spec `.with()` accepts — nested relations, each branch taking
|
|
1573
|
+
* its own `where` / `orderBy` / `limit` (nested filtering + sort). Reused as-is
|
|
1574
|
+
* so `crud.list`'s `include` is exactly what a hand-written `.with(...)` takes. */
|
|
1575
|
+
export declare type CrudInclude = Parameters<SelectBuilder['with']>[0];
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Options for `crud.list` — the read ergonomics every real list view needs, so a
|
|
1579
|
+
* generated list isn't limited to "all rows". All optional and additive: a bare
|
|
1580
|
+
* `crud.list('t')` still returns every (tenant-scoped, redacted) row.
|
|
1581
|
+
*/
|
|
1582
|
+
export declare interface CrudListOptions extends CrudReadOptions {
|
|
1583
|
+
/**
|
|
1584
|
+
* Build a WHERE from the request input — return a column→value map. Only
|
|
1585
|
+
* entries whose value is not `undefined` are applied, so an absent filter field
|
|
1586
|
+
* is simply ignored (`{ employeeId: input.employeeId, status: input.status }`).
|
|
1587
|
+
* The descriptor's `input` schema declares those fields; this maps them to a
|
|
1588
|
+
* scoped `.where(column, value)` on the store query.
|
|
1589
|
+
*/
|
|
1590
|
+
readonly filter?: (input: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>>;
|
|
1591
|
+
/**
|
|
1592
|
+
* Page the result from the request. BOTH styles are accepted, so a caller uses
|
|
1593
|
+
* whichever its UI thinks in:
|
|
1594
|
+
*
|
|
1595
|
+
* - **page-based** — `input.page` (1-based) + `input.pageSize` (default 100).
|
|
1596
|
+
* A table UI showing "page 3 of 12" sends `?page=3&pageSize=20`.
|
|
1597
|
+
* - **offset-based** — `input.limit` / `input.offset` (defaults 100 / 0).
|
|
1598
|
+
*
|
|
1599
|
+
* `page` wins when both are present. Pair with `crud.count` for the total a
|
|
1600
|
+
* page-based UI needs to render the last-page number.
|
|
1601
|
+
*/
|
|
1602
|
+
readonly paginate?: boolean;
|
|
1603
|
+
/**
|
|
1604
|
+
* Upper bound on the rows ONE request may ask for (default 1000). The page
|
|
1605
|
+
* size is caller-controlled, so without a cap `?limit=1000000` on a
|
|
1606
|
+
* `publicApi` list is a one-request resource-exhaustion lever for anyone who
|
|
1607
|
+
* can reach the endpoint. Raise it deliberately for an export-style endpoint;
|
|
1608
|
+
* a `limit`/`pageSize` above it is clamped, not rejected.
|
|
1609
|
+
*/
|
|
1610
|
+
readonly maxPageSize?: number;
|
|
1611
|
+
/** Multi-column sort, applied in order (`[{ column: 'createdAt', direction:
|
|
1612
|
+
* 'desc' }, …]`). */
|
|
1613
|
+
readonly sort?: ReadonlyArray<CrudSort>;
|
|
1614
|
+
/**
|
|
1615
|
+
* Eager-load related rows via `.with(...)` — the SAME spec a hand-written query
|
|
1616
|
+
* takes, so nested relations, and per-branch `where` / `orderBy` / `limit`
|
|
1617
|
+
* (nested filtering + sort) all work:
|
|
1618
|
+
*
|
|
1619
|
+
* include: { employee: { with: { team: true } }, tags: { orderBy: 'name' } }
|
|
1620
|
+
*/
|
|
1621
|
+
readonly include?: CrudInclude;
|
|
1622
|
+
/**
|
|
1623
|
+
* SQL column projection — narrow the `SELECT` so wide columns are never READ,
|
|
1624
|
+
* not merely dropped at the wire boundary. The output schema already strips
|
|
1625
|
+
* undeclared columns on encode (so nothing extra ships either way); this is the
|
|
1626
|
+
* PERFORMANCE half: a table with a large `json()` blob or a long text body that
|
|
1627
|
+
* a list view never shows shouldn't cost the read, the transfer from the DB, or
|
|
1628
|
+
* the decode.
|
|
1629
|
+
*
|
|
1630
|
+
* columns: ['id', 'title', 'createdAt'] // the big `body` is never read
|
|
1631
|
+
*
|
|
1632
|
+
* `.serverOnly()` columns are removed from the projection automatically — they
|
|
1633
|
+
* are stripped from the response anyway, so reading them is pure waste.
|
|
1634
|
+
*
|
|
1635
|
+
* TRAP with `include`: an eager branch joins on a foreign key, so a projection
|
|
1636
|
+
* that omits that FK column breaks the relation. Keep the FK in `columns` when
|
|
1637
|
+
* you also pass `include`.
|
|
1638
|
+
*/
|
|
1639
|
+
readonly columns?: ReadonlyArray<string>;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1550
1642
|
/** Options common to a generated READ. */
|
|
1551
1643
|
export declare interface CrudReadOptions {
|
|
1552
1644
|
/** Columns stripped from every returned row — a secret/credential a generated
|
|
@@ -1556,6 +1648,12 @@ export declare interface CrudReadOptions {
|
|
|
1556
1648
|
readonly redact?: ReadonlyArray<string>;
|
|
1557
1649
|
}
|
|
1558
1650
|
|
|
1651
|
+
/** A single sort term for `crud.list`. */
|
|
1652
|
+
export declare interface CrudSort {
|
|
1653
|
+
readonly column: string;
|
|
1654
|
+
readonly direction?: 'asc' | 'desc';
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1559
1657
|
/** Options for a generated WRITE — `redact` applies to the row the write echoes. */
|
|
1560
1658
|
export declare interface CrudWriteOptions {
|
|
1561
1659
|
readonly redact?: ReadonlyArray<string>;
|
|
@@ -2103,6 +2201,9 @@ export declare interface FluentStoreBackend {
|
|
|
2103
2201
|
/** Forget the calling subject's credential. Returns whether a row was removed. */
|
|
2104
2202
|
export declare const forgetCredential: (store: VaultStore, connectionId: string, subjectId: string) => Promise<boolean>;
|
|
2105
2203
|
|
|
2204
|
+
/** A boot-ready message for a set of leaks (empty → `undefined`, i.e. clean). */
|
|
2205
|
+
export declare const formatServerOnlyLeaks: (leaks: ReadonlyArray<ServerOnlyLeak>) => string | undefined;
|
|
2206
|
+
|
|
2106
2207
|
/** Serialise a span's context as a `traceparent` header value. */
|
|
2107
2208
|
export declare const formatTraceparent: (ctx: TraceContext) => string;
|
|
2108
2209
|
|
|
@@ -2532,6 +2633,22 @@ export declare interface JunctionLinks {
|
|
|
2532
2633
|
add(targetIds: ReadonlyArray<string>): Promise<ReadonlyArray<string>>;
|
|
2533
2634
|
/** Unlink `targetIds` that are currently linked. Returns the ids actually removed. */
|
|
2534
2635
|
remove(targetIds: ReadonlyArray<string>): Promise<ReadonlyArray<string>>;
|
|
2636
|
+
/**
|
|
2637
|
+
* Reconcile links that carry PER-ROW PAYLOAD — a junction with business columns
|
|
2638
|
+
* (a membership `role`, a `capacity` value). Each row is
|
|
2639
|
+
* `{ [targetColumn]: id, …payload }`; the diff is on the (source, target) pair:
|
|
2640
|
+
* an added row is inserted with its payload, a removed row deleted, and a
|
|
2641
|
+
* SURVIVING row whose payload actually changed is UPDATED — one whose payload
|
|
2642
|
+
* is unchanged is left untouched, so a reactive consumer sees a change only
|
|
2643
|
+
* where the payload differs (the `set(targetIds)` form can't express payload;
|
|
2644
|
+
* this is the drop+reinsert replacement for a junction that carries data).
|
|
2645
|
+
* Payload is compared by strict equality per column (scalars — capacity, role).
|
|
2646
|
+
*/
|
|
2647
|
+
setRows(rows: ReadonlyArray<Readonly<Record<string, unknown>>>): Promise<{
|
|
2648
|
+
readonly added: ReadonlyArray<string>;
|
|
2649
|
+
readonly removed: ReadonlyArray<string>;
|
|
2650
|
+
readonly updated: ReadonlyArray<string>;
|
|
2651
|
+
}>;
|
|
2535
2652
|
}
|
|
2536
2653
|
|
|
2537
2654
|
export declare interface KvFacade {
|
|
@@ -2794,6 +2911,24 @@ export declare const makeListConnectionsExecutor: (deps: ConnectionBuiltinDeps)
|
|
|
2794
2911
|
*/
|
|
2795
2912
|
export declare const makeMutationRunner: (deps: MutationRunnerDeps) => (mutation: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
|
|
2796
2913
|
|
|
2914
|
+
/**
|
|
2915
|
+
* Run a QUERY once and resolve its value — the read counterpart to
|
|
2916
|
+
* `makeMutationRunner` / `makeActionRunner`, for callers that have no
|
|
2917
|
+
* subscription: a descriptor projected to a public REST endpoint.
|
|
2918
|
+
*
|
|
2919
|
+
* It is deliberately built ON TOP of `makeQueryDescriptorProducer` rather than
|
|
2920
|
+
* beside it. That producer is where the declarative `guards:` gate, the
|
|
2921
|
+
* per-request row-filter resolution and the tenant-scoping `finalize` live, so
|
|
2922
|
+
* reusing it makes the one-shot path enforce EXACTLY what the WS path enforces.
|
|
2923
|
+
* A second implementation here would be an authorization bypass waiting to
|
|
2924
|
+
* happen — the REST projection of a guarded query must fail the same way the
|
|
2925
|
+
* socket does, and it does because it runs the same code.
|
|
2926
|
+
*
|
|
2927
|
+
* Both handler shapes resolve: a descriptor-returning (reactive) query is
|
|
2928
|
+
* finalized and executed to rows; a computed query yields its value.
|
|
2929
|
+
*/
|
|
2930
|
+
export declare const makeOneShotQueryRunner: <D>(deps: OneShotQueryRunnerDeps<D>) => (query: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
|
|
2931
|
+
|
|
2797
2932
|
export declare const makeOutboxFacade: (deps: OutboxFacadeDeps) => OutboxFacade;
|
|
2798
2933
|
|
|
2799
2934
|
export declare const makePostCommitWorkflowFacade: (base: WorkflowsAppContext, afterCommit: (work: () => Promise<unknown>) => void) => WorkflowsAppContext;
|
|
@@ -2837,6 +2972,25 @@ export declare const makeQueryDescriptorProducer: <D>(deps: QueryProducerDeps<D>
|
|
|
2837
2972
|
*/
|
|
2838
2973
|
export declare const makeQueryReauthorizer: (query: MutationLike, input: unknown) => (subject: Subject) => () => Promise<unknown>;
|
|
2839
2974
|
|
|
2975
|
+
/**
|
|
2976
|
+
* Subscribe a QUERY and stream its events to a non-rpc consumer — the SSE
|
|
2977
|
+
* projection of a `publicApi` query. Like `makeOneShotQueryRunner`, it runs the
|
|
2978
|
+
* shared `makeQueryDescriptorProducer`, so the declarative `guards:`, the row
|
|
2979
|
+
* filter and tenant scoping are the same code the socket path runs: an SSE
|
|
2980
|
+
* endpoint is exactly as gated as the WebSocket one.
|
|
2981
|
+
*
|
|
2982
|
+
* Returns an unsubscribe SYNCHRONOUSLY (the HTTP layer needs one immediately)
|
|
2983
|
+
* while the subscription opens in the background; unsubscribing before it
|
|
2984
|
+
* finishes tears it down as soon as it exists, so a client that disconnects
|
|
2985
|
+
* mid-setup cannot leak a subscription. A setup failure — including a guard
|
|
2986
|
+
* denial — is emitted as one `error` event rather than thrown, because by then
|
|
2987
|
+
* the response headers are already on the wire.
|
|
2988
|
+
*/
|
|
2989
|
+
export declare const makeQuerySubscriber: <D>(deps: QuerySubscriberDeps<D>) => (query: MutationLike, input: unknown, requestContext: ServeRequestContext, emit: (event: {
|
|
2990
|
+
readonly _tag: string;
|
|
2991
|
+
readonly [k: string]: unknown;
|
|
2992
|
+
}) => void) => (() => void);
|
|
2993
|
+
|
|
2840
2994
|
export declare const makeRouterActivity: () => RouterActivity;
|
|
2841
2995
|
|
|
2842
2996
|
/**
|
|
@@ -3225,6 +3379,15 @@ export declare interface OAuth2ConnectionDefinition extends ConnectionDefinition
|
|
|
3225
3379
|
*/
|
|
3226
3380
|
export declare const onBindConnectionSubject: (listener: (clientId: number, subject: Subject) => void) => () => void;
|
|
3227
3381
|
|
|
3382
|
+
export declare interface OneShotQueryRunnerDeps<D> extends QueryProducerDeps<D> {
|
|
3383
|
+
/**
|
|
3384
|
+
* Execute a FINALIZED (tenant-scoped) descriptor → rows. On the WS path the
|
|
3385
|
+
* dispatcher owns this step; a ONE-SHOT read (a `publicApi` REST GET, where
|
|
3386
|
+
* there is no subscription to drive) needs it inline.
|
|
3387
|
+
*/
|
|
3388
|
+
readonly queryRows: (descriptor: D) => Promise<ReadonlyArray<unknown>>;
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3228
3391
|
/** Thrown by `.expectVersion(n).set(...)` when the optimistic-lock guard
|
|
3229
3392
|
* matched no row (the row was concurrently updated or deleted). */
|
|
3230
3393
|
export declare class OptimisticLockError extends OptimisticLockError_base {
|
|
@@ -3529,6 +3692,13 @@ export declare interface QueryProducerDeps<D> {
|
|
|
3529
3692
|
readonly interceptor?: ServeRpcInterceptor;
|
|
3530
3693
|
}
|
|
3531
3694
|
|
|
3695
|
+
export declare interface QuerySubscriberDeps<D> extends QueryProducerDeps<D> {
|
|
3696
|
+
/** Open a dispatcher subscription for a finalized descriptor. */
|
|
3697
|
+
readonly subscribeDescriptor: (descriptor: D, emit: (event: unknown) => void, context: ServeRequestContext) => Promise<() => void>;
|
|
3698
|
+
/** Open a dispatcher subscription for a COMPUTED query (re-runs on source change). */
|
|
3699
|
+
readonly subscribeComputed: (computed: ComputedQuery, emit: (event: unknown) => void, context: ServeRequestContext) => Promise<() => void>;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3532
3702
|
/** What a reaction does when it fires — run an agent or start a workflow. Both
|
|
3533
3703
|
* identified by name; the serve layer resolves + runs them as `agentActor`. */
|
|
3534
3704
|
export declare type ReactionAct = {
|
|
@@ -4846,6 +5016,14 @@ export declare interface SchedulerHandle {
|
|
|
4846
5016
|
export declare interface SchedulerLogger {
|
|
4847
5017
|
info: (msg: string, fields?: Record<string, unknown>) => void;
|
|
4848
5018
|
warn: (msg: string, fields?: Record<string, unknown>) => void;
|
|
5019
|
+
/**
|
|
5020
|
+
* Failures. This channel did not exist, which is why a schedule whose handler
|
|
5021
|
+
* failed on EVERY firing was only ever a `warn` — invisible to `voltro logs
|
|
5022
|
+
* --level error`, and a schedule fires unattended, so that log line is the
|
|
5023
|
+
* whole discovery channel. Optional so an embedder passing a two-method logger
|
|
5024
|
+
* still compiles; it falls back to `warn` at the call site.
|
|
5025
|
+
*/
|
|
5026
|
+
error?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
4849
5027
|
/** Optional debug channel for high-frequency expected events
|
|
4850
5028
|
* (lost coordination claims on sub-minute schedules etc.). */
|
|
4851
5029
|
debug?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
@@ -4914,6 +5092,13 @@ export declare interface SchemaInfo {
|
|
|
4914
5092
|
readonly idScheme?: IdScheme;
|
|
4915
5093
|
}
|
|
4916
5094
|
|
|
5095
|
+
/**
|
|
5096
|
+
* Every property NAME that appears anywhere in a schema's shape. Recursive over
|
|
5097
|
+
* the common composition nodes so a column nested under `{ refs: [{ … }] }` or
|
|
5098
|
+
* behind a `NullOr` is still seen. Cycle-guarded via a seen-set on Suspend.
|
|
5099
|
+
*/
|
|
5100
|
+
export declare const schemaPropertyNames: (schema: Schema.Schema.Any) => ReadonlySet<string>;
|
|
5101
|
+
|
|
4917
5102
|
export declare interface SchemaRegistry {
|
|
4918
5103
|
readonly tables: ReadonlyMap<string, SchemaInfo>;
|
|
4919
5104
|
/** True iff the table was composed with the named mixin id. */
|
|
@@ -5061,6 +5246,20 @@ export declare interface ServeRequestContext {
|
|
|
5061
5246
|
readonly rowFilter?: RowFilterScope;
|
|
5062
5247
|
}
|
|
5063
5248
|
|
|
5249
|
+
/** One leak: a wire query that declares a serverOnly column in its output. */
|
|
5250
|
+
export declare interface ServerOnlyLeak {
|
|
5251
|
+
readonly query: string;
|
|
5252
|
+
readonly table: string;
|
|
5253
|
+
readonly column: string;
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
/**
|
|
5257
|
+
* Every serverOnly column a query's output declares. `serverOnlyByTable` maps a
|
|
5258
|
+
* table name to its `.serverOnly()` column names. A query with no `source`, or
|
|
5259
|
+
* whose source has no serverOnly columns, yields nothing.
|
|
5260
|
+
*/
|
|
5261
|
+
export declare const serverOnlyLeaks: (query: AuditableQuery, serverOnlyByTable: ReadonlyMap<string, ReadonlyArray<string>>) => ReadonlyArray<ServerOnlyLeak>;
|
|
5262
|
+
|
|
5064
5263
|
/** A plugin interceptor — wraps the base run Effect (Effect-native chain). */
|
|
5065
5264
|
export declare type ServeRpcInterceptor = (base: Effect.Effect<unknown, unknown, never>, meta: {
|
|
5066
5265
|
readonly tag: string;
|
|
@@ -6193,6 +6392,32 @@ export declare interface WorkflowLayerOptions<Context> {
|
|
|
6193
6392
|
readonly resolveStartContext?: (workflowName: string, executionId: string) => WorkflowCallerContext | undefined | Promise<WorkflowCallerContext | undefined>;
|
|
6194
6393
|
}
|
|
6195
6394
|
|
|
6395
|
+
/**
|
|
6396
|
+
* A start whose PAYLOAD does not match the workflow's schema.
|
|
6397
|
+
*
|
|
6398
|
+
* Distinct from a workflow that ran and failed, and the distinction is the whole
|
|
6399
|
+
* point: `ctx.workflows.start(name, payload)` is typed `(string, unknown)` — the
|
|
6400
|
+
* name is not checked against the registry and the payload is not checked against
|
|
6401
|
+
* anything — so a caller that drifts from the workflow's schema produces a failure
|
|
6402
|
+
* DEEP inside the engine, where it reads like the workflow itself misbehaved.
|
|
6403
|
+
*
|
|
6404
|
+
* A cron firing such a start hit that every single time and looked like a flaky
|
|
6405
|
+
* job. Naming the workflow, the missing fields, and the fact that the payload
|
|
6406
|
+
* (not the workflow) is what's wrong turns it into a one-read fix.
|
|
6407
|
+
*/
|
|
6408
|
+
export declare class WorkflowPayloadError extends Error {
|
|
6409
|
+
readonly workflowName: string;
|
|
6410
|
+
/** Top-level property names the schema requires and the payload omitted.
|
|
6411
|
+
* Empty when the mismatch is a type error rather than a missing key. */
|
|
6412
|
+
readonly missingFields: ReadonlyArray<string>;
|
|
6413
|
+
readonly detail: string;
|
|
6414
|
+
readonly _tag = "WorkflowPayloadError";
|
|
6415
|
+
constructor(workflowName: string,
|
|
6416
|
+
/** Top-level property names the schema requires and the payload omitted.
|
|
6417
|
+
* Empty when the mismatch is a type error rather than a missing key. */
|
|
6418
|
+
missingFields: ReadonlyArray<string>, detail: string);
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6196
6421
|
/** Options for {@link WorkflowsAppContext.retry}. */
|
|
6197
6422
|
export declare interface WorkflowRetryOptions {
|
|
6198
6423
|
/** Re-run the workflow against this payload instead of the original
|