@voltro/runtime 0.55.0 → 0.56.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
@@ -78,6 +78,7 @@ import { Sampler } from '@opentelemetry/sdk-trace-base';
78
78
  import { Schedule } from 'effect';
79
79
  import { Schema } from 'effect';
80
80
  import { ScopeError } from '@voltro/protocol';
81
+ import { Server } from 'node:http';
81
82
  import { ServerOnlyColumnWrite } from '@voltro/protocol';
82
83
  import { SpanProcessor } from '@opentelemetry/sdk-trace-base';
83
84
  import { spawn } from 'node:child_process';
@@ -1171,6 +1172,9 @@ export declare const applyInverse: (store: UndoApplyStore, op: InverseOp) => Pro
1171
1172
  * `synthesizeInverse`), as the caller's transaction wraps them. */
1172
1173
  export declare const applyInverses: (store: UndoApplyStore, ops: ReadonlyArray<InverseOp>) => Promise<void>;
1173
1174
 
1175
+ /** Apply the timeouts to a node HTTP server. */
1176
+ export declare const applyKeepAlive: (server: Server, t: KeepAliveTimeouts) => void;
1177
+
1174
1178
  /**
1175
1179
  * Apply every rule matching `change`.
1176
1180
  *
@@ -2947,6 +2951,18 @@ export declare const DEFAULT_DELIVERY_CONCURRENCY = 8;
2947
2951
  * framework default. */
2948
2952
  export declare const DEFAULT_HSTS = "max-age=15552000; includeSubDomains";
2949
2953
 
2954
+ /**
2955
+ * 72 seconds.
2956
+ *
2957
+ * The bound that matters is the PROXY's idle timeout, and the defaults cluster
2958
+ * at 60s (ALB, Envoy) and 75s (nginx). 72s clears the 60s group with margin and
2959
+ * sits just under nginx's — a deployment on nginx should raise this, and the
2960
+ * doc says so. Picking 76s instead would leave every ALB user holding a
2961
+ * connection the load balancer has already dropped, which is the same defect
2962
+ * mirrored; the 60s group is the larger one.
2963
+ */
2964
+ export declare const DEFAULT_KEEP_ALIVE_MS = 72000;
2965
+
2950
2966
  /** Ceiling the idle backoff climbs to. Deliberately short enough to be a
2951
2967
  * FLOOR under a missed wake rather than a substitute for one. */
2952
2968
  export declare const DEFAULT_MAX_IDLE_INTERVAL_MS = 30000;
@@ -2955,6 +2971,9 @@ export declare const DEFAULT_MAX_IDLE_INTERVAL_MS = 30000;
2955
2971
  * small enough to stop a pathological body being buffered into memory. */
2956
2972
  export declare const DEFAULT_MAX_RPC_BODY_BYTES: number;
2957
2973
 
2974
+ /** See {@link DrainDeps.claimLeaseMs}. */
2975
+ export declare const DEFAULT_OUTBOX_CLAIM_LEASE_MS: number;
2976
+
2958
2977
  /**
2959
2978
  * How many raw-SQL reads one request records for the undeclared-`dependsOn`
2960
2979
  * diagnostic. A bound rather than none: the recording lives for as long as the
@@ -2991,6 +3010,15 @@ export declare const DEFAULT_SOCKET_OVERRUN_AFTER_MS = 10000;
2991
3010
 
2992
3011
  export declare const DEFAULT_SOCKET_OVERSIZED_EVENT_BYTES = 262144;
2993
3012
 
3013
+ /**
3014
+ * How old a row may be before a reader should stop trusting it as current.
3015
+ *
3016
+ * Tunable rather than a constant because the write cadence is: a fleet that
3017
+ * writes every 30s and one that writes every 5m disagree about what "stale"
3018
+ * means, and a single number would be wrong for one of them.
3019
+ */
3020
+ export declare const DEFAULT_STALE_AFTER_MS = 120000;
3021
+
2994
3022
  /**
2995
3023
  * The message of an UNDECLARED throw, bounded, with nothing else attached.
2996
3024
  *
@@ -3436,13 +3464,39 @@ export declare interface DispatcherDependencies {
3436
3464
  export declare const DORMANCY_WAKEUP_TENANT = "default";
3437
3465
 
3438
3466
  export declare interface DrainDeps {
3439
- readonly store: Pick<DataStore, 'query' | 'update' | 'insert' | 'delete'>;
3467
+ readonly store: Pick<DataStore, 'query' | 'update' | 'updateMany' | 'insert' | 'delete'>;
3440
3468
  readonly handlers: ReadonlyMap<string, OutboxHandlerDefinition>;
3441
3469
  readonly now?: () => Date;
3442
3470
  /** Max rows per drain pass. */
3443
3471
  readonly batchSize?: number;
3444
3472
  /** Per-entry cap on retained attempt rows. See {@link OUTBOX_ATTEMPT_LOG_CAP}. */
3445
3473
  readonly attemptLogCap?: number;
3474
+ /**
3475
+ * Who this process is, stamped on every row it claims.
3476
+ *
3477
+ * REQUIRED, and deliberately so. The first version made it optional "so an
3478
+ * embedder driving a single process is not forced to invent an identity",
3479
+ * which meant a caller who omitted it silently got the old behaviour: every
3480
+ * replica delivers every row. An exactly-once gate that can be switched off by
3481
+ * leaving a field out is not a gate — it is a default, and the wrong one.
3482
+ *
3483
+ * The INSTANCE rather than the replica name: a pod that restarts under a
3484
+ * stable name must not look like it still holds the claim it died inside, or
3485
+ * the lease is reading its own ghost.
3486
+ */
3487
+ readonly claimedBy: string;
3488
+ /**
3489
+ * How long a claim stands before another replica may take the row.
3490
+ *
3491
+ * It is a LEASE, not a lock: the holder can die, and nothing it left behind
3492
+ * may block delivery forever. It must therefore exceed the slowest handler
3493
+ * you are willing to wait for — a lease shorter than a real delivery hands
3494
+ * the same row to a second replica while the first is still working, which is
3495
+ * the duplicate this whole mechanism removes, reintroduced by a number.
3496
+ *
3497
+ * Default 5 minutes.
3498
+ */
3499
+ readonly claimLeaseMs?: number;
3446
3500
  }
3447
3501
 
3448
3502
  /**
@@ -3474,14 +3528,6 @@ export declare const drainForShutdown: (options: {
3474
3528
  }) => void;
3475
3529
  }) => void;
3476
3530
 
3477
- /**
3478
- * One drain pass: claim due rows, run their handler, record the outcome.
3479
- *
3480
- * A row whose handler is unknown is left PENDING rather than dead-lettered —
3481
- * the usual cause is a deploy where the enqueuing code shipped before the
3482
- * handler, and discarding those would turn a rollout ordering detail into
3483
- * permanent data loss.
3484
- */
3485
3531
  export declare const drainOutbox: (deps: DrainDeps) => Promise<DrainResult>;
3486
3532
 
3487
3533
  export declare interface DrainResult {
@@ -4544,6 +4590,38 @@ export declare interface FinopsRunnerHandle {
4544
4590
  readonly runTick: () => void;
4545
4591
  }
4546
4592
 
4593
+ /** A member of the fleet, as membership sees it. The shape this module needs
4594
+ * from `instanceMembership`, named so the merge can be tested without one. */
4595
+ export declare interface FleetMember {
4596
+ readonly instanceId: string;
4597
+ readonly startedAt: number;
4598
+ }
4599
+
4600
+ export declare interface FleetMergeResult<T> {
4601
+ /** One entry per replica that has written, newest generation only. */
4602
+ readonly byReplica: ReadonlyArray<{
4603
+ readonly replicaId: string;
4604
+ readonly version: string | undefined;
4605
+ readonly ageMs: number;
4606
+ /** Where a peer can reach it, or `undefined`. */
4607
+ readonly reachableAt: string | undefined;
4608
+ readonly payload: T;
4609
+ }>;
4610
+ /** Replicas membership knows about that have written nothing readable. */
4611
+ readonly missing: ReadonlyArray<string>;
4612
+ readonly responded: number;
4613
+ readonly expected: number;
4614
+ /** Rows old enough that a reader should not treat them as current. */
4615
+ readonly stale: ReadonlyArray<{
4616
+ readonly replicaId: string;
4617
+ readonly ageMs: number;
4618
+ }>;
4619
+ /** Framework versions among the responders, counted. Present only when they
4620
+ * DIFFER — a single-version fleet needs no such note, and a note nobody
4621
+ * needs is one everybody learns to skip. */
4622
+ readonly versions: Readonly<Record<string, number>> | undefined;
4623
+ }
4624
+
4547
4625
  export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete' | 'query'> {
4548
4626
  /**
4549
4627
  * Diff-based many-to-many link writer for a junction table. `anchor` names the
@@ -4665,6 +4743,17 @@ export declare const forbiddenColumnsForSubject: (tableName: string, subject: Su
4665
4743
  /** Forget the calling subject's credential. Returns whether a row was removed. */
4666
4744
  export declare const forgetCredential: (store: VaultStore, connectionId: string, subjectId: string) => Promise<boolean>;
4667
4745
 
4746
+ /**
4747
+ * Drop rows for a replica GENERATION that is gone.
4748
+ *
4749
+ * Not a retention sweep — the table has no history to age out. This removes
4750
+ * the row of a process that restarted, because the replica's NEW row is
4751
+ * written under the same `(replicaId, kind)` key and the old generation is
4752
+ * simply overwritten. What this handles is a replica that went away for good
4753
+ * (a scale-down), whose row would otherwise be counted forever.
4754
+ */
4755
+ export declare const forgetReplicaObservations: (store: DataStore, replicaId: string) => Promise<void>;
4756
+
4668
4757
  /** A boot-ready message for a set of cache-scope leaks (empty → `undefined`). */
4669
4758
  export declare const formatCacheScopeLeaks: (leaks: ReadonlyArray<CacheScopeLeak>) => string | undefined;
4670
4759
 
@@ -4776,6 +4865,9 @@ export declare type HandlerBody = void | Promise<unknown> | Effect.Effect<unknow
4776
4865
  */
4777
4866
  export declare const hashUnitInterval: (s: string) => number;
4778
4867
 
4868
+ /** How much longer than `keepAliveTimeout` the headers deadline runs. */
4869
+ export declare const HEADERS_TIMEOUT_MARGIN_MS = 5000;
4870
+
4779
4871
  /** Define a histogram. `boundaries` default to the framework duration buckets. */
4780
4872
  export declare const histogramMetric: (name: string, boundaries?: MetricBoundaries.MetricBoundaries, description?: string) => Metric.Metric.Histogram<number>;
4781
4873
 
@@ -5294,6 +5386,23 @@ export declare interface JunctionLinks {
5294
5386
  }>;
5295
5387
  }
5296
5388
 
5389
+ /** The `http:` block's keep-alive knobs. */
5390
+ export declare interface KeepAliveConfig {
5391
+ readonly http?: {
5392
+ /** Idle keep-alive lifetime, ms. Default 72000. Raise it above YOUR
5393
+ * proxy's idle timeout — see `DEFAULT_KEEP_ALIVE_MS` for why 72s. */
5394
+ readonly keepAliveTimeoutMs?: number;
5395
+ /** Header-completion deadline, ms. Must exceed `keepAliveTimeoutMs`.
5396
+ * Default `keepAliveTimeoutMs + 5000`. */
5397
+ readonly headersTimeoutMs?: number;
5398
+ };
5399
+ }
5400
+
5401
+ export declare interface KeepAliveTimeouts {
5402
+ readonly keepAliveTimeoutMs: number;
5403
+ readonly headersTimeoutMs: number;
5404
+ }
5405
+
5297
5406
  export declare interface KvFacade {
5298
5407
  readonly kv: AsyncKv;
5299
5408
  /** The resolved Effect-native `Kv` service instance. Its methods close over
@@ -6029,6 +6138,25 @@ export declare const memoryRywPositionStore: (options?: MemoryRywOptions) => Ryw
6029
6138
  */
6030
6139
  export declare const mergeCookieHeader: (existing: string | undefined, patch: Readonly<Record<string, string>>) => string;
6031
6140
 
6141
+ /**
6142
+ * Merge rows into a fleet view, and say what is missing from it.
6143
+ *
6144
+ * The three things that make this honest, each of which a naive `rows.map()`
6145
+ * gets wrong:
6146
+ *
6147
+ * 1. **A replica membership knows about but that has no row is MISSING**, not
6148
+ * absent. Dropping it silently is the unmarked sample this whole feature
6149
+ * exists to end — one level up, and more expensive, because the reader now
6150
+ * believes they asked everybody.
6151
+ * 2. **A stale row is reported WITH its age** rather than filtered out.
6152
+ * Removing it would make a partial answer look complete; keeping it
6153
+ * unmarked would make fiction look current. Neither is available.
6154
+ * 3. **Mixed versions are named.** During a rolling deploy the fleet genuinely
6155
+ * spans two shapes, and an aggregate that averages them silently is wrong
6156
+ * in a way nothing downstream can detect.
6157
+ */
6158
+ export declare const mergeFleetObservations: <T>(rows: ReadonlyArray<ReplicaObservationRow>, members: ReadonlyArray<FleetMember>, staleAfterMs?: number) => FleetMergeResult<T>;
6159
+
6032
6160
  export { MetricBoundaries }
6033
6161
 
6034
6162
  export declare interface MetricBucketPoint {
@@ -7044,10 +7172,21 @@ export declare class ReactionRateLimiter {
7044
7172
  }
7045
7173
 
7046
7174
  export declare interface ReactionRunDeps {
7047
- /** Idempotency store — `has(key)` true ⇒ already acted. */
7175
+ /**
7176
+ * Atomically claim ONE logical change. `true` = ours, act. `false` = someone
7177
+ * already has it (or the claim could not be taken — fail-closed, like the
7178
+ * rate slot and the budget beside it).
7179
+ *
7180
+ * ONE operation, not `has` then `add`, and the difference is the whole
7181
+ * guarantee. A read-then-write cannot be won by exactly one caller: two
7182
+ * replicas both read "not seen", both act, and `dedupeKey` — the field
7183
+ * `defineReaction` REQUIRES precisely so an act cannot repeat — delivers N
7184
+ * acts on N replicas. It was worse than a missing gate, because it read as a
7185
+ * present one: the in-memory set behind it was per-process, so every test and
7186
+ * every single-instance run confirmed it worked.
7187
+ */
7048
7188
  readonly dedupe: {
7049
- readonly has: (key: string) => boolean | Promise<boolean>;
7050
- readonly add: (key: string) => void | Promise<void>;
7189
+ readonly claim: (key: string) => boolean | Promise<boolean>;
7051
7190
  };
7052
7191
  /** Resolve + run the reaction's act AS the agent actor. Injected by the serve
7053
7192
  * layer (it owns agent/workflow execution). */
@@ -7166,6 +7305,16 @@ export declare const readPollCeilingMs: (env?: {
7166
7305
  /** Is anything listening? Call sites skip their work when nothing is. */
7167
7306
  export declare const readRecordingActive: () => boolean;
7168
7307
 
7308
+ /**
7309
+ * Every replica's rows for one kind.
7310
+ *
7311
+ * `now` is injected so a caller can compute ages against one instant rather
7312
+ * than a moving clock — two rows compared against two `Date.now()` calls are
7313
+ * two measurements, and the difference between them is noise a reader would
7314
+ * read as signal.
7315
+ */
7316
+ export declare const readReplicaObservations: (store: DataStore, kind: ReplicaObservationKind, now?: number) => Promise<ReadonlyArray<ReplicaObservationRow>>;
7317
+
7169
7318
  /** `admin:full` (mirrors `@voltro/protocol`'s ADMIN_SCOPE) bypasses ReBAC. */
7170
7319
  export declare const REBAC_ADMIN_SCOPE = "admin:full";
7171
7320
 
@@ -7591,6 +7740,8 @@ export declare interface RelationTuple {
7591
7740
  */
7592
7741
  export declare const releaseWakeup: (store: DataStore, ref: WakeupRef) => Promise<void>;
7593
7742
 
7743
+ export declare const REPLICA_OBSERVATIONS_TABLE = "_voltro_replica_observations";
7744
+
7594
7745
  export declare interface ReplicaHealth {
7595
7746
  /** Whether this replica is currently eligible for reads. */
7596
7747
  isHealthy(replica: DataStore): boolean;
@@ -7620,6 +7771,27 @@ export declare interface ReplicaHealthOptions {
7620
7771
  readonly now?: () => number;
7621
7772
  }
7622
7773
 
7774
+ /** Which observation a row carries. A closed set — a new kind is a deliberate
7775
+ * addition with a reader, not a free-form label. */
7776
+ export declare type ReplicaObservationKind = 'resume' | 'subscriptions';
7777
+
7778
+ /** One replica's row, as a reader gets it back. */
7779
+ export declare interface ReplicaObservationRow {
7780
+ readonly replicaId: string;
7781
+ readonly startedAt: number;
7782
+ readonly version: string | undefined;
7783
+ readonly kind: ReplicaObservationKind;
7784
+ readonly payload: unknown;
7785
+ /** Where a peer can reach it, or `undefined` — never a guess. */
7786
+ readonly reachableAt: string | undefined;
7787
+ readonly writtenAt: number;
7788
+ /** How old this row is, on the READER's clock. Approximate by construction —
7789
+ * it compares two machines' clocks, which is exactly what
7790
+ * `instanceMembership` refuses to do for liveness. It is good enough to
7791
+ * say "this is minutes old", and must never be used to order two rows. */
7792
+ readonly ageMs: number;
7793
+ }
7794
+
7623
7795
  /** Captured per-read for observability. Pushed into trace buffers
7624
7796
  * by the runtime so `voltro traces` shows the routing decision +
7625
7797
  * any RYW wait latency. */
@@ -7919,6 +8091,8 @@ export declare interface ResolvedWorkflowCallerContext extends WorkflowCallerCon
7919
8091
  readonly traceId: string;
7920
8092
  }
7921
8093
 
8094
+ export declare const resolveKeepAlive: (config: KeepAliveConfig | null | undefined) => KeepAliveTimeouts;
8095
+
7922
8096
  /**
7923
8097
  * Resolve the guard's configuration.
7924
8098
  *
@@ -9900,6 +10074,61 @@ export declare interface SubscriberDefinition {
9900
10074
  readonly table: string;
9901
10075
  /** Default `'any'`. */
9902
10076
  readonly on?: SubscribeOp | ReadonlyArray<Exclude<SubscribeOp, 'any'>>;
10077
+ /**
10078
+ * Run the handler ONCE PER CHANGE across the whole fleet, instead of once on
10079
+ * every replica that sees it.
10080
+ *
10081
+ * **Without it, the handler runs on EVERY api instance.** That is the correct
10082
+ * default and not a gap: a subscriber that refreshes a search index, drops a
10083
+ * process-local cache entry or updates in-memory state has to run everywhere,
10084
+ * and a fleet-wide gate would leave every other replica stale. The default
10085
+ * assumes the handler is IDEMPOTENT.
10086
+ *
10087
+ * It is the wrong default for an EFFECT — a notification, a mail, a webhook, a
10088
+ * payment — because there is nothing to make idempotent: the effect IS the
10089
+ * write, so each run produces another one. Two replicas send two mails.
10090
+ *
10091
+ * `once` returns a deterministic key for the change, and exactly one replica
10092
+ * wins the claim on it (`_voltro_change_claims`, INSERT-wins on a UNIQUE — the
10093
+ * same arbiter the cron scheduler uses for a firing). The key is namespaced by
10094
+ * this subscriber's file id, so two subscribers on one table never lock each
10095
+ * other out.
10096
+ *
10097
+ * ```ts
10098
+ * export default defineSubscriber({
10099
+ * table: 'absence_requests',
10100
+ * on: ['insert'],
10101
+ * once: true,
10102
+ * handler: notifyApprovers,
10103
+ * })
10104
+ * ```
10105
+ *
10106
+ * **`once: true` derives the key for you**, and is the right default choice.
10107
+ * It names the change by its CONTENT plus its position among content-identical
10108
+ * repeats (`changeDigest` + `OccurrenceCounter`, `@voltro/database`) — which is
10109
+ * the only thing two replicas provably agree on, since a fleet change carries
10110
+ * no LSN, no commit id and no `traceId`. `A→B`, then `B→A`, then `A→B` again is
10111
+ * three changes, not two.
10112
+ *
10113
+ * **A function is for when you want to be COARSER than one-per-change.** Two
10114
+ * updates that differ only in a field you do not care about are two changes to
10115
+ * `once: true` and can be one to a key function that ignores that field.
10116
+ *
10117
+ * **If you write one, it must tell two genuine changes apart.** A row id is
10118
+ * enough for `insert` and `delete`, where a row changes state once. It is NOT
10119
+ * enough for `update`: two edits to the same row produce the same id, and the
10120
+ * second would be dropped as a duplicate of the first. Put something that moves
10121
+ * in the key — `` `${row.id}:${row.updatedAt}` `` — or use `once: true`.
10122
+ *
10123
+ * **AT MOST once, not exactly once.** The claim is taken BEFORE the handler
10124
+ * runs, so a replica that wins and then dies takes the event with it, and a
10125
+ * claim that cannot be written (database unreachable) is not taken by anyone.
10126
+ * Both are loud in the log and neither is retried — a subscriber is
10127
+ * best-effort by construction. When the effect must not be lost, the change
10128
+ * stream is the wrong seam for it: run it in the mutation, or start a workflow
10129
+ * from a `*.reaction.tsx`, where the durability is the primitive's job.
10130
+ */
10131
+ readonly once?: true | ((event: SubscribeEvent) => string);
9903
10132
  readonly handler: SubscribeHandler;
9904
10133
  }
9905
10134
 
@@ -10736,6 +10965,8 @@ export declare const VOLTRO_TENANT_MIXIN_ID: "voltro/tenant";
10736
10965
  */
10737
10966
  export declare const _voltroApprovalsTable: TableLike;
10738
10967
 
10968
+ export declare const _voltroChangeClaimsTable: TableLike;
10969
+
10739
10970
  /**
10740
10971
  * `_voltro_connection_grants` — an IN-FLIGHT oauth2 handshake. Separate from
10741
10972
  * the credential table on purpose: a handshake is a short-lived, single-use
@@ -11489,4 +11720,13 @@ export declare const wrapStoreWithBootCodec: (underlying: DataStore, dialectId:
11489
11720
 
11490
11721
  export declare const wrapStoreWithMixinBehaviour: (rawUnderlying: DataStore, ctx: StoreMiddlewareContext) => FluentStore;
11491
11722
 
11723
+ /**
11724
+ * Write (upsert) this replica's counters for one kind.
11725
+ *
11726
+ * Best-effort throughout: a diagnostic that can fail a request, or a boot, is a
11727
+ * new way to be down. A failure logs at debug and leaves the previous row —
11728
+ * which then reads as stale, which is the truth.
11729
+ */
11730
+ export declare const writeReplicaObservation: (store: DataStore, kind: ReplicaObservationKind, payload: unknown) => Promise<void>;
11731
+
11492
11732
  export { }