@proteinjs/db 1.52.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.
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.52.0",
3
+ "version": "1.53.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": "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>() =>
@@ -112,6 +113,13 @@ export interface DbDriver {
112
113
  * and a batch on it is always one statement.
113
114
  */
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;
115
123
  }
116
124
 
117
125
  export class Db<R extends Record = Record> implements DbService<R> {
@@ -219,7 +227,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
219
227
  serializedRecord as Partial<T>,
220
228
  this.statementConfigFactory.getStatementConfig(config)
221
229
  );
222
- 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
+ }
223
235
  if (encryptionContext) {
224
236
  // The same write that stored the ciphertext maintains its search tokens (rides the
225
237
  // ambient transaction when there is one).
@@ -288,7 +300,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
288
300
  statementRows as Partial<T>[],
289
301
  this.statementConfigFactory.getStatementConfig(config)
290
302
  );
291
- await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
303
+ try {
304
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
305
+ } catch (error) {
306
+ throw this.insertFailure(table, error);
307
+ }
292
308
  }
293
309
 
294
310
  for (const { recordCopy, encryptionContext } of prepared) {
@@ -1020,6 +1036,15 @@ export class Db<R extends Record = Record> implements DbService<R> {
1020
1036
  return { keyOwner: await new EncryptionRecordHooks().resolveKeyOwnerForWrite(table, record) };
1021
1037
  }
1022
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
+
1023
1048
  private async updateTouchesEncryptedColumns(table: Table<any>, record: any): Promise<boolean> {
1024
1049
  const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
1025
1050
  const encryptedColumns = new EncryptedColumns();
@@ -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
+ });