@voltro/sql-postgres 0.32.0 → 0.34.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
@@ -20,6 +20,29 @@ import { SqlDialect } from '@voltro/database';
20
20
  import { SqlError } from '@effect/sql';
21
21
  import { TransactionConnection } from '@effect/sql/SqlClient';
22
22
 
23
+ /**
24
+ * Every number the CDC consumer would otherwise pick on your behalf, with its
25
+ * default. Each is also readable from the environment, so an operator can tune
26
+ * a running deployment without a code change.
27
+ */
28
+ declare interface CdcRehydrateTunables {
29
+ /**
30
+ * Total budget for recovering ONE oversized change, retries included. The
31
+ * LISTEN consumer is serial, so this is also the longest a single oversized
32
+ * row can hold up the change stream. Past it the event is delivered
33
+ * image-less and counted `unrecovered`.
34
+ * Default `5_000`ms. Env: `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS`.
35
+ */
36
+ readonly rehydrateTimeoutMs: number;
37
+ /**
38
+ * Re-reads AFTER the first attempt, inside the budget above (50 ms, doubling).
39
+ * A transient read failure is the one part of this that IS retryable — the
40
+ * loss it prevents is not.
41
+ * Default `2`. `0` disables retry. Env: `VOLTRO_CDC_REHYDRATE_RETRIES`.
42
+ */
43
+ readonly rehydrateRetries: number;
44
+ }
45
+
23
46
  /**
24
47
  * Parse a `ConnectionConfig` from the cross-dialect interface into the
25
48
  * postgres-specific `PostgresConnection` shape.
@@ -108,6 +131,23 @@ export declare interface PostgresConnection {
108
131
  * `maxConnections`, the process is `maxConnections + 1`, and a budget built
109
132
  * on the first number is short by exactly one per pod — which surfaces as the
110
133
  * LAST pod of a rollout failing to connect, not as a pool warning.
134
+ *
135
+ * **`+1` is this process. It is NOT the deployment's number**, and writing it
136
+ * here as if it were is what made the same consumer measure THREE standalone
137
+ * connections against a docstring promising one. The web process
138
+ * (`voltro start`) opens up to two of its own, and one of them is not a
139
+ * `LISTEN` at all — so "count the LISTENs" undercounts:
140
+ *
141
+ * - api `serve` — the CDC LISTEN consumer above (+1)
142
+ * - web `start` — the ISR invalidator's LISTEN, when any page declares
143
+ * `cacheInvalidatesOn` (+1)
144
+ * - web `start` — the postgres ISR cache client, under
145
+ * `SSR_CACHE=postgres` (+1)
146
+ *
147
+ * Do not turn that into a bigger constant. The count is per PROCESS and only
148
+ * the process knows what it armed, which is why `formatDbPoolLine` takes the
149
+ * list and prints it at boot instead of this docstring naming a number an
150
+ * operator then has to trust.
111
151
  */
112
152
  readonly maxConnections?: number;
113
153
  /** TLS. `true` = `sslmode=require` semantics (encrypt, skip certificate
@@ -144,6 +184,14 @@ export declare interface PostgresConnection {
144
184
  * the pool would have freed up eventually: it names the pool as the cause, at
145
185
  * the moment it is the cause, instead of surfacing as an unexplained latency
146
186
  * spike in a place with no connection information in it.
187
+ *
188
+ * **It shipped bounded on ONE of the two layer paths**, which is the part
189
+ * worth remembering. The bound sat inside the hand-built `pg.Pool` branch —
190
+ * reached only when `DB_SCHEMA` or `DB_STATEMENT_TIMEOUT_MS` is set — while
191
+ * the default configuration went through `PgClient.layerConfig` with no
192
+ * `connectTimeout` at all. The fix is not "add a timeout", it is that BOTH
193
+ * paths now read one resolver (`resolveAcquireTimeoutMs`); a per-branch copy
194
+ * of a default is how the first one drifted.
147
195
  */
148
196
  readonly acquireTimeoutMs?: number;
149
197
  }
@@ -153,13 +201,33 @@ export declare class PostgresDataStore implements DataStore {
153
201
  private readonly runtime;
154
202
  private readonly changeStrategy;
155
203
  private readonly cdcChannel;
204
+ private readonly cdcRehydrate;
156
205
  private readonly emitter;
157
206
  private cdcFiber;
158
207
  /** In-flight count of `transactional()` calls. `close()` waits for
159
208
  * this to drop to 0 (or its grace period to expire) before disposing
160
209
  * the runtime — preventing connection-pool teardown mid-COMMIT. */
161
210
  private inflightTxns;
162
- constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string);
211
+ /** Reports the first oversized change per table + every unrecovered one.
212
+ * Instance-scoped so the once-per-table rate limit is the STORE's, not a
213
+ * module global that a second store in one process would silence. */
214
+ private readonly reportOversized;
215
+ /** PERF-23 — counts EVERY eager JSON-agg → walker degradation and logs a
216
+ * rate-limited line. Instance-scoped for the same reason as above. */
217
+ private readonly reportEagerFallback;
218
+ constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string, cdcRehydrate?: CdcRehydrateTunables);
219
+ /**
220
+ * The DIALECT half of the shared transaction bracket
221
+ * (`runStoreTransaction` / `runRetryingTransaction` in `@voltro/database`).
222
+ *
223
+ * Postgres owns exactly four things here: its client's `withTransaction`, its
224
+ * managed runtime, its retryable-failure predicate and the span labels.
225
+ * Attribution threading, retry, commit-defect promotion and exit settling are
226
+ * NOT here on purpose — every one of them had already drifted between the four
227
+ * dialect stores, and `transactional()` and `runInNamespace()` had drifted
228
+ * from each other inside this very file. See `transactionOutcome.ts`.
229
+ */
230
+ private txnSpec;
163
231
  /**
164
232
  * Bind a per-request tenant NAMESPACE (a postgres SCHEMA). The returned
165
233
  * view runs every operation inside a transaction whose first statement
@@ -195,6 +263,14 @@ export declare class PostgresDataStore implements DataStore {
195
263
  * fully isolated. The public methods pass `null/null`; the view passes
196
264
  * its own captured TxnContext + per-call events buffer.
197
265
  */
266
+ /**
267
+ * Run one SELECT.
268
+ *
269
+ * `namespace` is the physical-tenant schema when the read took the
270
+ * transaction-free fast path (see `PostgresNamespaceView.query`); `null` on
271
+ * every other path, including inside a `SET LOCAL search_path` transaction,
272
+ * where qualifying as well would be redundant.
273
+ */
198
274
  private executeQuery;
199
275
  private executeInsert;
200
276
  private executeUpdate;
@@ -239,13 +315,18 @@ export declare class PostgresDataStore implements DataStore {
239
315
  * The fallback-on-throw catches dialect quirks I couldn't predict
240
316
  * up front — anything that breaks the JSON-agg query (driver
241
317
  * compatibility, schema feature) silently degrades to walker
242
- * instead of surfacing a routing-level error. The log line on
243
- * fallback makes the degradation visible during dev.
318
+ * instead of surfacing a routing-level error.
319
+ *
320
+ * PERF-23 — the degradation is COUNTED, not just logged. A log line makes it
321
+ * visible to whoever is watching the terminal; `voltro_db_eager_fallback_total`
322
+ * makes it visible to whoever looks in three weeks, which is when a
323
+ * per-query cliff that has been running the whole time actually gets noticed.
244
324
  */
245
325
  private runWithEager;
246
326
  /** Friend accessor for the transactional view — same eager-aware
247
327
  * path as the public `query()` but pinned to the view's txn. */
248
328
  getInternalRunWithEager(): (d: QueryDescriptor, txn: TxnContext | null) => Promise<ReadonlyArray<Row>>;
329
+ /* Excluded from this release type: queryInNamespace */
249
330
  /**
250
331
  * Bracket a NON-transactional write: capture the request identity before the
251
332
  * first await (`withCapturedAttribution`), and hold this table's transport
@@ -332,16 +413,19 @@ export declare class PostgresDataStore implements DataStore {
332
413
  * 4. ChangeEvents queue in the view's buffer; on commit they drain
333
414
  * to the parent's emitter. On throw they're dropped.
334
415
  *
335
- * Resilience:
416
+ * Resilience (all of it owned by the shared bracket, not by this file):
336
417
  * - Automatic retry on `serialization_failure` (40001) and
337
418
  * `deadlock_detected` (40P01) with exponential backoff (10ms base,
338
- * up to 3 attempts). Each retry rebuilds a fresh view — the
419
+ * up to 3 retries). Each retry rebuilds a fresh view — the
339
420
  * aborted attempt's events were never drained, so the retry's
340
421
  * subscribers see exactly one event-set (the winning attempt's).
341
- * - The whole `transactional()` boundary is wrapped in an
342
- * `Effect.withSpan('store.transactional')` so OpenTelemetry
343
- * collectors see one span per logical mutation, with retry counts
344
- * and committed-event counts as attributes.
422
+ * - A conflict raised at COMMIT arrives as a DEFECT (`@effect/sql` runs
423
+ * COMMIT as `Effect.orDie`) and is promoted back to a failure so the
424
+ * schedule can see it.
425
+ * - The whole boundary is one `Effect.withSpan('store.transactional')` so
426
+ * OpenTelemetry collectors see one span per logical mutation.
427
+ * - The Exit settles through `settleTransactionExit`, so a typed error
428
+ * reaches the caller with its `_tag` rather than as a `FiberFailure`.
345
429
  */
346
430
  transactional<T>(work: (tx: DataStore) => Promise<T>): Promise<T>;
347
431
  onChange(listener: (event: ChangeEvent) => void): () => void;
@@ -374,10 +458,32 @@ export declare class PostgresDataStore implements DataStore {
374
458
  close(gracePeriodMs?: number): Promise<void>;
375
459
  /** Liveness probe — `SELECT 1`. Rejects if the pool can't answer. */
376
460
  ping(): Promise<void>;
461
+ /**
462
+ * Read one row back as the JSON the trigger could not send.
463
+ *
464
+ * `row_to_json(t)` on purpose: it is literally what `triggerFunctionSql`
465
+ * builds its images with, so a re-hydrated row and an ordinary CDC image are
466
+ * the same shape — dates as ISO strings, numerics as JSON numbers. A plain
467
+ * `SELECT *` would hand the driver's types to consumers that have only ever
468
+ * seen the JSON ones, which is a second shape to get right in every tap.
469
+ *
470
+ * SCHEMA-QUALIFIED, from the payload's own `schema`. Under namespace
471
+ * isolation the write landed in `tenant_<id>`, and an unqualified read
472
+ * resolves through this connection's `search_path` instead — which is another
473
+ * tenant's table of the same name, or none.
474
+ */
475
+ private readRowAsCdcJson;
377
476
  /**
378
477
  * Subscribe to the Postgres NOTIFY channel and translate each notification
379
478
  * into a ChangeEvent. Called automatically from `makePostgresDataStore`
380
479
  * when `changeStrategy: 'cdc'`.
480
+ *
481
+ * An OVERSIZED notification is re-hydrated HERE, before anything downstream
482
+ * sees it. This is the one place every consumer is downstream of — the
483
+ * dispatcher, the plugin taps, the analytics mirror, the DevTools CDC
484
+ * producer and the ISR invalidator all hang off the emitter this feeds — so
485
+ * it is the only place the repair can be made once. See `cdcRehydrate.ts` for
486
+ * what re-hydration can and cannot recover.
381
487
  */
382
488
  startCdcConsumer(): Promise<void>;
383
489
  }
@@ -401,6 +507,14 @@ export declare interface PostgresDataStoreOptions {
401
507
  readonly changeStrategy?: ChangeStrategy;
402
508
  /** Postgres NOTIFY channel name when changeStrategy is 'cdc'. Defaults to 'framework_changes'. */
403
509
  readonly cdcChannel?: string;
510
+ /** Budget for re-reading ONE row whose change exceeded the NOTIFY payload cap
511
+ * — see {@link CdcRehydrateTunables.rehydrateTimeoutMs}. Defaults to 5000ms
512
+ * (env `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS`). */
513
+ readonly cdcRehydrateTimeoutMs?: number;
514
+ /** Re-reads after the first attempt, inside that budget — see
515
+ * {@link CdcRehydrateTunables.rehydrateRetries}. Defaults to 2
516
+ * (env `VOLTRO_CDC_REHYDRATE_RETRIES`). */
517
+ readonly cdcRehydrateRetries?: number;
404
518
  }
405
519
 
406
520
  /**