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.
- package/CHANGELOG.md +8 -0
- package/REFERENCE.md +31 -1
- package/dist/auth/tenant/scoped-db.d.ts +9 -0
- package/dist/auth/tenant/scoped-db.d.ts.map +1 -1
- package/dist/auth/tenant/scoped-db.js +14 -0
- package/dist/auth/tenant/scoped-db.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/db/namespace.d.ts +4 -2
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +4 -2
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/rows.d.ts +1 -0
- package/dist/data/db/rows.d.ts.map +1 -1
- package/dist/data/db/rows.js +1 -1
- package/dist/data/db/rows.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/data/write/audit/verb-insert.d.ts.map +1 -1
- package/dist/data/write/audit/verb-insert.js +4 -1
- package/dist/data/write/audit/verb-insert.js.map +1 -1
- package/dist/data/write/audit/verb-update.d.ts.map +1 -1
- package/dist/data/write/audit/verb-update.js +14 -3
- package/dist/data/write/audit/verb-update.js.map +1 -1
- package/dist/data/write/batch-insert.d.ts +35 -0
- package/dist/data/write/batch-insert.d.ts.map +1 -0
- package/dist/data/write/batch-insert.js +171 -0
- package/dist/data/write/batch-insert.js.map +1 -0
- package/dist/data/write/execute.d.ts +1 -1
- package/dist/data/write/execute.d.ts.map +1 -1
- package/dist/data/write/execute.js +89 -2
- package/dist/data/write/execute.js.map +1 -1
- package/dist/data/write/index.d.ts +2 -0
- package/dist/data/write/index.d.ts.map +1 -1
- package/dist/data/write/index.js +1 -0
- package/dist/data/write/index.js.map +1 -1
- package/dist/data/write/insert.d.ts +8 -0
- package/dist/data/write/insert.d.ts.map +1 -1
- package/dist/data/write/insert.js +14 -0
- package/dist/data/write/insert.js.map +1 -1
- package/dist/data/write/state.d.ts +10 -0
- package/dist/data/write/state.d.ts.map +1 -1
- package/dist/data/write/update-each.d.ts +28 -0
- package/dist/data/write/update-each.d.ts.map +1 -0
- package/dist/data/write/update-each.js +104 -0
- package/dist/data/write/update-each.js.map +1 -0
- 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/auth/tenant/scoped-db.ts +13 -0
- 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/db/namespace.ts +4 -2
- package/src/data/db/rows.ts +1 -1
- package/src/data/schema/parse.ts +37 -2
- package/src/data/write/audit/verb-insert.ts +4 -1
- package/src/data/write/audit/verb-update.ts +13 -3
- package/src/data/write/batch-insert.ts +179 -0
- package/src/data/write/execute.ts +77 -2
- package/src/data/write/index.ts +2 -0
- package/src/data/write/insert.ts +15 -0
- package/src/data/write/state.ts +6 -0
- package/src/data/write/update-each.ts +120 -0
- 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,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:
|
|
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
|
}
|