@proteinjs/db 1.51.0 → 1.52.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.51.0",
3
+ "version": "1.52.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.10.2",
44
+ "@proteinjs/db-query": "^1.11.0",
45
45
  "@proteinjs/logger": "^1.2.0",
46
46
  "@proteinjs/reflection": "^1.2.0",
47
47
  "@proteinjs/serializer": "^1.1.10",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "1128b1e6374c7f52cd5de172620b619b8f92e119"
69
+ "gitHead": "57c2ed1dca68a508bbd7b194ea7695ac4cf93344"
70
70
  }
package/src/Db.ts CHANGED
@@ -104,6 +104,14 @@ export interface DbDriver {
104
104
  * configured its database to tolerate rather than a number of its own.
105
105
  */
106
106
  getOperationDeadlineMs(): number;
107
+ /**
108
+ * The most bound parameters one statement may carry on this driver's backend, when the backend
109
+ * bounds it — `Db.insertMany` sizes its multi-row statements by it (`columns × rows` parameters
110
+ * each), so a batch wider than the bound goes as the fewest statements that fit instead of one
111
+ * the backend refuses. A driver whose backend declares no such bound leaves this undeclared,
112
+ * and a batch on it is always one statement.
113
+ */
114
+ getStatementParameterLimit?(): number;
107
115
  }
108
116
 
109
117
  export class Db<R extends Record = Record> implements DbService<R> {
@@ -228,6 +236,77 @@ export class Db<R extends Record = Record> implements DbService<R> {
228
236
  return recordCopy as T;
229
237
  }
230
238
 
239
+ /**
240
+ * Insert `records` as ONE statement (a multi-row INSERT) instead of one statement per row,
241
+ * each row keeping every contract {@link insert} gives a single one: the table's default field
242
+ * values, the before-insert table watchers, the columns' before-insert hooks, the encryption
243
+ * context, then — after the statement — the search-token upkeep, the columns' after-insert
244
+ * hooks and the after-insert table watchers. Each phase runs per row, in the order given, and
245
+ * sees the row exactly as `insert` would; what differs from N single inserts is only that every
246
+ * row's before-phases precede the statement and every row's after-phases follow it.
247
+ *
248
+ * Atomic: inside a transaction the statement rides it; outside one this opens a transaction
249
+ * of its own around the batch, so a row the database refuses fails the whole batch and no row
250
+ * of it lands. A batch wider than the driver's statement parameter bound
251
+ * ({@link DbDriver.getStatementParameterLimit}) goes as the fewest statements that fit, in
252
+ * order, inside that same transaction.
253
+ *
254
+ * Returns the rows as inserted (defaults applied, watchers' changes carried), in order. An
255
+ * empty list inserts nothing and returns [].
256
+ */
257
+ async insertMany<T extends R>(table: Table<T>, records: Omit<T, keyof R>[]): Promise<T[]> {
258
+ if (!this.runAsSystem) {
259
+ this.auth.canInsert(table);
260
+ }
261
+ if (records.length === 0) {
262
+ return [];
263
+ }
264
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
265
+ const db = this.newSelfWrapDb();
266
+ return await db.runTransaction(async () => await db.insertMany(table, records));
267
+ }
268
+
269
+ const prepared: {
270
+ recordCopy: any;
271
+ encryptionContext?: { keyOwner: string };
272
+ serializedRecord: SerializedRecord;
273
+ }[] = [];
274
+ for (const record of records) {
275
+ let recordCopy = Object.assign({}, record);
276
+ await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
277
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
278
+ await this.addColumnInsertHooks(table, recordCopy);
279
+ const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
280
+ const serializedRecord = await new RecordSerializer(table, encryptionContext).serialize(recordCopy);
281
+ prepared.push({ recordCopy, encryptionContext, serializedRecord });
282
+ }
283
+
284
+ for (const statementRows of this.chunkByParameterLimit(prepared.map((row) => row.serializedRecord))) {
285
+ const generateInsert = (config: DbDriverDmlStatementConfig) =>
286
+ new StatementFactory<T>().insertMany(
287
+ table.name,
288
+ statementRows as Partial<T>[],
289
+ this.statementConfigFactory.getStatementConfig(config)
290
+ );
291
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
292
+ }
293
+
294
+ for (const { recordCopy, encryptionContext } of prepared) {
295
+ if (encryptionContext) {
296
+ const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
297
+ await new EncryptionTokenMaintenance().afterInsert(
298
+ table,
299
+ recordCopy,
300
+ encryptionContext.keyOwner,
301
+ this.newSystemDb()
302
+ );
303
+ }
304
+ await this.runColumnAfterInsertHooks(table, recordCopy);
305
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
306
+ }
307
+ return prepared.map((row) => row.recordCopy as T);
308
+ }
309
+
231
310
  async update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number> {
232
311
  if (!this.runAsSystem) {
233
312
  this.auth.canUpdate(table);
@@ -1045,6 +1124,29 @@ export class Db<R extends Record = Record> implements DbService<R> {
1045
1124
  return context.currentTransaction;
1046
1125
  }
1047
1126
 
1127
+ /**
1128
+ * The rows of one multi-row insert split into the fewest statements the driver's statement
1129
+ * parameter bound admits (`insertMany`): each statement binds `columns × rows` parameters,
1130
+ * the columns being the union over the whole batch (what `StatementFactory.insertMany` lists),
1131
+ * so the split follows the batch's widest shape rather than the first statement's. One
1132
+ * statement when the driver declares no bound.
1133
+ */
1134
+ private chunkByParameterLimit(rows: SerializedRecord[]): SerializedRecord[][] {
1135
+ const limit = this.dbDriver.getStatementParameterLimit?.();
1136
+ if (limit === undefined) {
1137
+ return [rows];
1138
+ }
1139
+
1140
+ const columns = new Set<string>();
1141
+ for (const row of rows) {
1142
+ for (const column of Object.keys(row)) {
1143
+ columns.add(column);
1144
+ }
1145
+ }
1146
+ const rowsPerStatement = Math.max(1, Math.floor(limit / Math.max(1, columns.size)));
1147
+ return this.chunk(rows, rowsPerStatement);
1148
+ }
1149
+
1048
1150
  // Utility: simple chunker
1049
1151
  private chunk<T>(arr: T[], size: number): T[][] {
1050
1152
  if (size <= 0) {
@@ -0,0 +1,298 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { Statement } from '@proteinjs/db-query';
3
+ import { Db, DbDriver, DbDriverDmlStatementConfig } from '../src/Db';
4
+ import { Table } from '../src/Table';
5
+ import { Record, withRecordColumns } from '../src/Record';
6
+ import { BooleanColumn, StringColumn } from '../src/Columns';
7
+ import { TableWatcher } from '../src/TableWatcher';
8
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
9
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
10
+
11
+ /**
12
+ * `Db.insertMany`'s contract against a recording driver (no database): N rows issue ONE statement
13
+ * carrying every row with its defaults; each per-row hook — the before-insert table watcher, the
14
+ * columns' before/after-insert hooks, the after-insert table watcher — fires once per row, in
15
+ * the order given, and sees the row exactly as the single `insert` road shows it; a batch wider
16
+ * than the driver's statement parameter bound splits into the fewest statements that fit; the
17
+ * batch is one transaction (the ambient one, else its own); a refused statement runs no
18
+ * after-phase. What the database does with the statement — every row landing, the rollback of a
19
+ * refused batch — is the drivers' emulator suites' (the reusable CRUD and transaction tests).
20
+ */
21
+
22
+ interface Doc extends Record {
23
+ title: string;
24
+ note?: string | null;
25
+ flagged?: boolean;
26
+ }
27
+
28
+ type HookEvent = [phase: string, title: string, idPresent: boolean];
29
+ const log: HookEvent[] = [];
30
+
31
+ class DocTable extends Table<Doc> {
32
+ public name = 'insert_many_doc';
33
+ public columns = withRecordColumns<Doc>({
34
+ title: new StringColumn('title', {
35
+ onBeforeInsert: async (_table, record) => {
36
+ log.push(['column.before', record.title, typeof record.id === 'string']);
37
+ record.title = `${record.title}!`;
38
+ },
39
+ onAfterInsert: async (_table, record) => {
40
+ log.push(['column.after', record.title, typeof record.id === 'string']);
41
+ },
42
+ }),
43
+ note: new StringColumn('note'),
44
+ flagged: new BooleanColumn('flagged', { defaultValue: async () => false }),
45
+ });
46
+ }
47
+
48
+ const docTable = new DocTable() as Table<Doc>;
49
+
50
+ class RecordingWatcher implements TableWatcher<Doc> {
51
+ name(): string {
52
+ return 'RecordingWatcher';
53
+ }
54
+
55
+ table(): Table<Doc> {
56
+ return docTable;
57
+ }
58
+
59
+ async beforeInsert<T extends Doc>(record: Omit<T, keyof Doc>): Promise<Omit<T, keyof Doc>> {
60
+ const doc = record as unknown as Doc;
61
+ log.push(['watcher.before', doc.title, typeof doc.id === 'string']);
62
+ return { ...record, note: `seen ${doc.title}` };
63
+ }
64
+
65
+ async afterInsert<T extends Doc>(record: T): Promise<void> {
66
+ log.push(['watcher.after', record.title, typeof record.id === 'string']);
67
+ }
68
+ }
69
+
70
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
71
+ private context: TransactionContextData = {};
72
+
73
+ getTransactionContext(): TransactionContextData {
74
+ return this.context;
75
+ }
76
+
77
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
78
+ const previous = this.context;
79
+ this.context = context;
80
+ try {
81
+ return await fn();
82
+ } finally {
83
+ this.context = previous;
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Records every DML statement the Db hands it, as the Spanner driver would generate it. */
89
+ class RecordingDriver implements DbDriver {
90
+ dml: { sql: string; params: { [name: string]: unknown }; transaction: unknown }[] = [];
91
+ transactions = 0;
92
+ refuse?: (sql: string) => boolean;
93
+
94
+ getDbName(): string {
95
+ return 'test';
96
+ }
97
+
98
+ async createDbIfNotExists(): Promise<void> {}
99
+
100
+ async createDb(): Promise<void> {}
101
+
102
+ async dropDb(): Promise<void> {}
103
+
104
+ getTableManager(): never {
105
+ throw new Error('not used by these tests');
106
+ }
107
+
108
+ async runQuery(): Promise<never[]> {
109
+ return [];
110
+ }
111
+
112
+ async runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: unknown) {
113
+ const statement = generateStatement({
114
+ useParams: true,
115
+ useNamedParams: true,
116
+ prefixTablesWithDb: false,
117
+ getDriverColumnType: () => 'STRING(MAX)',
118
+ });
119
+ this.dml.push({ sql: statement.sql, params: statement.namedParams?.params ?? {}, transaction });
120
+ if (this.refuse?.(statement.sql)) {
121
+ throw new Error(`refused: ${statement.sql}`);
122
+ }
123
+ return tuplesOf(statement.sql);
124
+ }
125
+
126
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
127
+ this.transactions += 1;
128
+ return await fn({ id: `transaction-${this.transactions}` });
129
+ }
130
+
131
+ getOperationDeadlineMs(): number {
132
+ return 1000;
133
+ }
134
+ }
135
+
136
+ class BoundedDriver extends RecordingDriver {
137
+ constructor(private readonly limit: number) {
138
+ super();
139
+ }
140
+
141
+ getStatementParameterLimit(): number {
142
+ return this.limit;
143
+ }
144
+ }
145
+
146
+ const tuplesOf = (sql: string): number => (sql.match(/\(@param\d+/g) ?? []).length;
147
+
148
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
149
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
150
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
151
+
152
+ /** A system Db over `driver`, its watcher map rebuilt from the recording watcher alone. */
153
+ const dbOver = (driver: DbDriver): Db<Doc> => {
154
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
155
+ return new Db<Doc>(driver, () => docTable, new TestTransactionContextFactory(), true);
156
+ };
157
+
158
+ const rowsOf = (titles: string[]): Omit<Doc, keyof Record>[] => titles.map((title) => ({ title }));
159
+ const eventsFor = (title: string) => log.filter(([, t]) => t === title || t === `${title}!`);
160
+ const shapeOf = (doc: Doc) => ({ title: doc.title, note: doc.note, flagged: doc.flagged });
161
+
162
+ describe('Db.insertMany', () => {
163
+ let originalWatchers: unknown[] | undefined;
164
+
165
+ beforeAll(() => {
166
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
167
+ objectCache()['@proteinjs/db/TableWatcher'] = [new RecordingWatcher()];
168
+ });
169
+
170
+ afterAll(() => {
171
+ if (originalWatchers) {
172
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
173
+ } else {
174
+ delete objectCache()['@proteinjs/db/TableWatcher'];
175
+ }
176
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
177
+ });
178
+
179
+ beforeEach(() => {
180
+ log.length = 0;
181
+ });
182
+
183
+ test('N rows issue exactly ONE statement, every row in it with its defaults, returned in order', async () => {
184
+ const driver = new RecordingDriver();
185
+ const db = dbOver(driver);
186
+
187
+ const inserted = await db.insertMany(docTable, rowsOf(['a', 'b', 'c']));
188
+
189
+ expect(driver.dml).toHaveLength(1);
190
+ expect(tuplesOf(driver.dml[0].sql)).toBe(3);
191
+ expect(driver.dml[0].sql).toMatch(/^INSERT INTO `insert_many_doc` \(.+\) VALUES \(.+\), \(.+\), \(.+\);$/);
192
+ expect(inserted.map(shapeOf)).toEqual([
193
+ { title: 'a!', note: 'seen a', flagged: false },
194
+ { title: 'b!', note: 'seen b', flagged: false },
195
+ { title: 'c!', note: 'seen c', flagged: false },
196
+ ]);
197
+ for (const doc of inserted) {
198
+ expect(typeof doc.id).toBe('string');
199
+ expect(doc.created).toBeDefined();
200
+ expect(doc.updated).toBeDefined();
201
+ }
202
+ // The statement carries what the rows carry: the ids the defaults set and the watcher's note.
203
+ const bound = Object.values(driver.dml[0].params);
204
+ for (const doc of inserted) {
205
+ expect(bound).toContain(doc.id);
206
+ expect(bound).toContain(doc.note);
207
+ }
208
+ });
209
+
210
+ test('every per-row hook fires once per row, in row order, and sees the row as the single insert shows it', async () => {
211
+ const serial = dbOver(new RecordingDriver());
212
+ for (const row of rowsOf(['a', 'b'])) {
213
+ await serial.insert(docTable, row);
214
+ }
215
+ const serialLog = [...log];
216
+ log.length = 0;
217
+
218
+ const batched = dbOver(new RecordingDriver());
219
+ await batched.insertMany(docTable, rowsOf(['a', 'b']));
220
+
221
+ // Each row's own sequence — what fired, in what order, with the id already set — is the
222
+ // serial road's exactly.
223
+ for (const title of ['a', 'b']) {
224
+ const serialEvents = serialLog.filter(([, t]) => t === title || t === `${title}!`);
225
+ expect(eventsFor(title)).toEqual(serialEvents);
226
+ expect(eventsFor(title).map(([phase]) => phase)).toEqual([
227
+ 'watcher.before',
228
+ 'column.before',
229
+ 'column.after',
230
+ 'watcher.after',
231
+ ]);
232
+ }
233
+ // Across rows, each phase runs in row order; the before-phases of every row precede the
234
+ // statement, the after-phases follow it.
235
+ expect(log.map(([phase, title]) => `${phase}:${title}`)).toEqual([
236
+ 'watcher.before:a',
237
+ 'column.before:a',
238
+ 'watcher.before:b',
239
+ 'column.before:b',
240
+ 'column.after:a!',
241
+ 'watcher.after:a!',
242
+ 'column.after:b!',
243
+ 'watcher.after:b!',
244
+ ]);
245
+ });
246
+
247
+ test("a batch wider than the driver's parameter bound goes as the fewest statements that fit, in order", async () => {
248
+ // Six columns per row (id, created, updated, title, note, flagged); a bound of 13 admits two rows a statement.
249
+ const driver = new BoundedDriver(13);
250
+ const db = dbOver(driver);
251
+
252
+ const inserted = await db.insertMany(docTable, rowsOf(['a', 'b', 'c', 'd', 'e']));
253
+
254
+ expect(driver.dml.map((statement) => tuplesOf(statement.sql))).toEqual([2, 2, 1]);
255
+ for (const statement of driver.dml) {
256
+ expect(Object.keys(statement.params).length).toBeLessThanOrEqual(13);
257
+ }
258
+ expect(inserted.map((doc) => doc.title)).toEqual(['a!', 'b!', 'c!', 'd!', 'e!']);
259
+ // One transaction around the whole batch: every statement rides the same one.
260
+ expect(driver.transactions).toBe(1);
261
+ expect(new Set(driver.dml.map((statement) => statement.transaction)).size).toBe(1);
262
+ });
263
+
264
+ test('the batch rides the ambient transaction when there is one, and opens its own when there is none', async () => {
265
+ const ambient = new RecordingDriver();
266
+ const db = dbOver(ambient);
267
+ await db.runTransaction(async () => {
268
+ await db.insertMany(docTable, rowsOf(['a', 'b']));
269
+ });
270
+ expect(ambient.transactions).toBe(1);
271
+ expect(ambient.dml[0].transaction).toEqual({ id: 'transaction-1' });
272
+
273
+ const own = new RecordingDriver();
274
+ await dbOver(own).insertMany(docTable, rowsOf(['a', 'b']));
275
+ expect(own.transactions).toBe(1);
276
+ expect(own.dml[0].transaction).toEqual({ id: 'transaction-1' });
277
+ });
278
+
279
+ test('no rows: nothing issued, nothing opened, an empty result', async () => {
280
+ const driver = new RecordingDriver();
281
+ const inserted = await dbOver(driver).insertMany(docTable, []);
282
+
283
+ expect(inserted).toEqual([]);
284
+ expect(driver.dml).toHaveLength(0);
285
+ expect(driver.transactions).toBe(0);
286
+ expect(log).toHaveLength(0);
287
+ });
288
+
289
+ test('a refused statement fails the batch: the error surfaces and no after-phase runs for any row', async () => {
290
+ const driver = new RecordingDriver();
291
+ driver.refuse = () => true;
292
+
293
+ await expect(dbOver(driver).insertMany(docTable, rowsOf(['a', 'b']))).rejects.toThrow('refused');
294
+
295
+ expect(driver.dml).toHaveLength(1);
296
+ expect(log.map(([phase]) => phase).filter((phase) => phase.endsWith('.after'))).toEqual([]);
297
+ });
298
+ });
@@ -1,4 +1,5 @@
1
1
  import { UserAuth } from '@proteinjs/user-auth';
2
+ import { Logger } from '@proteinjs/logger';
2
3
  import { Table } from '../src/Table';
3
4
  import { withRecordColumns, Record } from '../src/Record';
4
5
  import { StringColumn } from '../src/Columns';
@@ -267,6 +268,17 @@ describe('TableServiceAuth — default deny (no auth block)', () => {
267
268
  expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
268
269
  expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(true);
269
270
  });
271
+
272
+ it('the multi-row insert is a server-side door only: the service refuses it, admin included', () => {
273
+ setUser(['admin']);
274
+ const table = new NoAuthTable();
275
+ const errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
276
+ try {
277
+ expect(auth().canAccess('insertMany', [table, [{ title: 't' }]])).toBe(false);
278
+ } finally {
279
+ errorSpy.mockRestore();
280
+ }
281
+ });
270
282
  });
271
283
 
272
284
  describe('TableServiceAuth — updateArrayMembership / updatePreserving (update parity)', () => {
@@ -25,6 +25,36 @@ export const crudTests = (
25
25
  await db.delete(emplyeeTable, { id: fetchedEmployee.id });
26
26
  });
27
27
 
28
+ test('Insert many: every row lands with its defaults, as its own single insert would', async () => {
29
+ const emplyeeTable: Table<Employee> = crudTestTables.Employee;
30
+ const rows: Omit<Employee, keyof Record>[] = [
31
+ { name: 'Veronica', department: 'Cake Factory' },
32
+ { name: 'Ada', department: 'Cake Factory', jobTitle: null, isRemote: true },
33
+ { name: 'Sean' },
34
+ ];
35
+ const inserted = await db.insertMany(emplyeeTable, rows);
36
+ expect(inserted.map((employee) => employee.name)).toEqual(['Veronica', 'Ada', 'Sean']);
37
+ const qb = new QueryBuilder<Employee>(emplyeeTable.name).condition({
38
+ field: 'id',
39
+ operator: 'IN',
40
+ value: inserted.map((employee) => employee.id),
41
+ });
42
+ const fetched = await db.query(emplyeeTable, qb);
43
+ expect(fetched.length).toBe(3);
44
+ const byName = new Map(fetched.map((employee) => [employee.name, employee]));
45
+ expect(byName.get('Veronica')?.department).toBe('Cake Factory');
46
+ // A column one row carries and another leaves out stores NULL for the row without it —
47
+ // the stored value of a single-row insert that left it out.
48
+ expect(byName.get('Veronica')?.jobTitle).toBeNull();
49
+ expect(byName.get('Ada')?.isRemote).toBe(true);
50
+ expect(byName.get('Sean')?.department).toBeNull();
51
+ for (const employee of fetched) {
52
+ expect(employee.created).toBeTruthy();
53
+ expect(employee.updated).toBeTruthy();
54
+ }
55
+ await db.delete(emplyeeTable, qb);
56
+ });
57
+
28
58
  test('Update', async () => {
29
59
  const testEmployee: Omit<Employee, keyof Record> = { name: 'Veronica' };
30
60
  const emplyeeTable: Table<Employee> = crudTestTables.Employee;
@@ -88,6 +88,52 @@ export const transactionTests = (
88
88
  }
89
89
  });
90
90
 
91
+ test('Insert many inside a transaction: a refused row rolls the whole batch back, and the writes before it', async () => {
92
+ const emplyeeTable: Table<TransactionEmployee> = transactionTestTables.TransactionEmployee;
93
+ let singleId: string | undefined;
94
+ let batchIds: string[] = [];
95
+
96
+ await expect(
97
+ db.runTransaction(async () => {
98
+ const single = await db.insert(emplyeeTable, { name: 'Veronica', department: 'Engineering' });
99
+ singleId = single.id;
100
+ // The third row reuses the first row's id: the statement is refused as a whole.
101
+ const rows: Omit<TransactionEmployee, keyof Record>[] = [
102
+ { name: 'Ada', department: 'Engineering' },
103
+ { name: 'Sean', department: 'Engineering' },
104
+ { name: 'Cassidy', department: 'Engineering' },
105
+ ];
106
+ const first = await db.insertMany(emplyeeTable, [rows[0]]);
107
+ batchIds = [first[0].id];
108
+ await db.insertMany(emplyeeTable, [
109
+ rows[1],
110
+ { ...rows[2], id: first[0].id } as Omit<TransactionEmployee, keyof Record>,
111
+ ]);
112
+ })
113
+ ).rejects.toThrow();
114
+
115
+ expect(singleId).toBeDefined();
116
+ expect(await db.get(emplyeeTable, { id: singleId })).toBeFalsy();
117
+ expect(await db.get(emplyeeTable, { id: batchIds[0] })).toBeFalsy();
118
+ expect(await db.query(emplyeeTable, { department: 'Engineering' })).toHaveLength(0);
119
+ });
120
+
121
+ test('Insert many outside a transaction is one unit: a refused row lands none of the batch', async () => {
122
+ const emplyeeTable: Table<TransactionEmployee> = transactionTestTables.TransactionEmployee;
123
+ const anchor = await db.insert(emplyeeTable, { name: 'Veronica', department: 'Sales' });
124
+
125
+ await expect(
126
+ db.insertMany(emplyeeTable, [
127
+ { name: 'Ada', department: 'Sales' },
128
+ { name: 'Sean', department: 'Sales', id: anchor.id } as Omit<TransactionEmployee, keyof Record>,
129
+ ])
130
+ ).rejects.toThrow();
131
+
132
+ const sales = await db.query(emplyeeTable, { department: 'Sales' });
133
+ expect(sales.map((employee) => employee.name)).toEqual(['Veronica']);
134
+ await db.delete(emplyeeTable, { id: anchor.id });
135
+ });
136
+
91
137
  test('Nested transactions are not allowed', async () => {
92
138
  const testEmployee: Omit<TransactionEmployee, keyof Record> = {
93
139
  name: 'Veronica',