@voltro/runtime 0.4.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 +21 -0
- package/dist/index.d.ts +301 -19
- package/dist/index.js +1086 -823
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,27 @@ _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
|
+
|
|
42
63
|
## [0.4.0] — 2026-07-18
|
|
43
64
|
|
|
44
65
|
### ⚠ 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';
|
|
@@ -758,6 +758,28 @@ export declare interface AppContext {
|
|
|
758
758
|
* triggers. `emit(name, payload)` records the event and fans out to
|
|
759
759
|
* matching workflow triggers. */
|
|
760
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'];
|
|
761
783
|
}
|
|
762
784
|
|
|
763
785
|
/**
|
|
@@ -911,6 +933,9 @@ export declare interface AttachAnalyticsMirrorOptions {
|
|
|
911
933
|
*/
|
|
912
934
|
export declare const awaitServerListening: (server: ReturnType<typeof createServer>, timeoutMs?: number) => Promise<void>;
|
|
913
935
|
|
|
936
|
+
/** Exponential backoff with a ceiling — 1s, 2s, 4s … capped at 5 minutes. */
|
|
937
|
+
export declare const backoffMs: (attempt: number) => number;
|
|
938
|
+
|
|
914
939
|
/**
|
|
915
940
|
* Override the subject for an active connection. Called from the
|
|
916
941
|
* `auth.signin` (or any "I just authenticated this caller") handler
|
|
@@ -973,9 +998,17 @@ cache?: {
|
|
|
973
998
|
readonly swrMs: number | undefined;
|
|
974
999
|
readonly scope: "subject" | "global";
|
|
975
1000
|
readonly baseKey: string;
|
|
976
|
-
}
|
|
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>;
|
|
977
1010
|
|
|
978
|
-
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>;
|
|
979
1012
|
|
|
980
1013
|
export declare interface BrandedScheduleDefinition extends ScheduleDefinition {
|
|
981
1014
|
readonly [SCHEDULE_BRAND]: true;
|
|
@@ -1295,6 +1328,21 @@ export declare const currentRoutingContext: () => RoutingContext | undefined;
|
|
|
1295
1328
|
/** The trace context active on the current async stack, if any. */
|
|
1296
1329
|
export declare const currentTraceContext: () => LogTraceContext | undefined;
|
|
1297
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
|
+
|
|
1298
1346
|
export { DataStore }
|
|
1299
1347
|
|
|
1300
1348
|
/** DataStore-backed store over `_voltro_api_keys`. */
|
|
@@ -1346,6 +1394,16 @@ export declare const defineAggregate: <Row>(input: AggregateDefinitionInput<Row>
|
|
|
1346
1394
|
|
|
1347
1395
|
export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
|
|
1348
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
|
+
|
|
1349
1407
|
/** Declare a reaction. Validates that the MANDATORY guard is present — an
|
|
1350
1408
|
* ungated reaction is a spend-storm footgun, so this fails LOUD at boot. */
|
|
1351
1409
|
export declare const defineReaction: (def: ReactionDefinition) => ReactionDefinition;
|
|
@@ -1451,7 +1509,26 @@ export declare class Dispatcher {
|
|
|
1451
1509
|
/** Snapshot-cache binding (Layer 3). When present AND `deps.cache` is
|
|
1452
1510
|
* wired, the initial snapshot is served through the cache, tagged with
|
|
1453
1511
|
* the dependent-table set, and kept warm by the recompute path. */
|
|
1454
|
-
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;
|
|
1455
1532
|
/**
|
|
1456
1533
|
* Register a COMPUTED-query subscription. The handler already ran once
|
|
1457
1534
|
* (its value is `computed.value`); we emit that as the initial snapshot,
|
|
@@ -1496,6 +1573,31 @@ export declare interface DispatcherDependencies {
|
|
|
1496
1573
|
readonly cache?: SnapshotCache;
|
|
1497
1574
|
}
|
|
1498
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
|
+
|
|
1499
1601
|
/**
|
|
1500
1602
|
* Up to `limit` wakeups that are due now (`wakeAt <= now`), earliest
|
|
1501
1603
|
* first. Pending rows are read ordered by `wakeAt`, so the earliest —
|
|
@@ -1514,7 +1616,11 @@ export declare class EffectStore extends EffectStore_base {
|
|
|
1514
1616
|
declare const EffectStore_base: Context.TagClass<EffectStore, "@voltro/EffectStore", EffectStoreOps>;
|
|
1515
1617
|
|
|
1516
1618
|
export declare interface EffectStoreOps {
|
|
1517
|
-
|
|
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>;
|
|
1518
1624
|
readonly insert: (table: string, row: Row_4) => Effect.Effect<Row_4, StoreError>;
|
|
1519
1625
|
readonly update: (table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>) => Effect.Effect<Row_4 | null, StoreError>;
|
|
1520
1626
|
readonly delete: (table: string, primaryKey: string) => Effect.Effect<boolean, StoreError>;
|
|
@@ -1530,6 +1636,15 @@ export declare const emptySchemaRegistry: SchemaRegistry;
|
|
|
1530
1636
|
* columns use). Throws if no cipher is registered. */
|
|
1531
1637
|
export declare const encryptField: (plaintext: string) => string;
|
|
1532
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
|
+
|
|
1533
1648
|
/** The always-available default — reads `process.env`. Sync under the hood,
|
|
1534
1649
|
* Promise-wrapped to satisfy the async contract. */
|
|
1535
1650
|
export declare const envSecretsBackend: SecretsBackend;
|
|
@@ -1575,7 +1690,25 @@ export declare interface FieldChange {
|
|
|
1575
1690
|
*/
|
|
1576
1691
|
export declare const fingerprintFor: (table: string, predicate: Predicate, indexHint?: string) => string;
|
|
1577
1692
|
|
|
1578
|
-
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>>;
|
|
1579
1712
|
/** Fluent, scope-applying read builder: `select('notes').where(...).all()`. */
|
|
1580
1713
|
select(table: string): SelectBuilder;
|
|
1581
1714
|
/** Fluent predicate update: `update('notes').where('id', id).set({...})`. */
|
|
@@ -1655,6 +1788,8 @@ export declare const getResourcePolicy: (resourceType: string) => ResourcePolicy
|
|
|
1655
1788
|
* tap feeds it; the inspect endpoint reads it. */
|
|
1656
1789
|
export declare const getTimelineRecorder: () => TimelineRecorder;
|
|
1657
1790
|
|
|
1791
|
+
export declare const getTupleSource: () => TupleSource | undefined;
|
|
1792
|
+
|
|
1658
1793
|
/** Stable group key from the groupBy column values (JSON to disambiguate types). */
|
|
1659
1794
|
export declare const groupKeyOf: (row: Row_2, groupBy: ReadonlyArray<string> | undefined) => string;
|
|
1660
1795
|
|
|
@@ -1858,6 +1993,25 @@ export declare interface InspectStream {
|
|
|
1858
1993
|
|
|
1859
1994
|
export { inspectWorkflow }
|
|
1860
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
|
+
|
|
1861
2015
|
/** Fire every registered interrupt for `clientId` (called on disconnect). */
|
|
1862
2016
|
export declare const interruptConnectionStreams: (clientId: number) => void;
|
|
1863
2017
|
|
|
@@ -1993,6 +2147,13 @@ export declare const listResourcePolicies: () => ReadonlyArray<ResourcePolicy>;
|
|
|
1993
2147
|
|
|
1994
2148
|
export { listRetentions }
|
|
1995
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
|
+
|
|
1996
2157
|
/** Load the tuples for one (subject, resource) — the interceptor's tuple source.
|
|
1997
2158
|
* Returns [] for an anonymous subject (→ `can` denies anyway). */
|
|
1998
2159
|
export declare const loadResourceTuples: (store: DataStore, subjectId: string | null, resourceType: string, resourceId: string) => Promise<ReadonlyArray<RelationTuple>>;
|
|
@@ -2123,6 +2284,12 @@ export declare const makeBufferingSpanProcessor: (onSpanEnd: (record: TraceSpanR
|
|
|
2123
2284
|
*/
|
|
2124
2285
|
export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => ((name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle);
|
|
2125
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
|
+
|
|
2126
2293
|
/**
|
|
2127
2294
|
* Build the Layer that provides `EffectStore` from a `MutationStore`.
|
|
2128
2295
|
* dev.ts (CLI) and any standalone test setup uses this to wire up the
|
|
@@ -2168,6 +2335,8 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
|
|
|
2168
2335
|
*/
|
|
2169
2336
|
export declare const makeMutationRunner: (deps: MutationRunnerDeps) => (mutation: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
|
|
2170
2337
|
|
|
2338
|
+
export declare const makeOutboxFacade: (deps: OutboxFacadeDeps) => OutboxFacade;
|
|
2339
|
+
|
|
2171
2340
|
export declare const makePostCommitWorkflowFacade: (base: WorkflowsAppContext, afterCommit: (work: () => Promise<unknown>) => void) => WorkflowsAppContext;
|
|
2172
2341
|
|
|
2173
2342
|
/**
|
|
@@ -2188,6 +2357,27 @@ export declare const makeProcessAdapter: (supervisor: AppSupervisor) => WakeAdap
|
|
|
2188
2357
|
*/
|
|
2189
2358
|
export declare const makeQueryDescriptorProducer: <D>(deps: QueryProducerDeps<D>) => (query: MutationLike, input: unknown) => (requestContext: ServeRequestContext) => D | ComputedQuery | Effect.Effect<D | ComputedQuery, unknown, never>;
|
|
2190
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
|
+
|
|
2191
2381
|
export declare const makeRouterActivity: () => RouterActivity;
|
|
2192
2382
|
|
|
2193
2383
|
/**
|
|
@@ -2366,7 +2556,7 @@ export declare interface MutationLike {
|
|
|
2366
2556
|
readonly descriptor: {
|
|
2367
2557
|
readonly name: string;
|
|
2368
2558
|
readonly source?: string | ReadonlyArray<string> | undefined;
|
|
2369
|
-
readonly guards?: ReadonlyArray<
|
|
2559
|
+
readonly guards?: ReadonlyArray<AnyCheckSpec> | undefined;
|
|
2370
2560
|
};
|
|
2371
2561
|
executor(input: unknown, ctx: unknown): unknown;
|
|
2372
2562
|
}
|
|
@@ -2495,14 +2685,25 @@ export declare const noopAnalyticsSpec: AnalyticsSinkSpec;
|
|
|
2495
2685
|
* misses, writes are dropped. Mirrors the cache's `noopCache`. */
|
|
2496
2686
|
export declare const noopKv: AsyncKv;
|
|
2497
2687
|
|
|
2498
|
-
/**
|
|
2499
|
-
*
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
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;
|
|
2504
2698
|
}
|
|
2505
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
|
+
|
|
2506
2707
|
export declare interface Notification {
|
|
2507
2708
|
readonly subscriberId: string;
|
|
2508
2709
|
readonly fingerprint: string;
|
|
@@ -2520,13 +2721,17 @@ export declare const onBindConnectionSubject: (listener: (clientId: number, subj
|
|
|
2520
2721
|
|
|
2521
2722
|
/** Thrown by `.expectVersion(n).set(...)` when the optimistic-lock guard
|
|
2522
2723
|
* matched no row (the row was concurrently updated or deleted). */
|
|
2523
|
-
export declare class OptimisticLockError extends
|
|
2524
|
-
|
|
2525
|
-
readonly expected: number;
|
|
2526
|
-
readonly _tag = "OptimisticLockError";
|
|
2527
|
-
constructor(table: string, expected: number);
|
|
2724
|
+
export declare class OptimisticLockError extends OptimisticLockError_base {
|
|
2725
|
+
get message(): string;
|
|
2528
2726
|
}
|
|
2529
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
|
+
|
|
2530
2735
|
export declare interface OrchestratorTickDeps {
|
|
2531
2736
|
readonly store: DataStore;
|
|
2532
2737
|
readonly supervisor: AppSupervisor;
|
|
@@ -2537,6 +2742,51 @@ export declare interface OrchestratorTickDeps {
|
|
|
2537
2742
|
readonly log?: WakeOrchestratorLogger;
|
|
2538
2743
|
}
|
|
2539
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
|
+
|
|
2540
2790
|
declare interface P2COptions {
|
|
2541
2791
|
/**
|
|
2542
2792
|
* Random selector — `Math.random()` by default. Tests inject a
|
|
@@ -3882,7 +4132,17 @@ export declare class SelectBuilder {
|
|
|
3882
4132
|
maybeOne(): Promise<Row_4 | null>;
|
|
3883
4133
|
/** Alias of `maybeOne` — the first row or `null`. */
|
|
3884
4134
|
first(): Promise<Row_4 | null>;
|
|
3885
|
-
/**
|
|
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
|
+
*/
|
|
3886
4146
|
one(): Promise<Row_4>;
|
|
3887
4147
|
/** Terminal: COUNT(*) of matching rows (real aggregate, not a fetch). */
|
|
3888
4148
|
count(): Promise<number>;
|
|
@@ -3922,6 +4182,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
|
|
|
3922
4182
|
/** Test seam — swap (or reset with `undefined`) the process recorder. */
|
|
3923
4183
|
export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
|
|
3924
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
|
+
|
|
3925
4193
|
export declare const sha256Hex: (input: string) => string;
|
|
3926
4194
|
|
|
3927
4195
|
export declare type ShapeClassification = {
|
|
@@ -4492,6 +4760,20 @@ export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload =
|
|
|
4492
4760
|
*/
|
|
4493
4761
|
export declare const tryClaimWakeup: (store: DataStore, ref: WakeupRef) => Promise<boolean>;
|
|
4494
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
|
+
|
|
4495
4777
|
/**
|
|
4496
4778
|
* Remove the override for a connection. Called by the WS-close
|
|
4497
4779
|
* finalizer (or explicit logout flows). Idempotent.
|