pipework 0.292.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.
- package/CHANGELOG.md +8 -0
- package/REFERENCE.md +9 -0
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +17 -2
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/core/errors/http.d.ts +13 -3
- package/dist/core/errors/http.d.ts.map +1 -1
- package/dist/core/errors/http.js +13 -0
- package/dist/core/errors/http.js.map +1 -1
- package/dist/core/errors/index.d.ts +1 -1
- package/dist/core/errors/index.d.ts.map +1 -1
- package/dist/core/errors/index.js +1 -1
- package/dist/core/errors/index.js.map +1 -1
- package/dist/core/errors/namespace.d.ts +3 -1
- package/dist/core/errors/namespace.d.ts.map +1 -1
- package/dist/core/errors/namespace.js +3 -1
- package/dist/core/errors/namespace.js.map +1 -1
- package/dist/data/migrate/apply.d.ts +8 -0
- package/dist/data/migrate/apply.d.ts.map +1 -1
- package/dist/data/migrate/apply.js +43 -10
- package/dist/data/migrate/apply.js.map +1 -1
- package/dist/data/migrate/index.d.ts +1 -1
- package/dist/data/migrate/index.d.ts.map +1 -1
- package/dist/data/migrate/index.js +1 -1
- package/dist/data/migrate/index.js.map +1 -1
- package/dist/data/migrate/invalid-indexes.d.ts +51 -0
- package/dist/data/migrate/invalid-indexes.d.ts.map +1 -0
- package/dist/data/migrate/invalid-indexes.js +219 -0
- package/dist/data/migrate/invalid-indexes.js.map +1 -0
- package/dist/data/migrate/run.d.ts +23 -1
- package/dist/data/migrate/run.d.ts.map +1 -1
- package/dist/data/migrate/run.js +38 -0
- package/dist/data/migrate/run.js.map +1 -1
- package/dist/data/schema/parse.d.ts +2 -0
- package/dist/data/schema/parse.d.ts.map +1 -1
- package/dist/data/schema/parse.js +35 -2
- package/dist/data/schema/parse.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/request/di/resolve.js +3 -3
- package/dist/request/di/resolve.js.map +1 -1
- package/dist/request/http/server.d.ts.map +1 -1
- package/dist/request/http/server.js +11 -1
- package/dist/request/http/server.js.map +1 -1
- package/dist/request/http/types.d.ts +5 -0
- package/dist/request/http/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/migrate.ts +17 -2
- package/src/core/errors/http.ts +18 -3
- package/src/core/errors/index.ts +1 -1
- package/src/core/errors/namespace.ts +3 -1
- package/src/data/migrate/apply.ts +53 -9
- package/src/data/migrate/index.ts +1 -1
- package/src/data/migrate/invalid-indexes.ts +296 -0
- package/src/data/migrate/run.ts +51 -1
- package/src/data/schema/parse.ts +37 -2
- package/src/index.ts +1 -1
- package/src/request/di/resolve.ts +3 -3
- package/src/request/http/server.ts +11 -1
- package/src/request/http/types.ts +1 -1
|
@@ -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
|
+
}
|
package/src/data/migrate/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import postgres from 'postgres'
|
|
1
2
|
import { sql } from '../query/index.js'
|
|
2
3
|
import type { Manifold } from '../../core/pipework.js'
|
|
3
4
|
import type { ResolvedDatabase } from '../../core/config/index.js'
|
|
@@ -6,6 +7,7 @@ import { OWNER_LABEL_MIGRATE } from '../db/owner-labels.js'
|
|
|
6
7
|
import { checkMigrations, type MigrationCheckResult } from './check.js'
|
|
7
8
|
import { readAppliedState } from './applied.js'
|
|
8
9
|
import { applyMigrations, type MigrationNotice } from './apply.js'
|
|
10
|
+
import { repairInvalidIndexes, InvalidIndexRepairError, type InvalidIndex } from './invalid-indexes.js'
|
|
9
11
|
import { provisionFrameworkTables, type FrameworkTablesResult } from './provision-framework.js'
|
|
10
12
|
|
|
11
13
|
// Deliberately empty of hazard switches. Authorization for a hazardous
|
|
@@ -24,8 +26,16 @@ export interface MigrateResult {
|
|
|
24
26
|
safetyCheck: MigrationCheckResult
|
|
25
27
|
/** No-transaction files picked up half applied and finished. Issue #378. */
|
|
26
28
|
resumed: ResumedMigration[]
|
|
27
|
-
/** INVALID indexes a cut CREATE INDEX CONCURRENTLY left, dropped before the
|
|
29
|
+
/** INVALID indexes a cut CREATE INDEX CONCURRENTLY left, dropped before the statement that builds them reran. Issues #1003, #1005. */
|
|
28
30
|
droppedInvalidIndexes: DroppedInvalidIndex[]
|
|
31
|
+
/**
|
|
32
|
+
* Every INVALID index this run met, with its case, what was done or must be
|
|
33
|
+
* done, and the outcome: (a) an owned index rebuilt, (b) a leftover of an owned
|
|
34
|
+
* index dropped, (c) anything else, untouched. Issue #1005.
|
|
35
|
+
*/
|
|
36
|
+
invalidIndexes: InvalidIndex[]
|
|
37
|
+
/** Set when the catalog read for INVALID indexes failed, so none were checked. Issue #1005. */
|
|
38
|
+
invalidIndexReadError: string | null
|
|
29
39
|
/** Notices and warnings raised by migration statements, in arrival order. Issue #970. */
|
|
30
40
|
notices: MigrateNotice[]
|
|
31
41
|
/** Trace/audit tables, columns and day partitions this run provisioned. */
|
|
@@ -54,6 +64,8 @@ export interface MigrateNotice extends MigrationNotice {
|
|
|
54
64
|
statements: number
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
export { InvalidIndexRepairError, type InvalidIndex }
|
|
68
|
+
|
|
57
69
|
export class UnsafeMigrationError extends Error {
|
|
58
70
|
override readonly name = 'UnsafeMigrationError'
|
|
59
71
|
constructor(
|
|
@@ -76,7 +88,34 @@ export async function migrateAll(instance: Manifold, options: MigrateOptions = {
|
|
|
76
88
|
return results
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
/** One key per database: advisory locks are scoped to the database they are taken in. */
|
|
92
|
+
const MIGRATE_LOCK_KEY = `hashtextextended('pipework.migrate', 0)`
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The connection that holds the run lock. It must live as long as the run: the
|
|
96
|
+
* default `max_lifetime` would close it mid-run and release the lock, so both
|
|
97
|
+
* timeouts are off, as in src/test/setup.ts.
|
|
98
|
+
*/
|
|
99
|
+
export function openRunLock(url: string): ReturnType<typeof postgres> {
|
|
100
|
+
return postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 0, onnotice: () => {} })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Runs one database's migrations and index repairs under a session advisory
|
|
105
|
+
* lock held for the whole run. A second run at once waits for it, then finds
|
|
106
|
+
* nothing to apply. The lock dies with its connection. Issue #1005.
|
|
107
|
+
*/
|
|
79
108
|
export async function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase, options: MigrateOptions = {}): Promise<MigrateResult> {
|
|
109
|
+
const lock = openRunLock(dbConfig.url)
|
|
110
|
+
try {
|
|
111
|
+
await lock.unsafe(`SELECT pg_advisory_lock(${MIGRATE_LOCK_KEY})`)
|
|
112
|
+
return await migrateLocked(instance, dbConfig, options)
|
|
113
|
+
} finally {
|
|
114
|
+
await lock.end()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function migrateLocked(instance: Manifold, dbConfig: ResolvedDatabase, options: MigrateOptions): Promise<MigrateResult> {
|
|
80
119
|
const conn = instance.pool.getOrCreate(dbConfig, 'owner', OWNER_LABEL_MIGRATE)
|
|
81
120
|
|
|
82
121
|
// Scope the safety gate to pending migrations only — hazards in already-applied
|
|
@@ -124,6 +163,15 @@ export async function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase,
|
|
|
124
163
|
},
|
|
125
164
|
})
|
|
126
165
|
|
|
166
|
+
// After a run that finished without error only; a failing migration throws
|
|
167
|
+
// above and its rerun resumes and checks then. Throws when an owned index
|
|
168
|
+
// cannot be made valid.
|
|
169
|
+
const repaired = await repairInvalidIndexes({
|
|
170
|
+
migrationsFolder: dbConfig.migrations!,
|
|
171
|
+
migrationsTable: dbConfig.migrationTable,
|
|
172
|
+
connectionUrl: dbConfig.url,
|
|
173
|
+
})
|
|
174
|
+
|
|
127
175
|
// Trace and audit storage rides the default database; their partitioned
|
|
128
176
|
// tables are kept out of generated migrations (#313), so migrate provisions them.
|
|
129
177
|
const frameworkTables = await provisionFrameworkTables(
|
|
@@ -146,6 +194,8 @@ export async function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase,
|
|
|
146
194
|
safetyCheck,
|
|
147
195
|
resumed,
|
|
148
196
|
droppedInvalidIndexes,
|
|
197
|
+
invalidIndexes: repaired.indexes,
|
|
198
|
+
invalidIndexReadError: repaired.readError,
|
|
149
199
|
notices,
|
|
150
200
|
frameworkTables,
|
|
151
201
|
}
|
package/src/data/schema/parse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { z } from 'zod/v4'
|
|
2
|
-
import { ValidationError, type ValidationSource } from '../../core/errors/index.js'
|
|
2
|
+
import { ValidationError, OutputValidationError, type ValidationSource } from '../../core/errors/index.js'
|
|
3
3
|
|
|
4
4
|
export function parse<T extends z.ZodType>(schema: T, data: unknown, source?: ValidationSource): z.infer<T> {
|
|
5
5
|
const result = schema.safeParse(data)
|
|
@@ -16,7 +16,42 @@ export function parse<T extends z.ZodType>(schema: T, data: unknown, source?: Va
|
|
|
16
16
|
|
|
17
17
|
throw source === undefined
|
|
18
18
|
? new ValidationError(fieldErrors)
|
|
19
|
-
: new ValidationError(fieldErrors, undefined, { source, paths: result.error.issues
|
|
19
|
+
: new ValidationError(fieldErrors, undefined, { source, paths: issuesOf(result.error.issues, false) })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parses a handler's reply against its declared output schema; a failure is the server's (500), carrying paths and messages, never the value. */
|
|
23
|
+
export function parseOutput<T extends z.ZodType>(schema: T, data: unknown): z.infer<T> {
|
|
24
|
+
const result = schema.safeParse(data)
|
|
25
|
+
if (result.success) return result.data
|
|
26
|
+
throw new OutputValidationError(issuesOf(result.error.issues, true).map(i => ({ source: null, ...i })))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type FlatIssue = { path: string[]; message: string }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one mapping from zod issues to `{ path, message }` (never the value). With `flattenUnions`, a union
|
|
33
|
+
* failure (one issue at the union's own path) is replaced by its branches' issues (nested `errors`), each with
|
|
34
|
+
* the union's path prefixed, and exact duplicates (same path and message) are dropped, first seen kept.
|
|
35
|
+
*/
|
|
36
|
+
function issuesOf(issues: ReadonlyArray<z.core.$ZodIssue>, flattenUnions: boolean, prefix: readonly string[] = []): FlatIssue[] {
|
|
37
|
+
if (!flattenUnions) return issues.map(i => ({ path: i.path.map(String), message: i.message }))
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
return flattenBranches(issues, prefix).filter(i => {
|
|
40
|
+
const key = `${i.path.join('.')}\u0000${i.message}`
|
|
41
|
+
if (seen.has(key)) return false
|
|
42
|
+
seen.add(key)
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function flattenBranches(issues: ReadonlyArray<z.core.$ZodIssue>, prefix: readonly string[]): FlatIssue[] {
|
|
48
|
+
return issues.flatMap(i => {
|
|
49
|
+
const path = [...prefix, ...i.path.map(String)]
|
|
50
|
+
if (i.code === 'invalid_union' && i.errors.some(branch => branch.length > 0)) {
|
|
51
|
+
return i.errors.flatMap(branch => flattenBranches(branch, path))
|
|
52
|
+
}
|
|
53
|
+
return [{ path, message: i.message }]
|
|
54
|
+
})
|
|
20
55
|
}
|
|
21
56
|
|
|
22
57
|
export function tryParse<T extends z.ZodType>(
|
package/src/index.ts
CHANGED
|
@@ -213,7 +213,7 @@ export type { Surface, SurfaceKind, SurfaceHandle, SurfaceManifest, SurfaceConte
|
|
|
213
213
|
|
|
214
214
|
export { errors } from './core/errors/namespace.js'
|
|
215
215
|
export { PipeworkError, isPipeworkError, NotFoundError, UnauthorizedError, ForbiddenError, CrossSiteRequestError,
|
|
216
|
-
ValidationError, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './core/errors/index.js'
|
|
216
|
+
ValidationError, OutputValidationError, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './core/errors/index.js'
|
|
217
217
|
|
|
218
218
|
export { config } from './core/config/namespace.js'
|
|
219
219
|
export type { ResolvedConfig, ResolvedDatabase, ResolvedObservability, Blueprint, DatabaseConfig,
|
|
@@ -10,7 +10,7 @@ import type { HttpRequest, HttpResponse } from '../http/types.js'
|
|
|
10
10
|
import { requireContext } from '../context/store.js'
|
|
11
11
|
import { verifyTransactionBinding } from '../context/guard.js'
|
|
12
12
|
import { UnauthorizedError, ValidationError } from '../../core/errors/index.js'
|
|
13
|
-
import { parse } from '../../data/schema/parse.js'
|
|
13
|
+
import { parse, parseOutput } from '../../data/schema/parse.js'
|
|
14
14
|
import { unboundDb } from '../../data/db/unbound.js'
|
|
15
15
|
import { createScopedDb, createTenantGuardDb } from '../../auth/tenant/scoped-db.js'
|
|
16
16
|
|
|
@@ -203,13 +203,13 @@ export async function resolveAndExecute<TDeps extends Record<string, unknown>, T
|
|
|
203
203
|
const response = result as { status: number; body?: unknown }
|
|
204
204
|
const bodySchema = handler.meta.outputSchemas.get(response.status)
|
|
205
205
|
if (bodySchema) {
|
|
206
|
-
return { status: response.status, body:
|
|
206
|
+
return { status: response.status, body: parseOutput(bodySchema as Schema, response.body) } as TReturn
|
|
207
207
|
}
|
|
208
208
|
return result
|
|
209
209
|
}
|
|
210
210
|
const defaultSchema = handler.meta.outputSchemas.get(200)
|
|
211
211
|
if (defaultSchema) {
|
|
212
|
-
return
|
|
212
|
+
return parseOutput(defaultSchema as Schema, result) as TReturn
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
@@ -20,7 +20,7 @@ import { recordErrorStep } from './error-step.js'
|
|
|
20
20
|
import type { Flow } from '../context/index.js'
|
|
21
21
|
import type { ErrorResponse } from './types.js'
|
|
22
22
|
import { buildErrorResponse, buildInternalErrorResponse, buildClientErrorResponse, sendError } from './response.js'
|
|
23
|
-
import { isPipeworkError } from '../../core/errors/index.js'
|
|
23
|
+
import { isPipeworkError, OutputValidationError } from '../../core/errors/index.js'
|
|
24
24
|
import { checkUploadHeaders, uploadBodyLimit, startUpload, settleUpload, isUploadRefusal, newUploadTrack, type UploadTrack, MULTIPART_FIELD_LIMIT } from './upload.js'
|
|
25
25
|
import type { BinaryUpload, MultipartUpload, UploadConfig, UploadRefusal, RouteRefusal, RouteRefusedHook, HandlerMeta } from '../di/types.js'
|
|
26
26
|
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
@@ -596,6 +596,16 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
|
|
|
596
596
|
return send(500, { error: { code: 'FILE_RESPONSE_INVALID', message: error.message } })
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
if (error instanceof OutputValidationError) {
|
|
600
|
+
await failWith(500, 'OUTPUT_VALIDATION_ERROR')
|
|
601
|
+
const issues = error.issues.map(i => ({ path: i.path.join('.'), message: i.message }))
|
|
602
|
+
req.log.error({ route: `${method} ${path}`, requestId: String(req.id), issues }, 'Handler reply failed its output schema')
|
|
603
|
+
await reportInternalError(req, error, handler)
|
|
604
|
+
const body = buildErrorResponse(error)
|
|
605
|
+
if (config.exposeErrorDetails === true) body.error.details = { issues }
|
|
606
|
+
return send(500, body)
|
|
607
|
+
}
|
|
608
|
+
|
|
599
609
|
if (isPipeworkError(error)) {
|
|
600
610
|
const response = buildErrorResponse(error)
|
|
601
611
|
const isClient = error.httpStatus >= 400 && error.httpStatus < 500
|
|
@@ -248,6 +248,6 @@ export interface ErrorResponse {
|
|
|
248
248
|
fields?: Record<string, readonly string[]>
|
|
249
249
|
/** On a 429: whole seconds to wait; the same number as the Retry-After header. */
|
|
250
250
|
retryAfterSeconds?: number
|
|
251
|
-
details?: { name: string; message: string }
|
|
251
|
+
details?: { name: string; message: string } | { issues: Array<{ path: string; message: string }> }
|
|
252
252
|
}
|
|
253
253
|
}
|