@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/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +13 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +40 -15
- package/dist/src/Db.js.map +1 -1
- package/dist/src/DuplicateKeyError.d.ts +20 -0
- package/dist/src/DuplicateKeyError.d.ts.map +1 -0
- package/dist/src/DuplicateKeyError.js +47 -0
- package/dist/src/DuplicateKeyError.js.map +1 -0
- package/dist/test/DuplicateKeyError.test.d.ts +2 -0
- package/dist/test/DuplicateKeyError.test.d.ts.map +1 -0
- package/dist/test/DuplicateKeyError.test.js +287 -0
- package/dist/test/DuplicateKeyError.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/Db.ts +27 -2
- package/src/DuplicateKeyError.ts +26 -0
- package/test/DuplicateKeyError.test.ts +179 -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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|