Repository navigation
Expand file tree
/
Copy pathsql-driver-bulk-operations.ts
More file actions
354 lines (333 loc) · 20.2 KB
/
Copy pathsql-driver-bulk-operations.ts
File metadata and controls
354 lines (333 loc) · 20.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#29520 codemod] The "Bulk & Batch Operations" section of `SqlDriver`: the
* bodies of `bulkCreate`, `bulkUpdate`, `updateMany`, `rotatedUpdateById`,
* `count` moved out of `sql-driver.ts` whole by
* `scripts/codemod-split-sql-driver.mjs`.
*
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
* method of the same name exactly as it stood: not re-indented, not
* formatted, not edited. The method stays on the class with its docblock and
* signature, so the published `dist/*.d.ts`, an instance spy and a subclass
* override are what they were; the docblock there is the contract.
*
* The bodies reach the class through {@link BulkOperationsHost}: exactly the
* `SqlDriver` members they read. Each method hands its body one literal of the
* members that body reads, plus a `.call`: methods go as closures that call
* them on the driver at call time (the builder factory as a bound reference),
* so a subclass override or an instance spy is still the one reached; fields
* and accessors go as GETTERS, read when the body reads them, as they were on
* the class.
*
* The module imports `sql-driver.ts` for types only, so the two modules form
* no evaluation cycle. `index.ts` re-exports nothing from this module.
*/
import type { DriverOptions } from '@objectstack/spec/data';
import type { DriverQuery } from '@objectstack/spec/contracts';
import type { Knex } from 'knex';
import { nanoid } from 'nanoid';
import { AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, isUnresolvableColumnError } from './sql-driver-top-level.js';
import type { AutoNumberReservation } from './sql-driver-top-level.js';
import type { SqlDriver } from './sql-driver.js';
/**
* The `SqlDriver` members the functions below read — every one of them, and
* no other. Each is typed AS the class member (an indexed access on the
* class), so a change to a member's signature on the class is a change here
* too. `readonly` members are the getters.
*/
export interface BulkOperationsHost {
applyFilters: SqlDriver['applyFilters'];
applyTenantScope: SqlDriver['applyTenantScope'];
applyWriteColumnMap: SqlDriver['applyWriteColumnMap'];
auditMissingTenant: SqlDriver['auditMissingTenant'];
autoNumberCounterKey: SqlDriver['autoNumberCounterKey'];
backendStatementFault: SqlDriver['backendStatementFault'];
collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations'];
fillAutoNumberFields: SqlDriver['fillAutoNumberFields'];
formatInput: SqlDriver['formatInput'];
formatOutput: SqlDriver['formatOutput'];
getBuilder: SqlDriver['getBuilder'];
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
keepSuppliedUpdatedAt: SqlDriver['keepSuppliedUpdatedAt'];
readonly knex: SqlDriver['knex'];
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
rotationShardsOf: SqlDriver['rotationShardsOf'];
rotationWriteTarget: SqlDriver['rotationWriteTarget'];
stampInsertTimestamps: SqlDriver['stampInsertTimestamps'];
stampsUpdatedAt: SqlDriver['stampsUpdatedAt'];
unresolvableFilterColumnRefusal: SqlDriver['unresolvableFilterColumnRefusal'];
update: SqlDriver['update'];
updateWithPresumedTimestamp: SqlDriver['updateWithPresumedTimestamp'];
updatedAtStamp: SqlDriver['updatedAtStamp'];
updatedAtStampIsPresumed: SqlDriver['updatedAtStampIsPresumed'];
}
/** The body of `SqlDriver#bulkCreate`; the method's docblock there is its contract. */
export async function bulkCreate(this: Pick<BulkOperationsHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'autoNumberCounterKey' | 'collidingAutoNumberReservations' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: any[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
// Same client-side id assignment as create() (id/_id normalization,
// nanoid fallback when neither is supplied) — a row missing an id must
// not be silently inserted with a NULL primary key just because the
// engine batched it into an array. This path used to be rarely
// exercised; framework#2678 made it the common case for seed/import.
const rows = data.map((row) => {
if (!row || typeof row !== 'object') return row;
const { _id, ...rest } = row;
const toInsert: Record<string, any> = { ...rest };
if (_id !== undefined && toInsert.id === undefined) {
toInsert.id = _id;
} else if (toInsert.id === undefined) {
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
}
return toInsert;
});
this.auditMissingTenant(object, 'bulkCreate', options, rows);
for (const row of rows) {
if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options);
}
// #6943. A stale counter costs this path more than it costs `create()`.
// Each row reserves its own number in its own committed transaction, and
// then the whole batch goes in as ONE insert — so a single colliding row
// does not burn one number, it burns every number the batch reserved and
// fails the whole request. Measured on `main` @ `c8ff269` (counter at 10,
// rows 11–39 landed by a bypass path): a 3-row `bulkCreate` threw, wrote
// ZERO rows and moved `last_value` 10 → 13; the next call moved it 13 → 16
// and threw again. And the exposure is the worst available, because
// framework#2678 made this the common path for seed/import — which is
// precisely what *creates* the staleness (`isSystem` replay and
// `preserveAudit` import keep their explicit numbers and never enter
// `fillAutoNumberFields`, #5495/#5503).
//
// ## Batch semantics are deliberately NOT changed
//
// The card anticipated a decision about partial success and transaction
// boundaries. Measurement dissolves it: `insert(rows[])` is a SINGLE
// statement (`… select … union all select …` on SQLite, multi-row VALUES
// elsewhere), so the batch is ALREADY all-or-nothing — the failed batch
// above left the table exactly as it found it. Re-issuing and retrying the
// WHOLE batch therefore preserves the existing contract byte for byte: no
// partial success is introduced, no transaction is opened, no sibling-
// rollback question arises because siblings already fail together.
// Rejected instead:
// - **Per-row retry inside the batch** (the shape the card sketched).
// It requires splitting the one statement into N to learn which row
// failed, which INVENTS partial success where none exists, costs N
// round-trips on the hot seed/import path, and is what the engine's
// own #6806 note warns about ("re-writing the batch could DUPLICATE
// the rows that did land"). Nothing lands here, so nothing can.
// - **Wrapping the batch in a driver-opened transaction.** It would move
// the boundary the card was worried about, for no gain (the statement
// is already atomic), and it defeats the reservation model: each
// `getNextSequenceValue` commits on purpose, which is what makes a
// forward-only re-seed meaningful. On SQLite (pool max 1) it is also
// the deadlock `ensureSequencesTable` documents.
// - **Re-seed but do not re-issue** (what the engine does at #6806).
// Correct there, because the engine cannot know whether an arbitrary
// driver applied the batch partially. Inside THIS driver that is
// measurably false, so the caller can be handed a successful batch
// instead of an error it has to retry itself.
// - **Blanket retry on any unique violation.** Rejected for #5495's
// reason: it silently eats the caller's own 409.
const mayRetry = options?.transaction === undefined;
// What each row currently holds, ACROSS attempts. A retry regenerates only
// the fields it cleared, so `fillAutoNumberFields` reports only those; a
// value kept from the previous pass is still a live reservation and has to
// stay described here, or a second collision on a counter that did not go
// stale the first time would find nothing to route and rethrow blind.
const reservationsPerRow: AutoNumberReservation[][] = rows.map(() => []);
for (let attempt = 0; ; attempt++) {
// Reserve a persistent sequence value for each row's autonumber
// field(s) — the engine no longer pre-fills these (see #1603).
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || typeof row !== 'object') continue;
const reissued = await this.fillAutoNumberFields(object, row, options);
if (reissued.length === 0) continue;
const replaced = new Set(reissued.map((r) => r.field));
reservationsPerRow[i] = [...reservationsPerRow[i].filter((r) => !replaced.has(r.field)), ...reissued];
}
// Same write-side marshaling as create() (#2735): JSON-typed and
// object-valued fields must be serialized per row before they reach the
// knex binder — the raw batch used to hand `{lat, lng}` objects straight
// to SQLite ("Wrong API use: tried to bind a value of an unknown type"),
// silently failing the whole seed batch. Timestamp stamping runs on the
// FORMATTED copy, mirroring create().
const formattedRows = rows.map((row) => {
if (!row || typeof row !== 'object') return row;
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, row));
this.stampInsertTimestamps(object, formatted);
return formatted;
});
const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options);
try {
if (this.insertReturnsStoredRows) {
const result = await builder.insert(formattedRows).returning('*');
// Read-back parity with create(): JSON columns come back as their stored
// strings from `returning('*')` — decode them so batch callers see the
// same shapes single-insert callers do.
return Array.isArray(result)
? result.map((r) => this.formatOutput(object, r))
: result;
}
await builder.insert(formattedRows);
} catch (error) {
if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error;
const colliding = await this.collidingAutoNumberReservations(error, reservationsPerRow.flat(), options);
if (colliding.length === 0) throw error;
// Re-seed each stale counter ONCE, not once per row that drew from it:
// the scan is identical for every row sharing a counter and the update
// is forward-only, so the repeats are pure cost.
const stale = new Set<string>();
for (const reservation of colliding) {
const key = this.autoNumberCounterKey(reservation);
if (stale.has(key)) continue;
stale.add(key);
await this.resyncSequenceToDataMax(reservation);
}
// Re-issue EVERY row drawn from a stale counter — including rows whose
// own value did not collide. This is where the batch genuinely differs
// from `create()`, which keeps a non-colliding value to avoid burning a
// second number. Keeping them here is unsound: a batch that straddles
// the seeded range (say the counter is at 10, rows 11–39 exist, and the
// batch reserved 11–70) has its low rows collide and its high rows not,
// and regenerating only the low ones hands them numbers ABOVE the kept
// ones — an intra-batch duplicate this driver would have created
// itself. Counters that did NOT go stale keep their values, so another
// tenant's rows in the same batch are undisturbed.
for (let i = 0; i < rows.length; i++) {
for (const reservation of reservationsPerRow[i]) {
if (stale.has(this.autoNumberCounterKey(reservation))) delete rows[i][reservation.field];
}
}
continue;
}
// [#21227] Reached only when the INSERT landed and answered no rows (the
// MySQL family answered `[insertId]`: one element for the whole batch,
// which the engine's one-result-per-row guard then refused after every
// row had been stored). ONE read for the batch, by the ids written, in
// the written order. Outside the try for the reason `create` gives.
return this.readBackInsertedRows(
object,
this.rotationWriteTarget(object) ?? object,
rows,
options,
);
}
}
/** The body of `SqlDriver#bulkUpdate`; the method's docblock there is its contract. */
export async function bulkUpdate(this: Pick<BulkOperationsHost, 'knex' | 'update'>, object: string, updates: Array<{ id: string | number; data: Record<string, any> }>, options?: DriverOptions): Promise<Record<string, unknown>[]> {
// An empty batch issues no statement, exactly as before: a BEGIN/COMMIT
// pair — and the pool checkout behind it — buys nothing for zero rows.
if (updates.length === 0) return [];
const applyAll = async (scoped: DriverOptions): Promise<Record<string, any>[]> => {
const results: Record<string, any>[] = [];
for (const { id, data } of updates) {
const updated = await this.update(object, id, data, scoped);
if (updated) results.push(updated);
}
return results;
};
// The `(caller's transaction) ?? this.knex` runner idiom this file already
// uses (see {@link collidingAutoNumberReservations}); here the choice of
// runner is also the choice between a transaction and a savepoint.
const runner: Knex | Knex.Transaction = (options?.transaction as Knex.Transaction | undefined) ?? this.knex;
return runner.transaction(async (trx) => applyAll({ ...options, transaction: trx }));
}
/** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */
export async function updateMany(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'applyWriteColumnMap' | 'auditMissingTenant' | 'formatInput' | 'getBuilder' | 'keepSuppliedUpdatedAt' | 'rotationShardsOf' | 'stampsUpdatedAt' | 'updateWithPresumedTimestamp' | 'updatedAtStamp' | 'updatedAtStampIsPresumed'>, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
this.auditMissingTenant(object, 'updateMany', options);
// `data ?? {}` keeps the nullish tolerance this door already had on the
// stamping line below: a null payload normalises to "no keys", which emits
// the same stamp-only statement `main` emitted for it, instead of newly
// throwing inside `formatInput`.
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data ?? {}));
// Read from `formatted` rather than the caller's `data`, exactly as
// {@link update} and {@link rotatedUpdateById} do. On an external object
// whose columnMap remaps `updated_at` the two spellings disagree, and this
// door must reach the SAME decision those two reach rather than a private one.
const stamping = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options);
// Untouched when nothing is stamped, so a timestamp-less object's statement
// is byte-identical to the one `main` emits.
const payload = stamping ? { ...formatted, updated_at: this.updatedAtStamp() } : formatted;
// A rotation-managed object is recorded in `tablesWithTimestamps` by
// `initObjects` before `ensureRotation` runs, so it is never in the
// speculative state — the same property `rotatedUpdateById` relies on. The
// speculative path below therefore only ever sees the single-target loop.
const presumed = stamping && this.updatedAtStampIsPresumed(object);
let total = 0;
for (const target of this.rotationShardsOf(object) ?? [object]) {
// One definition of the statement, so the speculative attempt, the fenced
// retry and the plain path cannot drift in WHERE or tenant scope.
const issue = (candidate: Record<string, any>, issueOptions?: DriverOptions): Promise<number> => {
const builder = this.getBuilder(target, issueOptions);
this.applyTenantScope(builder, object, issueOptions);
if (query.where) this.applyFilters(builder, query.where, issueOptions);
return builder.update(candidate) as unknown as Promise<number>;
};
total +=
(presumed
? await this.updateWithPresumedTimestamp(object, payload, options, issue)
: await issue(payload, options)) || 0;
}
return total;
}
/** The body of `SqlDriver#rotatedUpdateById`; the method's docblock there is its contract. */
export async function rotatedUpdateById(this: Pick<BulkOperationsHost, 'applyTenantScope' | 'applyWriteColumnMap' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'keepSuppliedUpdatedAt' | 'stampsUpdatedAt' | 'updatedAtStamp'>, object: string, shards: string[], id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
// [commit 479fba50d] One definition of the decision, shared with {@link update}. No
// fallback is threaded here, and that is a property of the path rather than
// an omission: `rotationShardsOf` returns shards only once `ensureRotation`
// has run, and `initObjects` records the stronger `tablesWithTimestamps`
// fact on the line immediately before that call — so on this path the
// answer can never come from the declared-shape presumption, and there is
// nothing speculative to recover from.
//
// [#24835] Stamped into a FRESH object, for the reason {@link update} gives:
// `formatted` may be the caller's own `data`.
const payload = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options)
? { ...formatted, updated_at: this.updatedAtStamp() }
: formatted;
for (const shard of shards) {
const builder = this.getBuilder(shard, options).where('id', id);
this.applyTenantScope(builder, object, options);
const count = await builder.update(payload);
if (count > 0) {
const readback = this.getBuilder(shard, options).where('id', id);
this.applyTenantScope(readback, object, options);
const updated = await readback.first();
return this.formatOutput(object, updated) || null;
}
}
return null;
}
/** The body of `SqlDriver#count`; the method's docblock there is its contract. */
export async function count(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'backendStatementFault' | 'getBuilder' | 'unresolvableFilterColumnRefusal'>, object: string, query?: DriverQuery, options?: DriverOptions): Promise<number> {
const builder = this.getBuilder(object, options);
this.applyTenantScope(builder, object, options);
if (query?.where) {
this.applyFilters(builder, query.where, options);
}
// [#8790] `count()` has no recovery ladder and needs none — it carries no
// projection and no ORDER BY, so the only clause an unresolvable column can
// be in is the WHERE, which is the one clause {@link SqlDriver.findRows}'
// ladder may not drop either. What it needs is the ladder's *terminal*: the
// same `INVALID_FILTER` / 400, naming the same column, so the two halves of
// one list view stop answering one predicate two different ways. Before
// this, the dialect error travelled out untouched — `SQLITE_ERROR`, no
// `status`, bound literals inlined in the message.
let result: { count: number }[];
try {
result = await builder.count<{ count: number }[]>('* as count');
} catch (error) {
if (isUnresolvableColumnError(error)) {
throw this.unresolvableFilterColumnRefusal(object, error, query?.where);
}
// [#8931] The terminal catch-all — see {@link SqlDriver.backendStatementFault}.
throw this.backendStatementFault(object, error);
}
if (result && result.length > 0) {
const row: any = result[0];
return Number(row.count ?? row['count(*)'] ?? 0);
}
return 0;
}