turbine-orm 0.45.0 → 0.46.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.
@@ -121,21 +121,37 @@ export function listMigrationFiles(migrationsDir) {
121
121
  return files;
122
122
  }
123
123
  /**
124
- * Parse migration content string into UP and DOWN sections.
124
+ * The `-- turbine:no-transaction` directive: when present in a migration's
125
+ * header (before `-- UP`), the runner applies the file WITHOUT wrapping it in
126
+ * BEGIN/COMMIT and runs one statement per `client.query()` call. Required for
127
+ * `CREATE INDEX CONCURRENTLY`, which Postgres forbids inside any transaction,
128
+ * including the implicit transaction a multi-statement simple query creates.
129
+ */
130
+ const NO_TRANSACTION_DIRECTIVE = /^--\s*turbine:no-transaction\s*$/i;
131
+ /**
132
+ * Parse migration content string into UP and DOWN sections plus directives.
125
133
  * Exported for unit testing.
126
134
  */
127
135
  export function parseMigrationContent(content) {
128
136
  const lines = content.split('\n');
129
137
  let section = 'none';
138
+ let noTransaction = false;
130
139
  const upLines = [];
131
140
  const downLines = [];
132
141
  for (const line of lines) {
133
- const trimmed = line.trim().toUpperCase();
134
- if (trimmed === '-- UP') {
142
+ const trimmed = line.trim();
143
+ const upper = trimmed.toUpperCase();
144
+ // The directive is only honored in the header (before -- UP), so it can
145
+ // never be smuggled in via a DOWN-section comment.
146
+ if (section === 'none' && NO_TRANSACTION_DIRECTIVE.test(trimmed)) {
147
+ noTransaction = true;
148
+ continue;
149
+ }
150
+ if (upper === '-- UP') {
135
151
  section = 'up';
136
152
  continue;
137
153
  }
138
- if (trimmed === '-- DOWN') {
154
+ if (upper === '-- DOWN') {
139
155
  section = 'down';
140
156
  continue;
141
157
  }
@@ -147,8 +163,144 @@ export function parseMigrationContent(content) {
147
163
  return {
148
164
  up: upLines.join('\n').trim(),
149
165
  down: downLines.join('\n').trim(),
166
+ noTransaction,
150
167
  };
151
168
  }
169
+ /**
170
+ * Split a SQL script into individual statements on top-level semicolons.
171
+ *
172
+ * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
173
+ * string, a double-quoted identifier, a dollar-quoted body, a line comment
174
+ * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
175
+ * split. This is the one production-destroying failure mode of no-transaction
176
+ * migrations (a partial statement executed against production), so the behavior
177
+ * is pinned by exhaustive unit tests.
178
+ *
179
+ * Comment-only fragments are dropped; every returned statement is trimmed and
180
+ * carries no trailing semicolon.
181
+ */
182
+ export function splitSqlStatements(sql) {
183
+ const statements = [];
184
+ let current = '';
185
+ let i = 0;
186
+ const n = sql.length;
187
+ while (i < n) {
188
+ const ch = sql[i];
189
+ const next = sql[i + 1];
190
+ // Line comment: consume to end of line (kept verbatim in the statement).
191
+ if (ch === '-' && next === '-') {
192
+ let j = i;
193
+ while (j < n && sql[j] !== '\n')
194
+ j++;
195
+ current += sql.slice(i, j);
196
+ i = j;
197
+ continue;
198
+ }
199
+ // Block comment (Postgres allows nesting: /* /* */ */).
200
+ if (ch === '/' && next === '*') {
201
+ let depth = 1;
202
+ let j = i + 2;
203
+ current += '/*';
204
+ while (j < n && depth > 0) {
205
+ if (sql[j] === '/' && sql[j + 1] === '*') {
206
+ depth++;
207
+ current += '/*';
208
+ j += 2;
209
+ }
210
+ else if (sql[j] === '*' && sql[j + 1] === '/') {
211
+ depth--;
212
+ current += '*/';
213
+ j += 2;
214
+ }
215
+ else {
216
+ current += sql[j];
217
+ j++;
218
+ }
219
+ }
220
+ i = j;
221
+ continue;
222
+ }
223
+ // Single-quoted string ('' is an escaped quote, stays inside the string).
224
+ if (ch === "'") {
225
+ let j = i + 1;
226
+ current += "'";
227
+ while (j < n) {
228
+ if (sql[j] === "'" && sql[j + 1] === "'") {
229
+ current += "''";
230
+ j += 2;
231
+ continue;
232
+ }
233
+ if (sql[j] === "'") {
234
+ current += "'";
235
+ j++;
236
+ break;
237
+ }
238
+ current += sql[j];
239
+ j++;
240
+ }
241
+ i = j;
242
+ continue;
243
+ }
244
+ // Double-quoted identifier ("" is an escaped quote).
245
+ if (ch === '"') {
246
+ let j = i + 1;
247
+ current += '"';
248
+ while (j < n) {
249
+ if (sql[j] === '"' && sql[j + 1] === '"') {
250
+ current += '""';
251
+ j += 2;
252
+ continue;
253
+ }
254
+ if (sql[j] === '"') {
255
+ current += '"';
256
+ j++;
257
+ break;
258
+ }
259
+ current += sql[j];
260
+ j++;
261
+ }
262
+ i = j;
263
+ continue;
264
+ }
265
+ // Dollar-quoted body ($tag$ ... $tag$; tag is empty or an identifier, never
266
+ // digit-leading, so a `$1` parameter placeholder is not mistaken for one).
267
+ if (ch === '$') {
268
+ const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
269
+ if (tagMatch) {
270
+ const tag = tagMatch[0];
271
+ const end = sql.indexOf(tag, i + tag.length);
272
+ if (end === -1) {
273
+ current += sql.slice(i);
274
+ i = n;
275
+ continue;
276
+ }
277
+ current += sql.slice(i, end + tag.length);
278
+ i = end + tag.length;
279
+ continue;
280
+ }
281
+ }
282
+ // Top-level statement terminator.
283
+ if (ch === ';') {
284
+ const trimmed = current.trim();
285
+ if (trimmed)
286
+ statements.push(trimmed);
287
+ current = '';
288
+ i++;
289
+ continue;
290
+ }
291
+ current += ch;
292
+ i++;
293
+ }
294
+ const tail = current.trim();
295
+ if (tail)
296
+ statements.push(tail);
297
+ return statements.filter((s) => !isCommentOnlyStatement(s));
298
+ }
299
+ /** True when a fragment contains nothing but comments and whitespace. */
300
+ function isCommentOnlyStatement(stmt) {
301
+ const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
302
+ return withoutComments.trim().length === 0;
303
+ }
152
304
  /**
153
305
  * Parse a migration file into UP and DOWN sections.
154
306
  */
@@ -307,6 +459,9 @@ export function buildDiffMigrationBody(diff) {
307
459
  * - `autoContent`: pre-populate UP/DOWN from a schema diff.
308
460
  * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
309
461
  * Mutually exclusive with `autoContent`; an unknown recipe throws.
462
+ * - `options.header`: extra header line(s) injected BEFORE `-- UP` (the only
463
+ * place a `-- turbine:no-transaction` directive is honored). Used with
464
+ * `autoContent`.
310
465
  */
311
466
  export function createMigration(migrationsDir, name, autoContent, options) {
312
467
  mkdirSync(migrationsDir, { recursive: true });
@@ -335,10 +490,11 @@ ${body.down}
335
490
  `;
336
491
  }
337
492
  else if (autoContent) {
338
- template = `-- Migration: ${name} (auto-generated from schema diff)
493
+ const headerBlock = options?.header ? `${options.header}\n` : '';
494
+ template = `-- Migration: ${name} (auto-generated)
339
495
  -- Created: ${now.toISOString()}
340
496
  -- Review this file before running: npx turbine migrate up
341
-
497
+ ${headerBlock}
342
498
  -- UP
343
499
  ${autoContent.up}
344
500
 
@@ -621,25 +777,54 @@ export async function migrateUp(connectionString, migrationsDir, options) {
621
777
  const results = [];
622
778
  const errors = [];
623
779
  const outOfOrder = [];
780
+ const noTransactionApplied = [];
781
+ const flagOutOfOrder = (file) => {
782
+ // Flag an out-of-order apply: this file's timestamp is older than a
783
+ // migration that was already applied before this run started.
784
+ if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
785
+ outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
786
+ }
787
+ };
624
788
  for (const file of pending) {
625
- const { up } = parseMigrationSQL(file.path);
789
+ const parsed = parseMigrationSQL(file.path);
790
+ const up = parsed.up;
626
791
  if (!up) {
627
792
  errors.push({ file, error: 'No UP section found in migration file' });
628
793
  continue;
629
794
  }
630
795
  const content = readFileSync(file.path, 'utf-8');
631
796
  const hash = checksum(content);
797
+ const insertApplied = dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect));
798
+ if (parsed.noTransaction) {
799
+ // No BEGIN/COMMIT: run ONE statement per query() call (a multi-statement
800
+ // simple query would be wrapped in an implicit transaction, breaking
801
+ // CREATE INDEX CONCURRENTLY). Recording happens only after all statements
802
+ // succeed: a mid-file failure leaves earlier (idempotent) statements
803
+ // applied and the migration unrecorded, so a rerun resumes it.
804
+ options?.onNoTransaction?.(file);
805
+ noTransactionApplied.push(file);
806
+ try {
807
+ for (const stmt of splitSqlStatements(up)) {
808
+ await client.query(stmt);
809
+ }
810
+ await client.query(insertApplied, [file.name, hash]);
811
+ results.push(file);
812
+ flagOutOfOrder(file);
813
+ }
814
+ catch (err) {
815
+ const msg = err instanceof Error ? err.message : String(err);
816
+ errors.push({ file, error: msg });
817
+ break;
818
+ }
819
+ continue;
820
+ }
632
821
  try {
633
822
  await client.query('BEGIN');
634
823
  await client.query(up);
635
- await client.query(dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect)), [file.name, hash]);
824
+ await client.query(insertApplied, [file.name, hash]);
636
825
  await client.query('COMMIT');
637
826
  results.push(file);
638
- // Flag an out-of-order apply: this file's timestamp is older than a
639
- // migration that was already applied before this run started.
640
- if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
641
- outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
642
- }
827
+ flagOutOfOrder(file);
643
828
  }
644
829
  catch (err) {
645
830
  await client.query('ROLLBACK');
@@ -649,7 +834,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
649
834
  break;
650
835
  }
651
836
  }
652
- return { applied: results, errors, destructive, outOfOrder };
837
+ return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
653
838
  }
654
839
  finally {
655
840
  await releaseLock(client, lockId, adapter);
@@ -746,15 +931,34 @@ export async function migrateDown(connectionString, migrationsDir, options) {
746
931
  });
747
932
  continue;
748
933
  }
749
- const { down } = parseMigrationSQL(file.path);
934
+ const parsed = parseMigrationSQL(file.path);
935
+ const down = parsed.down;
750
936
  if (!down) {
751
937
  errors.push({ file, error: 'No DOWN section found in migration file' });
752
938
  continue;
753
939
  }
940
+ const deleteApplied = dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect));
941
+ if (parsed.noTransaction) {
942
+ // Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
943
+ // statement per query() call: same contract as the untransacted UP.
944
+ try {
945
+ for (const stmt of splitSqlStatements(down)) {
946
+ await client.query(stmt);
947
+ }
948
+ await client.query(deleteApplied, [migration.name]);
949
+ results.push(file);
950
+ }
951
+ catch (err) {
952
+ const msg = err instanceof Error ? err.message : String(err);
953
+ errors.push({ file, error: msg });
954
+ break;
955
+ }
956
+ continue;
957
+ }
754
958
  try {
755
959
  await client.query('BEGIN');
756
960
  await client.query(down);
757
- await client.query(dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)), [migration.name]);
961
+ await client.query(deleteApplied, [migration.name]);
758
962
  await client.query('COMMIT');
759
963
  results.push(file);
760
964
  }
@@ -38,6 +38,36 @@ export interface MissingRelationIndex {
38
38
  /** DROP INDEX statement for the fix migration's DOWN section */
39
39
  dropSql: string;
40
40
  }
41
+ /** Options for {@link buildCreateIndexSql}. All default to the plain, in-transaction form. */
42
+ export interface CreateIndexSqlOptions {
43
+ /**
44
+ * Emit `CREATE INDEX CONCURRENTLY`. A concurrent build never holds a write
45
+ * lock, but it CANNOT run inside a transaction block, so a migration carrying
46
+ * it must also carry the `-- turbine:no-transaction` directive.
47
+ */
48
+ concurrently?: boolean;
49
+ /** Emit `IF NOT EXISTS` (default true — required for idempotent no-transaction migrations). */
50
+ ifNotExists?: boolean;
51
+ /**
52
+ * Emit a partial index `... WHERE <col> IS NOT NULL`. Only applied for a
53
+ * single-column index (a relation probe correlates `child.fk = parent.pk`, and
54
+ * NULL never equals anything, so a mostly-NULL FK is fully served by a partial
55
+ * index at a fraction of the size). Ignored for composite indexes.
56
+ */
57
+ partialNotNull?: boolean;
58
+ }
59
+ /**
60
+ * Build a `CREATE INDEX` statement for a relation-probe fix. Pure string
61
+ * assembly (no stats, no DB read): the caller decides whether to pass
62
+ * `concurrently`/`partialNotNull` based on collected statistics, keeping this
63
+ * module topology-only.
64
+ */
65
+ export declare function buildCreateIndexSql(table: string, columns: string[], indexName: string, options?: CreateIndexSqlOptions): string;
66
+ /** Build the matching `DROP INDEX` statement. `concurrently` requires no-transaction execution. */
67
+ export declare function buildDropIndexSql(indexName: string, options?: {
68
+ concurrently?: boolean;
69
+ ifExists?: boolean;
70
+ }): string;
41
71
  /**
42
72
  * Whether an equality probe on `columns` is served by the table's indexes.
43
73
  *
Binary file
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Index statistics - cost-aware triage for the missing-index advisor.
3
+ *
4
+ * `index-advisor.ts` decides WHICH relation probes lack an index from pure
5
+ * schema topology. This module decides whether adding each one is worth it,
6
+ * reading live Postgres statistics (table size, write volume, existing indexes,
7
+ * HOT-update ratio, column null fraction) and scoring every finding into a tier:
8
+ *
9
+ * - take-freely large table, low write rate, few existing indexes;
10
+ * - take-deliberately real write rate, many existing indexes, or HOT at risk;
11
+ * - scrutinize tiny table, append-only log shape, or stats too young.
12
+ *
13
+ * DESIGN: the file splits into two halves.
14
+ * - The COLLECTOR half (`collectStatsSnapshot`) reads pg catalogs. It uses a
15
+ * single one-connection pool, sets a statement_timeout, and treats every
16
+ * catalog read as INDIVIDUALLY OPTIONAL: a read that fails (privileges,
17
+ * CockroachDB/YugabyteDB catalog gaps, missing view) degrades that one
18
+ * signal and records a notice rather than aborting the whole snapshot.
19
+ * - The PURE half (`scoreMissingIndex`, `findInvalidIndexes`, and the exported
20
+ * threshold constants) takes a typed {@link StatsSnapshot} and imports NO pg.
21
+ * It is fully unit-testable with hand-built snapshots and is what carries the
22
+ * thresholds the CLI prints alongside every verdict.
23
+ *
24
+ * All statistics features are Postgres-only. On a non-Postgres engine the
25
+ * collector returns an unavailable snapshot and the caller falls back to the
26
+ * topology-only report.
27
+ */
28
+ /**
29
+ * Heuristic thresholds behind the tier decision. Exported and surfaced in the
30
+ * doctor output so the reasoning is never a black box: if a threshold is wrong
31
+ * for a workload, the user can see the exact number that drove the verdict.
32
+ */
33
+ export declare const STATS_THRESHOLDS: {
34
+ /** reltuples below this = tiny table; a missing index there is noise → scrutinize. */
35
+ readonly tinyTableRows: 1000;
36
+ /**
37
+ * Writes/day (n_tup_ins+upd+del normalized by stats age) at or above this is a
38
+ * "real" write rate: the per-index write tax is worth a second look → take deliberately.
39
+ */
40
+ readonly highWritesPerDay: 50000;
41
+ /**
42
+ * Existing index count at or above this: every extra index compounds the write
43
+ * and storage tax, so a new one is no longer free → take deliberately.
44
+ */
45
+ readonly manyIndexes: 4;
46
+ /**
47
+ * HOT ratio (n_tup_hot_upd / n_tup_upd) at or above this, WITH real update
48
+ * volume, means the table currently relies on heap-only-tuple updates. A new
49
+ * index can disqualify HOT and amplify write cost → warning + take deliberately.
50
+ */
51
+ readonly hotRatioAtRisk: 0.3;
52
+ /** Minimum n_tup_upd for the HOT signal to be statistically meaningful. */
53
+ readonly hotMinUpdates: 1000;
54
+ /**
55
+ * null_frac at or above this on a probed FK column: the column is mostly NULL,
56
+ * so a partial `WHERE col IS NOT NULL` index covers every relation probe at a
57
+ * fraction of the size.
58
+ */
59
+ readonly partialNullFrac: 0.9;
60
+ /**
61
+ * Stats younger than this (days since stats_reset) cannot normalize a write
62
+ * rate; the whole report degrades to topology-only output.
63
+ */
64
+ readonly minStatsAgeDays: 1;
65
+ /** Append-heavy log shape: n_tup_ins at or above this ... */
66
+ readonly appendHeavyMinInserts: 100000;
67
+ /** ... with inserts making up at or above this fraction of all writes ... */
68
+ readonly appendHeavyInsertRatio: 0.95;
69
+ /** ... and seq_scan at or below this ("near-zero probe reads") → scrutinize. */
70
+ readonly appendHeavyMaxSeqScan: 5;
71
+ };
72
+ /** Per-table live statistics. Any field may be absent when its catalog read degraded. */
73
+ export interface TableStats {
74
+ table: string;
75
+ /** pg_class.reltuples. 0 or -1 means never-analyzed → treated as UNKNOWN (null rows). */
76
+ reltuples: number;
77
+ /** pg_stat_user_tables.n_live_tup, a cross-check for reltuples. */
78
+ nLiveTup?: number;
79
+ nTupIns?: number;
80
+ nTupUpd?: number;
81
+ nTupDel?: number;
82
+ nTupHotUpd?: number;
83
+ seqScan?: number;
84
+ seqTupRead?: number;
85
+ /** pg_total_relation_size (table + indexes + toast), bytes. */
86
+ totalSizeBytes?: number;
87
+ /** pg_relation_size (heap only), bytes. */
88
+ tableSizeBytes?: number;
89
+ /** Count of indexes already on the table (pg_index). */
90
+ existingIndexCount?: number;
91
+ }
92
+ /** A single index's identity + validity, from pg_index / pg_stat_user_indexes. */
93
+ export interface IndexStat {
94
+ table: string;
95
+ indexName: string;
96
+ columns: string[];
97
+ idxScan?: number;
98
+ isValid: boolean;
99
+ isUnique: boolean;
100
+ isPrimary: boolean;
101
+ isReplicaIdent: boolean;
102
+ }
103
+ /**
104
+ * A point-in-time read of the statistics the triage needs. Every part is
105
+ * optional at the field level so the pure scorer degrades honestly.
106
+ */
107
+ export interface StatsSnapshot {
108
+ /** True when at least the core table statistics were readable. */
109
+ available: boolean;
110
+ /** pg_stat_database.stats_reset for the current DB. NULL when never reset. */
111
+ statsReset: Date | null;
112
+ /** Days since stats_reset, or null when stats_reset is NULL / in the future. */
113
+ statsAgeDays: number | null;
114
+ /** Per-table stats, keyed by table name. */
115
+ tables: Record<string, TableStats>;
116
+ /** All indexes read (used for invalid-index detection). */
117
+ indexes: IndexStat[];
118
+ /** null_frac per probed column, keyed `table.column`. */
119
+ nullFrac: Record<string, number>;
120
+ /** Per-signal degradation notices (privileges, catalog gaps, timeouts). */
121
+ notices: string[];
122
+ }
123
+ /** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
124
+ export declare function emptyStatsSnapshot(notices?: string[]): StatsSnapshot;
125
+ export type IndexTier = 'take-freely' | 'take-deliberately' | 'scrutinize';
126
+ /** The numbers behind a verdict, all nullable so unknowns never masquerade as zero. */
127
+ export interface FindingMetrics {
128
+ /** reltuples, or null when never-analyzed (0/-1). */
129
+ rows: number | null;
130
+ /** pg_total_relation_size in bytes. */
131
+ sizeBytes: number | null;
132
+ /** Writes/day since stats reset, or null when the rate cannot be normalized. */
133
+ writesPerDay: number | null;
134
+ existingIndexCount: number | null;
135
+ probingRelations: number;
136
+ /** n_tup_hot_upd / n_tup_upd, or null. */
137
+ hotRatio: number | null;
138
+ /** Highest null_frac across the probed columns, or null when unknown. */
139
+ nullFrac: number | null;
140
+ statsAgeDays: number | null;
141
+ }
142
+ export interface ScoredMissingIndex {
143
+ table: string;
144
+ columns: string[];
145
+ tier: IndexTier;
146
+ /** Human-readable reasons, each carrying the number that drove it. */
147
+ reasons: string[];
148
+ metrics: FindingMetrics;
149
+ /** The HOT-update caveat when the table is at risk of losing HOT, else null. */
150
+ hotWarning: string | null;
151
+ /** When true, the emitted index should be a partial `WHERE col IS NOT NULL`. */
152
+ partialNotNull: boolean;
153
+ /** Sort key within a tier: bigger, more-probed tables first. */
154
+ benefitScore: number;
155
+ }
156
+ /** The subset of a topology finding the scorer needs. */
157
+ export interface ScorableMissingIndex {
158
+ table: string;
159
+ columns: string[];
160
+ probes: unknown[];
161
+ }
162
+ /** Format a byte count as a short human string (KB/MB/GB). */
163
+ export declare function formatBytes(bytes: number | null | undefined): string;
164
+ /**
165
+ * Score a single missing-index finding against a snapshot. Pure and total:
166
+ * every unknown degrades to a caveat rather than a fabricated number.
167
+ */
168
+ export declare function scoreMissingIndex(missing: ScorableMissingIndex, snapshot: StatsSnapshot): ScoredMissingIndex;
169
+ export interface InvalidIndex {
170
+ table: string;
171
+ indexName: string;
172
+ columns: string[];
173
+ /** `DROP INDEX CONCURRENTLY IF EXISTS` - the repair for an INVALID corpse. */
174
+ dropSql: string;
175
+ }
176
+ /**
177
+ * Indexes left INVALID (indisvalid = false) - the failure artifact of a
178
+ * `CREATE INDEX CONCURRENTLY` that errored. `IF NOT EXISTS` on a retry silently
179
+ * skips the corpse, so the fix is DROP INDEX CONCURRENTLY then rerun.
180
+ */
181
+ export declare function findInvalidIndexes(snapshot: StatsSnapshot): InvalidIndex[];
182
+ /**
183
+ * Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
184
+ * unavailable, or too-young stats degrade to the topology-only report.
185
+ */
186
+ export declare function isSnapshotUsable(snapshot: StatsSnapshot): boolean;
187
+ /** A tuple identifying a probed column whose null_frac the collector should read. */
188
+ export interface ProbedColumn {
189
+ table: string;
190
+ column: string;
191
+ }
192
+ export interface CollectSnapshotOptions {
193
+ connectionString: string;
194
+ schema: string;
195
+ /** Tables to read table-level stats + sizes for (the probed tables). */
196
+ tables: string[];
197
+ /** Columns to read null_frac for (single-column probes). */
198
+ columns: ProbedColumn[];
199
+ /** statement_timeout for each catalog read. Default 5000ms. */
200
+ statementTimeoutMs?: number;
201
+ }
202
+ /**
203
+ * Read a live statistics snapshot from Postgres. Each catalog read is wrapped
204
+ * individually: a failure records a notice and leaves that signal absent, so a
205
+ * privilege gap or a CockroachDB/YugabyteDB catalog difference degrades one
206
+ * signal rather than the whole snapshot.
207
+ */
208
+ export declare function collectStatsSnapshot(options: CollectSnapshotOptions): Promise<StatsSnapshot>;