pipework 0.293.0 → 0.294.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.
@@ -1,7 +1,9 @@
1
+ import postgres from 'postgres';
1
2
  import type { Manifold } from '../../core/pipework.js';
2
3
  import type { ResolvedDatabase } from '../../core/config/index.js';
3
4
  import { type MigrationCheckResult } from './check.js';
4
5
  import { type MigrationNotice } from './apply.js';
6
+ import { InvalidIndexRepairError, type InvalidIndex } from './invalid-indexes.js';
5
7
  import { type FrameworkTablesResult } from './provision-framework.js';
6
8
  export interface MigrateOptions {
7
9
  /** Called as each notice or warning a migration statement raises arrives. Issue #970. */
@@ -14,8 +16,16 @@ export interface MigrateResult {
14
16
  safetyCheck: MigrationCheckResult;
15
17
  /** No-transaction files picked up half applied and finished. Issue #378. */
16
18
  resumed: ResumedMigration[];
17
- /** INVALID indexes a cut CREATE INDEX CONCURRENTLY left, dropped before the resumed statement reran. Issue #1003. */
19
+ /** INVALID indexes a cut CREATE INDEX CONCURRENTLY left, dropped before the statement that builds them reran. Issues #1003, #1005. */
18
20
  droppedInvalidIndexes: DroppedInvalidIndex[];
21
+ /**
22
+ * Every INVALID index this run met, with its case, what was done or must be
23
+ * done, and the outcome: (a) an owned index rebuilt, (b) a leftover of an owned
24
+ * index dropped, (c) anything else, untouched. Issue #1005.
25
+ */
26
+ invalidIndexes: InvalidIndex[];
27
+ /** Set when the catalog read for INVALID indexes failed, so none were checked. Issue #1005. */
28
+ invalidIndexReadError: string | null;
19
29
  /** Notices and warnings raised by migration statements, in arrival order. Issue #970. */
20
30
  notices: MigrateNotice[];
21
31
  /** Trace/audit tables, columns and day partitions this run provisioned. */
@@ -40,11 +50,23 @@ export interface MigrateNotice extends MigrationNotice {
40
50
  statement: number;
41
51
  statements: number;
42
52
  }
53
+ export { InvalidIndexRepairError, type InvalidIndex };
43
54
  export declare class UnsafeMigrationError extends Error {
44
55
  readonly check: MigrationCheckResult;
45
56
  readonly name = "UnsafeMigrationError";
46
57
  constructor(check: MigrationCheckResult, message: string);
47
58
  }
48
59
  export declare function migrateAll(instance: Manifold, options?: MigrateOptions): Promise<MigrateResult[]>;
60
+ /**
61
+ * The connection that holds the run lock. It must live as long as the run: the
62
+ * default `max_lifetime` would close it mid-run and release the lock, so both
63
+ * timeouts are off, as in src/test/setup.ts.
64
+ */
65
+ export declare function openRunLock(url: string): ReturnType<typeof postgres>;
66
+ /**
67
+ * Runs one database's migrations and index repairs under a session advisory
68
+ * lock held for the whole run. A second run at once waits for it, then finds
69
+ * nothing to apply. The lock dies with its connection. Issue #1005.
70
+ */
49
71
  export declare function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase, options?: MigrateOptions): Promise<MigrateResult>;
50
72
  //# sourceMappingURL=run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../../src/data/migrate/run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAGlE,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEvE,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AAClE,OAAO,EAA4B,KAAK,qBAAqB,EAAE,MAAM,0BAA0B,CAAA;AAM/F,MAAM,WAAW,cAAc;IAC7B,yFAAyF;IACzF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAA;CAC3C;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,WAAW,EAAE,oBAAoB,CAAA;IACjC,4EAA4E;IAC5E,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,qHAAqH;IACrH,qBAAqB,EAAE,mBAAmB,EAAE,CAAA;IAC5C,yFAAyF;IACzF,OAAO,EAAE,aAAa,EAAE,CAAA;IACxB,2EAA2E;IAC3E,eAAe,EAAE,qBAAqB,CAAA;CACvC;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAA;IACrB,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,qBAAa,oBAAqB,SAAQ,KAAK;aAG3B,KAAK,EAAE,oBAAoB;IAF7C,SAAkB,IAAI,0BAAyB;gBAE7B,KAAK,EAAE,oBAAoB,EAC3C,OAAO,EAAE,MAAM;CAIlB;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAU3G;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAyErI"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../../src/data/migrate/run.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAA;AAE/B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAGlE,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEvE,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AAClE,OAAO,EAAwB,uBAAuB,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACvG,OAAO,EAA4B,KAAK,qBAAqB,EAAE,MAAM,0BAA0B,CAAA;AAM/F,MAAM,WAAW,cAAc;IAC7B,yFAAyF;IACzF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAA;CAC3C;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,WAAW,EAAE,oBAAoB,CAAA;IACjC,4EAA4E;IAC5E,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,sIAAsI;IACtI,qBAAqB,EAAE,mBAAmB,EAAE,CAAA;IAC5C;;;;OAIG;IACH,cAAc,EAAE,YAAY,EAAE,CAAA;IAC9B,+FAA+F;IAC/F,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,yFAAyF;IACzF,OAAO,EAAE,aAAa,EAAE,CAAA;IACxB,2EAA2E;IAC3E,eAAe,EAAE,qBAAqB,CAAA;CACvC;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAA;IACrB,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,OAAO,EAAE,uBAAuB,EAAE,KAAK,YAAY,EAAE,CAAA;AAErD,qBAAa,oBAAqB,SAAQ,KAAK;aAG3B,KAAK,EAAE,oBAAoB;IAF7C,SAAkB,IAAI,0BAAyB;gBAE7B,KAAK,EAAE,oBAAoB,EAC3C,OAAO,EAAE,MAAM;CAIlB;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAU3G;AAKD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAEpE;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAQrI"}
@@ -1,10 +1,13 @@
1
+ import postgres from 'postgres';
1
2
  import { sql } from '../query/index.js';
2
3
  import { quoteIdentifier } from '../db/identifiers.js';
3
4
  import { OWNER_LABEL_MIGRATE } from '../db/owner-labels.js';
4
5
  import { checkMigrations } from './check.js';
5
6
  import { readAppliedState } from './applied.js';
6
7
  import { applyMigrations } from './apply.js';
8
+ import { repairInvalidIndexes, InvalidIndexRepairError } from './invalid-indexes.js';
7
9
  import { provisionFrameworkTables } from './provision-framework.js';
10
+ export { InvalidIndexRepairError };
8
11
  export class UnsafeMigrationError extends Error {
9
12
  check;
10
13
  name = 'UnsafeMigrationError';
@@ -23,7 +26,32 @@ export async function migrateAll(instance, options = {}) {
23
26
  }
24
27
  return results;
25
28
  }
29
+ /** One key per database: advisory locks are scoped to the database they are taken in. */
30
+ const MIGRATE_LOCK_KEY = `hashtextextended('pipework.migrate', 0)`;
31
+ /**
32
+ * The connection that holds the run lock. It must live as long as the run: the
33
+ * default `max_lifetime` would close it mid-run and release the lock, so both
34
+ * timeouts are off, as in src/test/setup.ts.
35
+ */
36
+ export function openRunLock(url) {
37
+ return postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 0, onnotice: () => { } });
38
+ }
39
+ /**
40
+ * Runs one database's migrations and index repairs under a session advisory
41
+ * lock held for the whole run. A second run at once waits for it, then finds
42
+ * nothing to apply. The lock dies with its connection. Issue #1005.
43
+ */
26
44
  export async function migrateOne(instance, dbConfig, options = {}) {
45
+ const lock = openRunLock(dbConfig.url);
46
+ try {
47
+ await lock.unsafe(`SELECT pg_advisory_lock(${MIGRATE_LOCK_KEY})`);
48
+ return await migrateLocked(instance, dbConfig, options);
49
+ }
50
+ finally {
51
+ await lock.end();
52
+ }
53
+ }
54
+ async function migrateLocked(instance, dbConfig, options) {
27
55
  const conn = instance.pool.getOrCreate(dbConfig, 'owner', OWNER_LABEL_MIGRATE);
28
56
  // Scope the safety gate to pending migrations only — hazards in already-applied
29
57
  // migrations are history, not a decision the operator still gets to make.
@@ -62,6 +90,14 @@ export async function migrateOne(instance, dbConfig, options = {}) {
62
90
  options.onNotice?.(full);
63
91
  },
64
92
  });
93
+ // After a run that finished without error only; a failing migration throws
94
+ // above and its rerun resumes and checks then. Throws when an owned index
95
+ // cannot be made valid.
96
+ const repaired = await repairInvalidIndexes({
97
+ migrationsFolder: dbConfig.migrations,
98
+ migrationsTable: dbConfig.migrationTable,
99
+ connectionUrl: dbConfig.url,
100
+ });
65
101
  // Trace and audit storage rides the default database; their partitioned
66
102
  // tables are kept out of generated migrations (#313), so migrate provisions them.
67
103
  const frameworkTables = await provisionFrameworkTables(conn.drizzle, instance.config.raw, {
@@ -77,6 +113,8 @@ export async function migrateOne(instance, dbConfig, options = {}) {
77
113
  safetyCheck,
78
114
  resumed,
79
115
  droppedInvalidIndexes,
116
+ invalidIndexes: repaired.indexes,
117
+ invalidIndexReadError: repaired.readError,
80
118
  notices,
81
119
  frameworkTables,
82
120
  };
@@ -1 +1 @@
1
- {"version":3,"file":"run.js","sourceRoot":"","sources":["../../../src/data/migrate/run.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../../src/data/migrate/run.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pipework",
3
- "version": "0.293.0",
3
+ "version": "0.294.0",
4
4
  "description": "TypeScript framework for multi-tenant SaaS applications. PostgreSQL-only.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -121,10 +121,25 @@ function reportResumed(result: MigrateResult): void {
121
121
  }
122
122
  for (const d of result.droppedInvalidIndexes) {
123
123
  warn(
124
- `${result.database}: resumed "${d.tag}" at statement ${d.statement}; dropped INVALID index ${d.index} ` +
125
- `left by the cut build.`,
124
+ `${result.database}: "${d.tag}" statement ${d.statement}: dropped INVALID index ${d.index} ` +
125
+ `left by a cut build before building it again.`,
126
126
  )
127
127
  }
128
+ for (const i of result.invalidIndexes) {
129
+ if (i.case === 'a' && i.outcome === 'repaired') {
130
+ warn(`${result.database}: rebuilt INVALID index ${i.index} with the statement from "${i.tag}".`)
131
+ } else if (i.case === 'b' && i.outcome === 'repaired') {
132
+ warn(`${result.database}: dropped INVALID leftover ${i.index}; ${i.reason}.`)
133
+ } else {
134
+ warn(
135
+ `${result.database}: INVALID index ${i.index} on ${i.table} was left alone: ${i.reason}. ` +
136
+ `Its owner can run: ${i.printed.join(' ; ')}`,
137
+ )
138
+ }
139
+ }
140
+ if (result.invalidIndexReadError !== null) {
141
+ warn(`${result.database}: could not check for INVALID indexes: ${result.invalidIndexReadError}`)
142
+ }
128
143
  }
129
144
 
130
145
  function reportFrameworkTables(result: MigrateResult): void {
@@ -200,15 +200,14 @@ export async function applyMigrations(options: ApplyOptions): Promise<number> {
200
200
  if (from > 0) options.onResume?.(migration.tag, from + 1, migration.statements.length)
201
201
 
202
202
  for (let i = from; i < migration.statements.length; i++) {
203
- // Only the statement the cut interrupted (the first one run on a
204
- // resume), and only its own index: a cut CREATE INDEX CONCURRENTLY
205
- // leaves the name held by an INVALID index, which the plain form
206
- // fails on ("already exists") and IF NOT EXISTS silently keeps.
207
- // A valid index of that name is left alone. Issue #1003.
208
- if (i === from && from > 0) {
209
- const dropped = await dropInvalidIndexOf(client, migration.statements[i]!)
210
- if (dropped !== null) options.onDropInvalidIndex?.(migration.tag, i + 1, dropped)
211
- }
203
+ // Before every CREATE INDEX CONCURRENTLY, not only a resumed one: a
204
+ // cut build leaves the name held by an INVALID index, which the plain
205
+ // form fails on ("already exists") and IF NOT EXISTS silently keeps,
206
+ // and a cut in a file's first statement leaves no progress mark, so
207
+ // the rerun starts at 0. Only the statement's own index; a VALID
208
+ // index of that name is left alone. Issues #1003, #1005.
209
+ const dropped = await dropInvalidIndexOf(client, migration.statements[i]!)
210
+ if (dropped !== null) options.onDropInvalidIndex?.(migration.tag, i + 1, dropped)
212
211
  await inStatement(migration.tag, i + 1, migration.statements.length, () => client.unsafe(migration.statements[i]!))
213
212
  // Recorded AFTER the statement it describes: marking first would let
214
213
  // a death between the mark and the statement skip work silently,
@@ -222,6 +221,27 @@ export async function applyMigrations(options: ApplyOptions): Promise<number> {
222
221
  )
223
222
  options.onStatement?.(migration.tag, i + 1, migration.statements.length, applied + 1, migrations.length)
224
223
  }
224
+ // Never record a file applied while an index it built is INVALID.
225
+ // The progress mark goes back to that statement, so the rerun drops
226
+ // the index and builds it again. Issue #1005.
227
+ for (const [i, statement] of migration.statements.entries()) {
228
+ // Only builds the rerun can repair: `CREATE INDEX ... ON ONLY parent` is
229
+ // INVALID until every partition's index attaches, and no rerun fixes that.
230
+ const target = parseCreateIndexConcurrently(statement)
231
+ if (target === null) continue
232
+ if (await isInvalidIndex(client, target)) {
233
+ await client.unsafe(
234
+ `INSERT INTO ${progress} (tag, hash, statements_done) VALUES ($1, $2, $3)
235
+ ON CONFLICT (tag) DO UPDATE SET statements_done = EXCLUDED.statements_done, updated_at = now()`,
236
+ [migration.tag, migration.hash, i],
237
+ )
238
+ throw new Error(
239
+ `Migration "${migration.tag}" statement ${i + 1} built index ${target.index} on ${target.table}, ` +
240
+ `but the index is INVALID, so the migration is not recorded as applied. The next run drops it ` +
241
+ `and builds it again.`,
242
+ )
243
+ }
244
+ }
225
245
  await client.unsafe(
226
246
  `INSERT INTO ${table} ("hash", "created_at", "tag") VALUES ($1, $2, $3)`,
227
247
  [migration.hash, migration.folderMillis, migration.tag],
@@ -344,6 +364,10 @@ export function resumePoint(
344
364
  }
345
365
 
346
366
  const IDENT = String.raw`"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*`
367
+ const CREATE_INDEX_RE = new RegExp(
368
+ String.raw`^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})\s+ON\s+(?:ONLY\s+)?((?:${IDENT})(?:\s*\.\s*(?:${IDENT}))?)`,
369
+ 'i',
370
+ )
347
371
  const CREATE_INDEX_CONCURRENTLY_RE = new RegExp(
348
372
  String.raw`^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})\s+ON\s+(?:ONLY\s+)?((?:${IDENT})(?:\s*\.\s*(?:${IDENT}))?)`,
349
373
  'i',
@@ -364,6 +388,26 @@ export function parseCreateIndexConcurrently(statement: string): { index: string
364
388
  return { index: unquoteIdent(match[1]!), table: match[2]! }
365
389
  }
366
390
 
391
+ /**
392
+ * Like `parseCreateIndexConcurrently`, for a CREATE [UNIQUE] INDEX with or
393
+ * without CONCURRENTLY. Null for any other statement or an unnamed index.
394
+ */
395
+ export function parseCreateIndex(statement: string): { index: string; table: string } | null {
396
+ const match = CREATE_INDEX_RE.exec(stripComments(statement))
397
+ if (match === null) return null
398
+ return { index: unquoteIdent(match[1]!), table: match[2]! }
399
+ }
400
+
401
+ /** True when an index of this name on this table exists and is INVALID. */
402
+ async function isInvalidIndex(client: Sql, target: { index: string; table: string }): Promise<boolean> {
403
+ const found = await client.unsafe<{ one: number }[]>(
404
+ `SELECT 1 AS one FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
405
+ WHERE c.relname = $1 AND NOT i.indisvalid AND i.indrelid = to_regclass($2)`,
406
+ [target.index, target.table],
407
+ )
408
+ return found.length > 0
409
+ }
410
+
367
411
  /**
368
412
  * Drop the INVALID index a cut CREATE INDEX CONCURRENTLY left under the name
369
413
  * this statement is about to create, on the statement's own table. Returns the
@@ -1,4 +1,4 @@
1
- export { migrateAll, migrateOne, UnsafeMigrationError, type MigrateResult, type MigrateOptions } from './run.js'
1
+ export { migrateAll, migrateOne, UnsafeMigrationError, InvalidIndexRepairError, type InvalidIndex, type MigrateResult, type MigrateOptions } from './run.js'
2
2
  export { checkMigrations, formatCheckResult, type MigrationCheckResult, type FileHazards, type MigrationHazard, type Severity } from './check.js'
3
3
  export { hasPragma, PRAGMA_FOR, type PragmaKind } from './pragma.js'
4
4
  export { readAppliedState, type AppliedState } from './applied.js'
@@ -0,0 +1,296 @@
1
+ import postgres, { type Sql } from 'postgres'
2
+ import { stripComments } from './statements.js'
3
+ import { parseCreateIndex, readMigrations } from './apply.js'
4
+
5
+ /**
6
+ * What migrate does with an INVALID index met in a database it migrated.
7
+ * Issue #1005.
8
+ *
9
+ * - `a`: an index a migration owns. Dropped and rebuilt with the migration's
10
+ * own CREATE statement.
11
+ * - `b`: a `_ccnew` / `_ccold` leftover of an owned index whose counterpart is
12
+ * VALID. Dropped.
13
+ * - `c`: anything else. Nothing is touched; the statements to run are printed.
14
+ */
15
+ export interface InvalidIndex {
16
+ case: 'a' | 'b' | 'c'
17
+ /** Schema-qualified, quoted. */
18
+ index: string
19
+ /** Schema-qualified, quoted. */
20
+ table: string
21
+ /** What migrate did: rebuilt (a), dropped the leftover (b), or nothing (c). */
22
+ action: 'rebuilt' | 'dropped' | 'none'
23
+ outcome: 'repaired' | 'failed' | 'warned'
24
+ /** For (a): the tag of the migration whose CREATE was rerun. */
25
+ tag?: string
26
+ /** For (a): the statement rerun. */
27
+ statement?: string
28
+ /** For (c) and a failed (a): the statements its owner can run. */
29
+ printed: string[]
30
+ /** Why migrate left it alone, or what failed. */
31
+ reason?: string
32
+ }
33
+
34
+ export interface InvalidIndexReport {
35
+ indexes: InvalidIndex[]
36
+ /** Set when the catalog read failed; nothing was checked. */
37
+ readError: string | null
38
+ }
39
+
40
+ export class InvalidIndexRepairError extends Error {
41
+ override readonly name = 'InvalidIndexRepairError'
42
+ constructor(
43
+ public readonly indexes: InvalidIndex[],
44
+ message: string,
45
+ ) {
46
+ super(message)
47
+ }
48
+ }
49
+
50
+ export interface RepairOptions {
51
+ migrationsFolder: string
52
+ migrationsTable: string
53
+ connectionUrl: string
54
+ }
55
+
56
+ const LOCK_TIMEOUT = '30s'
57
+ const LEFTOVER_RE = /_cc(?:new|old)\d*$/
58
+ const IDENT = String.raw`"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*`
59
+ const DROP_INDEX_RE = new RegExp(
60
+ String.raw`^\s*DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?((?:(?:${IDENT})(?:\s*\.\s*(?:${IDENT}))?\s*,?\s*)+)`,
61
+ 'i',
62
+ )
63
+
64
+ interface Row {
65
+ oid: string
66
+ tableOid: string
67
+ schema: string
68
+ name: string
69
+ tableSchema: string
70
+ table: string
71
+ def: string
72
+ }
73
+
74
+ interface Owner {
75
+ tag: string
76
+ statement: string
77
+ }
78
+
79
+ function quoteIdent(name: string): string {
80
+ return `"${name.replace(/"/g, '""')}"`
81
+ }
82
+
83
+ function unquoteIdent(token: string): string {
84
+ return token.startsWith('"') ? token.slice(1, -1).replace(/""/g, '"') : token.toLowerCase()
85
+ }
86
+
87
+ /** The (schema?, name) pairs a DROP INDEX statement names; empty for any other statement. */
88
+ function parseDropIndex(statement: string): { schema: string | null; name: string }[] {
89
+ const match = DROP_INDEX_RE.exec(stripComments(statement))
90
+ if (match === null) return []
91
+ const tokens = match[1]!.match(new RegExp(`(?:${IDENT})(?:\\s*\\.\\s*(?:${IDENT}))?`, 'g')) ?? []
92
+ return tokens.map((token) => {
93
+ const parts = token.match(new RegExp(IDENT, 'g'))!
94
+ return parts.length === 2
95
+ ? { schema: unquoteIdent(parts[0]!), name: unquoteIdent(parts[1]!) }
96
+ : { schema: null, name: unquoteIdent(parts[0]!) }
97
+ })
98
+ }
99
+
100
+ /** An index definition with its own name removed, so a leftover compares equal to its counterpart. */
101
+ function nameless(def: string): string {
102
+ return def.replace(/^(CREATE (?:UNIQUE )?INDEX )(?:"(?:[^"]|"")+"|\S+)( ON )/, '$1$2')
103
+ }
104
+
105
+ const INVALID_INDEXES = `
106
+ SELECT i.indexrelid::text AS oid, i.indrelid::text AS "tableOid", n.nspname AS schema, c.relname AS name,
107
+ tn.nspname AS "tableSchema", t.relname AS "table", pg_get_indexdef(i.indexrelid) AS def
108
+ FROM pg_index i
109
+ JOIN pg_class c ON c.oid = i.indexrelid
110
+ JOIN pg_namespace n ON n.oid = c.relnamespace
111
+ JOIN pg_class t ON t.oid = i.indrelid
112
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
113
+ WHERE NOT i.indisvalid
114
+ AND c.relkind = 'i'
115
+ AND n.nspname <> 'information_schema' AND n.nspname NOT LIKE 'pg\\_%'
116
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d
117
+ WHERE d.classid = 'pg_class'::regclass AND d.objid = i.indexrelid AND d.deptype = 'e')
118
+ AND NOT EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.relid = i.indrelid)
119
+ ORDER BY n.nspname, c.relname`
120
+
121
+ async function building(client: Sql, tableOid: string): Promise<boolean> {
122
+ const rows = await client.unsafe(
123
+ `SELECT 1 FROM pg_stat_progress_create_index WHERE relid = $1::oid`,
124
+ [tableOid],
125
+ )
126
+ return rows.length > 0
127
+ }
128
+
129
+ /** The applied migrations' CREATE INDEX statements that still stand, keyed by table oid and index name. */
130
+ async function ownedIndexes(client: Sql, options: RepairOptions): Promise<Map<string, Owner>> {
131
+ const applied = new Set(
132
+ (await client.unsafe<{ tag: string }[]>(
133
+ `SELECT tag FROM ${quoteIdent(options.migrationsTable)} WHERE tag IS NOT NULL`,
134
+ )).map((r) => r.tag),
135
+ )
136
+ const owned = new Map<string, Owner & { schema: string }>()
137
+ for (const migration of readMigrations(options)) {
138
+ if (!applied.has(migration.tag)) continue
139
+ for (const statement of migration.statements) {
140
+ const create = parseCreateIndex(statement)
141
+ if (create !== null) {
142
+ const [table] = await client.unsafe<{ oid: string; schema: string }[]>(
143
+ `SELECT c.oid::text AS oid, n.nspname AS schema FROM pg_class c
144
+ JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = to_regclass($1)`,
145
+ [create.table],
146
+ )
147
+ if (table !== undefined) owned.set(`${table.oid}:${create.index}`, { tag: migration.tag, statement, schema: table.schema })
148
+ continue
149
+ }
150
+ for (const dropped of parseDropIndex(statement)) {
151
+ for (const [key, owner] of owned) {
152
+ if (key.endsWith(`:${dropped.name}`) && (dropped.schema === null || dropped.schema === owner.schema)) {
153
+ owned.delete(key)
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return owned
160
+ }
161
+
162
+ /**
163
+ * Repair the INVALID indexes the applied migrations made, and report the rest.
164
+ * Called once per database after `applyMigrations` finished without error,
165
+ * while the run's advisory lock is held. Throws `InvalidIndexRepairError` when
166
+ * an owned index cannot be made VALID. Issue #1005.
167
+ */
168
+ export async function repairInvalidIndexes(options: RepairOptions): Promise<InvalidIndexReport> {
169
+ const client = postgres(options.connectionUrl, { max: 1, onnotice: () => {} })
170
+ try {
171
+ let rows: Row[]
172
+ let owned: Map<string, Owner>
173
+ try {
174
+ rows = await client.unsafe<Row[]>(INVALID_INDEXES)
175
+ owned = rows.length === 0 ? new Map() : await ownedIndexes(client, options)
176
+ } catch (err) {
177
+ return { indexes: [], readError: err instanceof Error ? err.message : String(err) }
178
+ }
179
+
180
+ const qualified = (schema: string, name: string): string => `${quoteIdent(schema)}.${quoteIdent(name)}`
181
+ const results: InvalidIndex[] = []
182
+ const failures: string[] = []
183
+ const ownedKey = (r: Row): string => `${r.tableOid}:${r.name}`
184
+ const printed = (r: Row, rebuild: boolean): string[] => [
185
+ ...(rebuild ? [`REINDEX INDEX CONCURRENTLY ${qualified(r.schema, r.name)}`] : []),
186
+ `DROP INDEX CONCURRENTLY ${qualified(r.schema, r.name)}`,
187
+ ]
188
+
189
+ // (a) owned: drop and rerun the migration's own CREATE.
190
+ const leftovers: Row[] = []
191
+ for (const row of rows) {
192
+ const owner = owned.get(ownedKey(row))
193
+ if (owner === undefined) {
194
+ if (LEFTOVER_RE.test(row.name)) leftovers.push(row)
195
+ else {
196
+ results.push({
197
+ case: 'c', index: qualified(row.schema, row.name), table: qualified(row.tableSchema, row.table),
198
+ action: 'none', outcome: 'warned', printed: printed(row, true),
199
+ reason: 'no applied migration owns this index',
200
+ })
201
+ }
202
+ continue
203
+ }
204
+ // Another session may have started a build on the table since the read.
205
+ if (await building(client, row.tableOid)) continue
206
+ const entry: InvalidIndex = {
207
+ case: 'a', index: qualified(row.schema, row.name), table: qualified(row.tableSchema, row.table),
208
+ action: 'rebuilt', outcome: 'repaired', tag: owner.tag, statement: owner.statement, printed: [],
209
+ }
210
+ try {
211
+ await client.unsafe(`SET lock_timeout = '${LOCK_TIMEOUT}'`)
212
+ try {
213
+ await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${entry.index}`)
214
+ } finally {
215
+ await client.unsafe('RESET lock_timeout')
216
+ }
217
+ await client.unsafe(owner.statement)
218
+ } catch (err) {
219
+ const detail = err instanceof Error ? err.message : String(err)
220
+ const extra = (err as { detail?: unknown }).detail
221
+ entry.outcome = 'failed'
222
+ entry.reason = typeof extra === 'string' ? `${detail} (${extra})` : detail
223
+ entry.printed = printed(row, true)
224
+ failures.push(`${entry.index} (rerunning "${owner.tag}"): ${entry.reason}`)
225
+ }
226
+ results.push(entry)
227
+ }
228
+
229
+ // (b) / (c) leftovers, classified against the table's indexes as they stand after (a).
230
+ const byTable = new Map<string, { name: string; schema: string; valid: boolean; def: string }[]>()
231
+ for (const row of leftovers) {
232
+ if (!byTable.has(row.tableOid)) {
233
+ byTable.set(row.tableOid, await client.unsafe(
234
+ `SELECT c.relname AS name, n.nspname AS schema, i.indisvalid AS valid, pg_get_indexdef(i.indexrelid) AS def
235
+ FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
236
+ JOIN pg_namespace n ON n.oid = c.relnamespace WHERE i.indrelid = $1::oid`,
237
+ [row.tableOid],
238
+ ))
239
+ }
240
+ }
241
+ for (const row of leftovers) {
242
+ const own = nameless(row.def)
243
+ const matches = byTable.get(row.tableOid)!.filter((o) => o.name !== row.name && nameless(o.def) === own)
244
+ const counterpart = matches.length === 1 ? matches[0]! : undefined
245
+ const base = { index: qualified(row.schema, row.name), table: qualified(row.tableSchema, row.table) }
246
+ if (counterpart === undefined) {
247
+ results.push({
248
+ case: 'c', ...base, action: 'none', outcome: 'warned', printed: printed(row, true),
249
+ reason: matches.length === 0
250
+ ? 'a leftover of an index build or rebuild, and no index on the table matches it'
251
+ : 'a leftover of an index build or rebuild, and more than one index on the table matches it',
252
+ })
253
+ continue
254
+ }
255
+ const counterpartOwned = owned.has(`${row.tableOid}:${counterpart.name}`)
256
+ if (!counterpartOwned || !counterpart.valid) {
257
+ results.push({
258
+ case: 'c', ...base, action: 'none', outcome: 'warned', printed: printed(row, !counterpart.valid),
259
+ reason: counterpartOwned
260
+ ? `a leftover of ${qualified(counterpart.schema, counterpart.name)}, which is not valid`
261
+ : `a leftover of ${qualified(counterpart.schema, counterpart.name)}, which no applied migration owns`,
262
+ })
263
+ continue
264
+ }
265
+ if (await building(client, row.tableOid)) continue
266
+ const entry: InvalidIndex = { case: 'b', ...base, action: 'dropped', outcome: 'repaired', printed: [],
267
+ reason: `a leftover of ${qualified(counterpart.schema, counterpart.name)}, which stays in use` }
268
+ try {
269
+ await client.unsafe(`SET lock_timeout = '${LOCK_TIMEOUT}'`)
270
+ try {
271
+ await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${base.index}`)
272
+ } finally {
273
+ await client.unsafe('RESET lock_timeout')
274
+ }
275
+ } catch (err) {
276
+ entry.outcome = 'failed'
277
+ entry.action = 'none'
278
+ entry.reason = err instanceof Error ? err.message : String(err)
279
+ entry.printed = printed(row, false)
280
+ }
281
+ results.push(entry)
282
+ }
283
+
284
+ if (failures.length > 0) {
285
+ throw new InvalidIndexRepairError(
286
+ results,
287
+ `migrate could not make ${failures.length} INVALID ${failures.length === 1 ? 'index' : 'indexes'} ` +
288
+ `its migrations built valid:\n ${failures.join('\n ')}\n` +
289
+ `The next run drops each and builds it again; fix the cause first (for a UNIQUE index, the duplicate rows).`,
290
+ )
291
+ }
292
+ return { indexes: results, readError: null }
293
+ } finally {
294
+ await client.end()
295
+ }
296
+ }