pipework 0.291.0 → 0.292.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 (54) hide show
  1. package/CHANGELOG.md +4 -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/data/db/namespace.d.ts +4 -2
  8. package/dist/data/db/namespace.d.ts.map +1 -1
  9. package/dist/data/db/namespace.js +4 -2
  10. package/dist/data/db/namespace.js.map +1 -1
  11. package/dist/data/db/rows.d.ts +1 -0
  12. package/dist/data/db/rows.d.ts.map +1 -1
  13. package/dist/data/db/rows.js +1 -1
  14. package/dist/data/db/rows.js.map +1 -1
  15. package/dist/data/write/audit/verb-insert.d.ts.map +1 -1
  16. package/dist/data/write/audit/verb-insert.js +4 -1
  17. package/dist/data/write/audit/verb-insert.js.map +1 -1
  18. package/dist/data/write/audit/verb-update.d.ts.map +1 -1
  19. package/dist/data/write/audit/verb-update.js +14 -3
  20. package/dist/data/write/audit/verb-update.js.map +1 -1
  21. package/dist/data/write/batch-insert.d.ts +35 -0
  22. package/dist/data/write/batch-insert.d.ts.map +1 -0
  23. package/dist/data/write/batch-insert.js +171 -0
  24. package/dist/data/write/batch-insert.js.map +1 -0
  25. package/dist/data/write/execute.d.ts +1 -1
  26. package/dist/data/write/execute.d.ts.map +1 -1
  27. package/dist/data/write/execute.js +89 -2
  28. package/dist/data/write/execute.js.map +1 -1
  29. package/dist/data/write/index.d.ts +2 -0
  30. package/dist/data/write/index.d.ts.map +1 -1
  31. package/dist/data/write/index.js +1 -0
  32. package/dist/data/write/index.js.map +1 -1
  33. package/dist/data/write/insert.d.ts +8 -0
  34. package/dist/data/write/insert.d.ts.map +1 -1
  35. package/dist/data/write/insert.js +14 -0
  36. package/dist/data/write/insert.js.map +1 -1
  37. package/dist/data/write/state.d.ts +10 -0
  38. package/dist/data/write/state.d.ts.map +1 -1
  39. package/dist/data/write/update-each.d.ts +28 -0
  40. package/dist/data/write/update-each.d.ts.map +1 -0
  41. package/dist/data/write/update-each.js +104 -0
  42. package/dist/data/write/update-each.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/auth/tenant/scoped-db.ts +13 -0
  45. package/src/data/db/namespace.ts +4 -2
  46. package/src/data/db/rows.ts +1 -1
  47. package/src/data/write/audit/verb-insert.ts +4 -1
  48. package/src/data/write/audit/verb-update.ts +13 -3
  49. package/src/data/write/batch-insert.ts +179 -0
  50. package/src/data/write/execute.ts +77 -2
  51. package/src/data/write/index.ts +2 -0
  52. package/src/data/write/insert.ts +15 -0
  53. package/src/data/write/state.ts +6 -0
  54. package/src/data/write/update-each.ts +120 -0
@@ -0,0 +1,104 @@
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
+ import { pipe } from '../db/db.js';
11
+ import { rows } from '../db/rows.js';
12
+ import { assertNotExternal } from '../domain/external-registry.js';
13
+ import { executeWrite } from './execute.js';
14
+ import { batchLimits } from './batch-insert.js';
15
+ const ORDINAL = '_i';
16
+ function describeKey(columns, data, i) {
17
+ const part = (c) => {
18
+ const v = data[c][i];
19
+ return `${c}=${v instanceof Date ? v.toISOString() : JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x))}`;
20
+ };
21
+ return `{ ${columns.map(part).join(', ')} }`;
22
+ }
23
+ /**
24
+ * Updates the rows each input key matches in one statement and returns how many rows each key updated, in
25
+ * input order. By default a key that updated zero rows or more than one makes the call throw, naming the key,
26
+ * with the whole update undone; so does a key repeated in `data`. Runs in the caller's transaction and is audited
27
+ * as `pipe.update()` is (one audit row per row changed). An empty `data` returns `[]` without a statement.
28
+ */
29
+ export async function updateEach(// eslint-disable-line @typescript-eslint/no-explicit-any
30
+ table, options) {
31
+ assertNotExternal(table, 'update');
32
+ const names = Object.keys(options.keys);
33
+ if (names.includes(ORDINAL))
34
+ throw new Error(`[pipework] pipe.updateEach: "${ORDINAL}" is reserved for the input index; rename that key column.`);
35
+ const data = options.data;
36
+ const count = names.length === 0 ? 0 : (data[names[0]]?.length ?? 0);
37
+ const identity = options.key ?? names;
38
+ for (const c of identity) {
39
+ if (!names.includes(c))
40
+ throw new Error(`[pipework] pipe.updateEach: key column "${c}" is not one of the key rowset's columns (${names.join(', ')}).`);
41
+ }
42
+ const seen = new Map();
43
+ for (let i = 0; i < count; i++) {
44
+ const id = describeKey(identity, data, i);
45
+ const first = seen.get(id);
46
+ if (first !== undefined) {
47
+ 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.`);
48
+ }
49
+ seen.set(id, i);
50
+ }
51
+ if (count === 0)
52
+ return [];
53
+ // Over `batchLimits` the keys go as consecutive slices, in order, in the one transaction below: a refusal
54
+ // anywhere undoes every slice.
55
+ const slices = [];
56
+ let from = 0;
57
+ let bytes = 0;
58
+ for (let i = 0; i < count; i++) {
59
+ let rowBytes = 0;
60
+ for (const c of names) {
61
+ const v = data[c][i];
62
+ rowBytes += typeof v === 'string' ? Buffer.byteLength(v) : 8;
63
+ }
64
+ if (i > from && (i - from >= batchLimits.rows || bytes + rowBytes > batchLimits.bytes)) {
65
+ slices.push([from, i]);
66
+ from = i;
67
+ bytes = 0;
68
+ }
69
+ bytes += rowBytes;
70
+ }
71
+ slices.push([from, count]);
72
+ const dbName = table.$options.database;
73
+ const runSlice = async (a, b) => {
74
+ const part = Object.fromEntries(names.map(c => [c, data[c].slice(a, b)]));
75
+ const source = rows({ ...options.keys, [ORDINAL]: 'int' }, { ...part, [ORDINAL]: Array.from({ length: b - a }, (_, i) => i) });
76
+ const k = source;
77
+ const where = options.where(k);
78
+ if (where === undefined)
79
+ 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.');
80
+ return await executeWrite({
81
+ verb: 'update',
82
+ table,
83
+ set: options.set(k),
84
+ where,
85
+ each: { source, ordinal: source[ORDINAL], count: b - a },
86
+ });
87
+ };
88
+ const run = async () => {
89
+ const counts = [];
90
+ for (const [a, b] of slices)
91
+ for (const n of await runSlice(a, b))
92
+ counts.push(n);
93
+ if ((options.expect ?? 'one') === 'one') {
94
+ const bad = counts.findIndex(n => n !== 1);
95
+ if (bad !== -1) {
96
+ throw new Error(`[pipework] pipe.updateEach: key ${describeKey(identity, data, bad)} (input ${String(bad)}) matched ${String(counts[bad])} rows of "${table.$name}", expected exactly one. ` +
97
+ '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.');
98
+ }
99
+ }
100
+ return counts;
101
+ };
102
+ return dbName === undefined ? pipe.transaction(run) : pipe.transaction(dbName, run);
103
+ }
104
+ //# sourceMappingURL=update-each.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-each.js","sourceRoot":"","sources":["../../../src/data/write/update-each.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pipework",
3
- "version": "0.291.0",
3
+ "version": "0.292.0",
4
4
  "description": "TypeScript framework for multi-tenant SaaS applications. PostgreSQL-only.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 (
@@ -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()
@@ -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
  }