pipework 0.291.0 → 0.293.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 (89) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/REFERENCE.md +31 -1
  3. package/dist/auth/tenant/scoped-db.d.ts +9 -0
  4. package/dist/auth/tenant/scoped-db.d.ts.map +1 -1
  5. package/dist/auth/tenant/scoped-db.js +14 -0
  6. package/dist/auth/tenant/scoped-db.js.map +1 -1
  7. package/dist/core/errors/http.d.ts +13 -3
  8. package/dist/core/errors/http.d.ts.map +1 -1
  9. package/dist/core/errors/http.js +13 -0
  10. package/dist/core/errors/http.js.map +1 -1
  11. package/dist/core/errors/index.d.ts +1 -1
  12. package/dist/core/errors/index.d.ts.map +1 -1
  13. package/dist/core/errors/index.js +1 -1
  14. package/dist/core/errors/index.js.map +1 -1
  15. package/dist/core/errors/namespace.d.ts +3 -1
  16. package/dist/core/errors/namespace.d.ts.map +1 -1
  17. package/dist/core/errors/namespace.js +3 -1
  18. package/dist/core/errors/namespace.js.map +1 -1
  19. package/dist/data/db/namespace.d.ts +4 -2
  20. package/dist/data/db/namespace.d.ts.map +1 -1
  21. package/dist/data/db/namespace.js +4 -2
  22. package/dist/data/db/namespace.js.map +1 -1
  23. package/dist/data/db/rows.d.ts +1 -0
  24. package/dist/data/db/rows.d.ts.map +1 -1
  25. package/dist/data/db/rows.js +1 -1
  26. package/dist/data/db/rows.js.map +1 -1
  27. package/dist/data/schema/parse.d.ts +2 -0
  28. package/dist/data/schema/parse.d.ts.map +1 -1
  29. package/dist/data/schema/parse.js +35 -2
  30. package/dist/data/schema/parse.js.map +1 -1
  31. package/dist/data/write/audit/verb-insert.d.ts.map +1 -1
  32. package/dist/data/write/audit/verb-insert.js +4 -1
  33. package/dist/data/write/audit/verb-insert.js.map +1 -1
  34. package/dist/data/write/audit/verb-update.d.ts.map +1 -1
  35. package/dist/data/write/audit/verb-update.js +14 -3
  36. package/dist/data/write/audit/verb-update.js.map +1 -1
  37. package/dist/data/write/batch-insert.d.ts +35 -0
  38. package/dist/data/write/batch-insert.d.ts.map +1 -0
  39. package/dist/data/write/batch-insert.js +171 -0
  40. package/dist/data/write/batch-insert.js.map +1 -0
  41. package/dist/data/write/execute.d.ts +1 -1
  42. package/dist/data/write/execute.d.ts.map +1 -1
  43. package/dist/data/write/execute.js +89 -2
  44. package/dist/data/write/execute.js.map +1 -1
  45. package/dist/data/write/index.d.ts +2 -0
  46. package/dist/data/write/index.d.ts.map +1 -1
  47. package/dist/data/write/index.js +1 -0
  48. package/dist/data/write/index.js.map +1 -1
  49. package/dist/data/write/insert.d.ts +8 -0
  50. package/dist/data/write/insert.d.ts.map +1 -1
  51. package/dist/data/write/insert.js +14 -0
  52. package/dist/data/write/insert.js.map +1 -1
  53. package/dist/data/write/state.d.ts +10 -0
  54. package/dist/data/write/state.d.ts.map +1 -1
  55. package/dist/data/write/update-each.d.ts +28 -0
  56. package/dist/data/write/update-each.d.ts.map +1 -0
  57. package/dist/data/write/update-each.js +104 -0
  58. package/dist/data/write/update-each.js.map +1 -0
  59. package/dist/index.d.ts +1 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/request/di/resolve.js +3 -3
  64. package/dist/request/di/resolve.js.map +1 -1
  65. package/dist/request/http/server.d.ts.map +1 -1
  66. package/dist/request/http/server.js +11 -1
  67. package/dist/request/http/server.js.map +1 -1
  68. package/dist/request/http/types.d.ts +5 -0
  69. package/dist/request/http/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/auth/tenant/scoped-db.ts +13 -0
  72. package/src/core/errors/http.ts +18 -3
  73. package/src/core/errors/index.ts +1 -1
  74. package/src/core/errors/namespace.ts +3 -1
  75. package/src/data/db/namespace.ts +4 -2
  76. package/src/data/db/rows.ts +1 -1
  77. package/src/data/schema/parse.ts +37 -2
  78. package/src/data/write/audit/verb-insert.ts +4 -1
  79. package/src/data/write/audit/verb-update.ts +13 -3
  80. package/src/data/write/batch-insert.ts +179 -0
  81. package/src/data/write/execute.ts +77 -2
  82. package/src/data/write/index.ts +2 -0
  83. package/src/data/write/insert.ts +15 -0
  84. package/src/data/write/state.ts +6 -0
  85. package/src/data/write/update-each.ts +120 -0
  86. package/src/index.ts +1 -1
  87. package/src/request/di/resolve.ts +3 -3
  88. package/src/request/http/server.ts +11 -1
  89. package/src/request/http/types.ts +1 -1
@@ -0,0 +1,120 @@
1
+ // `pipe.updateEach(table, { keys, data, where, set })`: one UPDATE for many keys, a count per key.
2
+ //
3
+ // UPDATE "t" SET ... FROM (select * from unnest($1::uuid[], $2::int[], $3::int[]) as "rows"("id", "ordinal", "_i")) "rows"
4
+ // WHERE <where> RETURNING "rows"."_i"
5
+ //
6
+ // The keys travel as `pipe.rows` columns (one parameter per column), each with its input index `_i`. The
7
+ // rows the update returns are counted per `_i`, which is what tells a key that matched nothing (it never
8
+ // appears in the result) from one that matched once. A key that did not match exactly once rolls the update
9
+ // back (a savepoint inside the caller's transaction) and throws naming the key.
10
+
11
+ import type { SQL } from '../db/types.js'
12
+ import { pipe } from '../db/db.js'
13
+ import { rows } from '../db/rows.js'
14
+ import type { RowsColumns, RowsData, RowsSource } from '../db/rows.js'
15
+ import type { PgTable } from '../query/pg-core/index.js'
16
+ import { assertNotExternal } from '../domain/external-registry.js'
17
+ import type { DefinedTable } from '../domain/types.js'
18
+ import type { UpdateSetValues } from './values.js'
19
+ import { executeWrite } from './execute.js'
20
+ import { batchLimits } from './batch-insert.js'
21
+
22
+ const ORDINAL = '_i'
23
+
24
+ export interface UpdateEachOptions<T extends PgTable, TKeys extends RowsColumns> {
25
+ /** The key rowset's columns, each with its SQL type: `{ id: 'uuid', ordinal: 'int' }`. Read in `where` and `set` as `k.id`. */
26
+ readonly keys: TKeys
27
+ /** One array per column, all the same length; element i of each is input key i. */
28
+ readonly data: RowsData<TKeys>
29
+ /** Which columns identify an input key: they must be distinct across inputs, and name the key in an error. Default: all of `keys`. */
30
+ readonly key?: ReadonlyArray<keyof TKeys & string>
31
+ /** The match for one key, as for `pipe.update().where()`: `and(eq(t.id, k.id), isNull(t.closedAt))`. */
32
+ readonly where: (k: RowsSource<'rows', TKeys>) => SQL | undefined
33
+ /** What to set; a key column is a valid value: `{ closedAt: k.ordinal }`. */
34
+ readonly set: (k: RowsSource<'rows', TKeys>) => UpdateSetValues<T>
35
+ /** `'one'` (default): every key must update exactly one row or the update is refused. `'any'`: no refusal, read the counts. */
36
+ readonly expect?: 'one' | 'any'
37
+ }
38
+
39
+ function describeKey(columns: readonly string[], data: Record<string, readonly unknown[]>, i: number): string {
40
+ const part = (c: string): string => {
41
+ const v = data[c]![i]
42
+ return `${c}=${v instanceof Date ? v.toISOString() : JSON.stringify(v, (_k, x: unknown) => (typeof x === 'bigint' ? x.toString() : x))}`
43
+ }
44
+ return `{ ${columns.map(part).join(', ')} }`
45
+ }
46
+
47
+ /**
48
+ * Updates the rows each input key matches in one statement and returns how many rows each key updated, in
49
+ * input order. By default a key that updated zero rows or more than one makes the call throw, naming the key,
50
+ * with the whole update undone; so does a key repeated in `data`. Runs in the caller's transaction and is audited
51
+ * as `pipe.update()` is (one audit row per row changed). An empty `data` returns `[]` without a statement.
52
+ */
53
+ export async function updateEach<T extends DefinedTable<any> & PgTable, const TKeys extends RowsColumns>( // eslint-disable-line @typescript-eslint/no-explicit-any
54
+ table: T,
55
+ options: UpdateEachOptions<T, TKeys>,
56
+ ): Promise<number[]> {
57
+ assertNotExternal(table, 'update')
58
+ const names = Object.keys(options.keys)
59
+ if (names.includes(ORDINAL)) throw new Error(`[pipework] pipe.updateEach: "${ORDINAL}" is reserved for the input index; rename that key column.`)
60
+ const data = options.data as Record<string, readonly unknown[]>
61
+ const count = names.length === 0 ? 0 : (data[names[0]!]?.length ?? 0)
62
+ const identity: readonly string[] = options.key ?? names
63
+ for (const c of identity) {
64
+ if (!names.includes(c)) throw new Error(`[pipework] pipe.updateEach: key column "${c}" is not one of the key rowset's columns (${names.join(', ')}).`)
65
+ }
66
+ const seen = new Map<string, number>()
67
+ for (let i = 0; i < count; i++) {
68
+ const id = describeKey(identity, data, i)
69
+ const first = seen.get(id)
70
+ if (first !== undefined) {
71
+ throw new Error(`[pipework] pipe.updateEach: key ${id} appears at inputs ${String(first)} and ${String(i)}. Pass each key once; a repeated key would update one row twice.`)
72
+ }
73
+ seen.set(id, i)
74
+ }
75
+ if (count === 0) return []
76
+
77
+ // Over `batchLimits` the keys go as consecutive slices, in order, in the one transaction below: a refusal
78
+ // anywhere undoes every slice.
79
+ const slices: [number, number][] = []
80
+ let from = 0
81
+ let bytes = 0
82
+ for (let i = 0; i < count; i++) {
83
+ let rowBytes = 0
84
+ for (const c of names) { const v = data[c]![i]; rowBytes += typeof v === 'string' ? Buffer.byteLength(v) : 8 }
85
+ if (i > from && (i - from >= batchLimits.rows || bytes + rowBytes > batchLimits.bytes)) { slices.push([from, i]); from = i; bytes = 0 }
86
+ bytes += rowBytes
87
+ }
88
+ slices.push([from, count])
89
+
90
+ const dbName = table.$options.database
91
+ const runSlice = async (a: number, b: number): Promise<number[]> => {
92
+ const part = Object.fromEntries(names.map(c => [c, data[c]!.slice(a, b)]))
93
+ const source = rows({ ...options.keys, [ORDINAL]: 'int' }, { ...part, [ORDINAL]: Array.from({ length: b - a }, (_, i) => i) } as never)
94
+ const k = source as unknown as RowsSource<'rows', TKeys>
95
+ const where = options.where(k)
96
+ if (where === undefined) throw new Error('[pipework] pipe.updateEach: where() returned no condition. Match each key, e.g. eq(t.id, k.id): without one every key would update every row.')
97
+ return await executeWrite({
98
+ verb: 'update' as const,
99
+ table,
100
+ set: options.set(k) as Record<string, unknown>,
101
+ where,
102
+ each: { source, ordinal: (source as unknown as Record<string, SQL>)[ORDINAL]!, count: b - a },
103
+ }) as number[]
104
+ }
105
+ const run = async (): Promise<number[]> => {
106
+ const counts: number[] = []
107
+ for (const [a, b] of slices) for (const n of await runSlice(a, b)) counts.push(n)
108
+ if ((options.expect ?? 'one') === 'one') {
109
+ const bad = counts.findIndex(n => n !== 1)
110
+ if (bad !== -1) {
111
+ throw new Error(
112
+ `[pipework] pipe.updateEach: key ${describeKey(identity, data, bad)} (input ${String(bad)}) matched ${String(counts[bad])} rows of "${table.$name}", expected exactly one. ` +
113
+ 'Nothing was updated. A key matching none is absent or no longer eligible (check the where); a key matching several needs a narrower where. Pass expect: \'any\' to read the counts instead.',
114
+ )
115
+ }
116
+ }
117
+ return counts
118
+ }
119
+ return dbName === undefined ? pipe.transaction(run) : pipe.transaction(dbName, run)
120
+ }
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
  }