@voltro/sql-postgres 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
@@ -1,5 +1,6 @@
1
1
  import { ChangeEvent } from '@voltro/database';
2
2
  import { ChangeStrategy } from '@voltro/database';
3
+ import { ChangeStreamGap } from '@voltro/database';
3
4
  import { ConfigError } from 'effect';
4
5
  import { ConnectionConfig } from '@voltro/database';
5
6
  import { Context } from 'effect';
@@ -20,6 +21,51 @@ import { SqlDialect } from '@voltro/database';
20
21
  import { SqlError } from '@effect/sql';
21
22
  import { TransactionConnection } from '@effect/sql/SqlClient';
22
23
 
24
+ /** Marker payload for the heartbeat, on the same channel as the changes.
25
+ *
26
+ * A separate channel would need a second `LISTEN` — and a second thing that can
27
+ * be registered on a connection whose FIRST registration is the one in doubt.
28
+ * Sharing the channel means the probe exercises exactly the subscription the
29
+ * changes ride on, which is the whole point of probing. */
30
+ export declare const CDC_HEARTBEAT_MARKER = "__voltro_cdc_heartbeat";
31
+
32
+ export declare interface CdcLivenessState {
33
+ /** Wall-clock ms of the last notification of ANY kind on the channel. */
34
+ readonly lastHeardAtMs: number;
35
+ /** When the outstanding probe was sent; `null` when none is outstanding. */
36
+ readonly probeSentAtMs: number | null;
37
+ }
38
+
39
+ export declare interface CdcLivenessTunables {
40
+ /** Silence on the channel before a probe is sent. */
41
+ readonly idleMs: number;
42
+ /** How long a sent probe may go unheard before the consumer is declared dead. */
43
+ readonly timeoutMs: number;
44
+ }
45
+
46
+ /** What the watchdog should do on this tick. */
47
+ export declare type CdcLivenessVerdict =
48
+ /** Traffic is recent (or a probe is still within its deadline) — do nothing. */
49
+ 'healthy'
50
+ /** Nothing heard for `idleMs` — send a heartbeat and start the deadline. */
51
+ | 'probe'
52
+ /** A probe went unanswered for `timeoutMs` — the consumer is deaf; reconnect. */
53
+ | 'reconnect';
54
+
55
+ /**
56
+ * Pure verdict for one watchdog tick.
57
+ *
58
+ * The ordering of the two branches is load-bearing: an outstanding probe is
59
+ * resolved FIRST. Checking idleness first would re-arm the probe on every tick
60
+ * of a dead connection and the deadline would never expire — the watchdog would
61
+ * beat forever at a corpse and never once conclude anything.
62
+ */
63
+ export declare const cdcLivenessVerdict: (state: CdcLivenessState, nowMs: number, tunables?: CdcLivenessTunables) => CdcLivenessVerdict;
64
+
65
+ /** Reconnect backoff: 250ms doubling to a 10s ceiling. Uncapped attempts —
66
+ * a consumer that gives up is the defect this file exists to remove. */
67
+ export declare const cdcReconnectDelayMs: (attempt: number) => number;
68
+
23
69
  /**
24
70
  * Every number the CDC consumer would otherwise pick on your behalf, with its
25
71
  * default. Each is also readable from the environment, so an operator can tune
@@ -66,6 +112,8 @@ export declare type DatabaseConnectionLayer = ReturnType<typeof makePostgresSqlL
66
112
 
67
113
  export declare type DatabaseConnectionLayerInput = Parameters<typeof makePostgresSqlLayer>[0];
68
114
 
115
+ export declare const DEFAULT_CDC_LIVENESS: CdcLivenessTunables;
116
+
69
117
  /** Encode one row as a COPY TEXT line (no trailing newline). */
70
118
  export declare const encodeCopyRow: (row: Readonly<Record<string, unknown>>, columns: ReadonlyArray<string>, columnTypes?: Readonly<Record<string, string>>) => string;
71
119
 
@@ -242,8 +290,23 @@ export declare class PostgresDataStore implements DataStore {
242
290
  private readonly changeStrategy;
243
291
  private readonly cdcChannel;
244
292
  private readonly cdcRehydrate;
293
+ private readonly cdcLiveness;
245
294
  private readonly emitter;
246
295
  private cdcFiber;
296
+ /** Watchdog state for the LISTEN consumer — see `cdcLiveness.ts` for why a
297
+ * dropped LISTEN connection cannot be detected by waiting for an error. */
298
+ private cdcWatchdog;
299
+ private cdcLastHeardAt;
300
+ /** Notifications received on this channel, ever. A COUNTER and not the
301
+ * timestamp, because "did anything arrive since I asked" is not answerable
302
+ * from a clock two events can share a millisecond of. */
303
+ private cdcHeardCount;
304
+ private cdcProbeSentAt;
305
+ /** Serialises the watchdog's reconnect with any later one, so a slow
306
+ * re-listen cannot be raced into two live consumers on one channel. */
307
+ private cdcReconnecting;
308
+ private cdcStopped;
309
+ private readonly cdcGapListeners;
247
310
  /** In-flight count of `transactional()` calls. `close()` waits for
248
311
  * this to drop to 0 (or its grace period to expire) before disposing
249
312
  * the runtime — preventing connection-pool teardown mid-COMMIT. */
@@ -255,7 +318,7 @@ export declare class PostgresDataStore implements DataStore {
255
318
  /** PERF-23 — counts EVERY eager JSON-agg → walker degradation and logs a
256
319
  * rate-limited line. Instance-scoped for the same reason as above. */
257
320
  private readonly reportEagerFallback;
258
- constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string, cdcRehydrate?: CdcRehydrateTunables);
321
+ constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string, cdcRehydrate?: CdcRehydrateTunables, cdcLiveness?: CdcLivenessTunables);
259
322
  /**
260
323
  * The DIALECT half of the shared transaction bracket
261
324
  * (`runStoreTransaction` / `runRetryingTransaction` in `@voltro/database`).
@@ -569,6 +632,41 @@ export declare class PostgresDataStore implements DataStore {
569
632
  * what re-hydration can and cannot recover.
570
633
  */
571
634
  startCdcConsumer(): Promise<void>;
635
+ /**
636
+ * Wait until the consumer can PROVE it receives — by hearing its own
637
+ * heartbeat come back.
638
+ *
639
+ * `runFork` returns before the stream's `LISTEN` has executed, so a consumer
640
+ * that has merely been started is not yet a consumer that hears anything.
641
+ * That window is small and it is real: this test suite caught a change written
642
+ * 20ms after a "reconnected" log line being lost, which is the very failure the
643
+ * reconnect exists to end — reintroduced one level down, and reported as
644
+ * success. An echo is the only evidence that settles it.
645
+ */
646
+ private proveCdcListening;
647
+ /**
648
+ * Register the gap listener the reactive layer recovers with.
649
+ *
650
+ * Fires AFTER the consumer is listening again, never while it is down: the
651
+ * recovery is "re-run every live query", and re-running them against a
652
+ * connection that is still deaf just produces a second stale answer.
653
+ */
654
+ onChangeStreamGap(listener: (gap: ChangeStreamGap) => void): () => void;
655
+ private emitCdcGap;
656
+ /** Open (or re-open) the LISTEN stream and fork the consumer onto it. */
657
+ private attachCdcStream;
658
+ /** Probe an idle channel, and reconnect a consumer that stops answering. */
659
+ private startCdcWatchdog;
660
+ private cdcWatchdogTick;
661
+ /**
662
+ * Tear the consumer down and open a new one, retrying until it succeeds.
663
+ *
664
+ * Reports a gap on success. That second half is what makes the recovery
665
+ * correct rather than merely alive: every NOTIFY sent while the connection was
666
+ * down is gone — postgres queues nothing for a listener that is not there — so
667
+ * a consumer that reconnects silently keeps serving whatever it last saw.
668
+ */
669
+ private reconnectCdc;
572
670
  }
573
671
 
574
672
  export declare interface PostgresDataStoreOptions {
@@ -626,6 +724,11 @@ export declare const postgresReplicationAdapter: () => DialectReplicationAdapter
626
724
  /** `SqlDialect.retryFilter` implementation for postgres. */
627
725
  export declare const postgresRetryFilter: (err: unknown) => RetryDecision;
628
726
 
727
+ /** Read the tunables from env, ignoring values that would disable the watchdog.
728
+ * A zero or negative `idleMs` would probe on every tick; a zero `timeoutMs`
729
+ * would declare every probe dead before its echo could arrive. */
730
+ export declare const resolveCdcLiveness: (env?: Record<string, string | undefined>, overrides?: Partial<CdcLivenessTunables>) => CdcLivenessTunables;
731
+
629
732
  /**
630
733
  * Captured inside `transactional()`'s `withTransaction` scope. Holds the
631
734
  * `TransactionConnection` context tuple `[Connection, depth]` so we can