@proteinjs/db 1.51.0 → 1.53.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 (44) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/Db.d.ts +48 -0
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +206 -31
  13. package/dist/src/Db.js.map +1 -1
  14. package/dist/src/DuplicateKeyError.d.ts +20 -0
  15. package/dist/src/DuplicateKeyError.d.ts.map +1 -0
  16. package/dist/src/DuplicateKeyError.js +47 -0
  17. package/dist/src/DuplicateKeyError.js.map +1 -0
  18. package/dist/test/DuplicateKeyError.test.d.ts +2 -0
  19. package/dist/test/DuplicateKeyError.test.d.ts.map +1 -0
  20. package/dist/test/DuplicateKeyError.test.js +287 -0
  21. package/dist/test/DuplicateKeyError.test.js.map +1 -0
  22. package/dist/test/InsertMany.test.d.ts +2 -0
  23. package/dist/test/InsertMany.test.d.ts.map +1 -0
  24. package/dist/test/InsertMany.test.js +475 -0
  25. package/dist/test/InsertMany.test.js.map +1 -0
  26. package/dist/test/TableServiceAuth.test.js +12 -0
  27. package/dist/test/TableServiceAuth.test.js.map +1 -1
  28. package/dist/test/reusable/CrudTests.d.ts.map +1 -1
  29. package/dist/test/reusable/CrudTests.js +44 -0
  30. package/dist/test/reusable/CrudTests.js.map +1 -1
  31. package/dist/test/reusable/TransactionTests.d.ts.map +1 -1
  32. package/dist/test/reusable/TransactionTests.js +90 -0
  33. package/dist/test/reusable/TransactionTests.js.map +1 -1
  34. package/generated/index.ts +1 -1
  35. package/generated/test/index.ts +1 -1
  36. package/index.ts +1 -0
  37. package/package.json +3 -3
  38. package/src/Db.ts +128 -1
  39. package/src/DuplicateKeyError.ts +26 -0
  40. package/test/DuplicateKeyError.test.ts +179 -0
  41. package/test/InsertMany.test.ts +298 -0
  42. package/test/TableServiceAuth.test.ts +12 -0
  43. package/test/reusable/CrudTests.ts +30 -0
  44. package/test/reusable/TransactionTests.ts +46 -0
package/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from './src/Db';
2
2
  export * from './src/DetachedDbOps';
3
3
  export * from './src/Table';
4
4
  export * from './src/RecordAccessError';
5
+ export * from './src/DuplicateKeyError';
5
6
  export * from './src/auth/TableAuth';
6
7
  export * from './src/auth/TableServiceAuth';
7
8
  export * from './src/Columns';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.51.0",
3
+ "version": "1.53.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": "480320d415088dd0d378bd0e9863dc70353fa92f"
70
70
  }
package/src/Db.ts CHANGED
@@ -32,6 +32,7 @@ import { ReferenceArray } from './reference/ReferenceArray';
32
32
  import { ReferenceCache } from './reference/ReferenceCache';
33
33
  import { ArrayMembershipUpdate, applyArrayMembershipOps } from './reference/ArrayMembershipOps';
34
34
  import { PreservedPath, overlayPreservedPaths } from './UpdatePreserving';
35
+ import { DuplicateKeyError } from './DuplicateKeyError';
35
36
 
36
37
  /** get `Db` if on server, and `DbService` if on browser */
37
38
  export const getDb = <R extends Record = Record>() =>
@@ -104,6 +105,21 @@ export interface DbDriver {
104
105
  * configured its database to tolerate rather than a number of its own.
105
106
  */
106
107
  getOperationDeadlineMs(): number;
108
+ /**
109
+ * The most bound parameters one statement may carry on this driver's backend, when the backend
110
+ * bounds it — `Db.insertMany` sizes its multi-row statements by it (`columns × rows` parameters
111
+ * each), so a batch wider than the bound goes as the fewest statements that fit instead of one
112
+ * the backend refuses. A driver whose backend declares no such bound leaves this undeclared,
113
+ * and a batch on it is always one statement.
114
+ */
115
+ getStatementParameterLimit?(): number;
116
+ /**
117
+ * Whether `error` — what this driver's `runDml` threw — is the backend refusing a row because a
118
+ * row with the same key is already there (the primary key, or a unique index). `Db.insert` and
119
+ * `Db.insertMany` rethrow such a refusal as the typed `DuplicateKeyError`, so a caller can take
120
+ * it by type. A driver that declares no classifier lets the backend's error through as it came.
121
+ */
122
+ isDuplicateKeyError?(error: unknown): boolean;
107
123
  }
108
124
 
109
125
  export class Db<R extends Record = Record> implements DbService<R> {
@@ -211,7 +227,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
211
227
  serializedRecord as Partial<T>,
212
228
  this.statementConfigFactory.getStatementConfig(config)
213
229
  );
214
- await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
230
+ try {
231
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
232
+ } catch (error) {
233
+ throw this.insertFailure(table, error);
234
+ }
215
235
  if (encryptionContext) {
216
236
  // The same write that stored the ciphertext maintains its search tokens (rides the
217
237
  // ambient transaction when there is one).
@@ -228,6 +248,81 @@ export class Db<R extends Record = Record> implements DbService<R> {
228
248
  return recordCopy as T;
229
249
  }
230
250
 
251
+ /**
252
+ * Insert `records` as ONE statement (a multi-row INSERT) instead of one statement per row,
253
+ * each row keeping every contract {@link insert} gives a single one: the table's default field
254
+ * values, the before-insert table watchers, the columns' before-insert hooks, the encryption
255
+ * context, then — after the statement — the search-token upkeep, the columns' after-insert
256
+ * hooks and the after-insert table watchers. Each phase runs per row, in the order given, and
257
+ * sees the row exactly as `insert` would; what differs from N single inserts is only that every
258
+ * row's before-phases precede the statement and every row's after-phases follow it.
259
+ *
260
+ * Atomic: inside a transaction the statement rides it; outside one this opens a transaction
261
+ * of its own around the batch, so a row the database refuses fails the whole batch and no row
262
+ * of it lands. A batch wider than the driver's statement parameter bound
263
+ * ({@link DbDriver.getStatementParameterLimit}) goes as the fewest statements that fit, in
264
+ * order, inside that same transaction.
265
+ *
266
+ * Returns the rows as inserted (defaults applied, watchers' changes carried), in order. An
267
+ * empty list inserts nothing and returns [].
268
+ */
269
+ async insertMany<T extends R>(table: Table<T>, records: Omit<T, keyof R>[]): Promise<T[]> {
270
+ if (!this.runAsSystem) {
271
+ this.auth.canInsert(table);
272
+ }
273
+ if (records.length === 0) {
274
+ return [];
275
+ }
276
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
277
+ const db = this.newSelfWrapDb();
278
+ return await db.runTransaction(async () => await db.insertMany(table, records));
279
+ }
280
+
281
+ const prepared: {
282
+ recordCopy: any;
283
+ encryptionContext?: { keyOwner: string };
284
+ serializedRecord: SerializedRecord;
285
+ }[] = [];
286
+ for (const record of records) {
287
+ let recordCopy = Object.assign({}, record);
288
+ await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
289
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
290
+ await this.addColumnInsertHooks(table, recordCopy);
291
+ const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
292
+ const serializedRecord = await new RecordSerializer(table, encryptionContext).serialize(recordCopy);
293
+ prepared.push({ recordCopy, encryptionContext, serializedRecord });
294
+ }
295
+
296
+ for (const statementRows of this.chunkByParameterLimit(prepared.map((row) => row.serializedRecord))) {
297
+ const generateInsert = (config: DbDriverDmlStatementConfig) =>
298
+ new StatementFactory<T>().insertMany(
299
+ table.name,
300
+ statementRows as Partial<T>[],
301
+ this.statementConfigFactory.getStatementConfig(config)
302
+ );
303
+ try {
304
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
305
+ } catch (error) {
306
+ throw this.insertFailure(table, error);
307
+ }
308
+ }
309
+
310
+ for (const { recordCopy, encryptionContext } of prepared) {
311
+ if (encryptionContext) {
312
+ const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
313
+ await new EncryptionTokenMaintenance().afterInsert(
314
+ table,
315
+ recordCopy,
316
+ encryptionContext.keyOwner,
317
+ this.newSystemDb()
318
+ );
319
+ }
320
+ await this.runColumnAfterInsertHooks(table, recordCopy);
321
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
322
+ }
323
+ return prepared.map((row) => row.recordCopy as T);
324
+ }
325
+
231
326
  async update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number> {
232
327
  if (!this.runAsSystem) {
233
328
  this.auth.canUpdate(table);
@@ -941,6 +1036,15 @@ export class Db<R extends Record = Record> implements DbService<R> {
941
1036
  return { keyOwner: await new EncryptionRecordHooks().resolveKeyOwnerForWrite(table, record) };
942
1037
  }
943
1038
 
1039
+ /**
1040
+ * What an insert statement's failure becomes for the caller: the typed `DuplicateKeyError` when
1041
+ * the driver classifies the backend's refusal as a row already there; any other failure as it
1042
+ * came.
1043
+ */
1044
+ private insertFailure(table: Table<any>, error: unknown): unknown {
1045
+ return this.dbDriver.isDuplicateKeyError?.(error) ? new DuplicateKeyError(table.name, error) : error;
1046
+ }
1047
+
944
1048
  private async updateTouchesEncryptedColumns(table: Table<any>, record: any): Promise<boolean> {
945
1049
  const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
946
1050
  const encryptedColumns = new EncryptedColumns();
@@ -1045,6 +1149,29 @@ export class Db<R extends Record = Record> implements DbService<R> {
1045
1149
  return context.currentTransaction;
1046
1150
  }
1047
1151
 
1152
+ /**
1153
+ * The rows of one multi-row insert split into the fewest statements the driver's statement
1154
+ * parameter bound admits (`insertMany`): each statement binds `columns × rows` parameters,
1155
+ * the columns being the union over the whole batch (what `StatementFactory.insertMany` lists),
1156
+ * so the split follows the batch's widest shape rather than the first statement's. One
1157
+ * statement when the driver declares no bound.
1158
+ */
1159
+ private chunkByParameterLimit(rows: SerializedRecord[]): SerializedRecord[][] {
1160
+ const limit = this.dbDriver.getStatementParameterLimit?.();
1161
+ if (limit === undefined) {
1162
+ return [rows];
1163
+ }
1164
+
1165
+ const columns = new Set<string>();
1166
+ for (const row of rows) {
1167
+ for (const column of Object.keys(row)) {
1168
+ columns.add(column);
1169
+ }
1170
+ }
1171
+ const rowsPerStatement = Math.max(1, Math.floor(limit / Math.max(1, columns.size)));
1172
+ return this.chunk(rows, rowsPerStatement);
1173
+ }
1174
+
1048
1175
  // Utility: simple chunker
1049
1176
  private chunk<T>(arr: T[], size: number): T[][] {
1050
1177
  if (size <= 0) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * An insert the database refused because a row with the same key is already there — the primary
3
+ * key, or any unique index the table declares. `Db.insert` and `Db.insertMany` throw it in place
4
+ * of the driver's own error (which rides as `cause`, never printed: the backend's words quote the
5
+ * colliding key), so a caller for whom "that row already exists" is a branch of its own — an
6
+ * insert-then-update that keeps one row per key under concurrent writers — takes it by type,
7
+ * never by the backend's message text. Which errors are this class is the driver's call
8
+ * (`DbDriver.isDuplicateKeyError`); a driver that declares no classifier lets the backend's error
9
+ * through as it came.
10
+ *
11
+ * Name-tagged rather than relying on `instanceof`: the prototype chain is unreliable across
12
+ * package compile targets (the same reason `RecordAccessError` checks `name`).
13
+ */
14
+ export class DuplicateKeyError extends Error {
15
+ /** The driver's error — for callers in process; not enumerable, so a serialized error carries no row key. */
16
+ readonly cause: unknown;
17
+
18
+ constructor(tableName: string, cause: unknown) {
19
+ super(`(${tableName}) Insert refused: a row with the same key already exists`);
20
+ this.name = 'DuplicateKeyError';
21
+ Object.defineProperty(this, 'cause', { value: cause, enumerable: false, writable: false });
22
+ }
23
+ }
24
+
25
+ export const isDuplicateKeyError = (error: unknown): error is DuplicateKeyError =>
26
+ !!error && typeof error === 'object' && (error as { name?: string }).name === 'DuplicateKeyError';
@@ -0,0 +1,179 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { Statement } from '@proteinjs/db-query';
3
+ import { Db, DbDriver, DbDriverDmlStatementConfig } from '../src/Db';
4
+ import { DuplicateKeyError, isDuplicateKeyError } from '../src/DuplicateKeyError';
5
+ import { Table } from '../src/Table';
6
+ import { Record, withRecordColumns } from '../src/Record';
7
+ import { StringColumn } from '../src/Columns';
8
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
9
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
10
+
11
+ /**
12
+ * What an insert's refusal becomes for the caller: a refusal the driver classifies as a duplicate
13
+ * key (a row with the same key already there — the primary key or a unique index) reaches the
14
+ * caller as the typed `DuplicateKeyError`, name-tagged, naming the table and carrying the driver's
15
+ * error as a cause that never prints; every other failure, and every failure on a driver that
16
+ * declares no classifier, reaches the caller exactly as the driver threw it. The same on
17
+ * `insertMany`. What the database itself refuses is the drivers' emulator suites' (the settings
18
+ * package's one-row-per-name suite proves the unique index's refusal end to end).
19
+ */
20
+
21
+ interface Doc extends Record {
22
+ title: string;
23
+ }
24
+
25
+ class DocTable extends Table<Doc> {
26
+ public name = 'duplicate_key_doc';
27
+ public columns = withRecordColumns<Doc>({
28
+ title: new StringColumn('title'),
29
+ });
30
+ }
31
+
32
+ const docTable = new DocTable() as Table<Doc>;
33
+
34
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
35
+ private context: TransactionContextData = {};
36
+
37
+ getTransactionContext(): TransactionContextData {
38
+ return this.context;
39
+ }
40
+
41
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
42
+ const previous = this.context;
43
+ this.context = context;
44
+ try {
45
+ return await fn();
46
+ } finally {
47
+ this.context = previous;
48
+ }
49
+ }
50
+ }
51
+
52
+ /** A driver whose every DML fails with the error it is handed, classifying `code === 6` as a duplicate key. */
53
+ class RefusingDriver implements DbDriver {
54
+ constructor(private readonly refusal: unknown) {}
55
+
56
+ getDbName(): string {
57
+ return 'test';
58
+ }
59
+
60
+ async createDbIfNotExists(): Promise<void> {}
61
+
62
+ async createDb(): Promise<void> {}
63
+
64
+ async dropDb(): Promise<void> {}
65
+
66
+ getTableManager(): never {
67
+ throw new Error('not used by these tests');
68
+ }
69
+
70
+ async runQuery(): Promise<never[]> {
71
+ return [];
72
+ }
73
+
74
+ async runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement): Promise<number> {
75
+ generateStatement({
76
+ useParams: true,
77
+ useNamedParams: true,
78
+ prefixTablesWithDb: false,
79
+ getDriverColumnType: () => 'STRING(MAX)',
80
+ });
81
+ throw this.refusal;
82
+ }
83
+
84
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
85
+ return await fn({ id: 'transaction' });
86
+ }
87
+
88
+ getOperationDeadlineMs(): number {
89
+ return 1000;
90
+ }
91
+
92
+ isDuplicateKeyError(error: unknown): boolean {
93
+ return (error as { code?: unknown } | null | undefined)?.code === 6;
94
+ }
95
+ }
96
+
97
+ /** The same driver with no classifier declared. */
98
+ class UnclassifyingDriver extends RefusingDriver {
99
+ isDuplicateKeyError = undefined as unknown as (error: unknown) => boolean;
100
+ }
101
+
102
+ const dbOn = (driver: DbDriver) => new Db(driver, () => docTable, new TestTransactionContextFactory(), true);
103
+
104
+ const failureOf = async (act: () => Promise<unknown>): Promise<unknown> => {
105
+ try {
106
+ await act();
107
+ } catch (error) {
108
+ return error;
109
+ }
110
+ throw new Error('the insert was expected to fail');
111
+ };
112
+
113
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
114
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
115
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
116
+
117
+ describe('an insert the database refuses because the row is already there', () => {
118
+ const backendRefusal = Object.assign(new Error('Row {pk#id:"7f3"} in table duplicate_key_doc already exists'), {
119
+ code: 6,
120
+ });
121
+ let originalWatchers: unknown[] | undefined;
122
+
123
+ // No reflection graph in a unit test: the watcher registry is empty by declaration.
124
+ beforeAll(() => {
125
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
126
+ objectCache()['@proteinjs/db/TableWatcher'] = [];
127
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
128
+ });
129
+
130
+ afterAll(() => {
131
+ if (originalWatchers) {
132
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
133
+ } else {
134
+ delete objectCache()['@proteinjs/db/TableWatcher'];
135
+ }
136
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
137
+ });
138
+
139
+ test('reaches the caller as the typed DuplicateKeyError, naming the table, carrying the cause unprinted', async () => {
140
+ const failure = await failureOf(() => dbOn(new RefusingDriver(backendRefusal)).insert(docTable, { title: 'a' }));
141
+ expect(isDuplicateKeyError(failure)).toBe(true);
142
+ const typed = failure as DuplicateKeyError;
143
+ expect(typed.name).toBe('DuplicateKeyError');
144
+ expect(typed.message).toBe('(duplicate_key_doc) Insert refused: a row with the same key already exists');
145
+ expect(typed.cause).toBe(backendRefusal);
146
+ // The backend's words quote the colliding key: they ride for callers in process only.
147
+ expect(Object.keys(typed)).not.toContain('cause');
148
+ expect(JSON.stringify(typed)).not.toContain('pk#id');
149
+ });
150
+
151
+ test('the same on insertMany', async () => {
152
+ const failure = await failureOf(() =>
153
+ dbOn(new RefusingDriver(backendRefusal)).insertMany(docTable, [{ title: 'a' }, { title: 'b' }])
154
+ );
155
+ expect(isDuplicateKeyError(failure)).toBe(true);
156
+ expect((failure as DuplicateKeyError).cause).toBe(backendRefusal);
157
+ });
158
+
159
+ test('any other refusal reaches the caller as the driver threw it', async () => {
160
+ const aborted = Object.assign(new Error('Transaction was aborted'), { code: 10 });
161
+ const failure = await failureOf(() => dbOn(new RefusingDriver(aborted)).insert(docTable, { title: 'a' }));
162
+ expect(failure).toBe(aborted);
163
+ expect(isDuplicateKeyError(failure)).toBe(false);
164
+ });
165
+
166
+ test('a driver that declares no classifier lets the refusal through as it came', async () => {
167
+ const failure = await failureOf(() =>
168
+ dbOn(new UnclassifyingDriver(backendRefusal)).insert(docTable, { title: 'a' })
169
+ );
170
+ expect(failure).toBe(backendRefusal);
171
+ });
172
+
173
+ test('the guard reads the name, not the prototype chain', () => {
174
+ expect(isDuplicateKeyError(Object.assign(new Error('x'), { name: 'DuplicateKeyError' }))).toBe(true);
175
+ expect(isDuplicateKeyError(new Error('DuplicateKeyError'))).toBe(false);
176
+ expect(isDuplicateKeyError(undefined)).toBe(false);
177
+ expect(isDuplicateKeyError('DuplicateKeyError')).toBe(false);
178
+ });
179
+ });