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.
- package/CHANGELOG.md +4 -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/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/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/package.json +1 -1
- package/src/auth/tenant/scoped-db.ts +13 -0
- package/src/data/db/namespace.ts +4 -2
- package/src/data/db/rows.ts +1 -1
- 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
|
@@ -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
|
+
}
|