@voltro/runtime 0.23.0 → 0.25.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/dist/index.d.ts CHANGED
@@ -14,8 +14,16 @@ import { DialectId } from '@voltro/database';
14
14
  import { DialectReplicationAdapter } from '@voltro/database';
15
15
  import { Duration } from 'effect';
16
16
  import { Effect } from 'effect';
17
+ import { EventDeliverySemantics } from '@voltro/protocol';
18
+ import { EventDescriptor } from '@voltro/protocol';
19
+ import { EventKeyInvalid } from '@voltro/protocol';
20
+ import { EventPayloadInvalid } from '@voltro/protocol';
21
+ import { EventPayloadTooLarge } from '@voltro/protocol';
22
+ import { EventResumePoint } from '@voltro/protocol';
23
+ import { EventStreamEvent } from '@voltro/protocol';
17
24
  import { Fiber } from 'effect';
18
25
  import { FieldCipher } from '@voltro/database';
26
+ import { Guards } from '@voltro/protocol';
19
27
  import * as http from 'node:http';
20
28
  import { HttpClient } from '@effect/platform';
21
29
  import { HttpRequestInterceptor } from '@voltro/protocol';
@@ -855,7 +863,7 @@ export declare interface AppContext {
855
863
  * recompute. */
856
864
  readonly kv: AsyncKv;
857
865
  /** Webhooks service — present when `@voltro/plugin-webhooks` is in
858
- * the app's plugin list. Use `defineOutgoingEvent(...)` in a
866
+ * the app's plugin list. Declare the event with `defineEvent({ …, webhook })` in a
859
867
  * `*.webhook.tsx` file to declare an event, then `ctx.webhooks.emit(
860
868
  * 'eventId', payload)` from any handler. */
861
869
  readonly webhooks?: WebhooksAppContext;
@@ -1069,6 +1077,17 @@ export declare interface AuditableQuery {
1069
1077
  readonly source?: string | ReadonlyArray<string> | undefined;
1070
1078
  }
1071
1079
 
1080
+ /**
1081
+ * An Effect that is ALSO awaitable.
1082
+ *
1083
+ * The intersection is the type-level half of the fix: without `PromiseLike`,
1084
+ * `await ctx.events.publish(…)` type-checks (awaiting a non-Promise is legal)
1085
+ * and hands back the EFFECT rather than the result — so even after the runtime
1086
+ * behaviour was fixed, the value an async handler got would have been typed
1087
+ * wrong. Both halves are needed, and both are pinned by tests.
1088
+ */
1089
+ export declare type AwaitableEffect<A, E> = Effect.Effect<A, E> & PromiseLike<A>;
1090
+
1072
1091
  /**
1073
1092
  * Resolve once a server returned by {@link startRpcServer} has actually
1074
1093
  * bound its port (node `'listening'` event). REJECTS on `'error'`
@@ -1117,7 +1136,38 @@ export declare interface BeginOAuthResult {
1117
1136
  */
1118
1137
  export declare const bindConnectionSubject: (clientId: number, subject: Subject) => void;
1119
1138
 
1120
- export declare const bindMutation: <Input, Output, E = never>(execute: (input: Input, context: RuntimeContext) => Output | Promise<Output> | Effect.Effect<Output, E, never>, input: Input, spanName?: string) => Effect.Effect<Output, E, SubjectService | ConnectionInfo>;
1139
+ /**
1140
+ * Bind one client's subscription to a declared event.
1141
+ *
1142
+ * The TENANT comes from the resolved subject and from nowhere else, so a
1143
+ * cross-tenant subscription is not something a caller can ask for. `key` has
1144
+ * already been decoded by the rpc layer against the descriptor's schema.
1145
+ *
1146
+ * The stream ends when the client disconnects, through the same per-connection
1147
+ * interrupt registry subscriptions use — so one reconnect policy, one devtools
1148
+ * view, one connection lifecycle for both primitives.
1149
+ */
1150
+ export declare const bindEvent: (input: BindEventInput) => Stream.Stream<EventStreamEvent<unknown>, ScopeError, SubjectService | ConnectionInfo>;
1151
+
1152
+ export declare interface BindEventInput {
1153
+ readonly bus: EventBus;
1154
+ readonly event: string;
1155
+ /** Decoded routing key, exactly as the descriptor declares it. */
1156
+ readonly key: unknown;
1157
+ readonly options?: EventSubscribeOptions | undefined;
1158
+ /** Queue depth override — tests use a tiny one to force the drop path. */
1159
+ readonly queueSize?: number;
1160
+ /**
1161
+ * The descriptor's `guards:` — WHO MAY LISTEN.
1162
+ *
1163
+ * Passed in rather than read from a registry so there is exactly one way for a
1164
+ * subscription to be authorised, and it is the same list the manifest, the
1165
+ * doctor and the dashboard report on.
1166
+ */
1167
+ readonly guards?: Guards<unknown> | undefined;
1168
+ }
1169
+
1170
+ export declare const bindMutation: <Input, Output, E = never>(execute: (input: Input, context: RuntimeContext) => Output | Promise<Output> | Effect.Effect<Output, E, never>, input: Input, spanName?: string, codec?: MutationOutputCodec<unknown>, idempotency?: IdempotencyBinding) => Effect.Effect<Output, E, SubjectService | ConnectionInfo>;
1121
1171
 
1122
1172
  /**
1123
1173
  * Bind a non-reactive server→client stream (A3). The executor builds a
@@ -1323,6 +1373,7 @@ export declare interface CandidateShape {
1323
1373
  readonly windowed?: boolean;
1324
1374
  }
1325
1375
 
1376
+ /* Excluded from this release type: canonicalize */
1326
1377
  export { CaughtUpVerdict }
1327
1378
 
1328
1379
  /** A CDC change event, structurally — what the reactive engine already emits
@@ -1929,7 +1980,13 @@ export declare const defineAggregate: <Row>(input: AggregateDefinitionInput<Row>
1929
1980
  */
1930
1981
  export declare const defineConnection: <D extends ConnectionDefinition>(definition: D) => D;
1931
1982
 
1932
- export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
1983
+ export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag"> | (Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag" | "event"> & {
1984
+ /** A declared event. Its `name` becomes the matched event. */
1985
+ readonly on: {
1986
+ readonly kind: "event";
1987
+ readonly name: string;
1988
+ };
1989
+ })) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
1933
1990
 
1934
1991
  /**
1935
1992
  * Type a procedure executor against its descriptor's `output` (and `input`).
@@ -2116,6 +2173,27 @@ export declare class Dispatcher {
2116
2173
  onSubscriptionsChange(listener: () => void): () => void;
2117
2174
  /** Tear down the change-event listener. Call on server shutdown. */
2118
2175
  close(): void;
2176
+ /**
2177
+ * Re-run EVERY live subscription, because this replica proved it missed
2178
+ * changes it can never recover.
2179
+ *
2180
+ * The broadcast bus detects a serial gap from a peer; pub/sub keeps no log,
2181
+ * so the lost ChangeEvents are gone. That does not matter, and the reason it
2182
+ * does not matter is the whole design: a live query is IDEMPOTENT. Re-running
2183
+ * one is always safe and always lands on the truth, so a proven loss is
2184
+ * repaired by refreshing rather than by replaying.
2185
+ *
2186
+ * Without this a dropped message leaves a client stale FOREVER — its socket
2187
+ * never breaks, so the client-side reconnect never fires, and its query does
2188
+ * not re-run until something else happens to touch the same table. On a quiet
2189
+ * table that is never, and the only symptom is a user saying the page did not
2190
+ * update.
2191
+ *
2192
+ * Deliberately blunt. It re-queries every subscription rather than reasoning
2193
+ * about which tables the lost changes touched — we do not know, and guessing
2194
+ * narrower would reintroduce exactly the silent staleness this repairs.
2195
+ */
2196
+ refreshAll(reason: string): Promise<void>;
2119
2197
  private handleChange;
2120
2198
  }
2121
2199
 
@@ -2170,6 +2248,13 @@ export declare const drainForShutdown: (options: {
2170
2248
  readonly hooks: ReadonlyArray<() => void | Promise<void>>;
2171
2249
  readonly deadlineMs: number;
2172
2250
  readonly exit: () => void;
2251
+ /** How the drain ended, for the caller to report. A completed drain and one
2252
+ * CUT at the deadline are different incidents and were indistinguishable in
2253
+ * the log — asked for by a consumer who could see neither. */
2254
+ readonly onOutcome?: (outcome: {
2255
+ readonly reason: "drained" | "deadline";
2256
+ readonly ms: number;
2257
+ }) => void;
2173
2258
  }) => void;
2174
2259
 
2175
2260
  /**
@@ -2254,6 +2339,320 @@ export declare const enterRequestLoader: (loader: DataLoader) => void;
2254
2339
  * Promise-wrapped to satisfy the async contract. */
2255
2340
  export declare const envSecretsBackend: SecretsBackend;
2256
2341
 
2342
+ /**
2343
+ * How many deliveries one client may fall behind before the oldest are dropped.
2344
+ *
2345
+ * Sized to match the bus's ring: a client that falls further behind than the
2346
+ * server retains could not be made whole on reconnect anyway, so a deeper queue
2347
+ * would only delay the same honest answer.
2348
+ */
2349
+ export declare const EVENT_CLIENT_QUEUE_SIZE = 64;
2350
+
2351
+ /**
2352
+ * The queue depth for a `latest` event: exactly one.
2353
+ *
2354
+ * Not a smaller buffer — a DIFFERENT contract. A depth of one on a sliding queue
2355
+ * means a delivery arriving while another is pending REPLACES it, which is what
2356
+ * "only the current value matters" means when the consumer is slow. Anything
2357
+ * deeper would hand a 60Hz display a backlog to work through before it could
2358
+ * reach the state it actually wanted to show.
2359
+ */
2360
+ export declare const EVENT_LATEST_QUEUE_SIZE = 1;
2361
+
2362
+ export declare class EventBus {
2363
+ private readonly routes;
2364
+ private readonly origin;
2365
+ private readonly ringSize;
2366
+ private readonly ringTtlMs;
2367
+ private readonly maxRoutes;
2368
+ private readonly publishRemote;
2369
+ private readonly deliveryOf;
2370
+ /** Live local subscriber count per EVENT — the basis of channel interest. */
2371
+ private readonly interest;
2372
+ private readonly interestListeners;
2373
+ private readonly now;
2374
+ constructor(options: EventBusOptions);
2375
+ /** This instance's id — the `origin` on everything it publishes. */
2376
+ get instanceOrigin(): string;
2377
+ /**
2378
+ * Be told when this instance gains its FIRST or loses its LAST local
2379
+ * subscriber for an event.
2380
+ *
2381
+ * The transport uses it to hold a cross-instance subscription only while
2382
+ * somebody here is listening. Without it every replica receives, decodes and
2383
+ * materialises a route for every event of every peer — including the ones it
2384
+ * serves no clients for, which for a high-rate event is the bulk of the work
2385
+ * it does and none of the work it needs.
2386
+ *
2387
+ * Registering REPLAYS the currently-active events synchronously, because the
2388
+ * transport is attached after the bus exists: a subscriber that arrived in
2389
+ * between would otherwise be invisible until it left and came back.
2390
+ */
2391
+ onInterestChange(listener: (event: string, active: boolean) => void): () => void;
2392
+ private shiftInterest;
2393
+ private notifyInterest;
2394
+ /** Events this instance currently has at least one local subscriber for. */
2395
+ activeEvents(): ReadonlyArray<string>;
2396
+ /**
2397
+ * One event's declared delivery semantics — what the bridge sizes its queue
2398
+ * from, so the two cannot disagree about whether a drop is a loss.
2399
+ */
2400
+ deliveryFor(event: string): EventDeliverySemantics;
2401
+ private state;
2402
+ /**
2403
+ * Drop the coldest IDLE routes when over the ceiling.
2404
+ *
2405
+ * A route with live listeners is never evicted regardless of age: evicting it
2406
+ * would silently stop delivering to a connected subscriber, which is the exact
2407
+ * class of failure this bus exists to make impossible. Only retention is
2408
+ * sacrificed under pressure, never delivery.
2409
+ */
2410
+ private evictIfOverCapacity;
2411
+ /**
2412
+ * Drop what the ring may no longer replay.
2413
+ *
2414
+ * IN PLACE. The obvious version — `ring = ring.slice(drop)` — allocates and
2415
+ * copies the whole ring on EVERY publish once it is full, which measured as
2416
+ * the single largest cost in the publish path (5.8µs with a full ring against
2417
+ * 0.35µs for everything else combined: the key encoding, the route, and the
2418
+ * size gate's JSON put together). `splice` mutates, so a saturated route costs
2419
+ * the same as an empty one.
2420
+ *
2421
+ * Worth keeping in mind if this is ever rewritten as a true circular buffer:
2422
+ * the win here was removing the ALLOCATION, not the copy. A 64-element
2423
+ * `splice(0, 1)` is cheap; a 64-element fresh array every 5 microseconds is
2424
+ * what the garbage collector notices.
2425
+ */
2426
+ private pruneRing;
2427
+ /**
2428
+ * Publish an already-validated, already-size-checked envelope body.
2429
+ *
2430
+ * Validation lives at the `ctx.events.publish` seam rather than here because
2431
+ * that is where the descriptor is — the bus deals in routes and bytes.
2432
+ */
2433
+ publish(input: {
2434
+ readonly tenantId: string | null;
2435
+ readonly event: string;
2436
+ readonly key: unknown;
2437
+ readonly payload: unknown;
2438
+ }): EventEnvelope;
2439
+ /**
2440
+ * Take a delivery published by ANOTHER instance.
2441
+ *
2442
+ * Ignores our own origin echoed back: the local subscribers were already
2443
+ * served synchronously at publish time, so accepting it here would deliver
2444
+ * twice and break the serial contract (`n` would repeat).
2445
+ */
2446
+ injectRemote(envelope: EventEnvelope): void;
2447
+ private accept;
2448
+ /**
2449
+ * Subscribe to one route. Returns the unsubscribe.
2450
+ *
2451
+ * The listener is invoked synchronously from `publish`, in registration order.
2452
+ * Anything the subscriber needs to do slowly belongs behind its own queue —
2453
+ * see the bridge.
2454
+ */
2455
+ subscribe(input: {
2456
+ readonly tenantId: string | null;
2457
+ readonly event: string;
2458
+ readonly key: unknown;
2459
+ readonly listener: EventListener_2;
2460
+ readonly options?: EventSubscribeOptions | undefined;
2461
+ /**
2462
+ * Called SYNCHRONOUSLY at attach with the route's current watermarks.
2463
+ *
2464
+ * It is how a consumer learns where the stream stood the instant it joined,
2465
+ * with no window for a publish to slip in between reading and subscribing.
2466
+ * The bridge needs it because a delivery dropped BEFORE the client's first
2467
+ * read would otherwise be invisible: with no baseline, the first serial it
2468
+ * sees looks like a starting point rather than evidence of what came before.
2469
+ */
2470
+ readonly onAttach?: ((watermark: ReadonlyMap<string, number>) => void) | undefined;
2471
+ }): () => void;
2472
+ /**
2473
+ * Serve a re-attach: replay what the ring still holds, and report — exactly —
2474
+ * what it does not.
2475
+ *
2476
+ * The subtraction is the point. `owed` is what the client should have received
2477
+ * since its last serial (from the watermark, which survives ring eviction);
2478
+ * `replayed` is what we can actually hand over. An origin the client never
2479
+ * mentioned is one that appeared while it was away, so everything retained
2480
+ * from it is owed. A `gap` is emitted BEFORE the replay so a consumer reading
2481
+ * in order learns it is behind before it starts processing.
2482
+ */
2483
+ private replayForResume;
2484
+ stats(): EventBusStats;
2485
+ /** Per-route live numbers for the inspect surface + dashboard. */
2486
+ snapshot(): ReadonlyArray<EventRouteSnapshot>;
2487
+ /** Drop every route, listener and buffer. For tests and shutdown. */
2488
+ clear(): void;
2489
+ }
2490
+
2491
+ export declare interface EventBusOptions {
2492
+ /** This instance's id. Two processes must never share one. */
2493
+ readonly origin: string;
2494
+ /**
2495
+ * Ring depth per route. 64 by default — sized from what comparable products
2496
+ * allow (Ably caps replay at 100 messages / 2 minutes; Supabase at 25), i.e.
2497
+ * tens of messages and minutes. Anything materially larger is a durable queue
2498
+ * with extra steps, and the framework already has one (`ctx.outbox`).
2499
+ */
2500
+ readonly ringSize?: number;
2501
+ /** How long a ring entry stays eligible for replay. 5 minutes by default. */
2502
+ readonly ringTtlMs?: number;
2503
+ /**
2504
+ * Hard ceiling on how many routes retain a ring at once. A route is an
2505
+ * app-controlled cardinality (`{userId}` in a key is one route per user), so
2506
+ * without a ceiling an unbounded key space is an unbounded memory leak in a
2507
+ * long-lived process — the same failure shape as an append-only table with no
2508
+ * sweep, one layer up. Oldest-touched routes are evicted first.
2509
+ */
2510
+ readonly maxRoutes?: number;
2511
+ /** Hand-off to the cross-instance transport. Absent ⇒ single-instance. */
2512
+ readonly publishRemote?: (envelope: EventEnvelope) => void;
2513
+ /**
2514
+ * What a declared event's name means for retention and loss reporting.
2515
+ *
2516
+ * A lookup rather than a field on `subscribe`, because the answer must be the
2517
+ * SAME for every subscriber of one event and for the publisher — a per-call
2518
+ * argument is a per-call opportunity to disagree, and the disagreement would
2519
+ * show up as one client being told it missed messages another was told it did
2520
+ * not. Wired once from the declared events at boot.
2521
+ */
2522
+ readonly deliveryOf?: (event: string) => EventDeliverySemantics | undefined;
2523
+ readonly now?: () => number;
2524
+ }
2525
+
2526
+ export declare interface EventBusStats {
2527
+ readonly routes: number;
2528
+ readonly subscribers: number;
2529
+ readonly buffered: number;
2530
+ }
2531
+
2532
+ /** What a subscriber is told. `gap` is only ever emitted with a PROVEN count. */
2533
+ export declare type EventDelivery = {
2534
+ readonly kind: 'event';
2535
+ readonly envelope: EventEnvelope;
2536
+ /**
2537
+ * This route's watermark for `envelope.origin` IMMEDIATELY BEFORE this
2538
+ * envelope was accepted — the one number a subscriber cannot infer, and
2539
+ * without which it cannot tell two opposite situations apart.
2540
+ *
2541
+ * Both look identical from a subscriber's seat: an origin absent from the
2542
+ * attach watermark, and a first delivery carrying a high serial.
2543
+ *
2544
+ * prior > 0 this instance HAD been receiving that origin, so the
2545
+ * serials between what we forwarded and this one existed
2546
+ * here and were lost on the way to this subscriber.
2547
+ * prior = 0 this instance had accepted nothing from that origin on
2548
+ * this route, so nothing local could have been dropped. A
2549
+ * replica that started — or joined this event's channel —
2550
+ * while a peer was already at serial 5000 is in this case.
2551
+ *
2552
+ * Reading the second as the first is what made a freshly-started replica
2553
+ * announce a 5000-message loss to every client on its first delivery.
2554
+ */
2555
+ readonly prior: number;
2556
+ } | {
2557
+ readonly kind: 'gap';
2558
+ readonly missed: number;
2559
+ readonly reason: 'buffer' | 'resume';
2560
+ };
2561
+
2562
+ /** One delivery, as it travels locally and across instances. */
2563
+ export declare interface EventEnvelope {
2564
+ /** `tenant · event · key`, NUL-separated — built by `eventRoute`, read with
2565
+ * `parseEventRoute`. Never construct or split one by hand. */
2566
+ readonly route: string;
2567
+ /** Declared event name, carried separately so a consumer of the raw envelope
2568
+ * (broadcast bridge, inspect, metrics) needn't re-parse the route. */
2569
+ readonly event: string;
2570
+ /** Publishing instance. Serials are only comparable WITHIN one origin. */
2571
+ readonly origin: string;
2572
+ /** Monotonic per (origin, route). */
2573
+ readonly n: number;
2574
+ readonly emittedAt: number;
2575
+ readonly payload: unknown;
2576
+ }
2577
+
2578
+ declare type EventListener_2 = (delivery: EventDelivery) => void;
2579
+ export { EventListener_2 as EventListener }
2580
+
2581
+ export declare const eventMetricTagCacheSizeForTests: () => number;
2582
+
2583
+ /** What `publish` needs to know about the caller. */
2584
+ export declare interface EventPublishContext {
2585
+ readonly bus: EventBus;
2586
+ /** From the SUBJECT, never from a caller argument. */
2587
+ readonly tenantId: string | null;
2588
+ /**
2589
+ * Defer until commit, when a transaction is open.
2590
+ *
2591
+ * Present ⇒ the caller is inside `transactional()` and the delivery is queued
2592
+ * with the ChangeEvents the transactional view already buffers, so it is
2593
+ * flushed by the same `commitEvents()` that publishes row changes — and
2594
+ * discarded entirely on rollback. Absent ⇒ deliver now.
2595
+ *
2596
+ * A display reacting to a game start the database rolled back is not a race
2597
+ * that is unlikely, it is one that happens on every constraint violation.
2598
+ */
2599
+ readonly deferToCommit?: ((deliver: () => void) => void) | undefined;
2600
+ /**
2601
+ * The DURABLE audience — the workflow-trigger path.
2602
+ *
2603
+ * `publish` is one declaration reaching several audiences, and this is the
2604
+ * seam for the audience that outlives the process: it records the event and
2605
+ * starts whatever workflows are triggered by it. Absent when the app declares
2606
+ * no triggers, so an app that only fans out to clients pays nothing.
2607
+ *
2608
+ * Deliberately fire-and-forget from `publish`'s point of view: a trigger that
2609
+ * fails must not fail the mutation that published, for the same reason a
2610
+ * broker outage does not. The trigger's own delivery row records the failure.
2611
+ */
2612
+ readonly onPublished?: ((eventName: string, payload: unknown, descriptor: unknown) => void) | undefined;
2613
+ }
2614
+
2615
+ export declare interface EventPublisher {
2616
+ publish<Name extends string, Key extends Schema.Schema.Any, Payload extends Schema.Schema.Any>(descriptor: EventDescriptor<Name, Key, Payload>, key: Schema.Schema.Type<Key>, payload: Schema.Schema.Type<Payload>): AwaitableEffect<EventPublishResult, EventPublishError>;
2617
+ }
2618
+
2619
+ export declare type EventPublishError = EventPayloadInvalid | EventKeyInvalid | EventPayloadTooLarge;
2620
+
2621
+ /**
2622
+ * The `ctx.events.publish` facade, bound to one request's tenant + transaction.
2623
+ *
2624
+ * Typed at the call site from the descriptor alone: `key` and `payload` are both
2625
+ * inferred, so a wrong field name is a `tsc` error where it is written rather
2626
+ * than a handler that never fires.
2627
+ */
2628
+ /** What a publish resolves to, in either spelling. */
2629
+ export declare interface EventPublishResult {
2630
+ readonly n: number;
2631
+ readonly deferred: boolean;
2632
+ }
2633
+
2634
+ /** A declared event's live numbers, for the inspect surface. */
2635
+ export declare interface EventRouteSnapshot {
2636
+ readonly route: string;
2637
+ readonly event: string;
2638
+ readonly subscribers: number;
2639
+ readonly buffered: number;
2640
+ readonly lastEmittedAt: number | undefined;
2641
+ }
2642
+
2643
+ /**
2644
+ * The INTERNAL durable-event facade.
2645
+ *
2646
+ * No longer `ctx.events` — an app cannot reach this. `ctx.events.publish` is
2647
+ * the only emitter, and it drives this one as its durable audience, so a
2648
+ * workflow trigger and a client subscriber can never disagree about whether the
2649
+ * event happened.
2650
+ *
2651
+ * The string form survives here, one layer down, because the durable path
2652
+ * matches triggers BY NAME and the name is what a `defineEvent` descriptor
2653
+ * supplies. What was removed is the ability for an app to invent a name that
2654
+ * nothing declared — which is the whole defect.
2655
+ */
2257
2656
  export declare interface EventsAppContext {
2258
2657
  emit<Payload = unknown>(eventName: string, data: Payload, options?: WorkflowEventEmitOptions): Promise<WorkflowEventEmitResult>;
2259
2658
  }
@@ -2273,6 +2672,20 @@ export declare interface EventsFacadeOptions {
2273
2672
  readonly makeId?: (prefix: 'wfe' | 'wfed') => string;
2274
2673
  }
2275
2674
 
2675
+ export declare interface EventSubscribeOptions {
2676
+ /**
2677
+ * Where this client left off, per origin. PRESENT means re-attach and the
2678
+ * client is owed continuity; ABSENT means a first attach, which starts empty.
2679
+ *
2680
+ * The distinction cannot be inferred server-side — a reconnecting socket and a
2681
+ * freshly mounted component look identical from here — so the client states
2682
+ * it, and the framework's client does that automatically.
2683
+ */
2684
+ readonly resume?: ReadonlyArray<EventResumePoint> | undefined;
2685
+ /** Descriptor opt-in: deliver what the ring holds even on a FIRST attach. */
2686
+ readonly rewind?: boolean | undefined;
2687
+ }
2688
+
2276
2689
  /** Exchange an authorization code (from the callback) for a token set. */
2277
2690
  export declare const exchangeAuthorizationCode: (definition: OAuth2ConnectionDefinition, input: {
2278
2691
  readonly code: string;
@@ -2504,6 +2917,9 @@ export declare interface GroupState {
2504
2917
  readonly extreme?: number;
2505
2918
  }
2506
2919
 
2920
+ /** What a user handler may return. */
2921
+ export declare type HandlerBody = void | Promise<unknown> | Effect.Effect<unknown, unknown, never>;
2922
+
2507
2923
  /** Define a histogram. `boundaries` default to the framework duration buckets. */
2508
2924
  export declare const histogramMetric: (name: string, boundaries?: MetricBoundaries.MetricBoundaries, description?: string) => Metric.Metric.Histogram<number>;
2509
2925
 
@@ -2530,6 +2946,21 @@ export declare interface HttpSecretsOptions {
2530
2946
  readonly ttlMs?: number;
2531
2947
  }
2532
2948
 
2949
+ /**
2950
+ * WS-rpc idempotency store + TTL, passed to `bindMutation` as a closure param by
2951
+ * the boot path (`serveApi`/`dev`) — NOT a service. @effect/rpc runs a handler in
2952
+ * a NARROWED context (only the middleware `provides` + declared deps), so a plain
2953
+ * merged service is invisible to `Effect.serviceOption` inside the handler; the
2954
+ * store+ttl are boot-time constants the bind site already holds, so a closure is
2955
+ * both correct and simpler. Omitted → WS mutation dedup is off (the default).
2956
+ * Reuses the SAME `dataStoreIdempotencyStore` + `_voltro_idempotency` table as
2957
+ * the REST path — enable once, protect both.
2958
+ */
2959
+ export declare interface IdempotencyBinding {
2960
+ readonly store: IdempotencyStore;
2961
+ readonly ttlMs: number;
2962
+ }
2963
+
2533
2964
  export declare interface IdleCheckOptions {
2534
2965
  readonly store: DataStore;
2535
2966
  readonly signals: ActivitySignals;
@@ -2715,6 +3146,47 @@ export { inspectWorkflow }
2715
3146
  */
2716
3147
  export declare const installPolicyGuardResolver: () => void;
2717
3148
 
3149
+ /**
3150
+ * The live membership of this deployment, as THIS instance can observe it.
3151
+ *
3152
+ * Transport-agnostic, like the event bus: `announce` is injected and remote
3153
+ * heartbeats arrive through `receive`. That keeps it testable with no broker and
3154
+ * makes the cross-instance wiring one place rather than a concern spread through
3155
+ * the registry.
3156
+ */
3157
+ export declare class InstanceMembership {
3158
+ private readonly members;
3159
+ private readonly listeners;
3160
+ private readonly instanceId;
3161
+ private readonly startedAt;
3162
+ private readonly meta;
3163
+ private readonly heartbeatMs;
3164
+ private readonly downAfterMs;
3165
+ private readonly announce;
3166
+ private readonly now;
3167
+ private timer;
3168
+ constructor(options: MembershipOptions);
3169
+ get id(): string;
3170
+ /** Begin announcing, and start expiring the silent. */
3171
+ start(): void;
3172
+ stop(): void;
3173
+ private beat;
3174
+ /**
3175
+ * Take a heartbeat from another instance.
3176
+ *
3177
+ * Our own id echoed back by the broker is ignored: self-liveness is local, and
3178
+ * accepting the echo would let a broker outage look like a self-death.
3179
+ */
3180
+ receive(heartbeat: MembershipHeartbeat): void;
3181
+ /** Drop instances that have gone silent past the threshold. */
3182
+ sweep(): void;
3183
+ /** Everyone this instance currently believes is alive, self included. */
3184
+ list(): ReadonlyArray<MemberSnapshot>;
3185
+ /** Subscribe to membership changes. Returns the unsubscribe. */
3186
+ onChange(listener: MembershipListener): () => void;
3187
+ private emit;
3188
+ }
3189
+
2718
3190
  /** Fire every registered interrupt for `clientId` (called on disconnect). */
2719
3191
  export declare const interruptConnectionStreams: (clientId: number) => void;
2720
3192
 
@@ -2775,6 +3247,15 @@ export declare const isOrglessUserSubject: (subject: {
2775
3247
  readonly tenantId?: string | null;
2776
3248
  } | null | undefined) => boolean;
2777
3249
 
3250
+ /** A query cancelled for exceeding the `statementTimeoutMs` deadline, across
3251
+ * dialects: pg `57014` (query_canceled — what `statement_timeout` raises),
3252
+ * MySQL `3024` (ER_QUERY_TIMEOUT), MariaDB `1969` (ER_STATEMENT_TIMEOUT), mssql
3253
+ * `ETIMEOUT` (tedious request timeout), sqlite `SQLITE_INTERRUPT`. NOT a
3254
+ * transient error — a re-run just repeats the runaway, so it must not retry
3255
+ * (`servePipeline`'s transient set deliberately excludes it). Lets a consumer /
3256
+ * observability layer name the failure instead of reading an opaque SqlError. */
3257
+ export declare const isQueryTimeout: (dbCause: Record<string, unknown>) => boolean;
3258
+
2778
3259
  /**
2779
3260
  * Optimistic-concurrency guard: an undo is safe only when the row still looks
2780
3261
  * like what the forward mutation left (`next`). If another writer changed it
@@ -3067,6 +3548,17 @@ export declare const makeAppAccess: (subject: Subject) => AppAccess;
3067
3548
  * fall back to `database`. */
3068
3549
  export declare const makeAsyncKv: ({ store, env }: KvFacadeOptions) => Promise<KvFacade>;
3069
3550
 
3551
+ /**
3552
+ * Detect a serial jump per origin, and report it as a proven count.
3553
+ *
3554
+ * Stateful across one client's stream, which is why it is built per
3555
+ * subscription rather than shared.
3556
+ */
3557
+ export declare const makeBufferGapDetector: (attachWatermark?: ReadonlyMap<string, number>) => (event: {
3558
+ readonly origin: string;
3559
+ readonly n: number;
3560
+ }, prior?: number) => number;
3561
+
3070
3562
  /**
3071
3563
  * Build a SpanProcessor that maps every finished span to a flat record
3072
3564
  * and hands it to `onSpanEnd`. The callback must not throw (a thrown
@@ -3117,6 +3609,8 @@ export declare const makeDisconnectConnectionExecutor: (deps: ConnectionBuiltinD
3117
3609
  */
3118
3610
  export declare const makeEffectStoreLayer: (store: MutationStore) => Layer.Layer<EffectStore>;
3119
3611
 
3612
+ export declare const makeEventPublisher: (ctx: EventPublishContext) => EventPublisher;
3613
+
3120
3614
  export declare const makeEventsFacade: (options: EventsFacadeOptions) => EventsAppContext;
3121
3615
 
3122
3616
  /** Convenience: derive a key from a passphrase and build the cipher. */
@@ -3143,6 +3637,14 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
3143
3637
  /** `__voltro.connections.list` — every declared connection, for the caller. */
3144
3638
  export declare const makeListConnectionsExecutor: (deps: ConnectionBuiltinDeps) => (_input: Record<string, never>, ctx: ConnectionExecutorCtx) => Promise<ReadonlyArray<ConnectionState>>;
3145
3639
 
3640
+ /**
3641
+ * Build the replay codec from a mutation's `descriptor.output` Schema (call it at
3642
+ * the bind site). Both directions fall back to the raw value if the Schema can't
3643
+ * round-trip it — a guard for exotic schemas; for a value the handler actually
3644
+ * produced, `encodeUnknownSync` never throws. `undefined` schema → no codec.
3645
+ */
3646
+ export declare const makeMutationOutputCodec: (outputSchema: Schema.Schema.AnyNoContext | undefined) => MutationOutputCodec<unknown> | undefined;
3647
+
3146
3648
  /**
3147
3649
  * Build the shared mutation runner. Used by BOTH the rpc WS handler and
3148
3650
  * the `/_voltro/inspect/invoke` endpoint (and the prod entrypoint) so they
@@ -3345,6 +3847,77 @@ export declare const materializeIvm: (shape: AggregateShape, state: IvmState) =>
3345
3847
  readonly value: number | null;
3346
3848
  }>;
3347
3849
 
3850
+ /** Told when the membership changes. `restarted` is a `left` + `joined` pair
3851
+ * for one id, reported as such so a consumer drops the old state rather than
3852
+ * resuming it. */
3853
+ export declare type MembershipEvent = {
3854
+ readonly kind: 'joined';
3855
+ readonly instanceId: string;
3856
+ readonly startedAt: number;
3857
+ } | {
3858
+ readonly kind: 'left';
3859
+ readonly instanceId: string;
3860
+ readonly reason: 'silent';
3861
+ } | {
3862
+ readonly kind: 'restarted';
3863
+ readonly instanceId: string;
3864
+ readonly startedAt: number;
3865
+ };
3866
+
3867
+ /** What one instance broadcasts about itself. */
3868
+ export declare interface MembershipHeartbeat {
3869
+ readonly instanceId: string;
3870
+ /**
3871
+ * When this PROCESS started, as the sender's own epoch ms.
3872
+ *
3873
+ * Carried as an IDENTITY, never compared to our clock. Its job is to make a
3874
+ * restart distinguishable from a hiccup: an instance that comes back with a
3875
+ * NEW `startedAt` is a fresh process whose owned state is gone, so a consumer
3876
+ * must drop what it held for the old one rather than resume it. A returning
3877
+ * instance with the SAME value only ever missed a few beats.
3878
+ */
3879
+ readonly startedAt: number;
3880
+ /** Free-form, for a dashboard: region, version, pod name. */
3881
+ readonly meta?: Readonly<Record<string, string | number>> | undefined;
3882
+ }
3883
+
3884
+ export declare type MembershipListener = (event: MembershipEvent) => void;
3885
+
3886
+ export declare interface MembershipOptions {
3887
+ readonly instanceId: string;
3888
+ /** This process's start time. Defaults to now. */
3889
+ readonly startedAt?: number;
3890
+ readonly meta?: Readonly<Record<string, string | number>>;
3891
+ /**
3892
+ * How often to announce. 5 s by default — frequent enough that a death is
3893
+ * noticed inside a page load, cheap enough that a hundred replicas produce
3894
+ * twenty messages a second between them.
3895
+ */
3896
+ readonly heartbeatMs?: number;
3897
+ /**
3898
+ * Silence after which an instance is PRESUMED down. Default 3× the heartbeat.
3899
+ *
3900
+ * Three missed beats rather than one, because a single missed beat is a GC
3901
+ * pause or a broker hiccup, and evicting on it would make a healthy cluster
3902
+ * flap — every flap dropping and re-adding that instance's owned state, which
3903
+ * a presence roster shows as everyone briefly leaving and coming back.
3904
+ */
3905
+ readonly downAfterMs?: number;
3906
+ /** Send a heartbeat onto the transport. Absent ⇒ single-instance. */
3907
+ readonly announce?: (heartbeat: MembershipHeartbeat) => void;
3908
+ readonly now?: () => number;
3909
+ }
3910
+
3911
+ export declare interface MemberSnapshot {
3912
+ readonly instanceId: string;
3913
+ readonly startedAt: number;
3914
+ readonly meta: Readonly<Record<string, string | number>> | undefined;
3915
+ /** Our clock, when we last heard from it. Never the sender's. */
3916
+ readonly lastHeardAt: number;
3917
+ /** True for this process. */
3918
+ readonly self: boolean;
3919
+ }
3920
+
3348
3921
  /** In-memory store (tests + single-process dev). */
3349
3922
  export declare const memoryApiKeyStore: () => ApiKeyStore;
3350
3923
 
@@ -3438,6 +4011,20 @@ export declare interface MutationLike {
3438
4011
  executor(input: unknown, ctx: unknown): unknown;
3439
4012
  }
3440
4013
 
4014
+ /**
4015
+ * Encode/decode a mutation's output for idempotent replay. On a FRESH call the
4016
+ * WIRE form (`encode`) is stored, so a later replay `decode`s it back to the exact
4017
+ * Output the handler would have returned and the rpc layer re-encodes it
4018
+ * identically. Storing the DECODED Output directly would corrupt `Date`/etc.
4019
+ * fields through the store's JSON round-trip (encode there expects a `Date`, not
4020
+ * the ISO string a round-trip produced). Built from `descriptor.output` at the
4021
+ * call site; both fns fall back to the raw value if the Schema can't round-trip.
4022
+ */
4023
+ export declare interface MutationOutputCodec<Output> {
4024
+ readonly encode: (output: Output) => unknown;
4025
+ readonly decode: (stored: unknown) => Output;
4026
+ }
4027
+
3441
4028
  export declare interface MutationRunnerDeps {
3442
4029
  readonly store: TransactionalStore;
3443
4030
  /** Build the per-call `AppContext` bound to the transactional `tx`. */
@@ -4007,6 +4594,17 @@ export declare interface PublicApiKey {
4007
4594
  readonly metadata?: Readonly<Record<string, unknown>> | null;
4008
4595
  }
4009
4596
 
4597
+ /**
4598
+ * Validate, size-check and publish one event.
4599
+ *
4600
+ * Returns the assigned serial so a caller (and the tests) can observe ordering
4601
+ * without reaching into the bus.
4602
+ */
4603
+ export declare const publishEvent: <Name extends string, Key extends Schema.Schema.Any, Payload extends Schema.Schema.Any>(ctx: EventPublishContext, descriptor: EventDescriptor<Name, Key, Payload>, key: Schema.Schema.Type<Key>, payload: Schema.Schema.Type<Payload>) => Effect.Effect<{
4604
+ readonly n: number;
4605
+ readonly deferred: boolean;
4606
+ }, EventPublishError>;
4607
+
4010
4608
  /** A typed builder or its descriptor — the single-row terminals accept either,
4011
4609
  * so a call site never has to reach for `.descriptor` just to use them. */
4012
4610
  export declare type QueryLike<R = Row_5> = QueryDescriptor<R> | {
@@ -4175,6 +4773,19 @@ export declare interface RecordedSample {
4175
4773
  readonly status: 'ok' | 'error';
4176
4774
  }
4177
4775
 
4776
+ /** `count` deliveries handed to subscribers of `event`. */
4777
+ export declare const recordEventDelivered: (event: string, count?: number) => void;
4778
+
4779
+ /** `count` deliveries PROVABLY lost — `buffer` (a slow consumer) or `resume`
4780
+ * (a re-attach older than the ring). Never called with a guess. */
4781
+ export declare const recordEventDropped: (event: string, reason: "buffer" | "resume", count?: number) => void;
4782
+
4783
+ /** One publish of `event`. */
4784
+ export declare const recordEventPublished: (event: string) => void;
4785
+
4786
+ /** Subscriber attached (+1) or detached (-1). */
4787
+ export declare const recordEventSubscribers: (event: string, delta: number) => void;
4788
+
4178
4789
  /**
4179
4790
  * Record one framework sample into the Effect metric registry. Synchronous —
4180
4791
  * metric updates are pure, so `runSync` is cheap and safe to call from the
@@ -4182,6 +4793,15 @@ export declare interface RecordedSample {
4182
4793
  */
4183
4794
  export declare const recordSample: (s: RecordedSample) => void;
4184
4795
 
4796
+ /**
4797
+ * Record one schedule firing into the registry: the run counter (by name +
4798
+ * status), the duration histogram, and — on success — the last-success gauge.
4799
+ * Synchronous, like the other recorders (metric updates are pure). Called from
4800
+ * the scheduler's success + failure arms so every cron in every app is covered
4801
+ * without per-handler wiring.
4802
+ */
4803
+ export declare const recordScheduleRun: (r: ScheduleRunRecord) => void;
4804
+
4185
4805
  /** Move the live-subscriptions gauge for `label`'s tag by `delta` (+1 open, -1 close). */
4186
4806
  export declare const recordSubscriptionActive: (label: string, delta: number) => void;
4187
4807
 
@@ -4203,6 +4823,14 @@ export declare const recordTimelineEvent: (change: CdcChange & {
4203
4823
  readonly tenantId?: string | null;
4204
4824
  }) => void;
4205
4825
 
4826
+ /**
4827
+ * Record one workflow run's terminal outcome: the run counter (by name + status),
4828
+ * the duration histogram, and — on success — the last-success gauge. Synchronous,
4829
+ * like the other recorders. Called from the workflow run-recording seam through an
4830
+ * injected hook so `@voltro/workflow` keeps its no-`@voltro/runtime`-dep boundary.
4831
+ */
4832
+ export declare const recordWorkflowRun: (r: WorkflowRunRecord) => void;
4833
+
4206
4834
  /**
4207
4835
  * Strip `redact` columns from a set of rows. Exposed on its own so a hand-written
4208
4836
  * handler that isn't a plain CRUD read can still redact declaratively and be
@@ -4350,6 +4978,7 @@ export declare interface RegistryTableLike {
4350
4978
  * it. MariaDB/Postgres reject an explicit value, so the MutationStore
4351
4979
  * strips any the caller supplied before the INSERT reaches the dialect.
4352
4980
  */
4981
+ readonly versionColumn?: boolean;
4353
4982
  readonly generatedAs?: {
4354
4983
  readonly expr: string;
4355
4984
  readonly stored: boolean;
@@ -4570,6 +5199,9 @@ export declare const resetComputedQueryCacheWarnings: () => void;
4570
5199
  /** Test/dev-only — clear ALL overrides. Don't call from app code. */
4571
5200
  export declare const _resetConnectionSubjectsForTest: () => void;
4572
5201
 
5202
+ /** Test seams for the tag-cache guard — the cache is module-private otherwise. */
5203
+ export declare const resetEventMetricTagCacheForTests: () => void;
5204
+
4573
5205
  /** Drop everything recorded. For tests; not called by the runtime. */
4574
5206
  export declare const resetObservedGraph: () => void;
4575
5207
 
@@ -5340,7 +5972,20 @@ export declare interface ScheduleFireContext {
5340
5972
  */
5341
5973
  export declare type ScheduleFireInterceptor = (next: () => Promise<void>, ctx: ScheduleFireContext) => Promise<void>;
5342
5974
 
5343
- export declare type ScheduleHandler = (ctx: ScheduleContext) => void | Promise<void>;
5975
+ /**
5976
+ * A schedule body. Promise-form or Effect-form; both run.
5977
+ *
5978
+ * The Effect arm is not sugar. Before it existed the call site was
5979
+ * `await def.handler(ctx)`, and an Effect is not a thenable — so `await`
5980
+ * returned it unchanged, the body never executed, and the firing was recorded
5981
+ * as a success. In an Effect-first framework the natural thing to write was the
5982
+ * thing that silently did nothing. Same bridge a file-based migration's `up`
5983
+ * has carried since it shipped.
5984
+ *
5985
+ * `R = never`: the effect must carry its own requirements. Everything a
5986
+ * schedule needs is on `ctx.app`.
5987
+ */
5988
+ export declare type ScheduleHandler = (ctx: ScheduleContext) => void | Promise<void> | Effect.Effect<unknown, unknown, never>;
5344
5989
 
5345
5990
  /** What happens when a firing arrives while the previous run of the
5346
5991
  * same schedule is still in flight. */
@@ -5433,6 +6078,16 @@ export declare interface SchedulerLogger {
5433
6078
  debug?: (msg: string, fields?: Record<string, unknown>) => void;
5434
6079
  }
5435
6080
 
6081
+ export declare interface ScheduleRunRecord {
6082
+ readonly name: string;
6083
+ readonly status: 'succeeded' | 'failed' | 'skipped' | 'missed';
6084
+ readonly durationMs: number;
6085
+ /** UNIX SECONDS of completion — set only on a success (drives the last-success
6086
+ * gauge). Omit for a non-success; the gauge is deliberately not moved then, so
6087
+ * it keeps pointing at the last time the job actually worked. */
6088
+ readonly completedAtSec?: number;
6089
+ }
6090
+
5436
6091
  /** Trigger driver — `self` runs an in-app supervised timer; `external`
5437
6092
  * exposes an HTTP endpoint that an outside scheduler (k8s CronJob,
5438
6093
  * AWS EventBridge, GCP Cloud Scheduler, …) invokes. */
@@ -5461,6 +6116,8 @@ export declare interface SchemaInfo {
5461
6116
  * on every UPDATE against the merged post-update row.
5462
6117
  */
5463
6118
  readonly computedFields: ReadonlyMap<string, (row: Readonly<Record<string, unknown>>) => unknown>;
6119
+ /** The column marked `.version()`, when the table declares one. */
6120
+ readonly versionColumn?: string;
5464
6121
  /**
5465
6122
  * DB-generated columns (`generatedAs`). The MutationStore strips any
5466
6123
  * caller-supplied value for these before the INSERT — the engine computes
@@ -5510,6 +6167,10 @@ export declare interface SchemaRegistry {
5510
6167
  /** Convenience: `hasMixin(table, VOLTRO_AUDIT_MIXIN_ID)`. */
5511
6168
  hasAudit(table: string): boolean;
5512
6169
  hasSoftDelete(table: string): boolean;
6170
+ /** True when the table carries `expires()`. Reads filter expired rows on
6171
+ * EVERY dialect from this flag — the postgres-only sweep is a separate,
6172
+ * optional half. */
6173
+ hasExpires(table: string): boolean;
5513
6174
  hasTenant(table: string): boolean;
5514
6175
  /** Field-existence check — used to skip stamping a column that the
5515
6176
  * table doesn't actually declare (extra defensive). */
@@ -5537,6 +6198,14 @@ export declare interface SchemaRegistry {
5537
6198
  * overwrites whatever the caller passed for that column.
5538
6199
  */
5539
6200
  computedFields(table: string): ReadonlyMap<string, (row: Readonly<Record<string, unknown>>) => unknown>;
6201
+ /**
6202
+ * The table's optimistic-concurrency column, or `undefined`.
6203
+ *
6204
+ * Resolved ONCE at registration rather than scanned per write: an update is
6205
+ * the hot path, and a per-write `Object.entries` over every column of every
6206
+ * table is the kind of cost that only shows up under load.
6207
+ */
6208
+ versionColumn(table: string): string | undefined;
5540
6209
  /**
5541
6210
  * DB-generated columns (`generatedAs`) for a table. Empty set if none.
5542
6211
  * Read by MutationStore.stampedForInsert to STRIP any caller-supplied
@@ -5698,6 +6367,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
5698
6367
  /** Test seam — swap (or reset with `undefined`) the process recorder. */
5699
6368
  export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
5700
6369
 
6370
+ /**
6371
+ * Normalise a handler's return value to "a promise, or nothing to wait for".
6372
+ *
6373
+ * `undefined` means the body was synchronous and has already run. Anything else
6374
+ * is a promise the caller disposes of as its context requires.
6375
+ */
6376
+ export declare const settleHandlerBody: (body: HandlerBody) => Promise<unknown> | undefined;
6377
+
5701
6378
  /**
5702
6379
  * Register (or clear) the process-global tuple source. Last write wins.
5703
6380
  *
@@ -5716,6 +6393,18 @@ export declare type ShapeClassification = {
5716
6393
  readonly reason: string;
5717
6394
  };
5718
6395
 
6396
+ /**
6397
+ * The teardown deadline, from `VOLTRO_SHUTDOWN_GRACE_MS` (milliseconds), clamped
6398
+ * to `[1s, 5min]`. Operators set it to sit JUST UNDER their orchestrator's hard
6399
+ * kill — k8s `terminationGracePeriodSeconds`, ECS `stopTimeout` — so the process
6400
+ * drains in-flight work and exits cleanly on its own BEFORE SIGKILL truncates it
6401
+ * mid-drain (which would strand exactly the finalizers this path exists to run:
6402
+ * connection-pool close, plugin `onDeactivate`, analytics flush, trace persist).
6403
+ * A non-numeric / non-positive value falls back to the 10s default rather than
6404
+ * producing a `setTimeout(…, NaN)` that fires immediately and defeats the drain.
6405
+ */
6406
+ export declare const shutdownGraceMsFromEnv: () => number;
6407
+
5719
6408
  /** No-coordination gate for single-instance deployments (PM2,
5720
6409
  * single pod, dev). */
5721
6410
  export declare const singleCoordinator: Coordinator;
@@ -5967,13 +6656,44 @@ export declare interface SubscribeContext {
5967
6656
  * workflow" belongs in a `*.reaction.tsx`, whose `act` is crash-safe.
5968
6657
  */
5969
6658
  readonly store: FluentStore;
6659
+ /**
6660
+ * Publish a declared event from a table change — the bridge between the two
6661
+ * axes.
6662
+ *
6663
+ * A row changing and a thing happening are not the same statement, and most
6664
+ * of the time only one of them is what a client cares about: nobody watches
6665
+ * `attendance` rows, they watch "attendance changed". Without this, an app
6666
+ * with a table-derived event has to publish from every mutation that touches
6667
+ * the table and remember to do it in the next one too — which is the
6668
+ * fail-open-by-omission shape a declaration exists to remove.
6669
+ *
6670
+ * Present only when the app declares at least one `defineEvent`, so reaching
6671
+ * for it in an app with none is a type error rather than a runtime undefined.
6672
+ *
6673
+ * BEST-EFFORT, like everything else in a subscriber: this fires AFTER the
6674
+ * commit, so there is no transaction to couple to and a failed publish cannot
6675
+ * roll anything back. If the event must not be lost, publish it from the
6676
+ * mutation instead — there it rides the commit.
6677
+ */
6678
+ readonly publish?: EventPublisher['publish'];
5970
6679
  }
5971
6680
 
5972
6681
  /** Event shape passed to a subscriber handler. Same as the
5973
6682
  * framework's CDC `ChangeEvent` re-exported for ergonomics. */
5974
6683
  export declare type SubscribeEvent = ChangeEvent;
5975
6684
 
5976
- export declare type SubscribeHandler = (event: SubscribeEvent, ctx: SubscribeContext) => void | Promise<void>;
6685
+ /**
6686
+ * A subscriber body. Promise-form or Effect-form; both run.
6687
+ *
6688
+ * The Effect arm is not sugar — see `ScheduleHandler`. The runner tested
6689
+ * `result instanceof Promise`, an Effect is not one, so the branch was skipped
6690
+ * and the body never executed. No error, no log line, the change event handled
6691
+ * "successfully".
6692
+ *
6693
+ * `R = never`: the effect must carry its own requirements. Everything a
6694
+ * subscriber needs is on `ctx`.
6695
+ */
6696
+ export declare type SubscribeHandler = (event: SubscribeEvent, ctx: SubscribeContext) => void | Promise<void> | Effect.Effect<unknown, unknown, never>;
5977
6697
 
5978
6698
  /** Which row op(s) the subscriber listens on. `'any'` matches every
5979
6699
  * op; an array enumerates the concrete ops. */
@@ -6383,7 +7103,36 @@ export declare interface TransactionalStore {
6383
7103
  transactional<T>(work: (tx: unknown) => Promise<T>): Promise<T>;
6384
7104
  }
6385
7105
 
6386
- export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
7106
+ /**
7107
+ * Declare a workflow trigger.
7108
+ *
7109
+ * TWO forms, and the descriptor one is the one to use:
7110
+ *
7111
+ * ```ts
7112
+ * triggerWorkflow({ on: gameStarted, workflow: 'postGameReport' }) // typed
7113
+ * triggerWorkflow({ event: 'games.started', workflow: 'postGameReport' }) // string
7114
+ * ```
7115
+ *
7116
+ * `on:` takes a `defineEvent` descriptor and reads its NAME, so the trigger and
7117
+ * the producer cannot drift: rename the event and this call site moves with it,
7118
+ * where the string form silently stops matching and the workflow simply never
7119
+ * runs again. That failure is exactly what a consumer reported having with their
7120
+ * own string channels — `on-game-scores` beside `games/evo5/on-game-scores`,
7121
+ * both live, one dead since the day it was written — and it is the reason the
7122
+ * string form is going away rather than being kept as an alternative.
7123
+ *
7124
+ * It is ADDITIVE today on purpose. Shipping the descriptor form with the event
7125
+ * primitive means a third string namespace never comes into existence even
7126
+ * briefly; removing the string form is a separate, breaking change with its own
7127
+ * codemod, and nothing here has to be undone for it.
7128
+ */
7129
+ export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag"> | (Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag" | "event"> & {
7130
+ /** A declared event. Its `name` becomes the matched event. */
7131
+ readonly on: {
7132
+ readonly kind: "event";
7133
+ readonly name: string;
7134
+ };
7135
+ })) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
6387
7136
 
6388
7137
  /**
6389
7138
  * Atomically claim a pending wakeup for waking — CAS `pending → fired`.
@@ -6572,6 +7321,11 @@ export declare const visibleRows: <Row extends {
6572
7321
  */
6573
7322
  export declare const VOLTRO_AUDIT_MIXIN_ID: "voltro/audit";
6574
7323
 
7324
+ /** Re-declared here for the same reason the soft-delete id is: the registry
7325
+ * must not import the mixin (which pulls its column builders into every
7326
+ * consumer). Kept in step by `expiresMixinId.test.ts`. */
7327
+ export declare const VOLTRO_EXPIRES_MIXIN_ID: "voltro/expires";
7328
+
6575
7329
  export declare const VOLTRO_SOFT_DELETE_MIXIN_ID: "voltro/softDelete";
6576
7330
 
6577
7331
  export declare const VOLTRO_TENANT_MIXIN_ID: "voltro/tenant";
@@ -6924,6 +7678,16 @@ export declare interface WorkflowRunOptions {
6924
7678
  /* Excluded from this release type: callerContext */
6925
7679
  }
6926
7680
 
7681
+ export declare interface WorkflowRunRecord {
7682
+ readonly name: string;
7683
+ /** Terminal outcome only. `failed` is the dead-letter state (no framework retry). */
7684
+ readonly status: 'succeeded' | 'failed';
7685
+ readonly durationMs: number;
7686
+ /** UNIX SECONDS of completion — set only on success (drives the last-success
7687
+ * gauge). Omit on failure so the gauge keeps pointing at the last real success. */
7688
+ readonly completedAtSec?: number;
7689
+ }
7690
+
6927
7691
  /** Persisted run lifecycle states recorded in `_voltro_workflow_runs`.
6928
7692
  * Distinct from {@link WorkflowRunStatus} (which is derived from a live
6929
7693
  * engine poll and carries `succeeded`/`unknown`); this enum matches the