@voltro/database 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
@@ -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
@@ -2724,6 +2776,10 @@ export declare const decryptFieldsOnRead: (rows: ReadonlyArray<Row>, table: Tabl
2724
2776
  */
2725
2777
  export declare const DEFAULT_ACQUIRE_TIMEOUT_MS = 10000;
2726
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
+
2727
2783
  /**
2728
2784
  * How often a persistent fallback is allowed to say so.
2729
2785
  *
@@ -3498,7 +3554,6 @@ export declare const formatSubjectGraph: (graph: SubjectGraph) => string;
3498
3554
  */
3499
3555
  export declare const FRAMEWORK_LIVE_TABLES: ReadonlySet<string>;
3500
3556
 
3501
- /** All framework tables as an array — useful for bootstrap DDL emission. */
3502
3557
  export declare const frameworkTables: readonly [TableLike, TableLike, TableLike];
3503
3558
 
3504
3559
  declare type FullTextIndexMeta = {
@@ -4601,6 +4656,67 @@ export declare const numeric: (precision: number, scale?: number) => ColumnBuild
4601
4656
  */
4602
4657
  export declare const observeDbOp: <T>(dialect: DialectId | string, op: DbOp, run: () => Promise<T>) => Promise<T>;
4603
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
+
4604
4720
  /**
4605
4721
  * What a decrypt failure does. `'throw'` (default, and the ONLY safe production
4606
4722
  * behaviour) surfaces a typed `FieldDecryptionError`. `'null'` degrades the one
@@ -4732,6 +4848,12 @@ export declare const paginateBy: (descriptor: QueryDescriptor, column: string, c
4732
4848
  */
4733
4849
  export declare const paginateById: (descriptor: QueryDescriptor, cursor: string | undefined, limit: number) => QueryDescriptor;
4734
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
+
4735
4857
  /** Test seam — how many registrations are outstanding. */
4736
4858
  export declare const pendingAttributionCount: () => number;
4737
4859
 
@@ -8050,8 +8172,20 @@ export declare const _voltroDataTransfersTable: TableLike;
8050
8172
  * (the `Idempotency-Key` header on REST routes). `scope` namespaces the key
8051
8173
  * per tenant + method + path; the UNIQUE on (scope, key) is the atomic claim
8052
8174
  * arbiter (INSERT/upsert-on-conflict). `response` holds the cached
8053
- * `{ status, body }` for replay. Created only when `idempotency` is set in
8054
- * `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.
8055
8189
  */
8056
8190
  export declare const _voltroIdempotencyTable: TableLike;
8057
8191
 
@@ -8104,6 +8238,43 @@ export declare const _voltroMssqlCdcOffsetsTable: TableLike;
8104
8238
  */
8105
8239
  export declare const _voltroReplaceInProgressTable: TableLike;
8106
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
+
8107
8278
  /**
8108
8279
  * `_voltro_seeds` — one row per registered seed. Written to by the
8109
8280
  * seed runner as each seed's lifecycle changes. Unlike migrations,