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.
- package/dist/cjs/cli/index.js +245 -52
- package/dist/cjs/cli/migrate.js +221 -16
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +450 -0
- package/dist/cli/index.d.ts +5 -1
- package/dist/cli/index.js +246 -53
- package/dist/cli/migrate.d.ts +41 -9
- package/dist/cli/migrate.js +220 -16
- package/dist/index-advisor.d.ts +30 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +208 -0
- package/dist/index-stats.js +408 -0
- package/package.json +1 -1
package/dist/cli/migrate.js
CHANGED
|
@@ -121,21 +121,37 @@ export function listMigrationFiles(migrationsDir) {
|
|
|
121
121
|
return files;
|
|
122
122
|
}
|
|
123
123
|
/**
|
|
124
|
-
*
|
|
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()
|
|
134
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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(
|
|
824
|
+
await client.query(insertApplied, [file.name, hash]);
|
|
636
825
|
await client.query('COMMIT');
|
|
637
826
|
results.push(file);
|
|
638
|
-
|
|
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
|
|
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(
|
|
961
|
+
await client.query(deleteApplied, [migration.name]);
|
|
758
962
|
await client.query('COMMIT');
|
|
759
963
|
results.push(file);
|
|
760
964
|
}
|
package/dist/index-advisor.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/index-advisor.js
CHANGED
|
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>;
|