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
@@ -34,6 +34,19 @@ export function unscopedExecutor(db: DB): { readonly db: DB; readonly tenant: st
34
34
  return scoped === undefined ? { db, tenant: null } : { db: scoped.raw, tenant: scoped.tenantId }
35
35
  }
36
36
 
37
+ /**
38
+ * For a batch insert that writes its own SQL: runs the connection's policy for an insert into `table` (a guard,
39
+ * `pipe.system()` or a door refuses a tenant table exactly as `insert(table)` does) and returns the tenant column
40
+ * and value the rows must carry, or null when the table is not tenant-scoped or the handle has no tenant.
41
+ */
42
+ export function insertTenant(db: DB, table: unknown): { readonly columnKey: string; readonly tenantId: string } | null {
43
+ ;(db as unknown as { insert: (t: unknown) => unknown }).insert(table)
44
+ const scoped = scopedHandles.get(db as object)
45
+ if (scoped === undefined) return null
46
+ const config = getScopeConfig(table as PgTable)
47
+ return config === null ? null : { columnKey: config.columnKey, tenantId: scoped.tenantId }
48
+ }
49
+
37
50
  /** The API's name and why it cannot be confined to one tenant, for the error a scoped handle gets. */
38
51
  function refuseScopedMessage(api: string, tenant: string, reason: string): string {
39
52
  return (
@@ -39,10 +39,10 @@ export class CrossSiteRequestError extends PipeworkError {
39
39
  }
40
40
  }
41
41
 
42
- /** Where the failing input came from: the request parts a route validates, or the handler's own response. */
43
- export type ValidationSource = 'body' | 'query' | 'params' | 'headers' | 'response'
42
+ /** Where the failing input came from: the request parts a route validates. */
43
+ export type ValidationSource = 'body' | 'query' | 'params' | 'headers'
44
44
 
45
- /** One failed check: the path to the field (empty for the root) and the schema's message. Never the input value. */
45
+ /** One failed check: the path to the field (empty for the root) and the schema's message. Never the value. A null source means it did not come from a request part (a hand-raised error, or a handler's reply). */
46
46
  export interface ValidationFieldIssue {
47
47
  readonly source: ValidationSource | null
48
48
  readonly path: readonly string[]
@@ -78,6 +78,21 @@ export class ValidationError extends PipeworkError {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * A handler's own reply failed the output schema its route declares. The fault is the server's, not the
83
+ * caller's: 500, never the 422 of {@link ValidationError}. Carries the failing paths, never the value.
84
+ */
85
+ export class OutputValidationError extends PipeworkError {
86
+ readonly code = 'OUTPUT_VALIDATION_ERROR' as const
87
+ readonly httpStatus = 500 as const
88
+ readonly issues: readonly ValidationFieldIssue[]
89
+
90
+ constructor(issues: readonly ValidationFieldIssue[], message: string = 'The server\'s reply failed its declared output schema') {
91
+ super(message, { issues: issues.map(i => ({ path: i.path.join('.'), message: i.message })) })
92
+ this.issues = issues
93
+ }
94
+ }
95
+
81
96
  export class TooManyRequestsError extends PipeworkError {
82
97
  readonly code = 'TOO_MANY_REQUESTS' as const
83
98
  readonly httpStatus = 429 as const
@@ -1,2 +1,2 @@
1
1
  export { PipeworkError, isPipeworkError } from './base.js'
2
- export { NotFoundError, UnauthorizedError, ForbiddenError, CrossSiteRequestError, AuthConfigurationError, AuthUnavailableError, ValidationError, type ValidationSource, type ValidationFieldIssue, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './http.js'
2
+ export { NotFoundError, UnauthorizedError, ForbiddenError, CrossSiteRequestError, AuthConfigurationError, AuthUnavailableError, ValidationError, OutputValidationError, type ValidationSource, type ValidationFieldIssue, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './http.js'
@@ -1,4 +1,4 @@
1
- import { PipeworkError, NotFoundError, UnauthorizedError, ForbiddenError, CrossSiteRequestError, AuthConfigurationError, AuthUnavailableError, ValidationError, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './index.js'
1
+ import { PipeworkError, NotFoundError, UnauthorizedError, ForbiddenError, CrossSiteRequestError, AuthConfigurationError, AuthUnavailableError, ValidationError, OutputValidationError, ConflictError, TooManyRequestsError, PayloadTooLargeError, UnsupportedMediaTypeError, MalformedMultipartError } from './index.js'
2
2
 
3
3
  /** Standard error classes — all extend PipeworkError with structured error codes and actionable messages. */
4
4
  export const errors = {
@@ -18,6 +18,8 @@ export const errors = {
18
18
  AuthUnavailable: AuthUnavailableError,
19
19
  /** 422 — input validation failed, carries field-level details. */
20
20
  Validation: ValidationError,
21
+ /** 500 `OUTPUT_VALIDATION_ERROR` — a handler's own reply failed its declared output schema; the server's fault, not the caller's. */
22
+ OutputValidation: OutputValidationError,
21
23
  /** 409 — resource state conflict (duplicate, version mismatch). */
22
24
  Conflict: ConflictError,
23
25
  /** 429 — too many attempts; carries `retryAfterSeconds`, sent in both the `Retry-After` header and the body's `error.retryAfterSeconds`. */
@@ -11,7 +11,7 @@ import { bulkSet } from './bulk-set.js'
11
11
  import { values } from './values.js'
12
12
  import { rows } from './rows.js'
13
13
  import { fn } from './function-source.js'
14
- import { update, insert, remove, upsert } from '../write/index.js'
14
+ import { update, updateEach, insert, remove, upsert } from '../write/index.js'
15
15
 
16
16
  import { aliasedRelation, aliasedTable, aliasedTableColumn, getTableColumns, getTableName, getViewName, getViewSelectedFields, isTable, createMany, createOne, relations } from '../query/index.js'
17
17
 
@@ -102,7 +102,9 @@ export const pipe = Object.assign(getDb, {
102
102
 
103
103
  /** Pipework-owned UPDATE builder — the only consumer-facing way to update pipework-managed tables (Q8). */
104
104
  update,
105
- /** Pipework-owned INSERT builder — the only consumer-facing way to insert into pipework-managed tables (Q8). A list of any size is split into statements under Postgres's parameter limit, in one transaction. */
105
+ /** One UPDATE for many keys, returning how many rows each key updated (in input order); refuses, naming the key, when a key does not match exactly one row — `pipe.updateEach(t, { keys: { id: 'uuid', n: 'int' }, data: { id, n }, where: k => eq(t.id, k.id), set: k => ({ n: k.n }) })`. In the caller's transaction, audited as `pipe.update`. */
106
+ updateEach,
107
+ /** Pipework-owned INSERT builder — the only consumer-facing way to insert into pipework-managed tables (Q8). A list of any size is split into statements under Postgres's parameter limit, in one transaction; `.batch(rows)` sends every row as one statement instead, arrays and NULLs included. */
106
108
  insert,
107
109
  /** Pipework-owned DELETE builder — the only consumer-facing way to delete from pipework-managed tables (Q8). */
108
110
  delete: remove,
@@ -49,7 +49,7 @@ function quote(name: string): string {
49
49
  // One column as a Postgres array literal, sent as a single text parameter and cast to `T[]`. postgres.js
50
50
  // serialises a JS array only for the few element types it knows (text, int, uuid ...), so a `timestamptz[]`
51
51
  // parameter would throw; a literal string is parsed by Postgres for any type.
52
- function arrayLiteral(cells: readonly unknown[]): string {
52
+ export function arrayLiteral(cells: readonly unknown[]): string {
53
53
  const item = (v: unknown): string => {
54
54
  if (v === null || v === undefined) return 'NULL'
55
55
  const text = v instanceof Date ? v.toISOString()
@@ -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>(
@@ -9,6 +9,7 @@ import { buildAuditInserts, buildEntityIdExpr, type AuditSelectContext } from '.
9
9
  import { AUDIT_COLUMNS_SQL } from './audit-columns.js'
10
10
  import { buildFinalSelect } from './final-select.js'
11
11
  import { applyOnConflict } from '../conflict.js'
12
+ import { batchInsertSql } from '../batch-insert.js'
12
13
  import type { SQL } from '../../db/types.js'
13
14
  import type { WriteState } from '../state.js'
14
15
 
@@ -19,7 +20,9 @@ export function compileInsertAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
19
20
  // `.getSQL()` strips the auto-paren drizzle adds around SQLWrapper builders
20
21
  // when they are templated — we add explicit parens at the CTE boundary so
21
22
  // the WITH clause keeps a uniform shape across verbs.
22
- const inner = applyOnConflict(db.insert(state.table).values(state.values ?? []), state).returning().getSQL()
23
+ const inner = state.batch === true
24
+ ? sql`${batchInsertSql(state)} RETURNING *`
25
+ : applyOnConflict(db.insert(state.table).values(state.values ?? []), state).returning().getSQL()
23
26
  const after = buildTraceSafeProjection(state.table, 'inserted') ?? sql`NULL::jsonb`
24
27
  const auditSelect = buildAuditInserts(ctx, sql`FROM inserted`, buildEntityIdExpr(state.table, 'inserted'), sql`NULL::jsonb`, after)
25
28
  const finalSelect = buildFinalSelect(state, 'inserted')
@@ -7,6 +7,7 @@
7
7
  // zero rows from `before`, suppressing the audit insert.
8
8
 
9
9
  import { sql } from '../../db/sql.js'
10
+ import { getTableColumns } from '../../query/index.js'
10
11
  import { quoteIdentifier } from '../../db/identifiers.js'
11
12
  import { camelToSnake } from '../../domain/naming.js'
12
13
  import { buildTraceSafeProjection } from './projection.js'
@@ -26,11 +27,20 @@ export function compileUpdateAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
26
27
  // `.getSQL()` strips the auto-paren drizzle adds around SQLWrapper builders
27
28
  // when they are templated — we add explicit parens at the CTE boundary so
28
29
  // the WITH clause keeps a uniform shape across verbs.
30
+ const each = state.each
29
31
  let updateChain = db.update(state.table).set(set)
32
+ if (each !== undefined) updateChain = updateChain.from(each.source)
30
33
  if (state.where !== undefined) updateChain = updateChain.where(state.where)
31
- const updateInner = updateChain.returning().getSQL()
34
+ // An update for many keys also returns the index of the key each row matched, to count rows per key.
35
+ const updateInner = (each === undefined
36
+ ? updateChain.returning()
37
+ : updateChain.returning({ ...(getTableColumns(state.table as never) as Record<string, unknown>), __i: each.ordinal.as('__i') })).getSQL()
32
38
 
33
- let beforeChain = db.select().from(state.table)
39
+ // Over many keys a row can match several of them; it is snapshotted once, as the update changes it once.
40
+ let beforeChain = each === undefined
41
+ ? db.select().from(state.table)
42
+ : db.selectDistinctOn(pk.map(k => (state.table as unknown as Record<string, unknown>)[k]), getTableColumns(state.table as never))
43
+ .from(state.table).crossJoin(each.source)
34
44
  if (state.where !== undefined) beforeChain = beforeChain.where(state.where)
35
45
  const beforeInner = beforeChain.getSQL()
36
46
 
@@ -45,7 +55,7 @@ export function compileUpdateAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
45
55
  const after = buildTraceSafeProjection(state.table, 'updated') ?? sql`NULL::jsonb`
46
56
  const fromClause = sql.raw(`FROM updated JOIN before ON ${joinOn}`)
47
57
  const auditSelect = buildAuditInserts(ctx, fromClause, buildEntityIdExpr(state.table, 'updated'), before, after)
48
- const finalSelect = buildFinalSelect(state, 'updated')
58
+ const finalSelect = each === undefined ? buildFinalSelect(state, 'updated') : sql.raw('SELECT "__i" FROM updated')
49
59
 
50
60
  return sql`
51
61
  WITH before AS (${beforeInner}),
@@ -0,0 +1,179 @@
1
+ // `pipe.insert(table).batch(rows)`: many rows as ONE statement, whatever the row count.
2
+ //
3
+ // INSERT INTO "t" ("a", "b") SELECT "batch"."a"::uuid, "batch"."b"::text[] FROM unnest($1::text[], $2::text[]) AS "batch"("a", "b")
4
+ //
5
+ // Each column travels as one text-array parameter holding the cells exactly as the column's own driver
6
+ // encoding (`mapToDriverValue`) would send them for a single-row insert, and is cast back to the column's SQL
7
+ // type in the SELECT. Two parameters per two columns, not one per cell, so the row count never reaches
8
+ // Postgres's parameter limit; and because the cast happens per cell, an array column (any dimension) needs no
9
+ // `unnest` of its own, which would flatten it. It is plain INSERT ... SELECT, so RLS insert policies, `ON CONFLICT`,
10
+ // `RETURNING` and the audit CTE all apply as they do to a `values()` insert (`COPY FROM` does none of these: see REFERENCE.md).
11
+
12
+ import { getTableColumns, is, SQL as SQLClass } from '../query/index.js'
13
+ import { getTableConfig } from '../query/pg-core/index.js'
14
+ import type { PgTable } from '../query/pg-core/index.js'
15
+ import { sql } from '../db/sql.js'
16
+ import type { SQL } from '../db/types.js'
17
+ import { arrayLiteral } from '../db/rows.js'
18
+ import { insertTenant } from '../../auth/tenant/scoped-db.js'
19
+ import type { WriteState } from './state.js'
20
+
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- drizzle's column type is deeply generic; only the members read below are used
22
+ type AnyColumn = any
23
+
24
+ const SQL_TYPE = /^[A-Za-z_"][A-Za-z0-9_". ]*(\([0-9, ]+\))?(\[[0-9]*\])*$/
25
+ const SERIAL_AS: Record<string, string> = { serial: 'integer', bigserial: 'bigint', smallserial: 'smallint' }
26
+
27
+ function fail(problem: string, fix: string): never {
28
+ throw new Error(`[pipework] pipe.insert().batch(): ${problem}\n\n Fix: ${fix}\n`)
29
+ }
30
+
31
+ function quote(name: string): string {
32
+ return `"${name.replace(/"/g, '""')}"`
33
+ }
34
+
35
+ /** The table's name as SQL: schema-qualified when it has a schema. */
36
+ export function qualifiedName(table: PgTable): string {
37
+ const config = getTableConfig(table)
38
+ return config.schema === undefined ? quote(config.name) : `${quote(config.schema)}.${quote(config.name)}`
39
+ }
40
+
41
+ /**
42
+ * The state with the tenant column set on every row when the connection is tenant-scoped (a row naming another
43
+ * tenant throws, as `values()` does). Also runs the connection's insert policy, so a guard refuses a tenant table.
44
+ */
45
+ export function scopeBatchRows(state: WriteState, db: unknown): WriteState {
46
+ const tenant = insertTenant(db as never, state.table)
47
+ if (tenant === null || state.values === undefined) return state
48
+ const { columnKey, tenantId } = tenant
49
+ const values = state.values.map(row => {
50
+ const existing = row[columnKey]
51
+ if (existing !== undefined && existing !== tenantId) {
52
+ throw new Error(
53
+ `[pipework] Tenant-scoped insert: value has ${columnKey} "${String(existing)}" ` +
54
+ `but the current context tenant is "${tenantId}".\n\n` +
55
+ ` Remove the explicit ${columnKey} from your insert values — it's set automatically.\n` +
56
+ ' If you need cross-tenant writes, file an issue on the pipework repo.\n',
57
+ )
58
+ }
59
+ return { ...row, [columnKey]: tenantId }
60
+ })
61
+ return { ...state, values }
62
+ }
63
+
64
+ function cellText(column: AnyColumn, key: string, index: number, value: unknown): string | null {
65
+ if (value === null) return null
66
+ if (is(value, SQLClass)) {
67
+ fail(`row ${String(index)} sets "${key}" to a sql expression.`, 'a batch carries values only; use pipe.insert().values() for a row that needs an expression.')
68
+ }
69
+ const driver: unknown = column.mapToDriverValue(value)
70
+ if (driver === null || driver === undefined) return null
71
+ if (typeof driver === 'string') return driver
72
+ if (typeof driver === 'number' || typeof driver === 'bigint' || typeof driver === 'boolean') return String(driver)
73
+ if (driver instanceof Date) return driver.toISOString()
74
+ if (driver instanceof Uint8Array) return `\\x${Buffer.from(driver).toString('hex')}`
75
+ return JSON.stringify(driver)
76
+ }
77
+
78
+ /**
79
+ * When a batch is split: the library sends at most this many rows, and at most this many bytes of parameter
80
+ * text, per statement, several in order in one transaction. Measured on the test cluster, 139,000 rows of a
81
+ * uuid, a text, an int and a text[] were one statement of 19 MB of parameters in 2.4 s; the bounds sit well
82
+ * above that and well under Postgres's 1 GB cap on a single parameter. Mutable so tests can shrink them.
83
+ */
84
+ export const batchLimits = { rows: 250_000, bytes: 256 * 1024 * 1024 }
85
+
86
+ export interface BatchColumn { readonly key: string; readonly name: string; readonly type: string; readonly cells: (string | null)[] }
87
+
88
+ export function encode(state: WriteState): BatchColumn[] {
89
+ const rows = state.values ?? []
90
+ const columns = getTableColumns(state.table as unknown as PgTable) as Record<string, AnyColumn>
91
+ for (let i = 0; i < rows.length; i++) {
92
+ for (const key of Object.keys(rows[i]!)) {
93
+ if (columns[key] === undefined) {
94
+ fail(`row ${String(i)} has "${key}", which is not a field of "${state.table.$name}".`, `use the table's field keys: ${Object.keys(columns).join(', ')}.`)
95
+ }
96
+ }
97
+ }
98
+ const out: BatchColumn[] = []
99
+ for (const [key, column] of Object.entries(columns)) {
100
+ if (column.shouldDisableInsert() === true) continue
101
+ const given = rows.map(row => row[key])
102
+ const supplied = given.filter(v => v !== undefined).length
103
+ // A JS-side default (`$defaultFn`, `$onUpdateFn`) fills an omitted cell as `values()` does.
104
+ const jsDefault = column.defaultFn ?? (column.default === undefined || column.default === null ? column.onUpdateFn : undefined)
105
+ if (supplied === 0 && jsDefault === undefined) continue
106
+ if (supplied > 0 && supplied < rows.length && jsDefault === undefined) {
107
+ const missing = given.findIndex(v => v === undefined)
108
+ fail(
109
+ `row ${String(missing)} omits "${key}" while ${String(supplied)} of ${String(rows.length)} rows set it.`,
110
+ `a batch is one statement, so every row names the same columns: pass null for NULL, a value for each row, or omit "${key}" from all of them (its column default applies).`,
111
+ )
112
+ }
113
+ const cells = given.map((v, i) => {
114
+ if (v !== undefined) return cellText(column, key, i, v)
115
+ return cellText(column, key, i, (jsDefault as () => unknown)())
116
+ })
117
+ const raw = String(column.getSQLType())
118
+ const type = SERIAL_AS[raw] ?? raw
119
+ if (!SQL_TYPE.test(type)) fail(`column "${key}" has the SQL type "${type}", which a batch cannot cast to.`, 'use pipe.insert().values() for this table.')
120
+ out.push({ key, name: String(column.name), type, cells })
121
+ }
122
+ if (out.length === 0) fail('no row sets a column.', 'give at least one column a value in some row, or a column with a JS-side default.')
123
+ return out
124
+ }
125
+
126
+ /**
127
+ * The statements a batch is sent as: the whole list in one when it fits `batchLimits`, otherwise consecutive
128
+ * slices (each slice's rows, in input order) that each fit. Every slice carries its already-encoded columns.
129
+ */
130
+ export function planBatch(state: WriteState): WriteState[] {
131
+ const rows = state.values ?? []
132
+ const columns = encode(state)
133
+ const slices: [number, number][] = []
134
+ let from = 0
135
+ let bytes = 0
136
+ for (let i = 0; i < rows.length; i++) {
137
+ let rowBytes = 0
138
+ for (const c of columns) rowBytes += c.cells[i] === null ? 0 : Buffer.byteLength(c.cells[i]!)
139
+ if (i > from && (i - from >= batchLimits.rows || bytes + rowBytes > batchLimits.bytes)) {
140
+ slices.push([from, i])
141
+ from = i
142
+ bytes = 0
143
+ }
144
+ bytes += rowBytes
145
+ }
146
+ slices.push([from, rows.length])
147
+ if (slices.length === 1) return [{ ...state, encoded: columns }]
148
+ return slices.map(([a, b]) => ({
149
+ ...state,
150
+ values: rows.slice(a, b),
151
+ encoded: columns.map(c => ({ ...c, cells: c.cells.slice(a, b) })),
152
+ }))
153
+ }
154
+
155
+ /** Row i of the result is row i of `state.values`; the statement is `INSERT ... SELECT FROM unnest(...)` with the state's `ON CONFLICT` clause, no RETURNING. */
156
+ export function batchInsertSql(state: WriteState): SQL {
157
+ const columns = (state.encoded as BatchColumn[] | undefined) ?? encode(state)
158
+ const table = state.table as unknown as PgTable
159
+ const names = columns.map(c => quote(c.name))
160
+ const arrays = columns.map(c => sql`${sql.param(arrayLiteral(c.cells))}::text[]`)
161
+ const picked = columns.map(c => `"batch".${quote(c.name)}::${c.type}`)
162
+ let conflict = sql.raw('')
163
+ if (state.onConflict !== undefined) {
164
+ const target = state.onConflict.target
165
+ if (target === undefined) {
166
+ conflict = sql` ON CONFLICT DO NOTHING`
167
+ } else {
168
+ const cols = getTableColumns(table) as Record<string, AnyColumn>
169
+ const list = target.map(k => {
170
+ if (cols[k] === undefined) {
171
+ throw new Error(`pipe.insert: onConflictDoNothing target "${k}" is not a field of "${state.table.$name}". Use a field key declared on the table.`)
172
+ }
173
+ return quote(String(cols[k].name))
174
+ })
175
+ conflict = sql` ON CONFLICT (${sql.raw(list.join(', '))}) DO NOTHING`
176
+ }
177
+ }
178
+ return sql`INSERT INTO ${sql.raw(qualifiedName(table))} (${sql.raw(names.join(', '))}) SELECT ${sql.raw(picked.join(', '))} FROM unnest(${sql.join(arrays, sql`, `)}) AS "batch"(${sql.raw(names.join(', '))})${conflict}`
179
+ }
@@ -26,9 +26,17 @@ import { resolveActorId } from './audit/actor.js'
26
26
  import type { AuditSelectContext } from './audit/audit-select.js'
27
27
  import { rowsPerStatement } from '../db/insert-split.js'
28
28
  import { applyOnConflict, reportSkipped } from './conflict.js'
29
+ import { batchInsertSql, planBatch, scopeBatchRows } from './batch-insert.js'
30
+ import { buildFinalSelect } from './audit/final-select.js'
31
+ import { sql } from '../db/sql.js'
29
32
 
30
- export async function executeWrite(state: WriteState): Promise<unknown> {
31
- const db = pipe(state.table.$options.database)
33
+ export async function executeWrite(input: WriteState): Promise<unknown> {
34
+ const db = pipe(input.table.$options.database)
35
+ // A batch carries the tenant on its rows before the audit and vanilla paths read them.
36
+ const state = input.batch === true ? scopeBatchRows(input, db) : input
37
+ if (state.batch === true && (state.values === undefined || state.values.length === 0)) {
38
+ return state.returning === undefined || state.returning === null ? undefined : []
39
+ }
32
40
  const flow = getContext()
33
41
  if (flow !== undefined && flow.trace !== null) {
34
42
  const stepCtx = flow.trace.buffer.currentStep()
@@ -51,6 +59,7 @@ export async function executeWrite(state: WriteState): Promise<unknown> {
51
59
  if (chunkValueSize !== null) {
52
60
  return executeChunkedAuditWrite(state, auditCtx, chunkValueSize)
53
61
  }
62
+ if (state.batch === true) return executeBatchAudit(state, auditCtx)
54
63
  const stmt = compileAuditWrite(db, state, auditCtx)
55
64
  // The compiled CTE is already tenant-scoped at compile time (the user
56
65
  // write inside the CTE was built against the scoped `pipe()` DB, so it
@@ -60,10 +69,12 @@ export async function executeWrite(state: WriteState): Promise<unknown> {
60
69
  const rawDb = pipeRaw(state.table.$options.database)
61
70
  const rows = await rawDb.execute(stmt)
62
71
  for (const touch of selection.written) stepCtx.markWrote(touch)
72
+ if (state.each !== undefined) return tally(state.each.count, rows, '__i')
63
73
  if (state.onConflict !== undefined) reportSkipped(state, Array.isArray(rows) ? rows.length : 0)
64
74
  return decodeReturning(state, rows)
65
75
  }
66
76
  }
77
+ if (state.batch === true) return executeBatchInsert(state)
67
78
  if ((state.verb === 'insert' || state.verb === 'upsert') && state.values !== undefined) {
68
79
  const size = rowsPerStatement(state.table)
69
80
  if (state.values.length > size) return executeChunkedWrite(state, size)
@@ -90,6 +101,8 @@ function computeChunkValueSize(
90
101
  if (state.verb !== 'insert' && state.verb !== 'upsert') return null
91
102
  if (state.values === undefined) return null
92
103
  if (touches === 0) return null
104
+ // A batch is one statement by contract: its audit rows come from one INSERT ... SELECT, with no parameter per row.
105
+ if (state.batch === true) return null
93
106
 
94
107
  const limit = flow.instance.config.trace.auditChunkSize
95
108
  const projectedAuditRows = state.values.length * touches
@@ -176,8 +189,70 @@ type AnyDb = any
176
189
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
190
  type AnyChain = any
178
191
 
192
+ /** Rows per input index of a keys rowset: how many table rows each key updated. */
193
+ function tally(count: number, rows: unknown, column: string): number[] {
194
+ const counts = new Array<number>(count).fill(0)
195
+ if (Array.isArray(rows)) for (const row of rows as Record<string, unknown>[]) counts[Number(row[column])]! += 1
196
+ return counts
197
+ }
198
+
199
+ /**
200
+ * Runs the statements of a batch plan in order. A batch over `batchLimits` is several statements, so they run in
201
+ * one transaction (a savepoint when nested): all of it lands or none, and each statement reads the one before.
202
+ */
203
+ async function inBatches(state: WriteState, one: (chunk: WriteState) => Promise<unknown[]>): Promise<unknown[]> {
204
+ const plan = planBatch(state)
205
+ if (plan.length === 1) return one(plan[0]!)
206
+ const out: unknown[] = []
207
+ const run = async (): Promise<void> => {
208
+ for (const chunk of plan) for (const row of await one(chunk)) out.push(row)
209
+ }
210
+ const dbName = state.table.$options.database
211
+ if (dbName === undefined) await pipe.transaction(run)
212
+ else await pipe.transaction(dbName, run)
213
+ return out
214
+ }
215
+
216
+ /** `INSERT ... SELECT FROM unnest(...)`, one statement per plan slice; rows come back as `values()` returns them, in input order. */
217
+ async function executeBatchInsert(state: WriteState): Promise<unknown> {
218
+ const dbName = state.table.$options.database
219
+ const returns = state.returning !== undefined && state.returning !== null
220
+ const rows = await inBatches(state, async chunk => {
221
+ const rawDb = pipeRaw(dbName)
222
+ const insertSql = batchInsertSql(chunk)
223
+ if (!returns && state.onConflict === undefined) {
224
+ await rawDb.execute(insertSql)
225
+ return []
226
+ }
227
+ // Under onConflictDoNothing the rows written are the rows returned, so they are asked for even when the caller did not.
228
+ const out = await rawDb.execute(sql`WITH inserted AS (${insertSql} RETURNING *) ${buildFinalSelect(state, 'inserted')}`)
229
+ return Array.isArray(out) ? [...out] : []
230
+ })
231
+ if (state.onConflict !== undefined) reportSkipped(state, rows.length)
232
+ if (!returns) return state.onConflict === undefined ? undefined : []
233
+ return decodeReturning(state, rows)
234
+ }
235
+
236
+ /** The write-and-audit statement, one per plan slice, in one transaction when there are several; each slice's audit rows land with its rows. */
237
+ async function executeBatchAudit(state: WriteState, ctx: AuditSelectContext): Promise<unknown> {
238
+ const dbName = state.table.$options.database
239
+ const rows = await inBatches(state, async chunk => {
240
+ const stmt = compileAuditWrite(pipe(dbName), chunk, ctx)
241
+ const out = await pipeRaw(dbName).execute(stmt)
242
+ return Array.isArray(out) ? [...out] : []
243
+ })
244
+ for (const touch of ctx.written) ctx.step.markWrote(touch)
245
+ if (state.onConflict !== undefined) reportSkipped(state, rows.length)
246
+ return decodeReturning(state, rows)
247
+ }
248
+
179
249
  async function executeUpdate(db: AnyDb, state: WriteState): Promise<unknown> {
180
250
  let q: AnyChain = db.update(state.table).set(state.set ?? {})
251
+ if (state.each !== undefined) {
252
+ q = q.from(state.each.source)
253
+ if (state.where !== undefined) q = q.where(state.where)
254
+ return tally(state.each.count, await q.returning({ i: state.each.ordinal }), 'i')
255
+ }
181
256
  if (state.where !== undefined) q = q.where(state.where)
182
257
  return runWithReturning(q, state.returning)
183
258
  }
@@ -9,3 +9,5 @@ export { insert, InsertBuilder } from './insert.js'
9
9
  export { remove, DeleteBuilder } from './delete.js'
10
10
  export { upsert, UpsertBuilder } from './upsert.js'
11
11
  export type { WriteState, WriteVerb, ReturningShape } from './state.js'
12
+ export { updateEach } from './update-each.js'
13
+ export type { UpdateEachOptions } from './update-each.js'
@@ -19,6 +19,21 @@ export class InsertBuilder<T extends PgTable, TResult = unknown> implements Prom
19
19
  })
20
20
  }
21
21
 
22
+ /**
23
+ * Sends the rows as ONE statement up to 250,000 rows or 256 MiB of parameter text, then as consecutive statements of that size in one transaction (the library splits, never the caller): each column travels as a single array parameter
24
+ * (so no parameter limit and no chunking) and is cast back per cell, which carries array columns, NULLs and
25
+ * every type `values()` carries, encoded as `values()` would. Every row must name the same columns; a column
26
+ * no row names takes its default. Composes with `onConflictDoNothing()` and `returning()`; the rows come back in
27
+ * input order. Runs under RLS insert policies and audits as `values()` does, in the caller's transaction.
28
+ */
29
+ batch(rows: ReadonlyArray<InsertValues<T>>): InsertBuilder<T, TResult> {
30
+ return new InsertBuilder<T, TResult>({
31
+ ...this.state,
32
+ batch: true,
33
+ values: rows as ReadonlyArray<Record<string, unknown>>,
34
+ })
35
+ }
36
+
22
37
  /**
23
38
  * Leaves a row that conflicts with an existing one and inserts the rest.
24
39
  * Without `target`, any unique or exclusion constraint conflict is skipped.
@@ -35,6 +35,12 @@ export interface WriteState {
35
35
  readonly onConflictColumns?: readonly string[]
36
36
  /** `ON CONFLICT DO NOTHING` for `insert`; `target` is field keys, absent for the bare clause. */
37
37
  readonly onConflict?: { readonly action: 'nothing'; readonly target?: readonly string[] }
38
+ /** `insert` only: send every row as one statement (`INSERT ... SELECT FROM unnest(...)`), whatever the row count. */
39
+ readonly batch?: boolean
40
+ /** `insert` batch only: the columns already encoded for this statement (set by the batch planner). */
41
+ readonly encoded?: unknown
42
+ /** `update` only: one update for many keys, `from` a keys rowset; the result is the row count per input key. */
43
+ readonly each?: { readonly source: unknown; readonly ordinal: SQL; readonly count: number }
38
44
  /** What `RETURNING` should produce. `null` (default) emits no RETURNING. */
39
45
  readonly returning?: ReturningShape
40
46
  }