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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/REFERENCE.md +9 -0
  3. package/dist/cli/commands/migrate.d.ts.map +1 -1
  4. package/dist/cli/commands/migrate.js +17 -2
  5. package/dist/cli/commands/migrate.js.map +1 -1
  6. package/dist/core/errors/http.d.ts +13 -3
  7. package/dist/core/errors/http.d.ts.map +1 -1
  8. package/dist/core/errors/http.js +13 -0
  9. package/dist/core/errors/http.js.map +1 -1
  10. package/dist/core/errors/index.d.ts +1 -1
  11. package/dist/core/errors/index.d.ts.map +1 -1
  12. package/dist/core/errors/index.js +1 -1
  13. package/dist/core/errors/index.js.map +1 -1
  14. package/dist/core/errors/namespace.d.ts +3 -1
  15. package/dist/core/errors/namespace.d.ts.map +1 -1
  16. package/dist/core/errors/namespace.js +3 -1
  17. package/dist/core/errors/namespace.js.map +1 -1
  18. package/dist/data/migrate/apply.d.ts +8 -0
  19. package/dist/data/migrate/apply.d.ts.map +1 -1
  20. package/dist/data/migrate/apply.js +43 -10
  21. package/dist/data/migrate/apply.js.map +1 -1
  22. package/dist/data/migrate/index.d.ts +1 -1
  23. package/dist/data/migrate/index.d.ts.map +1 -1
  24. package/dist/data/migrate/index.js +1 -1
  25. package/dist/data/migrate/index.js.map +1 -1
  26. package/dist/data/migrate/invalid-indexes.d.ts +51 -0
  27. package/dist/data/migrate/invalid-indexes.d.ts.map +1 -0
  28. package/dist/data/migrate/invalid-indexes.js +219 -0
  29. package/dist/data/migrate/invalid-indexes.js.map +1 -0
  30. package/dist/data/migrate/run.d.ts +23 -1
  31. package/dist/data/migrate/run.d.ts.map +1 -1
  32. package/dist/data/migrate/run.js +38 -0
  33. package/dist/data/migrate/run.js.map +1 -1
  34. package/dist/data/schema/parse.d.ts +2 -0
  35. package/dist/data/schema/parse.d.ts.map +1 -1
  36. package/dist/data/schema/parse.js +35 -2
  37. package/dist/data/schema/parse.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/request/di/resolve.js +3 -3
  43. package/dist/request/di/resolve.js.map +1 -1
  44. package/dist/request/http/server.d.ts.map +1 -1
  45. package/dist/request/http/server.js +11 -1
  46. package/dist/request/http/server.js.map +1 -1
  47. package/dist/request/http/types.d.ts +5 -0
  48. package/dist/request/http/types.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/cli/commands/migrate.ts +17 -2
  51. package/src/core/errors/http.ts +18 -3
  52. package/src/core/errors/index.ts +1 -1
  53. package/src/core/errors/namespace.ts +3 -1
  54. package/src/data/migrate/apply.ts +53 -9
  55. package/src/data/migrate/index.ts +1 -1
  56. package/src/data/migrate/invalid-indexes.ts +296 -0
  57. package/src/data/migrate/run.ts +51 -1
  58. package/src/data/schema/parse.ts +37 -2
  59. package/src/index.ts +1 -1
  60. package/src/request/di/resolve.ts +3 -3
  61. package/src/request/http/server.ts +11 -1
  62. 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
+ }
@@ -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 resumed statement reran. Issue #1003. */
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
  }
@@ -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.map(i => ({ path: i.path.map(String), message: i.message })) })
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: parse(bodySchema as Schema, response.body, 'response') } as TReturn
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 parse(defaultSchema as Schema, result, 'response') as TReturn
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
  }