@proteinjs/db 1.52.0 → 1.54.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 (43) 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 +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/Db.d.ts +58 -5
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +83 -24
  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/src/TransactionRetryExhaustedError.d.ts +25 -0
  19. package/dist/src/TransactionRetryExhaustedError.d.ts.map +1 -0
  20. package/dist/src/TransactionRetryExhaustedError.js +54 -0
  21. package/dist/src/TransactionRetryExhaustedError.js.map +1 -0
  22. package/dist/src/transaction/TransactionRetryPolicy.d.ts +34 -0
  23. package/dist/src/transaction/TransactionRetryPolicy.d.ts.map +1 -0
  24. package/dist/src/transaction/TransactionRetryPolicy.js +3 -0
  25. package/dist/src/transaction/TransactionRetryPolicy.js.map +1 -0
  26. package/dist/test/DuplicateKeyError.test.d.ts +2 -0
  27. package/dist/test/DuplicateKeyError.test.d.ts.map +1 -0
  28. package/dist/test/DuplicateKeyError.test.js +287 -0
  29. package/dist/test/DuplicateKeyError.test.js.map +1 -0
  30. package/dist/test/TransactionRetryOptions.test.d.ts +2 -0
  31. package/dist/test/TransactionRetryOptions.test.d.ts.map +1 -0
  32. package/dist/test/TransactionRetryOptions.test.js +293 -0
  33. package/dist/test/TransactionRetryOptions.test.js.map +1 -0
  34. package/generated/index.ts +1 -1
  35. package/generated/test/index.ts +1 -1
  36. package/index.ts +3 -0
  37. package/package.json +2 -2
  38. package/src/Db.ts +98 -12
  39. package/src/DuplicateKeyError.ts +26 -0
  40. package/src/TransactionRetryExhaustedError.ts +37 -0
  41. package/src/transaction/TransactionRetryPolicy.ts +31 -0
  42. package/test/DuplicateKeyError.test.ts +179 -0
  43. package/test/TransactionRetryOptions.test.ts +195 -0
package/index.ts CHANGED
@@ -2,6 +2,8 @@ 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';
6
+ export * from './src/TransactionRetryExhaustedError';
5
7
  export * from './src/auth/TableAuth';
6
8
  export * from './src/auth/TableServiceAuth';
7
9
  export * from './src/Columns';
@@ -24,6 +26,7 @@ export * from './src/TableWatcher';
24
26
  export * from './src/transaction/Transaction';
25
27
  export * from './src/transaction/TransactionRunner';
26
28
  export * from './src/transaction/TransactionContextFactory';
29
+ export * from './src/transaction/TransactionRetryPolicy';
27
30
 
28
31
  export * from './src/schema/SchemaOperations';
29
32
  export * from './src/schema/SchemaMetadata';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.52.0",
3
+ "version": "1.54.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "57c2ed1dca68a508bbd7b194ea7695ac4cf93344"
69
+ "gitHead": "856ac58e8bb61e5d46261ffb57434994fb808390"
70
70
  }
package/src/Db.ts CHANGED
@@ -32,6 +32,8 @@ 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';
36
+ import { TransactionOptions } from './transaction/TransactionRetryPolicy';
35
37
 
36
38
  /** get `Db` if on server, and `DbService` if on browser */
37
39
  export const getDb = <R extends Record = Record>() =>
@@ -93,8 +95,24 @@ export interface DbDriver {
93
95
  generateStatement: (config: DbDriverQueryStatementConfig) => Statement,
94
96
  transaction?: any
95
97
  ): Promise<SerializedRecord[]>;
96
- runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: any): Promise<number>;
97
- runTransaction<T>(fn: (transaction: any) => Promise<T>): Promise<T>;
98
+ /**
99
+ * Run one write statement. On `transaction` it rides that transaction; without one it is a
100
+ * transaction of its own, and `options.retry` (`TransactionRetryPolicy`) is how the driver
101
+ * re-runs it when the database aborts it — the driver's own road when omitted, else the bound
102
+ * named, past which the driver throws `TransactionRetryExhaustedError`. A driver that never
103
+ * re-runs an aborted statement (the abort reaches the caller at once) already satisfies every
104
+ * policy.
105
+ */
106
+ runDml(
107
+ generateStatement: (config: DbDriverDmlStatementConfig) => Statement,
108
+ transaction?: any,
109
+ options?: TransactionOptions
110
+ ): Promise<number>;
111
+ /**
112
+ * Run `fn` as one transaction. `options.retry` (`TransactionRetryPolicy`) is how the driver
113
+ * re-runs the whole body when the database aborts the transaction — see `runDml`.
114
+ */
115
+ runTransaction<T>(fn: (transaction: any) => Promise<T>, options?: TransactionOptions): Promise<T>;
98
116
  /**
99
117
  * The driver's per-operation deadline, in milliseconds: the longest one statement it runs may
100
118
  * take before the driver fails it — the bound the driver enforces, as configured. Not a whole
@@ -112,6 +130,13 @@ export interface DbDriver {
112
130
  * and a batch on it is always one statement.
113
131
  */
114
132
  getStatementParameterLimit?(): number;
133
+ /**
134
+ * Whether `error` — what this driver's `runDml` threw — is the backend refusing a row because a
135
+ * row with the same key is already there (the primary key, or a unique index). `Db.insert` and
136
+ * `Db.insertMany` rethrow such a refusal as the typed `DuplicateKeyError`, so a caller can take
137
+ * it by type. A driver that declares no classifier lets the backend's error through as it came.
138
+ */
139
+ isDuplicateKeyError?(error: unknown): boolean;
115
140
  }
116
141
 
117
142
  export class Db<R extends Record = Record> implements DbService<R> {
@@ -201,10 +226,18 @@ export class Db<R extends Record = Record> implements DbService<R> {
201
226
  return (await this.query(table, query, options))[0];
202
227
  }
203
228
 
204
- async insert<T extends R>(table: Table<T>, record: Omit<T, keyof R>): Promise<T> {
229
+ /**
230
+ * Insert one row. Outside a transaction the insert is a transaction of its own, and
231
+ * `options.retry` names how it is re-run when the database aborts it (`TransactionRetryPolicy`:
232
+ * the driver's own road when omitted; a bounded policy for a best-effort write, past which the
233
+ * caller takes `TransactionRetryExhaustedError`). Inside a transaction the transaction's policy
234
+ * governs, and an insert naming one of its own is refused.
235
+ */
236
+ async insert<T extends R>(table: Table<T>, record: Omit<T, keyof R>, options?: TransactionOptions): Promise<T> {
205
237
  if (!this.runAsSystem) {
206
238
  this.auth.canInsert(table);
207
239
  }
240
+ const driverOptions = this.ownTransactionOptions(options);
208
241
 
209
242
  let recordCopy = Object.assign({}, record);
210
243
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
@@ -219,7 +252,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
219
252
  serializedRecord as Partial<T>,
220
253
  this.statementConfigFactory.getStatementConfig(config)
221
254
  );
222
- await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
255
+ try {
256
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver(), driverOptions);
257
+ } catch (error) {
258
+ throw this.insertFailure(table, error);
259
+ }
223
260
  if (encryptionContext) {
224
261
  // The same write that stored the ciphertext maintains its search tokens (rides the
225
262
  // ambient transaction when there is one).
@@ -288,7 +325,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
288
325
  statementRows as Partial<T>[],
289
326
  this.statementConfigFactory.getStatementConfig(config)
290
327
  );
291
- await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
328
+ try {
329
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
330
+ } catch (error) {
331
+ throw this.insertFailure(table, error);
332
+ }
292
333
  }
293
334
 
294
335
  for (const { recordCopy, encryptionContext } of prepared) {
@@ -307,7 +348,20 @@ export class Db<R extends Record = Record> implements DbService<R> {
307
348
  return prepared.map((row) => row.recordCopy as T);
308
349
  }
309
350
 
310
- async update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number> {
351
+ /**
352
+ * Update the rows `query` selects (the one row `record.id` names when there is no query).
353
+ * Outside a transaction the update is a transaction of its own, and `options.retry` names how
354
+ * it is re-run when the database aborts it (`TransactionRetryPolicy`: the driver's own road when
355
+ * omitted; a bounded policy for a best-effort write — a live progress row the next step
356
+ * rewrites anyway — past which the caller takes `TransactionRetryExhaustedError`). Inside a
357
+ * transaction the transaction's policy governs, and an update naming one of its own is refused.
358
+ */
359
+ async update<T extends R>(
360
+ table: Table<T>,
361
+ record: Partial<T>,
362
+ query?: Query<T>,
363
+ options?: TransactionOptions
364
+ ): Promise<number> {
311
365
  if (!this.runAsSystem) {
312
366
  this.auth.canUpdate(table);
313
367
  }
@@ -315,6 +369,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
315
369
  if (!query && !record.id) {
316
370
  throw new Error(`Update must be called with either a Query or a record with an id property`);
317
371
  }
372
+ const driverOptions = this.ownTransactionOptions(options);
318
373
 
319
374
  let recordCopy = Object.assign({}, record);
320
375
  // Immutable columns can never be rewritten through an update — strip them from the payload
@@ -337,7 +392,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
337
392
  recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
338
393
  let recordUpdateCount: number;
339
394
  if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
340
- recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb);
395
+ recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb, driverOptions);
341
396
  } else {
342
397
  const recordSerializer = new RecordSerializer<T>(table);
343
398
  const serializedRecord = await recordSerializer.serialize(recordCopy);
@@ -349,7 +404,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
349
404
  qb,
350
405
  this.statementConfigFactory.getStatementConfig(config)
351
406
  );
352
- recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
407
+ recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver(), driverOptions);
353
408
  }
354
409
  if (!this.runAsSystem && recordUpdateCount === 0) {
355
410
  const id = this.singleRowIdTarget(record, query);
@@ -912,6 +967,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
912
967
  *
913
968
  * Note: Nested transactions are not supported; will throw.
914
969
  *
970
+ * `options.retry` names how the driver re-runs the whole body when the database aborts the
971
+ * transaction (`TransactionRetryPolicy`: the driver's own road when omitted; a bounded policy
972
+ * for best-effort work, past which this throws `TransactionRetryExhaustedError`). Every write
973
+ * inside the body rides this policy; none may name one of its own.
974
+ *
915
975
  * Note: work spawned inside the body but NOT awaited by it escapes the transaction's
916
976
  * lifetime while still holding its context — such work fails loudly on its next db
917
977
  * operation (see TransactionContextData.ended). Await everything inside the body, or run
@@ -930,7 +990,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
930
990
  * });
931
991
  * ```
932
992
  */
933
- async runTransaction<T>(fn: () => Promise<T>): Promise<T> {
993
+ async runTransaction<T>(fn: () => Promise<T>, options?: TransactionOptions): Promise<T> {
934
994
  if (this.transactionContextFactory.getTransactionContext().currentTransaction) {
935
995
  throw new Error(`Nested transactions are not supported. A transaction is already running in this context.`);
936
996
  }
@@ -953,7 +1013,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
953
1013
  // silent op on a finished transaction (see transactionForDriver).
954
1014
  contextData.ended = true;
955
1015
  }
956
- });
1016
+ }, options);
957
1017
 
958
1018
  // COMMIT BOUNDARY: the driver resolves only after the transaction is durably committed (and
959
1019
  // rejects on rollback, in which case the queue above is never drained). Hook failures are
@@ -1020,6 +1080,15 @@ export class Db<R extends Record = Record> implements DbService<R> {
1020
1080
  return { keyOwner: await new EncryptionRecordHooks().resolveKeyOwnerForWrite(table, record) };
1021
1081
  }
1022
1082
 
1083
+ /**
1084
+ * What an insert statement's failure becomes for the caller: the typed `DuplicateKeyError` when
1085
+ * the driver classifies the backend's refusal as a row already there; any other failure as it
1086
+ * came.
1087
+ */
1088
+ private insertFailure(table: Table<any>, error: unknown): unknown {
1089
+ return this.dbDriver.isDuplicateKeyError?.(error) ? new DuplicateKeyError(table.name, error) : error;
1090
+ }
1091
+
1023
1092
  private async updateTouchesEncryptedColumns(table: Table<any>, record: any): Promise<boolean> {
1024
1093
  const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
1025
1094
  const encryptedColumns = new EncryptedColumns();
@@ -1037,7 +1106,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
1037
1106
  private async updateEncrypted<T extends R>(
1038
1107
  table: Table<T>,
1039
1108
  recordCopy: Partial<T>,
1040
- qb: QueryBuilder<T>
1109
+ qb: QueryBuilder<T>,
1110
+ options?: TransactionOptions
1041
1111
  ): Promise<number> {
1042
1112
  const targetRows = await this._query(table, qb);
1043
1113
  if (targetRows.length === 0) {
@@ -1071,7 +1141,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
1071
1141
  groupQb,
1072
1142
  this.statementConfigFactory.getStatementConfig(config)
1073
1143
  );
1074
- recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
1144
+ recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver(), options);
1075
1145
  await tokenMaintenance.afterUpdate(table, ids, recordCopy, owner, this.newSystemDb());
1076
1146
  }
1077
1147
 
@@ -1124,6 +1194,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
1124
1194
  return context.currentTransaction;
1125
1195
  }
1126
1196
 
1197
+ /**
1198
+ * A write's transaction options as the driver takes them: its own, when the write runs outside
1199
+ * any transaction and so is a transaction of its own. Inside a transaction the write rides the
1200
+ * transaction, whose policy was named on `runTransaction`; a policy named on the write would be
1201
+ * one nothing honours — refused by name, never dropped.
1202
+ */
1203
+ private ownTransactionOptions(options?: TransactionOptions): TransactionOptions | undefined {
1204
+ if (options?.retry !== undefined && this.transactionContextFactory.getTransactionContext().currentTransaction) {
1205
+ throw new Error(
1206
+ `A write inside a transaction cannot name a retry policy of its own: the transaction's policy (runTransaction's options) governs every write in it`
1207
+ );
1208
+ }
1209
+
1210
+ return options;
1211
+ }
1212
+
1127
1213
  /**
1128
1214
  * The rows of one multi-row insert split into the fewest statements the driver's statement
1129
1215
  * parameter bound admits (`insertMany`): each statement binds `columns × rows` parameters,
@@ -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,37 @@
1
+ /**
2
+ * A transaction the database aborted (a lock conflict it resolved against this transaction) that
3
+ * its caller's retry policy (`TransactionRetryPolicy`) did not allow to be re-run any further:
4
+ * every attempt the policy allowed was made, or its time budget was spent, and the last one was
5
+ * aborted too. The driver throws it in place of the database's own abort, so a caller that chose
6
+ * a bounded policy — a best-effort write that loses to the next write of its kind — takes the
7
+ * refusal by type and decides what to do with it: nothing was written by this transaction, and
8
+ * nothing was retried behind the caller's back.
9
+ *
10
+ * `attempts` is how many attempts were made in all; `elapsedMs` the wall-clock from the first
11
+ * attempt's start to the last abort; `cause` the abort that ended the last attempt (the driver's
12
+ * own error for it — for callers in process; not enumerable, so a serialized error carries none
13
+ * of the backend's words).
14
+ *
15
+ * Name-tagged rather than relying on `instanceof`: the prototype chain is unreliable across
16
+ * package compile targets (the same reason `DuplicateKeyError` checks `name`).
17
+ */
18
+ export class TransactionRetryExhaustedError extends Error {
19
+ readonly cause: unknown;
20
+
21
+ constructor(
22
+ operation: string,
23
+ readonly attempts: number,
24
+ readonly elapsedMs: number,
25
+ cause: unknown
26
+ ) {
27
+ super(
28
+ `Transaction aborted and not retried further: its retry policy allowed ${attempts} attempt${attempts === 1 ? '' : 's'}, ` +
29
+ `made in ${Math.round(elapsedMs)}ms (${operation})`
30
+ );
31
+ this.name = 'TransactionRetryExhaustedError';
32
+ Object.defineProperty(this, 'cause', { value: cause, enumerable: false, writable: false });
33
+ }
34
+ }
35
+
36
+ export const isTransactionRetryExhaustedError = (error: unknown): error is TransactionRetryExhaustedError =>
37
+ !!error && typeof error === 'object' && (error as { name?: string }).name === 'TransactionRetryExhaustedError';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How a transaction is re-run when the database ABORTS it — a lock conflict the database
3
+ * resolves by aborting one of the transactions in it, delivered at that transaction's next
4
+ * statement or at its commit. The policy is the CALLER's, named at the call, never a default a
5
+ * caller cannot see:
6
+ *
7
+ * - Omitted: the driver's own road — the one right for a durable write, which must land however
8
+ * long the conflict lasts (on Spanner, the client library's runner re-runs the body on its
9
+ * backoff ladder inside the driver's configured budget).
10
+ * - `'none'`: one attempt. The abort reaches the caller at once, typed
11
+ * (`TransactionRetryExhaustedError`).
12
+ * - `{ attempts, maxMs }`: at most `attempts` attempts in all (the first included), and no
13
+ * attempt starts once `maxMs` milliseconds have passed since the first began; when either
14
+ * bound is reached the last abort reaches the caller typed. `maxMs` omitted bounds by
15
+ * attempts alone.
16
+ *
17
+ * A BEST-EFFORT write names a bounded policy: a write that the next write of its kind supersedes
18
+ * anyway (a live progress row rewritten at every step) must not be re-run for minutes against a
19
+ * conflict it will lose — it loses promptly, and the caller handles the typed refusal.
20
+ *
21
+ * The policy belongs to a transaction: a write issued outside any transaction is a transaction
22
+ * of its own and names it at the write (`Db.insert`, `Db.update`); a `Db.runTransaction` body
23
+ * names it on the transaction, and every write inside that body rides it — a write inside a
24
+ * transaction cannot name a policy of its own.
25
+ */
26
+ export type TransactionRetryPolicy = 'none' | { attempts: number; maxMs?: number };
27
+
28
+ /** The options of a transaction — a `Db.runTransaction` body's, or a write's own when it runs outside one. */
29
+ export type TransactionOptions = {
30
+ retry?: TransactionRetryPolicy;
31
+ };
@@ -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
+ });