@voltro/sql-postgres 0.33.0 → 0.35.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.
@@ -43,6 +66,27 @@ export declare type DatabaseConnectionLayer = ReturnType<typeof makePostgresSqlL
43
66
 
44
67
  export declare type DatabaseConnectionLayerInput = Parameters<typeof makePostgresSqlLayer>[0];
45
68
 
69
+ /** Encode one row as a COPY TEXT line (no trailing newline). */
70
+ export declare const encodeCopyRow: (row: Readonly<Record<string, unknown>>, columns: ReadonlyArray<string>, columnTypes?: Readonly<Record<string, string>>) => string;
71
+
72
+ /**
73
+ * Encode one field value to its LOGICAL text (before COPY escaping).
74
+ * `null` means SQL NULL (`\N` on the wire).
75
+ *
76
+ * `columnType` is the bundle's logical type for the column, used only where
77
+ * the JS value alone is ambiguous: a JS array under a `json` column is JSON
78
+ * text, under an `array` column a pg array literal, under a `vector` column
79
+ * the pgvector `[…]` form.
80
+ */
81
+ export declare const encodeCopyValue: (value: unknown, columnType: string | undefined) => string | null;
82
+
83
+ /**
84
+ * Open a COPY session on its OWN connection (the same posture as the native
85
+ * `pg_dump`/`pg_restore` runners — bulk load does not ride the app pool).
86
+ * The connection opens lazily on the first `copyInto`.
87
+ */
88
+ export declare const makePgCopySession: (config: ConnectionConfig) => PgCopySession;
89
+
46
90
  /**
47
91
  * Construct a `PostgresDataStore`. The owned `ManagedRuntime` keeps the
48
92
  * connection pool alive for the lifetime of the store; `close()` disposes it.
@@ -76,6 +120,25 @@ export declare const makePostgresSqlLayerFromConfig: (config: ConnectionConfig)
76
120
 
77
121
  export { PgClient }
78
122
 
123
+ export declare interface PgCopyBatch {
124
+ readonly table: string;
125
+ readonly columns: ReadonlyArray<string>;
126
+ /** Bundle logical column types (json/array/vector/…), for the values the JS
127
+ * type alone cannot disambiguate. */
128
+ readonly columnTypes?: Readonly<Record<string, string>>;
129
+ readonly rows: ReadonlyArray<Record<string, unknown>>;
130
+ }
131
+
132
+ export declare interface PgCopySession {
133
+ /** The dialect this loader serves — the importer's eligibility key. */
134
+ readonly dialect: 'postgres';
135
+ /** COPY one batch into `table`. Resolves the server-reported row count.
136
+ * Atomic per call: a failure applied NOTHING (single statement). */
137
+ readonly copyInto: (batch: PgCopyBatch) => Promise<number>;
138
+ /** Close the dedicated connection. Idempotent. */
139
+ readonly close: () => Promise<void>;
140
+ }
141
+
79
142
  /**
80
143
  * Internal handle a `PostgresDataStore` exposes so this adapter can
81
144
  * issue raw SQL against the same managed runtime + connection pool.
@@ -161,6 +224,14 @@ export declare interface PostgresConnection {
161
224
  * the pool would have freed up eventually: it names the pool as the cause, at
162
225
  * the moment it is the cause, instead of surfacing as an unexplained latency
163
226
  * spike in a place with no connection information in it.
227
+ *
228
+ * **It shipped bounded on ONE of the two layer paths**, which is the part
229
+ * worth remembering. The bound sat inside the hand-built `pg.Pool` branch —
230
+ * reached only when `DB_SCHEMA` or `DB_STATEMENT_TIMEOUT_MS` is set — while
231
+ * the default configuration went through `PgClient.layerConfig` with no
232
+ * `connectTimeout` at all. The fix is not "add a timeout", it is that BOTH
233
+ * paths now read one resolver (`resolveAcquireTimeoutMs`); a per-branch copy
234
+ * of a default is how the first one drifted.
164
235
  */
165
236
  readonly acquireTimeoutMs?: number;
166
237
  }
@@ -170,13 +241,33 @@ export declare class PostgresDataStore implements DataStore {
170
241
  private readonly runtime;
171
242
  private readonly changeStrategy;
172
243
  private readonly cdcChannel;
244
+ private readonly cdcRehydrate;
173
245
  private readonly emitter;
174
246
  private cdcFiber;
175
247
  /** In-flight count of `transactional()` calls. `close()` waits for
176
248
  * this to drop to 0 (or its grace period to expire) before disposing
177
249
  * the runtime — preventing connection-pool teardown mid-COMMIT. */
178
250
  private inflightTxns;
179
- constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string);
251
+ /** Reports the first oversized change per table + every unrecovered one.
252
+ * Instance-scoped so the once-per-table rate limit is the STORE's, not a
253
+ * module global that a second store in one process would silence. */
254
+ private readonly reportOversized;
255
+ /** PERF-23 — counts EVERY eager JSON-agg → walker degradation and logs a
256
+ * rate-limited line. Instance-scoped for the same reason as above. */
257
+ private readonly reportEagerFallback;
258
+ constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string, cdcRehydrate?: CdcRehydrateTunables);
259
+ /**
260
+ * The DIALECT half of the shared transaction bracket
261
+ * (`runStoreTransaction` / `runRetryingTransaction` in `@voltro/database`).
262
+ *
263
+ * Postgres owns exactly four things here: its client's `withTransaction`, its
264
+ * managed runtime, its retryable-failure predicate and the span labels.
265
+ * Attribution threading, retry, commit-defect promotion and exit settling are
266
+ * NOT here on purpose — every one of them had already drifted between the four
267
+ * dialect stores, and `transactional()` and `runInNamespace()` had drifted
268
+ * from each other inside this very file. See `transactionOutcome.ts`.
269
+ */
270
+ private txnSpec;
180
271
  /**
181
272
  * Bind a per-request tenant NAMESPACE (a postgres SCHEMA). The returned
182
273
  * view runs every operation inside a transaction whose first statement
@@ -212,6 +303,14 @@ export declare class PostgresDataStore implements DataStore {
212
303
  * fully isolated. The public methods pass `null/null`; the view passes
213
304
  * its own captured TxnContext + per-call events buffer.
214
305
  */
306
+ /**
307
+ * Run one SELECT.
308
+ *
309
+ * `namespace` is the physical-tenant schema when the read took the
310
+ * transaction-free fast path (see `PostgresNamespaceView.query`); `null` on
311
+ * every other path, including inside a `SET LOCAL search_path` transaction,
312
+ * where qualifying as well would be redundant.
313
+ */
215
314
  private executeQuery;
216
315
  private executeInsert;
217
316
  private executeUpdate;
@@ -256,13 +355,18 @@ export declare class PostgresDataStore implements DataStore {
256
355
  * The fallback-on-throw catches dialect quirks I couldn't predict
257
356
  * up front — anything that breaks the JSON-agg query (driver
258
357
  * compatibility, schema feature) silently degrades to walker
259
- * instead of surfacing a routing-level error. The log line on
260
- * fallback makes the degradation visible during dev.
358
+ * instead of surfacing a routing-level error.
359
+ *
360
+ * PERF-23 — the degradation is COUNTED, not just logged. A log line makes it
361
+ * visible to whoever is watching the terminal; `voltro_db_eager_fallback_total`
362
+ * makes it visible to whoever looks in three weeks, which is when a
363
+ * per-query cliff that has been running the whole time actually gets noticed.
261
364
  */
262
365
  private runWithEager;
263
366
  /** Friend accessor for the transactional view — same eager-aware
264
367
  * path as the public `query()` but pinned to the view's txn. */
265
368
  getInternalRunWithEager(): (d: QueryDescriptor, txn: TxnContext | null) => Promise<ReadonlyArray<Row>>;
369
+ /* Excluded from this release type: queryInNamespace */
266
370
  /**
267
371
  * Bracket a NON-transactional write: capture the request identity before the
268
372
  * first await (`withCapturedAttribution`), and hold this table's transport
@@ -349,16 +453,19 @@ export declare class PostgresDataStore implements DataStore {
349
453
  * 4. ChangeEvents queue in the view's buffer; on commit they drain
350
454
  * to the parent's emitter. On throw they're dropped.
351
455
  *
352
- * Resilience:
456
+ * Resilience (all of it owned by the shared bracket, not by this file):
353
457
  * - Automatic retry on `serialization_failure` (40001) and
354
458
  * `deadlock_detected` (40P01) with exponential backoff (10ms base,
355
- * up to 3 attempts). Each retry rebuilds a fresh view — the
459
+ * up to 3 retries). Each retry rebuilds a fresh view — the
356
460
  * aborted attempt's events were never drained, so the retry's
357
461
  * subscribers see exactly one event-set (the winning attempt's).
358
- * - The whole `transactional()` boundary is wrapped in an
359
- * `Effect.withSpan('store.transactional')` so OpenTelemetry
360
- * collectors see one span per logical mutation, with retry counts
361
- * and committed-event counts as attributes.
462
+ * - A conflict raised at COMMIT arrives as a DEFECT (`@effect/sql` runs
463
+ * COMMIT as `Effect.orDie`) and is promoted back to a failure so the
464
+ * schedule can see it.
465
+ * - The whole boundary is one `Effect.withSpan('store.transactional')` so
466
+ * OpenTelemetry collectors see one span per logical mutation.
467
+ * - The Exit settles through `settleTransactionExit`, so a typed error
468
+ * reaches the caller with its `_tag` rather than as a `FiberFailure`.
362
469
  */
363
470
  transactional<T>(work: (tx: DataStore) => Promise<T>): Promise<T>;
364
471
  onChange(listener: (event: ChangeEvent) => void): () => void;
@@ -391,10 +498,32 @@ export declare class PostgresDataStore implements DataStore {
391
498
  close(gracePeriodMs?: number): Promise<void>;
392
499
  /** Liveness probe — `SELECT 1`. Rejects if the pool can't answer. */
393
500
  ping(): Promise<void>;
501
+ /**
502
+ * Read one row back as the JSON the trigger could not send.
503
+ *
504
+ * `row_to_json(t)` on purpose: it is literally what `triggerFunctionSql`
505
+ * builds its images with, so a re-hydrated row and an ordinary CDC image are
506
+ * the same shape — dates as ISO strings, numerics as JSON numbers. A plain
507
+ * `SELECT *` would hand the driver's types to consumers that have only ever
508
+ * seen the JSON ones, which is a second shape to get right in every tap.
509
+ *
510
+ * SCHEMA-QUALIFIED, from the payload's own `schema`. Under namespace
511
+ * isolation the write landed in `tenant_<id>`, and an unqualified read
512
+ * resolves through this connection's `search_path` instead — which is another
513
+ * tenant's table of the same name, or none.
514
+ */
515
+ private readRowAsCdcJson;
394
516
  /**
395
517
  * Subscribe to the Postgres NOTIFY channel and translate each notification
396
518
  * into a ChangeEvent. Called automatically from `makePostgresDataStore`
397
519
  * when `changeStrategy: 'cdc'`.
520
+ *
521
+ * An OVERSIZED notification is re-hydrated HERE, before anything downstream
522
+ * sees it. This is the one place every consumer is downstream of — the
523
+ * dispatcher, the plugin taps, the analytics mirror, the DevTools CDC
524
+ * producer and the ISR invalidator all hang off the emitter this feeds — so
525
+ * it is the only place the repair can be made once. See `cdcRehydrate.ts` for
526
+ * what re-hydration can and cannot recover.
398
527
  */
399
528
  startCdcConsumer(): Promise<void>;
400
529
  }
@@ -418,6 +547,14 @@ export declare interface PostgresDataStoreOptions {
418
547
  readonly changeStrategy?: ChangeStrategy;
419
548
  /** Postgres NOTIFY channel name when changeStrategy is 'cdc'. Defaults to 'framework_changes'. */
420
549
  readonly cdcChannel?: string;
550
+ /** Budget for re-reading ONE row whose change exceeded the NOTIFY payload cap
551
+ * — see {@link CdcRehydrateTunables.rehydrateTimeoutMs}. Defaults to 5000ms
552
+ * (env `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS`). */
553
+ readonly cdcRehydrateTimeoutMs?: number;
554
+ /** Re-reads after the first attempt, inside that budget — see
555
+ * {@link CdcRehydrateTunables.rehydrateRetries}. Defaults to 2
556
+ * (env `VOLTRO_CDC_REHYDRATE_RETRIES`). */
557
+ readonly cdcRehydrateRetries?: number;
421
558
  }
422
559
 
423
560
  /**