@voltro/database 0.54.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
@@ -621,6 +621,11 @@ export declare interface CdcConfig {
621
621
  readonly exclusionRefreshFollows?: boolean;
622
622
  }
623
623
 
624
+ /** Env var that tunes how long a change-claim row survives. Declared here rather
625
+ * than at the retention registration, because the OCCURRENCE COUNTER's window
626
+ * has to be derived from the same number and lives in this file. */
627
+ export declare const CHANGE_CLAIM_TTL_ENV = "VOLTRO_CHANGE_CLAIMS_TTL_HOURS";
628
+
624
629
  /** Maximum `onChange` listeners a DataStore's change bus accepts before Node
625
630
  * warns. See this file's header for why it is 512 and why it is not `0`. */
626
631
  export declare const CHANGE_LISTENER_CEILING = 512;
@@ -638,6 +643,17 @@ export declare interface ChangeCaptureExclusionRefresh {
638
643
  /** A row was removed from the table. `new` is null, always. */
639
644
  export declare const changeDelete: (table: string, row: Row, meta?: ChangeEventMeta) => ChangeEvent;
640
645
 
646
+ /** The content half of a change identity — equal iff two replicas are looking
647
+ * at the same change (or at two changes with byte-identical images, which the
648
+ * occurrence counter then separates). */
649
+ export declare const changeDigest: (input: {
650
+ readonly pipe: string;
651
+ readonly op: "insert" | "update" | "delete";
652
+ readonly key: string;
653
+ readonly next: Record<string, unknown> | null;
654
+ readonly prev: Record<string, unknown> | null;
655
+ }) => string;
656
+
641
657
  /**
642
658
  * A row-change event emitted by the data store (in-memory) or the CDC
643
659
  * consumer (Postgres logical replication). Drives the reactive engine.
@@ -766,6 +782,22 @@ export declare const changeSoftDelete: (table: string, row: Row, opts?: {
766
782
  */
767
783
  export declare type ChangeStrategy = 'inline' | 'cdc';
768
784
 
785
+ /**
786
+ * A proven interruption in a store's change stream.
787
+ *
788
+ * Deliberately NOT a list of what was missed. A pub/sub transport keeps no log,
789
+ * so the missed changes are gone — the only honest recovery is to re-derive
790
+ * everything downstream, and a consumer that were handed a partial list would be
791
+ * tempted to patch instead. `reason` is for the log line; the instruction is the
792
+ * event itself.
793
+ */
794
+ export declare interface ChangeStreamGap {
795
+ /** Human-readable cause — goes into the refresh's log line. */
796
+ readonly reason: string;
797
+ /** Wall-clock ms the stream was known to be down, when it is known. */
798
+ readonly downForMs?: number;
799
+ }
800
+
769
801
  /** A row changed. Both sides are present, because an update has both. */
770
802
  export declare const changeUpdate: (table: string, before: Row, after: Row, meta?: ChangeEventMeta) => ChangeEvent;
771
803
 
@@ -1772,6 +1804,9 @@ export declare const compileRawFragment: <Row extends object = Record<string, un
1772
1804
 
1773
1805
  export declare const compileSelect: <Row extends object>(descriptor: QueryDescriptor, sql: SqlConstructor, namespace?: QueryNamespace) => Statement.Statement<Row>;
1774
1806
 
1807
+ /** Compose the durable identity written to `_voltro_cdcout_claims.changeKey`. */
1808
+ export declare const composeChangeKey: (digest: string, occurrence: number) => string;
1809
+
1775
1810
  /**
1776
1811
  * Compute the embedded row from a {@link VectorEmbeddingContext}. Returns
1777
1812
  * the patch to merge into the row: `{ [as]: <vector> }`. When the source
@@ -2298,6 +2333,23 @@ export declare interface DataStore {
2298
2333
  * must elect a single worker instead of filtering by origin.
2299
2334
  */
2300
2335
  readonly changeScope?: 'local' | 'fleet';
2336
+ /**
2337
+ * Subscribe to CHANGE-STREAM GAPS — moments where this process proved it
2338
+ * stopped receiving changes for a while and has now started again.
2339
+ *
2340
+ * A gap is not an error to show a user; it is an instruction to the reactive
2341
+ * layer: everything derived from this store may be stale, so re-run it. A
2342
+ * live query is idempotent, which is what makes "re-run all of them" both
2343
+ * safe and the complete recovery. `voltro dev` and `voltro serve` wire this
2344
+ * to the dispatcher's `refreshAll`, the same recovery the broadcast bus's
2345
+ * peer-serial gap uses.
2346
+ *
2347
+ * Optional because only a store whose changes arrive over a TRANSPORT can
2348
+ * have one. An inline store emits its own writes in-process — there is no
2349
+ * connection to lose — and correctly does not implement this. Absent
2350
+ * therefore means "gaps are impossible here", never "gaps are not reported".
2351
+ */
2352
+ readonly onChangeStreamGap?: (listener: (gap: ChangeStreamGap) => void) => () => void;
2301
2353
  /**
2302
2354
  * Bind a per-request tenant NAMESPACE, returning a store view whose
2303
2355
  * every read + write is physically isolated to that namespace
@@ -2478,6 +2530,38 @@ export declare interface DataStore {
2478
2530
  * callers guard with `if (store.refreshChangeCaptureExclusions)`.
2479
2531
  */
2480
2532
  refreshChangeCaptureExclusions?(): Promise<ChangeCaptureExclusionRefresh>;
2533
+ /**
2534
+ * Run an Effect that needs this store's own `SqlClient` — the raw-SQL seam.
2535
+ *
2536
+ * The capability the STAGED SWAP is built on. `--mode replace` loads into
2537
+ * `_voltro_staging_<t>` tables and then swaps the CONTENT in one short
2538
+ * transaction (`DELETE FROM t; INSERT INTO t (…) SELECT … FROM
2539
+ * _voltro_staging_t`), which moves the long, network-bound load OUT of the
2540
+ * destructive transaction. None of that is expressible through the typed row
2541
+ * API: it is DDL plus a server-side `INSERT … SELECT`, and the whole point is
2542
+ * that no row crosses the wire.
2543
+ *
2544
+ * OPTIONAL on the interface, and declared here rather than duck-typed at the
2545
+ * call site for the reason the house rule gives: the four dialect stores are
2546
+ * four hand-written copies of one interface, and a capability nothing declares
2547
+ * is a capability nothing can be red about. A store that loses it now fails to
2548
+ * satisfy this member instead of silently falling out of `canStage()` and
2549
+ * taking the weaker path forever, on one dialect, in silence.
2550
+ *
2551
+ * The DECISION does not live per store — that would be the four-copies trap
2552
+ * wearing a different hat. `@voltro/data-transfer`'s `stagedSwap.ts` writes
2553
+ * the per-dialect SQL ONCE and sends it down this seam; the store contributes
2554
+ * a connection, not a policy.
2555
+ *
2556
+ * Callers guard with `if (store.run)` (or `canStage(store)`), and a store
2557
+ * without it keeps the path it already has: the in-memory store has no SQL
2558
+ * and no catalog, and neither does a bare `DataStore` view.
2559
+ *
2560
+ * `R` is deliberately `SqlClient.SqlClient`: the effect asks for the client
2561
+ * and the store provides its own, which is what makes this a SEAM rather than
2562
+ * a way to hand a store somebody else's connection.
2563
+ */
2564
+ run?<A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>): Promise<A>;
2481
2565
  }
2482
2566
 
2483
2567
  /** One transfer, as the inspector reads it back. */
@@ -2692,6 +2776,10 @@ export declare const decryptFieldsOnRead: (rows: ReadonlyArray<Row>, table: Tabl
2692
2776
  */
2693
2777
  export declare const DEFAULT_ACQUIRE_TIMEOUT_MS = 10000;
2694
2778
 
2779
+ /** Default lifetime of a change-claim row, in hours. A lock ledger, not a
2780
+ * history — nobody reads yesterday's claim. */
2781
+ export declare const DEFAULT_CHANGE_CLAIM_TTL_HOURS = 1;
2782
+
2695
2783
  /**
2696
2784
  * How often a persistent fallback is allowed to say so.
2697
2785
  *
@@ -3466,7 +3554,6 @@ export declare const formatSubjectGraph: (graph: SubjectGraph) => string;
3466
3554
  */
3467
3555
  export declare const FRAMEWORK_LIVE_TABLES: ReadonlySet<string>;
3468
3556
 
3469
- /** All framework tables as an array — useful for bootstrap DDL emission. */
3470
3557
  export declare const frameworkTables: readonly [TableLike, TableLike, TableLike];
3471
3558
 
3472
3559
  declare type FullTextIndexMeta = {
@@ -3828,6 +3915,13 @@ export declare interface InterruptedReplace {
3828
3915
  readonly tables: number | null;
3829
3916
  readonly capture: string | null;
3830
3917
  readonly via: string;
3918
+ /** The staging tables the run created. Empty for every kind but `staging`. */
3919
+ readonly staging: ReadonlyArray<string>;
3920
+ /** Last sign of life from the run, or `null` when it never wrote one. */
3921
+ readonly heartbeatAt: Date | null;
3922
+ /** `true` when the run was started `--no-atomic` — its staging is a resume
3923
+ * point, not a leftover. */
3924
+ readonly resumable: boolean;
3831
3925
  }
3832
3926
 
3833
3927
  /**
@@ -3972,6 +4066,41 @@ export declare const isSeedDefinition: (value: unknown) => value is SeedDefiniti
3972
4066
  */
3973
4067
  export declare const isSqliteFamily: (dialect: DialectId) => boolean;
3974
4068
 
4069
+ /**
4070
+ * True when `name` is a `--mode replace` staging table.
4071
+ *
4072
+ * ── This is a THIRD rule, and it must not be folded into the other two ─────
4073
+ *
4074
+ * The framework-table filter above is deliberately ASYMMETRIC: a framework
4075
+ * table nobody declares is never planned for a drop, and one we DO declare
4076
+ * diffs like any other table. Collapsing those two into one filter is what
4077
+ * produced a postgres-only evolution path for the framework's own tables, and
4078
+ * the comment in `boot.ts` exists to stop it being collapsed again.
4079
+ *
4080
+ * A staging table satisfies NEITHER half. It is not a schema fact at all — it is
4081
+ * a scratch copy of a target table that exists for the length of one import and
4082
+ * is dropped after the swap. So:
4083
+ *
4084
+ * - the DROP direction is already covered, because `_voltro_staging_` starts
4085
+ * with `_voltro_` and `isFrameworkOwnedLiveTable` therefore says "never
4086
+ * drop it". Correct: a `replace` in flight on another replica is loading
4087
+ * into it right now.
4088
+ * - the CREATE direction is NOT, and that is the gap this closes. `--mode
4089
+ * replace` REGISTERS each staging table as a clone of its target for the
4090
+ * duration of the load — it has to, or a typed write cannot resolve the
4091
+ * column types and a `json()` column is written unencoded. A registered
4092
+ * table is a DECLARED table, and by the asymmetry above a declared
4093
+ * framework table diffs like any other: a plan computed while an import is
4094
+ * in flight proposes `create-table _voltro_staging_notes` and, once
4095
+ * applied, a table nothing will ever declare again is kept forever.
4096
+ *
4097
+ * So staging is excluded from the DECLARED side (in `declaredSnapshot`, which is
4098
+ * the one input both the plan and its fingerprint are built from) and left to
4099
+ * the existing prefix rule on the live side. Two different rules, two different
4100
+ * places, on purpose.
4101
+ */
4102
+ export declare const isStagingTableName: (name: string) => boolean;
4103
+
3975
4104
  /**
3976
4105
  * Does this table emit change events at all?
3977
4106
  *
@@ -4527,6 +4656,67 @@ export declare const numeric: (precision: number, scale?: number) => ColumnBuild
4527
4656
  */
4528
4657
  export declare const observeDbOp: <T>(dialect: DialectId | string, op: DbOp, run: () => Promise<T>) => Promise<T>;
4529
4658
 
4659
+ /**
4660
+ * How many times this replica has already seen each digest inside the dedup
4661
+ * window. Bounded on BOTH axes — a per-change map with no ceiling is an
4662
+ * unbounded table in memory:
4663
+ * * by time — entries untouched for `windowMs` are pruned, and
4664
+ * * by size — `maxEntries` oldest-first, so a burst of unique changes can't
4665
+ * grow it without limit.
4666
+ *
4667
+ * `windowMs` MUST be longer than the lifetime of a claim row in the database.
4668
+ * That ordering is the invariant: if a claim outlived the counter that
4669
+ * produced it, the next sighting of that digest would restart at occurrence 0,
4670
+ * collide with the retained claim, and be dropped as a duplicate it is not.
4671
+ *
4672
+ * **`maxEntries` is the SAME invariant reached by size, and it is worth saying
4673
+ * plainly rather than reassuringly.** An evicted digest also restarts at 0, so
4674
+ * an eviction is only harmless while no claim for that digest is still retained
4675
+ * — which is not the same thing as "the position a freshly-booted replica is
4676
+ * in", because a fresh replica has no claims of its own to collide with. The
4677
+ * exposure is narrow (it needs more than `maxEntries` distinct digests inside
4678
+ * the claim TTL *and* a content-identical repeat of an evicted one), and it is
4679
+ * real. Size the map against the change rate the gated tables actually carry,
4680
+ * the way `plugin-cdc-out` sizes it against its handoff buffer.
4681
+ */
4682
+ export declare class OccurrenceCounter {
4683
+ #private;
4684
+ constructor(opts: {
4685
+ readonly windowMs: number;
4686
+ readonly maxEntries: number;
4687
+ });
4688
+ get size(): number;
4689
+ /** Record a sighting and return its occurrence index (0 for the first). */
4690
+ next(digest: string, nowMs: number): number;
4691
+ /**
4692
+ * Align this replica with the fleet from the claims already recorded in the
4693
+ * database — the counters of a process that booted mid-window would
4694
+ * otherwise restart at 0 and re-key changes the incumbents have already
4695
+ * keyed. Call ONCE, before the first observed change is keyed.
4696
+ */
4697
+ seedFromChangeKeys(changeKeys: Iterable<string>, nowMs: number): void;
4698
+ /** Drop entries whose last sighting is older than the window. */
4699
+ prune(nowMs: number): void;
4700
+ }
4701
+
4702
+ /**
4703
+ * The counter window that keeps {@link OccurrenceCounter}'s invariant true for
4704
+ * the claim TTL this deployment actually runs with.
4705
+ *
4706
+ * The invariant is stated on the class: the window MUST be longer than the
4707
+ * lifetime of a claim row, or a digest whose counter entry was pruned restarts
4708
+ * at occurrence 0, collides with the retained claim, and is dropped as a
4709
+ * duplicate it is not. Three call sites hard-coded two hours against a
4710
+ * one-hour default — correct until an operator raises the TTL, at which point
4711
+ * the invariant breaks SILENTLY and in the direction that loses effects.
4712
+ *
4713
+ * So it is derived, not written down: twice the TTL, from the same env var the
4714
+ * retention policy reads.
4715
+ */
4716
+ export declare const occurrenceWindowMs: (env?: {
4717
+ readonly [key: string]: string | undefined;
4718
+ }) => number;
4719
+
4530
4720
  /**
4531
4721
  * What a decrypt failure does. `'throw'` (default, and the ONLY safe production
4532
4722
  * behaviour) surfaces a typed `FieldDecryptionError`. `'null'` degrades the one
@@ -4658,6 +4848,12 @@ export declare const paginateBy: (descriptor: QueryDescriptor, column: string, c
4658
4848
  */
4659
4849
  export declare const paginateById: (descriptor: QueryDescriptor, cursor: string | undefined, limit: number) => QueryDescriptor;
4660
4850
 
4851
+ /** Split a stored `changeKey` back into its two halves (`null` when malformed). */
4852
+ export declare const parseChangeKey: (changeKey: string) => {
4853
+ digest: string;
4854
+ occurrence: number;
4855
+ } | null;
4856
+
4661
4857
  /** Test seam — how many registrations are outstanding. */
4662
4858
  export declare const pendingAttributionCount: () => number;
4663
4859
 
@@ -6593,6 +6789,86 @@ export declare interface SqlDialect {
6593
6789
  readonly retryFilter: (error: unknown) => RetryDecision;
6594
6790
  }
6595
6791
 
6792
+ /**
6793
+ * How long a staged run's silence has to last before its scratch tables are
6794
+ * treated as abandoned.
6795
+ *
6796
+ * Generous on purpose. The beat is written every couple of seconds while rows
6797
+ * land, so this is not "how long a load may take" — it is how long a load may
6798
+ * be stalled, or its bookkeeping write failing, before a boot on another
6799
+ * replica decides nobody is coming back. The cost of being wrong here is that
6800
+ * an in-flight import's swap fails with a missing table and the operator
6801
+ * re-runs it; the target is untouched either way, which is what makes an
6802
+ * automatic sweep affordable at all.
6803
+ *
6804
+ * Declared as `dataTransfer.stagingStaleMinutes` in `app.config.ts`;
6805
+ * `VOLTRO_STAGING_STALE_MINUTES` overrides that in turn, on the same rule every
6806
+ * other tunable here follows — an operator acting on a running deployment
6807
+ * outranks what the project declared.
6808
+ */
6809
+ export declare const STAGING_STALE_MINUTES = 30;
6810
+
6811
+ /**
6812
+ * The prefix a STAGING table carries: `_voltro_staging_<target>`.
6813
+ *
6814
+ * Defined in this leaf module, not in `@voltro/data-transfer` where the swap
6815
+ * lives, because the differ has to know it and `@voltro/database` cannot depend
6816
+ * on the package that imports it. One definition; `stagedSwap.ts` re-exports
6817
+ * this one rather than spelling the prefix a second time.
6818
+ */
6819
+ export declare const STAGING_TABLE_PREFIX = "_voltro_staging_";
6820
+
6821
+ /**
6822
+ * What a boot says about them.
6823
+ *
6824
+ * A NOTE, never a refusal — the target of a staged run is untouched by
6825
+ * definition. But it is said out loud, because the alternative is what shipped:
6826
+ * a set of tables holding a whole bundle that only an introspection could find,
6827
+ * so nobody knew they existed until a disk filled up.
6828
+ */
6829
+ export declare const stagingLeftoverReport: (leftovers: StagingLeftovers, now: number) => string | undefined;
6830
+
6831
+ /** One staged run's leftovers, and what a boot may do about them. */
6832
+ export declare interface StagingLeftovers {
6833
+ /** Every `kind: 'staging'` row, for the report. */
6834
+ readonly all: ReadonlyArray<InterruptedReplace>;
6835
+ /**
6836
+ * The rows whose tables a boot may DROP: the beat is stale AND the run was
6837
+ * not resumable.
6838
+ *
6839
+ * Both halves are load-bearing and neither is enough alone. A fresh beat is
6840
+ * an import in flight — on this pod or another replica — and dropping its
6841
+ * staging would destroy a load in progress. A resumable run's staging is a
6842
+ * RESUME POINT that is meant to outlive the process, so no amount of silence
6843
+ * makes it collectable.
6844
+ */
6845
+ readonly droppable: ReadonlyArray<InterruptedReplace>;
6846
+ }
6847
+
6848
+ /**
6849
+ * Decide, from the marker rows alone, which staging sets a boot may collect.
6850
+ *
6851
+ * Pure, and separated from the boot so the DECISION can be tested without a
6852
+ * database — the rule is three lines and every one of them has a way to be
6853
+ * quietly wrong in the direction that destroys someone's work.
6854
+ */
6855
+ export declare const stagingLeftovers: (rows: ReadonlyArray<InterruptedReplace>, now: number, staleAfterMs: number) => StagingLeftovers;
6856
+
6857
+ /** Split a `kind: 'staging'` row's comma-separated name list. */
6858
+ export declare const stagingNamesOf: (raw: unknown) => ReadonlyArray<string>;
6859
+
6860
+ /**
6861
+ * The threshold in ms: the env override, else what the app declared, else the
6862
+ * default above.
6863
+ *
6864
+ * A non-positive or unparseable value at EITHER level falls through to the next
6865
+ * one rather than failing. This runs on a boot path whose whole contract is that
6866
+ * scratch-table housekeeping cannot stop a boot, so a typo in a tunable must not
6867
+ * be the thing that does — and the fall-through lands on a threshold that is
6868
+ * generous in the safe direction.
6869
+ */
6870
+ export declare const stagingStaleAfterMs: (env: Record<string, string | undefined>, declaredMinutes?: number) => number;
6871
+
6596
6872
  /**
6597
6873
  * Fill `row.id` from the table's declared scheme when it is absent.
6598
6874
  *
@@ -7896,8 +8172,20 @@ export declare const _voltroDataTransfersTable: TableLike;
7896
8172
  * (the `Idempotency-Key` header on REST routes). `scope` namespaces the key
7897
8173
  * per tenant + method + path; the UNIQUE on (scope, key) is the atomic claim
7898
8174
  * arbiter (INSERT/upsert-on-conflict). `response` holds the cached
7899
- * `{ status, body }` for replay. Created only when `idempotency` is set in
7900
- * `app.config.ts`. A periodic sweep / lazy-TTL drops rows past their window.
8175
+ * `{ status, body }` for replay.
8176
+ *
8177
+ * Created for EVERY sql app (`when: 'always'` in the cli's registry), empty
8178
+ * unless `idempotency` is set in `app.config.ts`. This used to say "created only
8179
+ * when idempotency is set", which is what the config gates — not what the table
8180
+ * registry does.
8181
+ *
8182
+ * Two things clean it up, and only one of them existed for most of this table's
8183
+ * life. The LAZY TTL drops a stale row when the SAME key is claimed again — and
8184
+ * a key is used once by definition, so a row whose key never returns was never
8185
+ * read and never deleted. The retention SWEEP is the half that bounds it
8186
+ * (`retentionSweep.ts`), and its window is floored by the app's own dedup window:
8187
+ * a record dropped while still inside that window lets the duplicate request it
8188
+ * exists to stop execute a second time.
7901
8189
  */
7902
8190
  export declare const _voltroIdempotencyTable: TableLike;
7903
8191
 
@@ -7950,6 +8238,43 @@ export declare const _voltroMssqlCdcOffsetsTable: TableLike;
7950
8238
  */
7951
8239
  export declare const _voltroReplaceInProgressTable: TableLike;
7952
8240
 
8241
+ /**
8242
+ * `_voltro_replica_observations` — what each replica knows about itself, put
8243
+ * somewhere every replica can read it.
8244
+ *
8245
+ * ── Why the shared store and not a fan-out ────────────────────────────────
8246
+ *
8247
+ * The fleet-wide questions an operator asks — "how many subscriptions is each
8248
+ * pod holding", "which of my queries can resume anywhere" — are answers each
8249
+ * process holds privately. Asking the others at read time means inventing
8250
+ * request/response over a pub/sub bus: a correlation id, a reply channel, a
8251
+ * deadline to guess, and a partial-result protocol. And it puts the DIAGNOSTIC
8252
+ * on the failure path, so the moment you most need it is the moment it times
8253
+ * out.
8254
+ *
8255
+ * Writing instead inverts that. The transport is the database — the one thing
8256
+ * that must be up for anything to work — and a replica that dies goes STALE
8257
+ * rather than silent: staleness is a number, and a number can be reported.
8258
+ * Paired with `instanceMembership`'s roster, "old" becomes "missing", which is
8259
+ * the honest partial result the fan-out would have had to build by hand.
8260
+ *
8261
+ * `coordinationState.recentReplicaIds` already answers a fleet question this
8262
+ * way, by reading `_voltro_schedule_runs`. This generalises that one case.
8263
+ *
8264
+ * ── The shape, and what it must not become ────────────────────────────────
8265
+ *
8266
+ * One row per `(replicaId, kind)`, upserted. So the table is bounded by fleet
8267
+ * size times the number of kinds, not by time — there is no history here and
8268
+ * there must not be: a history would need a retention policy, and this is a
8269
+ * cache of the present, not a log of the past.
8270
+ *
8271
+ * `payload` is a JSON STRING and small on purpose. Anything big enough to
8272
+ * warrant paging belongs on the process that holds it, reachable per replica —
8273
+ * writing a full subscription list here every heartbeat would be write
8274
+ * amplification for state nobody is reading.
8275
+ */
8276
+ export declare const _voltroReplicaObservationsTable: TableLike;
8277
+
7953
8278
  /**
7954
8279
  * `_voltro_seeds` — one row per registered seed. Written to by the
7955
8280
  * seed runner as each seed's lifecycle changes. Unlike migrations,