@voltro/runtime 0.3.0 → 0.5.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 +50 -0
- package/dist/index.d.ts +329 -19
- package/dist/index.js +1204 -927
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,56 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.5.0] — 2026-07-18
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/runtime** — `.one()` now fails when a query matches MORE than one row, not only when it matches none. It previously probed with `LIMIT 1`, so a filter that quietly stopped being unique returned an arbitrary row while the thrown error still claimed "expected exactly one row". It now probes with `LIMIT 2` and fails with `NoRowFound({ found: 2 })`. Migration: a `.one()` whose filter is not unique and that meant "any match" becomes `.first()` / `.maybeOne()`; one that meant "the unique row" stays as-is and now fails loudly when that assumption breaks. `NoRowFound` and `OptimisticLockError` are now `Schema.TaggedError`s, so they can be declared directly in a descriptor's `error:` union and caught with `Effect.catchTag`. Previously they carried a `_tag` field that looked declarable but did not typecheck there, forcing every caller to catch and re-wrap them in a hand-written tagged error — that wrapper can be deleted. Constructing one directly now takes an object: `new NoRowFound({ table, found })`, `new OptimisticLockError({ table, expected })`. `instanceof` and `_tag` checks are unaffected.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/mcp** — Relationship (ReBAC) authorization is now declarative. A descriptor can carry `guards: [{ action, resourceType, resource: (input) => input.id }]` alongside its scope guards; the framework resolves it before the executor (for a mutation, before the transaction opens) and fails with a typed `ScopeError` naming `<resourceType>:<action>`. `defineResourcePolicy` previously enforced nothing on its own. Making it bite required hand-building a map from rpc tag to policy rule and installing an interceptor — undocumented plumbing that nobody wired, and **fail-open by omission**: an rpc missing from the map passed unchecked, with no type error and no boot warning. A guard on the descriptor cannot be forgotten for an rpc that exists, because it is part of the rpc. `setTupleSource` registers where relations are read from — a real registry, not a per-call parameter. Both entrypoints register a default reading `_voltro_rebac_tuples`; an app whose relations already live in its own tables (a `teamMembers` row) registers its own instead of copying data into a framework table. Every unanswerable case DENIES: no tuple source, no policy for the type, an input that does not identify a resource, or a tuple source that throws. An authorization question nobody can answer is a refusal. The capability manifest now carries the declared guards, so a client gates UI on the same declaration the server enforces instead of a hand-kept copy. Only the data crosses the wire — the pure `resource` extractor stays server-side and is reported as `resourceScoped: true`, so a client knows the real answer is per-row and asks rather than assuming. Because guards are re-checked on every subscription delivery, a relationship revoked mid-session now ends the stream rather than continuing to serve it.
|
|
51
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli** — `paginateBy(descriptor, column, cursor, limit, direction?)` generalises `paginateById` to any orderable column. `paginateById` hardcoded `id`, which is right for a sortable key and useless for what feeds actually need — "the next page by `createdAt`" — so apps fell back to hand-rolled `limit + 1` / slice / `hasMore` triples on cursors the helper could not express. `paginateById` is now literally `paginateBy(descriptor, 'id', …)`, so the two cannot drift. `direction` flips the comparison as well as the sort: a `desc` feed pages with `<`. Mismatching those is the classic keyset bug — an ascending comparison under a descending sort returns the same first page forever. The docs example previously demonstrated a related trap (ordering by `createdAt desc` and then calling `paginateById`, which silently re-orders by `id asc`); it now shows the correct form and names the trap. `ctx.load` / `ctx.loadMany` add request-scoped batching. Same-tick reads of one table coalesce into a single `WHERE id IN (...)`, so a breadth-first walk costs one query per LEVEL rather than per node — the assembly shape `relations()` + `.with()` cannot express, because each level's ids come from the level above. Misses are cached too (a repeated dangling reference is fetched once) and a failed batch rejects its waiters without poisoning the cache, so a transient store error does not become "these rows do not exist" for the rest of the request. The cache is request-scoped as a correctness requirement, not a tuning choice: anything longer-lived would serve one subject's rows to another. Workflow steps can now `yield* EffectStore`. Handlers always could; workflow executors could not, so a step reading the store had to lift `ctx.store` with `Effect.tryPromise` — the idiom the rest of the framework tells you to avoid, because it discards the typed `StoreError` channel. The asymmetry was an oversight; the layer is now provided from the workflow context's own store.
|
|
52
|
+
- **@voltro/cli** — `voltro capabilities [--json]` enumerates the framework's export surface by reading the `.d.ts` files in the project's own `node_modules`, so the answer to "what does this framework export" can be verified rather than recalled. The `--json` form is locale-independent and byte-stable, so it can be diffed across upgrades. Symbols that ship but appear nowhere in the project's seeded agent guide are flagged — limited to primitives and hooks, because counting every undocumented export on a real tree gave 1,088 (mostly types and internal Layers), a number too large to act on. `voltro doctor`'s hand-roll detector gained server rules: a hand-written not-found branch on `rows[0]` (→ `.one()`), several sequential `store.query` calls assembling related data (→ `relations()` + `.with()`), `ctx.store` lifted with `Effect.promise` (→ `EffectStore`), an imperative scope check at the top of an executor (→ `guards:`), a credential-shaped column with no `.encrypted()`, a notify/webhook helper at a mutation's tail (→ `defineSubscriber` / `defineReaction`), and hand-rolled cursor pagination (→ `paginateById`). Its scan roots now include the API app directories (`queries/`, `mutations/`, `database/`, …) — without that the server rules could never have fired. The always-loaded agent core now carries a "Pick the SERVER primitive" rubric alongside the client one, and a doc-coverage gate keeps the server surface from drifting out of it.
|
|
53
|
+
- **@voltro/runtime, @voltro/cli** — `ctx.outbox.enqueue(effect, payload, options?)` — a reliable external side effect from a mutation. The enqueue writes through `ctx.store`, which inside a mutation IS the transactional view, so the intent to deliver commits in the same transaction as the domain write or not at all. That closes the window a post-commit tap cannot: `@voltro/plugin-cdc-out` is at-least-once *from enqueue* (its own docs say so — a crash between commit and tap loses the event). Here the enqueue cannot be lost, because losing it means the domain write rolled back too. Delivery after commit remains at-least-once, so handlers must be idempotent; `idempotencyKey` makes that easy to honour. Delivery is declared per effect in a `*.outbox.ts` via `defineOutboxHandler`. The worker is nudged on commit for the fast path and polls every 5s — the poll is the contract, not the optimisation: it recovers rows whose nudge was lost to a crash, rows from another replica, and rows waiting out a backoff. Exponential backoff capped at 5 minutes, configurable `maxAttempts` (default 8), and a dead-letter that stays queryable in `_voltro_outbox` with its `lastError`. An effect with no registered handler is left PENDING rather than dead-lettered — the usual cause is a deploy where the enqueuing code shipped ahead of its handler, and discarding those would turn a rollout ordering detail into permanent loss of a side effect the app believes happened. Two handlers claiming one effect are refused at boot with both filenames. `_voltro_outbox` rides the declarative differ and is created only when the app declares at least one handler. Wired in both `voltro dev` and `voltro serve`.
|
|
54
|
+
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store: ```ts const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor) rows[0].title // string — previously `rows[0]['title'] as string` ``` The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. A hand-built descriptor still resolves to `Row` — this never types less than before, so it is additive: existing code keeps compiling, and the casts it contains simply become redundant. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **@voltro/runtime, @voltro/cli** — Declarative `guards:` are now re-checked before EVERY subscription delivery, not only when the subscription is opened. A subscription is a long-lived authorization grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Previously the gate ran once at subscribe and every later delivery re-ran the query and pushed rows out without re-asking, so a revoked subject kept receiving live updates until their socket happened to drop. A denial now ends the subscription with the typed `ScopeError` rather than silently freezing the subscriber on its last authorized value, and the re-check runs BEFORE the read, so a revoked subject's rows are never materialised. Both the descriptor and computed-query paths are covered, in both `voltro dev` and `voltro serve`. Non-authorization failures (a bad predicate, a dropped connection) still leave the subscriber on its last good snapshot as before.
|
|
59
|
+
- **@voltro/testing, @voltro/cli** — - **`@voltro/testing`'s context now provides `ctx.load` / `ctx.loadMany`.** The request-scoped batching helpers were added to the handler context but not to the test harness, so `makeTestContext` no longer satisfied `AppContext` — any suite building a context failed to typecheck, and a handler calling `ctx.load` could not be tested at all. The harness now builds a loader over the same underlying store (one per context, so `withSubject` / `withTenant` can't serve one subject's cached rows to another), mirroring the real builder. - **The `.one()` codemod is declared for 0.5.0, not 0.4.0.** It was authored while 0.4.0 was unreleased; since codemods are selected with `from < version <= to`, leaving it at 0.4.0 would have silently skipped it for everyone upgrading from 0.4.0 — exactly the users who need it.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## [0.4.0] — 2026-07-18
|
|
64
|
+
|
|
65
|
+
### ⚠ BREAKING
|
|
66
|
+
|
|
67
|
+
- **@voltro/protocol, @voltro/plugin-rbac, @voltro/runtime** — **Resource-aware declarative guards + one denial tag.** `guards: [{ scope, resource }]` now actually scopes to a resource. Register a resolver — either `setResourceScopeResolver((req) => Effect<boolean>)` from `@voltro/protocol`, or `rbacPlugin({ resolveResourceRoles: (subject, resourceId) => roleSlugs })` — and the framework asks it, per request, whether the caller holds the scope **on the extracted resource** (a team, workspace, document), before the executor runs (mutations: before the transaction opens). A globally-held scope or `admin:full` still short-circuits without a resolver call; a resolver error fails **closed**. With no resolver registered, `resource` stays advisory (global check) exactly as before, so single-tenant apps are unaffected. **BREAKING — `@voltro/plugin-rbac` no longer exports `Forbidden`.** rbac's `permission()` / `assertPermission()` / `anyPermission()` now fail with protocol's `ScopeError` — the SAME typed error the declarative `guards:` enforcement already raised. One denial tag across the framework: a client that matches `_tag === 'ScopeError'` (or `errorTag(err)`) recognizes both a descriptor-guard denial and a `permission()` denial with one branch. The `@voltro/plugin-rbac/errors` subpath is removed. Migration (the `voltro update` codemod rewrites the mechanical part): - `import { Forbidden } from '@voltro/plugin-rbac/errors'` → `import { ScopeError } from '@voltro/protocol'`; `import { Forbidden } from '@voltro/plugin-rbac'` → `import { ScopeError } from '@voltro/plugin-rbac'` (re-exported from the root). - Every `Forbidden` identifier → `ScopeError` (incl. `error: Forbidden` on a descriptor). - By hand: a constructed `new Forbidden({ required, reason })` becomes `new ScopeError({ required, message })` (the field is `message` and is REQUIRED); and any `Effect.catchTag('Forbidden', …)` / `err._tag === 'Forbidden'` string becomes `'ScopeError'`. Guards are normally caught, not constructed, so most apps only need the import rewrite the codemod does. Also note: `requireScope` / declarative `guards:` enforcement is now async-capable (it awaits the resource resolver when one is registered) — no change to handler code, which never called the internal enforcement directly.
|
|
68
|
+
- **@voltro/client, @voltro/plugin-rbac** — **`@voltro/plugin-rbac/web` is removed — the UI permission gate lives in `@voltro/client`.** The framework shipped `useCan` TWICE, with the same name and the same semantics, over two different React contexts. An app that mounted `<PermissionProvider>` and imported the plugin's `useCan` got an empty scope set and silently hid every gated affordance — no error, just a UI where nothing is permitted. One of the two had to go. `@voltro/client` survives because scopes are a FRAMEWORK concept: `@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access`, and rbac is only one way to PRODUCE scopes — an app can register its own `setResourceScopeResolver` over its own tables and never install the plugin. The hook consumes scopes, which is generic; producing them is rbac-specific. Keeping the hook behind the plugin would force an rbac dependency on apps that deliberately don't use rbac, purely to gate a button. `@voltro/plugin-rbac` keeps everything that IS rbac: the roles map, role→scope compilation, the `permission()` / `assertPermission()` / `anyPermission()` server guards, the role tables, and `resolveResourceRoles`. The codemod rewrites every mechanical site: `RbacScopeProvider` → `PermissionProvider`, `canFromScopes` → `canCall`, `canAnyFromScopes` → `canCallAny`, and repoints `useCan` / `useCanAny` / `ADMIN_SCOPE` to `@voltro/client`. A raw `useContext(RbacScopeContext)` is ANNOTATED rather than rewritten — the client equivalent is `usePermissions()`, which returns `{ scopes }` instead of the bare array, so a silent rewrite would compile and then hand back the wrong shape.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **@voltro/runtime, @voltro/cli** — **`read({ where })` on aggregates.** Aggregate reads were all-or-nothing, so every consumer that wanted one slice pulled the whole materialised set over the wire and filtered in the component — the cost scaling with the aggregate, not the slice. `where` entries are ANDed; a scalar means strict equality, an array means IN; filtering runs before `orderBy`/`limit`. Deliberately a data filter over already materialised rows, not a predicate language — aggregates stay aggregates.
|
|
73
|
+
- **@voltro/client** — **Two ergonomics that remove the two most-repeated shapes in a Voltro frontend.** - **`mutate(input, options)` — `onSuccess` / `onError` / `notify`.** Every write site used to re-type `try { await mutate() } catch { toast.error(…) } finally { setSubmitting(false) }`. `pending` already replaced the `finally`; these replace the rest. Load-bearing semantic: supplying an error handler (`onError` or `notify.error`) marks the failure HANDLED — `mutate` resolves with `undefined` instead of rejecting, which is what actually deletes the try/catch. With no handler it rejects exactly as before, so unhandled failures stay loud. `notify` routes to an app-registered sink (`setMutationNotifier`) — the framework stays unbound to any toast library. - **`useSubscription` now returns `loading` and `isEmpty`, and accepts `fallback`.** Call sites branched on `data === undefined`, conflating "no snapshot yet" with "zero rows" — the cause of a flash of empty-state before the first snapshot. They are now distinct, derived once. `fallback` fills `data` while loading without lying about `loading`. Also documented: `error` carries a COLD-START stream failure (check it to avoid an infinite skeleton); a failure after data arrived deliberately does not blank good data — those reach the error bus (`useOnRpcError`).
|
|
74
|
+
- **@voltro/cli** — **Make the client hook surface discoverable — and keep it that way.** A downstream app built its whole frontend on three transport hooks (`useSubscription` / `useMutation` / `useAction`) and hand-rolled forms, tables, upload, permission gates, debounce and pagination, because the agent guide documented ~none of the ~40 client hooks that already ship. Three changes: - **The always-loaded core now carries a "Pick the CLIENT primitive" rubric** — the same decision-rubric treatment the backend primitives get, plus a "you're about to write X → reach for Y" table (per-field `useState` → `useFormBinding`, hand-rolled table → `useDataTable`, `FileReader` → `useUpload`, `useMemo` fan-in → `useDerived`, …). - **The hook reference is complete.** `reference/hooks-overview.md` presented itself as "the client-side hook surface" while documenting 13 of 44 hooks; it now enumerates the schema-driven-UI, files/permissions/utilities, and AI families (en + de). - **A coverage GATE** (`agentsMdTemplate.test.ts`): every exported `use*` hook of `@voltro/client` must appear in the composed agent-docs corpus, or the test fails with the list. Docs can no longer silently fall behind the export surface. Plus **`voltro doctor` now flags hand-rolls**: it scans an app's source for the patterns a shipped primitive covers (hand-rolled form/table, `FileReader` upload, `setTimeout` debounce, `useMemo` fan-in, local Next.js compat shims, hand-rolled presence) and names the primitive to use. Advisory, never fatal — a false-positive lecture must not fail a build.
|
|
75
|
+
- **@voltro/cli** — **Generated `matchError` / `AppError` / `AppErrorTag`.** Codegen now emits a per-app exhaustive error matcher into `rpcGroup.generated.ts`, derived BY REFERENCE from every descriptor's `error:` schema plus the cross-cutting plugin errors. `matchError(err, { [tag]: handler }, fallback?)` dispatches on `_tag` with the handler keys constrained to the app's ACTUAL error tags — so a hand-maintained tag list that silently drifts (dead/renamed tags) is gone; a stale tag is a compile error. Browser-safe (reads `_tag` structurally, no runtime dep). Workflows keep their existing `WorkflowErrors`.
|
|
76
|
+
- **@voltro/cli** — **`voltro doctor` + `voltro update --codemods-only`/`--from`** — two upgrade-path ergonomics from AWB adoption feedback: - **`voltro doctor [app]`** (and **`voltro serve --preflight`**) preflights an API app for production serve: it verifies the precompiled serve bundle exists and, if not, prints the exact fix and exits 1 — so a Dockerfile / CI step catches "prod `voltro serve` with no `voltro build`" at BUILD time instead of at cold-start (0.3.0 made an unbuilt production serve fatal). - **`voltro update --codemods-only` (alias `--run-codemods`) + `--from <version>`** — re-apply the codemods + manual notes for an explicit `[from, to]` delta WITHOUT bumping `package.json` or installing. The recovery path for a hand-edited version bump (`bump package.json` + install first), which otherwise makes `voltro update` report "already on X — nothing to do" and silently skip the codemods. `--from` also overrides the auto-detected source version on a normal update.
|
|
77
|
+
- **@voltro/runtime** — **`encryptField` / `decryptField` — standalone field cipher for raw-SQL paths.** `.encrypted()` columns encrypt/decrypt transparently inside the `ctx.store` middleware, so a code path that reaches the DB by RAW SQL (an auth strategy with no store handle reading/writing a session token, a one-off backfill) bypasses it. `@voltro/runtime` now exposes the SAME registered cipher standalone: `encryptField` encrypts a value; `decryptField` decrypts an `enc:v1:…` value and passes a non-ciphertext value through unchanged (so a raw-SQL path can adopt encryption while pre-existing plaintext rows keep working). Both throw a clear error when no cipher is registered. Encryption stops being all-or-nothing tied to going through `ctx.store`.
|
|
78
|
+
- **@voltro/testing** — **`makeVoltroTestClient` — the missing half of the test story.** The framework shipped a strong BACKEND test story (`makeTestContext`, `mockStore`, `mockAi`, the workflow runner) and nothing for the client. A Voltro frontend is a reactive-data app: with no way to render a component against mocked `useSubscription` / `useMutation` — and to inject a loading state, a stream error, or a failing write — it is structurally untestable, which is why real apps end up with zero frontend tests. `import { makeVoltroTestClient } from '@voltro/testing/client'` returns a `Provider` plus `setSubscription` / `failSubscription` / `resetSubscription` and a `calls` log of every write with its input. A tag ABSENT from the fixture stays in the loading state — the distinction from an empty result is exactly what you want to assert. Deliberately NOT a renderer: it hands you a Provider, so it works with `react-dom/client` + `act`, testing-library, or your own harness, and locks you into none of them.
|
|
79
|
+
- **@voltro/i18n** — **Plurals, Intl formatters, and catalogs a bundler can actually split.** - **`plural(locale, count, forms)` + `usePlural`** — real CLDR categories via `Intl.PluralRules`. Replaces the hardcoded `"{count} epic(s)"` pattern, whose literal `(s)` is simply wrong outside English, and which cannot express languages that distinguish few from many (Polish 2–4 vs 5+). An explicit `zero` form is honoured for exactly 0; ordinals via `{ type: 'ordinal' }`. - **`useFormatDate` / `useRelativeTime` / `useFormatNumber` / `useFormatCurrency` / `useFormatters`** — locale-bound `Intl` wrappers, so an app stops carrying several divergent hand-rolled "X minutes ago" helpers that each round differently. - **`defineCatalogs` + `LazyI18nProvider`** — code-split catalogs. `pickCatalog({ en, de })` is a STATIC import map: every locale is a value-level import, so the bundler must emit them in one chunk and a German-only visitor downloads English too, growing linearly per locale. A map of `() => import()` loaders is the only shape a bundler treats as a chunk boundary. `preload(locale)` before hydration keeps first paint synchronous via `peek()`; the provider's `fallback` is for a locale SWITCH, not first paint. Unknown locales degrade to the base catalog, and a failed chunk load stays retryable.
|
|
80
|
+
- **@voltro/protocol, @voltro/client** — **Nested optimistic: `shapeItem` + bulk (multi-item) targets.** Two additive improvements to path-targeted auto-optimistic: - **`shapeItem`** — the nested counterpart of `shape`, typed to the ITEM of the nested array (not the mutation's output), so a path-target patch reads/returns the item without casting `current`. (Previously the nested shaper was the flat `shape`, pinned to the output row — every adopter had to cast. `shape` stays bound to the output for flat targets; a single field can't be both, so the nested shaper is its own.) - **Bulk `identify`** — a target's `identify` may now return `string[]` to patch/delete MANY items (or top-level rows) in one mutation — the group-drag / batch-edit case where per-item parallel writes used to race. Each patched item keeps its own key. Runtime-compatible: a nested target that still uses `shape` falls back transparently. No codemod — additive.
|
|
81
|
+
- **@voltro/web, @voltro/client** — **Two hooks apps kept hand-rolling, because nothing shipped them.** - **`useTheme` (`@voltro/web`)** — returns `{ theme, resolvedTheme, setTheme }` over the framework's own `voltro:theme` cookie and the `html.dark` class. The bug it removes is specific: an app that hand-rolls a second theme store ends up with two writers on one class, so the toggle's state and the rendered theme can disagree, and the pre-paint script flashes the wrong one. `'system'` resolves through `matchMedia` and tracks live OS changes. - **`useConnectionStatus` (`@voltro/client`)** — `'connected' | 'degraded' | 'offline'`, derived from the only two signals the client honestly has: `navigator.onLine` (browser says the network is gone) and the rpc error bus (a call actually failed). `degraded` means "we saw a failure and no success since"; coming back online clears it, because the failures counted during an offline window ARE that window. Deliberately no polling ping just to colour an indicator.
|
|
82
|
+
- **@voltro/database** — **`.uniqueActive([cols])` now works on mysql / mariadb** (it previously failed loud at migrate — those engines have no partial index). It lowers automatically to a STORED generated column per key column — `CASE WHEN <predicate> THEN CAST(<col> AS CHAR(255)) ELSE NULL END` — plus a UNIQUE over them. NULL-distinct uniqueness means a soft-deleted row's generated columns are all NULL and never collide, so re-creating the key just works — the same resurrection-safe semantics the partial index gives on postgres/sqlite/mssql. The lowering is applied identically on the emit side and the declared-snapshot side, so it round-trips through the declarative differ (verified live on MariaDB: declare→migrate→introspect→re-plan is a no-diff, and a duplicate among active rows is rejected while soft-deleting one frees the key). You write the same `.uniqueActive([...])` on every dialect.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- **@voltro/cli** — **Standalone `voltro codegen` now emits the SAME `rpcGroup.generated.ts` as `voltro dev` / `voltro build`.** Previously the standalone command skipped the app's plugins, so it dropped every plugin's cross-cutting error union AND its client RPC routes — an inconsistent generated file (typed plugin errors gone, plugin route tags unresolved) vs the dev/serve boot path. The command now loads `app.config` and gathers the plugins' codegen inputs through the SAME `gatherCodegenPluginImports` the boot path uses (single-sourced, so the three paths can't drift).
|
|
87
|
+
- **@voltro/cli** — **The hook-coverage gate was too weak, and it let dead documentation through.** It asked only whether a hook's NAME appeared in the agent-docs corpus — which a one-line row in a link table satisfies. Ten client hooks had exactly that, and six of those rows linked to a page that never mentions the hook, so a reader following the link learned nothing. Two sharper checks replace it: - **Substance** (`agentsMdTemplate.test.ts`): a hook must appear at least once OUTSIDE a table row, so a bare row no longer counts as documentation. - **Dead references** (`gen-agent-docs.mjs`): a row promising `[`useX`](/docs/…)` must point at a page that actually mentions `useX`. This lives in the generator because that is the only thing which reads the docs tree — and it walks BOTH language trees, since an en-only check leaves every German dead link invisible.
|
|
88
|
+
- **@voltro/cli** — - **Serve bundle: inline the pure-JS SQL drivers (`pg`/`mysql2`/`tedious`) instead of runtime-shimming them.** A production `voltro serve` with a postgres/mysql/mssql store crashed at boot with `Pg.Pool is not a constructor` (and the mysql/mssql equivalents): the driver's `@effect/sql-*` consumer links its leaf as a namespace (`import * as Pg from 'pg'; new Pg.Pool()`), but the native-leaf CJS shim (`module.exports = <leaf>`) did not surface the named members through esbuild's `import * as` interop, so `Pg.Pool` was `undefined`. These drivers are pure JS (no `.node`), so they are now inlined into the serve bundle like the rest of the framework — esbuild links the real module and the namespace resolves correctly. Only genuinely-native leaves (`better-sqlite3`, the turso/libsql addons, `pg-native`) and the dynamically-imported `ioredis`/`nodemailer` still resolve via the runtime shim. Verified end-to-end from a relocated prod tree (a real `POST /rpc` through the inlined `pg`).
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
42
92
|
## [0.3.0] — 2026-07-18
|
|
43
93
|
|
|
44
94
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AnyCheckSpec } from '@voltro/protocol';
|
|
1
2
|
import { AuthStrategy } from '@voltro/protocol';
|
|
2
3
|
import { CaughtUpVerdict } from '@voltro/database';
|
|
3
4
|
import { ChangeEvent } from '@voltro/database';
|
|
@@ -12,7 +13,6 @@ import { DialectReplicationAdapter } from '@voltro/database';
|
|
|
12
13
|
import { Duration } from 'effect';
|
|
13
14
|
import { Effect } from 'effect';
|
|
14
15
|
import { FieldCipher } from '@voltro/database';
|
|
15
|
-
import { GuardCheckSpec } from '@voltro/protocol';
|
|
16
16
|
import * as http from 'node:http';
|
|
17
17
|
import { HttpClient } from '@effect/platform';
|
|
18
18
|
import { HttpRequestInterceptor } from '@voltro/protocol';
|
|
@@ -341,6 +341,23 @@ export declare interface AggregateQuery {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
export declare interface AggregateReadOptions {
|
|
344
|
+
/**
|
|
345
|
+
* Filter the materialized rows — the PARAMETERIZED read.
|
|
346
|
+
*
|
|
347
|
+
* Without it an aggregate can only ever be "the one global roll-up", so every
|
|
348
|
+
* tenant-/team-/year-scoped roll-up (most real ones) has to stay client-side.
|
|
349
|
+
* A row matches when EVERY entry matches: a scalar compares by strict
|
|
350
|
+
* equality, an array means "one of" (an IN set).
|
|
351
|
+
*
|
|
352
|
+
* ```ts
|
|
353
|
+
* yield* handle.read({ where: { teamId, year: 2026 } })
|
|
354
|
+
* yield* handle.read({ where: { status: ['open', 'blocked'] } })
|
|
355
|
+
* ```
|
|
356
|
+
*
|
|
357
|
+
* Deliberately DATA, not a predicate function: it stays serializable, so the
|
|
358
|
+
* filter can later push down to SQL instead of filtering in memory.
|
|
359
|
+
*/
|
|
360
|
+
readonly where?: Readonly<Record<string, unknown>>;
|
|
344
361
|
/** Reject if last refresh is older than this. Throws `AggregateStale`. */
|
|
345
362
|
readonly maxAgeMs?: number;
|
|
346
363
|
/** Pagination — max rows to return. */
|
|
@@ -741,6 +758,28 @@ export declare interface AppContext {
|
|
|
741
758
|
* triggers. `emit(name, payload)` records the event and fans out to
|
|
742
759
|
* matching workflow triggers. */
|
|
743
760
|
readonly events?: EventsAppContext;
|
|
761
|
+
/**
|
|
762
|
+
* Transactional outbox (`ctx.outbox`). Absent when the app declares no
|
|
763
|
+
* `*.outbox.ts` handler — an enqueue with nobody to deliver it would be a
|
|
764
|
+
* side effect the app believes happened and which never will, so the field
|
|
765
|
+
* simply isn't there rather than silently accepting writes.
|
|
766
|
+
*/
|
|
767
|
+
readonly outbox?: OutboxFacade;
|
|
768
|
+
/**
|
|
769
|
+
* Request-scoped batching (`ctx.load` / `ctx.loadMany`). Coalesces
|
|
770
|
+
* same-tick reads of one table into a single `WHERE id IN (...)`.
|
|
771
|
+
*
|
|
772
|
+
* For assembly whose SHAPE depends on the data — a tree walk where each
|
|
773
|
+
* level's ids come from the level above — which `relations()` + `.with()`
|
|
774
|
+
* cannot express statically. Reach for the relation first; this is the
|
|
775
|
+
* fallback, not the default.
|
|
776
|
+
*
|
|
777
|
+
* Scoped to the request on purpose: a longer-lived cache would serve one
|
|
778
|
+
* subject's rows to another (a data-isolation bug on a tenant-scoped store,
|
|
779
|
+
* not a performance detail) and would go stale across a mutation.
|
|
780
|
+
*/
|
|
781
|
+
readonly load: DataLoader['load'];
|
|
782
|
+
readonly loadMany: DataLoader['loadMany'];
|
|
744
783
|
}
|
|
745
784
|
|
|
746
785
|
/**
|
|
@@ -894,6 +933,9 @@ export declare interface AttachAnalyticsMirrorOptions {
|
|
|
894
933
|
*/
|
|
895
934
|
export declare const awaitServerListening: (server: ReturnType<typeof createServer>, timeoutMs?: number) => Promise<void>;
|
|
896
935
|
|
|
936
|
+
/** Exponential backoff with a ceiling — 1s, 2s, 4s … capped at 5 minutes. */
|
|
937
|
+
export declare const backoffMs: (attempt: number) => number;
|
|
938
|
+
|
|
897
939
|
/**
|
|
898
940
|
* Override the subject for an active connection. Called from the
|
|
899
941
|
* `auth.signin` (or any "I just authenticated this caller") handler
|
|
@@ -956,9 +998,17 @@ cache?: {
|
|
|
956
998
|
readonly swrMs: number | undefined;
|
|
957
999
|
readonly scope: "subject" | "global";
|
|
958
1000
|
readonly baseKey: string;
|
|
959
|
-
}
|
|
1001
|
+
},
|
|
1002
|
+
/**
|
|
1003
|
+
* Per-subject authorization re-check, built by
|
|
1004
|
+
* `makeQueryReauthorizer(query, input)` in the serve pipeline. Subscribing
|
|
1005
|
+
* passes `guards:` once; this re-runs them before every delivery so a
|
|
1006
|
+
* revoked grant CLOSES the stream rather than continuing to serve it.
|
|
1007
|
+
* Omitted for a query that declares no guards.
|
|
1008
|
+
*/
|
|
1009
|
+
reauthorize?: (subject: Subject) => () => Promise<unknown>) => Stream.Stream<SubscriptionEvent<T>, never, SubjectService | ConnectionInfo>;
|
|
960
1010
|
|
|
961
|
-
export declare const bindSubscriptionUntyped: (descriptor: QueryDescriptor, dispatcher: Dispatcher, context: RuntimeContext, indexHint: MatcherIndexHint | undefined, label: string, cacheBinding: SnapshotCacheBinding | undefined) => Stream.Stream<SubscriptionEvent<ReadonlyArray<Row_4>>, never, never>;
|
|
1011
|
+
export declare const bindSubscriptionUntyped: (descriptor: QueryDescriptor, dispatcher: Dispatcher, context: RuntimeContext, indexHint: MatcherIndexHint | undefined, label: string, cacheBinding: SnapshotCacheBinding | undefined, reauthorize: (() => Promise<unknown>) | undefined) => Stream.Stream<SubscriptionEvent<ReadonlyArray<Row_4>>, never, never>;
|
|
962
1012
|
|
|
963
1013
|
export declare interface BrandedScheduleDefinition extends ScheduleDefinition {
|
|
964
1014
|
readonly [SCHEDULE_BRAND]: true;
|
|
@@ -1278,6 +1328,21 @@ export declare const currentRoutingContext: () => RoutingContext | undefined;
|
|
|
1278
1328
|
/** The trace context active on the current async stack, if any. */
|
|
1279
1329
|
export declare const currentTraceContext: () => LogTraceContext | undefined;
|
|
1280
1330
|
|
|
1331
|
+
export declare interface DataLoader {
|
|
1332
|
+
/**
|
|
1333
|
+
* Load one row by primary key. Calls made in the same tick for the same
|
|
1334
|
+
* table are coalesced into ONE `WHERE id IN (...)`.
|
|
1335
|
+
*
|
|
1336
|
+
* Returns `null` for a missing row rather than throwing — a loader is used
|
|
1337
|
+
* to assemble, and a missing edge in a graph walk is usually data, not an
|
|
1338
|
+
* error. Use `.one()` when absence IS an error.
|
|
1339
|
+
*/
|
|
1340
|
+
load(table: string, id: string): Promise<Row_4 | null>;
|
|
1341
|
+
/** Load many by key, in the order asked. Missing rows come back as `null`,
|
|
1342
|
+
* so the result lines up positionally with the input. */
|
|
1343
|
+
loadMany(table: string, ids: ReadonlyArray<string>): Promise<ReadonlyArray<Row_4 | null>>;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1281
1346
|
export { DataStore }
|
|
1282
1347
|
|
|
1283
1348
|
/** DataStore-backed store over `_voltro_api_keys`. */
|
|
@@ -1288,6 +1353,13 @@ export declare const dataStoreIdempotencyStore: (store: DataStore) => Idempotenc
|
|
|
1288
1353
|
/** Build a durable `KvStoreShape` over a raw `DataStore`. */
|
|
1289
1354
|
export declare const dataStoreKvStore: (store: DataStore) => KvStoreShape;
|
|
1290
1355
|
|
|
1356
|
+
/** Decrypt a value produced by `encryptField` / an `.encrypted()` column. A
|
|
1357
|
+
* value that is NOT ciphertext (`enc:v1:…`) is returned unchanged — so a
|
|
1358
|
+
* raw-SQL read path can be switched to encryption while pre-existing plaintext
|
|
1359
|
+
* rows keep working until they're re-written encrypted. Throws only when no
|
|
1360
|
+
* cipher is registered (a genuine ciphertext with a wrong key throws GCM). */
|
|
1361
|
+
export declare const decryptField: (value: string) => string;
|
|
1362
|
+
|
|
1291
1363
|
/** Default `POST /rpc` body cap: 8 MiB. Generous for any JSON rpc envelope,
|
|
1292
1364
|
* small enough to stop a pathological body being buffered into memory. */
|
|
1293
1365
|
export declare const DEFAULT_MAX_RPC_BODY_BYTES: number;
|
|
@@ -1322,6 +1394,16 @@ export declare const defineAggregate: <Row>(input: AggregateDefinitionInput<Row>
|
|
|
1322
1394
|
|
|
1323
1395
|
export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
|
|
1324
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* Declare who delivers an effect. One per `*.outbox.ts` file.
|
|
1399
|
+
*
|
|
1400
|
+
* The handler runs AFTER the enqueuing transaction committed, outside it, and
|
|
1401
|
+
* may do external I/O — that is the entire point. It must be IDEMPOTENT:
|
|
1402
|
+
* delivery is at-least-once, so a process that dies between "the remote
|
|
1403
|
+
* accepted it" and "we marked it delivered" will retry.
|
|
1404
|
+
*/
|
|
1405
|
+
export declare const defineOutboxHandler: (definition: OutboxHandlerDefinition) => OutboxHandlerDefinition;
|
|
1406
|
+
|
|
1325
1407
|
/** Declare a reaction. Validates that the MANDATORY guard is present — an
|
|
1326
1408
|
* ungated reaction is a spend-storm footgun, so this fails LOUD at boot. */
|
|
1327
1409
|
export declare const defineReaction: (def: ReactionDefinition) => ReactionDefinition;
|
|
@@ -1427,7 +1509,26 @@ export declare class Dispatcher {
|
|
|
1427
1509
|
/** Snapshot-cache binding (Layer 3). When present AND `deps.cache` is
|
|
1428
1510
|
* wired, the initial snapshot is served through the cache, tagged with
|
|
1429
1511
|
* the dependent-table set, and kept warm by the recompute path. */
|
|
1430
|
-
cacheBinding?: SnapshotCacheBinding
|
|
1512
|
+
cacheBinding?: SnapshotCacheBinding,
|
|
1513
|
+
/** Re-run the query's `guards:` before every delivery — see
|
|
1514
|
+
* `ActiveSubscription.reauthorize`. Omitted for unguarded queries. */
|
|
1515
|
+
reauthorize?: () => Promise<unknown>): Promise<() => void>;
|
|
1516
|
+
/**
|
|
1517
|
+
* Tear a subscription down because authorization was WITHDRAWN mid-stream,
|
|
1518
|
+
* and tell the client why.
|
|
1519
|
+
*
|
|
1520
|
+
* A denial is not a transient failure, so it must not be handled like one.
|
|
1521
|
+
* The re-query paths deliberately keep a failing subscriber on its last
|
|
1522
|
+
* good snapshot (a bad predicate shouldn't wedge the stream) — but doing
|
|
1523
|
+
* that for a revoked subject would leave authorized data sitting in a
|
|
1524
|
+
* client that is no longer entitled to it, with no signal that anything
|
|
1525
|
+
* changed. Emitting the typed `ScopeError` and closing is the honest
|
|
1526
|
+
* outcome: the client surfaces the denial and can re-subscribe if the
|
|
1527
|
+
* grant comes back.
|
|
1528
|
+
*/
|
|
1529
|
+
private revokeSubscription;
|
|
1530
|
+
/** `revokeSubscription` for the computed path. Same reasoning. */
|
|
1531
|
+
private revokeComputed;
|
|
1431
1532
|
/**
|
|
1432
1533
|
* Register a COMPUTED-query subscription. The handler already ran once
|
|
1433
1534
|
* (its value is `computed.value`); we emit that as the initial snapshot,
|
|
@@ -1472,6 +1573,31 @@ export declare interface DispatcherDependencies {
|
|
|
1472
1573
|
readonly cache?: SnapshotCache;
|
|
1473
1574
|
}
|
|
1474
1575
|
|
|
1576
|
+
export declare interface DrainDeps {
|
|
1577
|
+
readonly store: Pick<DataStore, 'query' | 'update'>;
|
|
1578
|
+
readonly handlers: ReadonlyMap<string, OutboxHandlerDefinition>;
|
|
1579
|
+
readonly now?: () => Date;
|
|
1580
|
+
/** Max rows per drain pass. */
|
|
1581
|
+
readonly batchSize?: number;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* One drain pass: claim due rows, run their handler, record the outcome.
|
|
1586
|
+
*
|
|
1587
|
+
* A row whose handler is unknown is left PENDING rather than dead-lettered —
|
|
1588
|
+
* the usual cause is a deploy where the enqueuing code shipped before the
|
|
1589
|
+
* handler, and discarding those would turn a rollout ordering detail into
|
|
1590
|
+
* permanent data loss.
|
|
1591
|
+
*/
|
|
1592
|
+
export declare const drainOutbox: (deps: DrainDeps) => Promise<DrainResult>;
|
|
1593
|
+
|
|
1594
|
+
export declare interface DrainResult {
|
|
1595
|
+
readonly delivered: number;
|
|
1596
|
+
readonly failed: number;
|
|
1597
|
+
readonly dead: number;
|
|
1598
|
+
readonly skipped: number;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1475
1601
|
/**
|
|
1476
1602
|
* Up to `limit` wakeups that are due now (`wakeAt <= now`), earliest
|
|
1477
1603
|
* first. Pending rows are read ordered by `wakeAt`, so the earliest —
|
|
@@ -1490,7 +1616,11 @@ export declare class EffectStore extends EffectStore_base {
|
|
|
1490
1616
|
declare const EffectStore_base: Context.TagClass<EffectStore, "@voltro/EffectStore", EffectStoreOps>;
|
|
1491
1617
|
|
|
1492
1618
|
export declare interface EffectStoreOps {
|
|
1493
|
-
|
|
1619
|
+
/** Typed the same way `FluentStore.query` is — the row type rides in on the
|
|
1620
|
+
* descriptor, so an Effect-form handler reads real fields instead of
|
|
1621
|
+
* casting off `Record<string, unknown>`. Falls back to `Row` for a
|
|
1622
|
+
* hand-built descriptor. */
|
|
1623
|
+
readonly query: <R = Row_4>(descriptor: QueryDescriptor<R>) => Effect.Effect<ReadonlyArray<R>, StoreError>;
|
|
1494
1624
|
readonly insert: (table: string, row: Row_4) => Effect.Effect<Row_4, StoreError>;
|
|
1495
1625
|
readonly update: (table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>) => Effect.Effect<Row_4 | null, StoreError>;
|
|
1496
1626
|
readonly delete: (table: string, primaryKey: string) => Effect.Effect<boolean, StoreError>;
|
|
@@ -1502,6 +1632,19 @@ export declare interface EffectStoreOps {
|
|
|
1502
1632
|
* discovered tables. */
|
|
1503
1633
|
export declare const emptySchemaRegistry: SchemaRegistry;
|
|
1504
1634
|
|
|
1635
|
+
/** Encrypt a value with the registered field cipher (same one `.encrypted()`
|
|
1636
|
+
* columns use). Throws if no cipher is registered. */
|
|
1637
|
+
export declare const encryptField: (plaintext: string) => string;
|
|
1638
|
+
|
|
1639
|
+
export declare interface EnqueueOptions {
|
|
1640
|
+
/** Drop this enqueue if an undelivered row already carries the same key. */
|
|
1641
|
+
readonly idempotencyKey?: string;
|
|
1642
|
+
/** Override the handler's `maxAttempts` for this one effect. */
|
|
1643
|
+
readonly maxAttempts?: number;
|
|
1644
|
+
/** Delay the first attempt (ms from now). */
|
|
1645
|
+
readonly delayMs?: number;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1505
1648
|
/** The always-available default — reads `process.env`. Sync under the hood,
|
|
1506
1649
|
* Promise-wrapped to satisfy the async contract. */
|
|
1507
1650
|
export declare const envSecretsBackend: SecretsBackend;
|
|
@@ -1547,7 +1690,25 @@ export declare interface FieldChange {
|
|
|
1547
1690
|
*/
|
|
1548
1691
|
export declare const fingerprintFor: (table: string, predicate: Predicate, indexHint?: string) => string;
|
|
1549
1692
|
|
|
1550
|
-
export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete'> {
|
|
1693
|
+
export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete' | 'query'> {
|
|
1694
|
+
/**
|
|
1695
|
+
* Execute a query descriptor and return the matching rows — TYPED.
|
|
1696
|
+
*
|
|
1697
|
+
* The row type rides in on the descriptor (`QueryDescriptor<R>`), so
|
|
1698
|
+
* `ctx.store.query(database.notes.where(...).descriptor)` gives you
|
|
1699
|
+
* `ReadonlyArray<Note>`, not `ReadonlyArray<Record<string, unknown>>`. The
|
|
1700
|
+
* builder always knew the shape; it used to be dropped exactly here, which
|
|
1701
|
+
* is why reading a field meant writing `row['title'] as string` — one
|
|
1702
|
+
* downstream app accumulated 2,032 of those casts.
|
|
1703
|
+
*
|
|
1704
|
+
* A hand-built descriptor still resolves to `Row`, i.e. the previous
|
|
1705
|
+
* behaviour. This never types LESS than before.
|
|
1706
|
+
*
|
|
1707
|
+
* The driver-level `DataStore.query` stays untyped on purpose — it is the
|
|
1708
|
+
* SPI every dialect store implements, and it genuinely does return untyped
|
|
1709
|
+
* rows off the wire. The type is re-applied here, at the handler boundary.
|
|
1710
|
+
*/
|
|
1711
|
+
query<R = Row_5>(descriptor: QueryDescriptor<R>): Promise<ReadonlyArray<R>>;
|
|
1551
1712
|
/** Fluent, scope-applying read builder: `select('notes').where(...).all()`. */
|
|
1552
1713
|
select(table: string): SelectBuilder;
|
|
1553
1714
|
/** Fluent predicate update: `update('notes').where('id', id).set({...})`. */
|
|
@@ -1627,6 +1788,8 @@ export declare const getResourcePolicy: (resourceType: string) => ResourcePolicy
|
|
|
1627
1788
|
* tap feeds it; the inspect endpoint reads it. */
|
|
1628
1789
|
export declare const getTimelineRecorder: () => TimelineRecorder;
|
|
1629
1790
|
|
|
1791
|
+
export declare const getTupleSource: () => TupleSource | undefined;
|
|
1792
|
+
|
|
1630
1793
|
/** Stable group key from the groupBy column values (JSON to disambiguate types). */
|
|
1631
1794
|
export declare const groupKeyOf: (row: Row_2, groupBy: ReadonlyArray<string> | undefined) => string;
|
|
1632
1795
|
|
|
@@ -1830,6 +1993,25 @@ export declare interface InspectStream {
|
|
|
1830
1993
|
|
|
1831
1994
|
export { inspectWorkflow }
|
|
1832
1995
|
|
|
1996
|
+
/**
|
|
1997
|
+
* Install the resolver that answers `guards: [{ action, resourceType }]`.
|
|
1998
|
+
*
|
|
1999
|
+
* Called once at boot by both entrypoints. Every denial path is explicit
|
|
2000
|
+
* because each one is a place where a plausible implementation would instead
|
|
2001
|
+
* pass:
|
|
2002
|
+
*
|
|
2003
|
+
* - unknown resource type → DENY. A guard naming a policy that was never
|
|
2004
|
+
* registered is a misconfiguration, and a misconfigured authorization
|
|
2005
|
+
* check must not be a permissive one.
|
|
2006
|
+
* - no tuple source → DENY. Nothing can answer the question.
|
|
2007
|
+
* - tuple source throws → DENY, and log it. A database blip must not become
|
|
2008
|
+
* an open door.
|
|
2009
|
+
*
|
|
2010
|
+
* `can()` itself already handles the `admin:full` bypass, anonymous denial and
|
|
2011
|
+
* cross-tenant denial, so this layer does not re-implement them.
|
|
2012
|
+
*/
|
|
2013
|
+
export declare const installPolicyGuardResolver: () => void;
|
|
2014
|
+
|
|
1833
2015
|
/** Fire every registered interrupt for `clientId` (called on disconnect). */
|
|
1834
2016
|
export declare const interruptConnectionStreams: (clientId: number) => void;
|
|
1835
2017
|
|
|
@@ -1965,6 +2147,13 @@ export declare const listResourcePolicies: () => ReadonlyArray<ResourcePolicy>;
|
|
|
1965
2147
|
|
|
1966
2148
|
export { listRetentions }
|
|
1967
2149
|
|
|
2150
|
+
export declare interface LoaderDeps {
|
|
2151
|
+
readonly store: Pick<DataStore, 'query'>;
|
|
2152
|
+
/** Coalesce window. Loads issued within the same microtask batch together;
|
|
2153
|
+
* the default (a resolved promise tick) needs no timers. */
|
|
2154
|
+
readonly schedule?: (flush: () => void) => void;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1968
2157
|
/** Load the tuples for one (subject, resource) — the interceptor's tuple source.
|
|
1969
2158
|
* Returns [] for an anonymous subject (→ `can` denies anyway). */
|
|
1970
2159
|
export declare const loadResourceTuples: (store: DataStore, subjectId: string | null, resourceType: string, resourceId: string) => Promise<ReadonlyArray<RelationTuple>>;
|
|
@@ -2095,6 +2284,12 @@ export declare const makeBufferingSpanProcessor: (onSpanEnd: (record: TraceSpanR
|
|
|
2095
2284
|
*/
|
|
2096
2285
|
export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => ((name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle);
|
|
2097
2286
|
|
|
2287
|
+
/**
|
|
2288
|
+
* Build a request-scoped loader. One instance per AppContext — see the module
|
|
2289
|
+
* header for why this must not be shared across requests.
|
|
2290
|
+
*/
|
|
2291
|
+
export declare const makeDataLoader: (deps: LoaderDeps) => DataLoader;
|
|
2292
|
+
|
|
2098
2293
|
/**
|
|
2099
2294
|
* Build the Layer that provides `EffectStore` from a `MutationStore`.
|
|
2100
2295
|
* dev.ts (CLI) and any standalone test setup uses this to wire up the
|
|
@@ -2140,6 +2335,8 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
|
|
|
2140
2335
|
*/
|
|
2141
2336
|
export declare const makeMutationRunner: (deps: MutationRunnerDeps) => (mutation: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
|
|
2142
2337
|
|
|
2338
|
+
export declare const makeOutboxFacade: (deps: OutboxFacadeDeps) => OutboxFacade;
|
|
2339
|
+
|
|
2143
2340
|
export declare const makePostCommitWorkflowFacade: (base: WorkflowsAppContext, afterCommit: (work: () => Promise<unknown>) => void) => WorkflowsAppContext;
|
|
2144
2341
|
|
|
2145
2342
|
/**
|
|
@@ -2160,6 +2357,27 @@ export declare const makeProcessAdapter: (supervisor: AppSupervisor) => WakeAdap
|
|
|
2160
2357
|
*/
|
|
2161
2358
|
export declare const makeQueryDescriptorProducer: <D>(deps: QueryProducerDeps<D>) => (query: MutationLike, input: unknown) => (requestContext: ServeRequestContext) => D | ComputedQuery | Effect.Effect<D | ComputedQuery, unknown, never>;
|
|
2162
2359
|
|
|
2360
|
+
/**
|
|
2361
|
+
* Build the per-subscription authorization re-check.
|
|
2362
|
+
*
|
|
2363
|
+
* `guards:` are enforced once when a subscription is opened. That is not
|
|
2364
|
+
* enough on its own: a subscription is a LONG-LIVED grant, and the scopes
|
|
2365
|
+
* that justified it can be withdrawn while it is still open (a role
|
|
2366
|
+
* revoked, a resource un-shared, a membership ended). Without a re-check
|
|
2367
|
+
* the socket keeps delivering rows the subject may no longer read until
|
|
2368
|
+
* the client happens to disconnect.
|
|
2369
|
+
*
|
|
2370
|
+
* So every delivery re-runs the same `checkGuardsEffect` the subscribe-time
|
|
2371
|
+
* gate ran — including the async resource-scope resolver, which is where a
|
|
2372
|
+
* per-row/per-team revocation actually shows up. Returns `null` when the
|
|
2373
|
+
* subject still passes, or the typed `ScopeError` that denied it.
|
|
2374
|
+
*
|
|
2375
|
+
* Returns a closure that always resolves `null` when the descriptor carries
|
|
2376
|
+
* no guards, so the caller needs no branch and an unguarded query pays only
|
|
2377
|
+
* a resolved promise.
|
|
2378
|
+
*/
|
|
2379
|
+
export declare const makeQueryReauthorizer: (query: MutationLike, input: unknown) => (subject: Subject) => () => Promise<unknown>;
|
|
2380
|
+
|
|
2163
2381
|
export declare const makeRouterActivity: () => RouterActivity;
|
|
2164
2382
|
|
|
2165
2383
|
/**
|
|
@@ -2338,7 +2556,7 @@ export declare interface MutationLike {
|
|
|
2338
2556
|
readonly descriptor: {
|
|
2339
2557
|
readonly name: string;
|
|
2340
2558
|
readonly source?: string | ReadonlyArray<string> | undefined;
|
|
2341
|
-
readonly guards?: ReadonlyArray<
|
|
2559
|
+
readonly guards?: ReadonlyArray<AnyCheckSpec> | undefined;
|
|
2342
2560
|
};
|
|
2343
2561
|
executor(input: unknown, ctx: unknown): unknown;
|
|
2344
2562
|
}
|
|
@@ -2467,14 +2685,25 @@ export declare const noopAnalyticsSpec: AnalyticsSinkSpec;
|
|
|
2467
2685
|
* misses, writes are dropped. Mirrors the cache's `noopCache`. */
|
|
2468
2686
|
export declare const noopKv: AsyncKv;
|
|
2469
2687
|
|
|
2470
|
-
/**
|
|
2471
|
-
*
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2688
|
+
/**
|
|
2689
|
+
* `.one()` matched a number of rows other than exactly one.
|
|
2690
|
+
*
|
|
2691
|
+
* `found` distinguishes the two failure modes without a second error type:
|
|
2692
|
+
* `0` is "the row you required is missing", `2` is "your filter is not as
|
|
2693
|
+
* unique as you assumed" (the terminal probes with LIMIT 2, so `2` means
|
|
2694
|
+
* "at least two" — it never counts the whole set just to report a number).
|
|
2695
|
+
*/
|
|
2696
|
+
export declare class NoRowFound extends NoRowFound_base {
|
|
2697
|
+
get message(): string;
|
|
2476
2698
|
}
|
|
2477
2699
|
|
|
2700
|
+
declare const NoRowFound_base: Schema.TaggedErrorClass<NoRowFound, "NoRowFound", {
|
|
2701
|
+
readonly _tag: Schema.tag<"NoRowFound">;
|
|
2702
|
+
} & {
|
|
2703
|
+
table: typeof Schema.String;
|
|
2704
|
+
found: typeof Schema.Number;
|
|
2705
|
+
}>;
|
|
2706
|
+
|
|
2478
2707
|
export declare interface Notification {
|
|
2479
2708
|
readonly subscriberId: string;
|
|
2480
2709
|
readonly fingerprint: string;
|
|
@@ -2492,13 +2721,17 @@ export declare const onBindConnectionSubject: (listener: (clientId: number, subj
|
|
|
2492
2721
|
|
|
2493
2722
|
/** Thrown by `.expectVersion(n).set(...)` when the optimistic-lock guard
|
|
2494
2723
|
* matched no row (the row was concurrently updated or deleted). */
|
|
2495
|
-
export declare class OptimisticLockError extends
|
|
2496
|
-
|
|
2497
|
-
readonly expected: number;
|
|
2498
|
-
readonly _tag = "OptimisticLockError";
|
|
2499
|
-
constructor(table: string, expected: number);
|
|
2724
|
+
export declare class OptimisticLockError extends OptimisticLockError_base {
|
|
2725
|
+
get message(): string;
|
|
2500
2726
|
}
|
|
2501
2727
|
|
|
2728
|
+
declare const OptimisticLockError_base: Schema.TaggedErrorClass<OptimisticLockError, "OptimisticLockError", {
|
|
2729
|
+
readonly _tag: Schema.tag<"OptimisticLockError">;
|
|
2730
|
+
} & {
|
|
2731
|
+
table: typeof Schema.String;
|
|
2732
|
+
expected: typeof Schema.Number;
|
|
2733
|
+
}>;
|
|
2734
|
+
|
|
2502
2735
|
export declare interface OrchestratorTickDeps {
|
|
2503
2736
|
readonly store: DataStore;
|
|
2504
2737
|
readonly supervisor: AppSupervisor;
|
|
@@ -2509,6 +2742,51 @@ export declare interface OrchestratorTickDeps {
|
|
|
2509
2742
|
readonly log?: WakeOrchestratorLogger;
|
|
2510
2743
|
}
|
|
2511
2744
|
|
|
2745
|
+
export declare const OUTBOX_TABLE = "_voltro_outbox";
|
|
2746
|
+
|
|
2747
|
+
export declare interface OutboxFacade {
|
|
2748
|
+
/**
|
|
2749
|
+
* Persist the intent to run `effect` after this transaction commits.
|
|
2750
|
+
*
|
|
2751
|
+
* Returns the outbox row id, which doubles as the delivery id a client can
|
|
2752
|
+
* watch to render external-side-effect progress ("saving… syncing… synced").
|
|
2753
|
+
*/
|
|
2754
|
+
enqueue(effect: string, payload: Record<string, unknown>, options?: EnqueueOptions): Promise<string>;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
export declare interface OutboxFacadeDeps {
|
|
2758
|
+
/** The REQUEST's store. Inside a mutation this is the transactional view —
|
|
2759
|
+
* which is what makes the enqueue atomic with the domain write. */
|
|
2760
|
+
readonly store: Pick<DataStore, 'insert' | 'query'>;
|
|
2761
|
+
readonly subject: Subject;
|
|
2762
|
+
readonly traceId: string | null;
|
|
2763
|
+
/** Nudge the drain worker once the transaction commits. Optional: without
|
|
2764
|
+
* it the row still delivers on the next poll tick, just later. */
|
|
2765
|
+
readonly afterCommit?: (work: () => Promise<unknown>) => void;
|
|
2766
|
+
/** Wake the drain loop. */
|
|
2767
|
+
readonly nudge?: () => void;
|
|
2768
|
+
readonly now?: () => Date;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
export declare interface OutboxHandlerContext {
|
|
2772
|
+
readonly payload: Record<string, unknown>;
|
|
2773
|
+
readonly attempt: number;
|
|
2774
|
+
readonly subjectId: string | null;
|
|
2775
|
+
readonly tenantId: string | null;
|
|
2776
|
+
readonly traceId: string | null;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
export declare interface OutboxHandlerDefinition {
|
|
2780
|
+
readonly effect: string;
|
|
2781
|
+
readonly handler: (ctx: OutboxHandlerContext) => Promise<unknown>;
|
|
2782
|
+
/** Give up after this many attempts, then dead-letter. Default 8. */
|
|
2783
|
+
readonly maxAttempts?: number;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
export declare type OutboxStatus = 'pending' | 'delivering' | 'delivered' | 'dead';
|
|
2787
|
+
|
|
2788
|
+
export declare const outboxTable: TableLike;
|
|
2789
|
+
|
|
2512
2790
|
declare interface P2COptions {
|
|
2513
2791
|
/**
|
|
2514
2792
|
* Random selector — `Math.random()` by default. Tests inject a
|
|
@@ -3854,7 +4132,17 @@ export declare class SelectBuilder {
|
|
|
3854
4132
|
maybeOne(): Promise<Row_4 | null>;
|
|
3855
4133
|
/** Alias of `maybeOne` — the first row or `null`. */
|
|
3856
4134
|
first(): Promise<Row_4 | null>;
|
|
3857
|
-
/**
|
|
4135
|
+
/**
|
|
4136
|
+
* Terminal: EXACTLY one row. Fails with `NoRowFound` on zero matches —
|
|
4137
|
+
* and equally on two or more.
|
|
4138
|
+
*
|
|
4139
|
+
* The over-fetch to LIMIT 2 is deliberate. A `LIMIT 1` probe cannot tell
|
|
4140
|
+
* "the one row you meant" from "the first of several", so a filter that
|
|
4141
|
+
* silently stopped being unique would keep returning an arbitrary row and
|
|
4142
|
+
* the bug would surface far away from its cause. One extra row on the
|
|
4143
|
+
* wire buys a loud failure at the point the assumption breaks. Use
|
|
4144
|
+
* `.first()` / `.maybeOne()` when you genuinely want "any match".
|
|
4145
|
+
*/
|
|
3858
4146
|
one(): Promise<Row_4>;
|
|
3859
4147
|
/** Terminal: COUNT(*) of matching rows (real aggregate, not a fetch). */
|
|
3860
4148
|
count(): Promise<number>;
|
|
@@ -3894,6 +4182,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
|
|
|
3894
4182
|
/** Test seam — swap (or reset with `undefined`) the process recorder. */
|
|
3895
4183
|
export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
|
|
3896
4184
|
|
|
4185
|
+
/**
|
|
4186
|
+
* Register (or clear) the process-global tuple source. Last write wins.
|
|
4187
|
+
*
|
|
4188
|
+
* Registering a source is what ACTIVATES relationship guards. Until then they
|
|
4189
|
+
* deny — see `installPolicyGuardResolver`.
|
|
4190
|
+
*/
|
|
4191
|
+
export declare const setTupleSource: (source: TupleSource | undefined) => void;
|
|
4192
|
+
|
|
3897
4193
|
export declare const sha256Hex: (input: string) => string;
|
|
3898
4194
|
|
|
3899
4195
|
export declare type ShapeClassification = {
|
|
@@ -4464,6 +4760,20 @@ export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload =
|
|
|
4464
4760
|
*/
|
|
4465
4761
|
export declare const tryClaimWakeup: (store: DataStore, ref: WakeupRef) => Promise<boolean>;
|
|
4466
4762
|
|
|
4763
|
+
/**
|
|
4764
|
+
* Reads the relationship tuples for one (subject, resource).
|
|
4765
|
+
*
|
|
4766
|
+
* Registered once at boot. The default implementation reads
|
|
4767
|
+
* `_voltro_rebac_tuples`; an app whose relationships live in its own tables
|
|
4768
|
+
* (a `teamMembers` row, say) registers its own instead of copying data into a
|
|
4769
|
+
* framework table.
|
|
4770
|
+
*/
|
|
4771
|
+
export declare type TupleSource = (req: {
|
|
4772
|
+
readonly subjectId: string | null;
|
|
4773
|
+
readonly resourceType: string;
|
|
4774
|
+
readonly resourceId: string;
|
|
4775
|
+
}) => Promise<ReadonlyArray<RelationTuple>> | ReadonlyArray<RelationTuple>;
|
|
4776
|
+
|
|
4467
4777
|
/**
|
|
4468
4778
|
* Remove the override for a connection. Called by the WS-close
|
|
4469
4779
|
* finalizer (or explicit logout flows). Idempotent.
|