@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.
- package/CHANGELOG.md +22 -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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +58 -5
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +83 -24
- 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/src/TransactionRetryExhaustedError.d.ts +25 -0
- package/dist/src/TransactionRetryExhaustedError.d.ts.map +1 -0
- package/dist/src/TransactionRetryExhaustedError.js +54 -0
- package/dist/src/TransactionRetryExhaustedError.js.map +1 -0
- package/dist/src/transaction/TransactionRetryPolicy.d.ts +34 -0
- package/dist/src/transaction/TransactionRetryPolicy.d.ts.map +1 -0
- package/dist/src/transaction/TransactionRetryPolicy.js +3 -0
- package/dist/src/transaction/TransactionRetryPolicy.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/dist/test/TransactionRetryOptions.test.d.ts +2 -0
- package/dist/test/TransactionRetryOptions.test.d.ts.map +1 -0
- package/dist/test/TransactionRetryOptions.test.js +293 -0
- package/dist/test/TransactionRetryOptions.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +3 -0
- package/package.json +2 -2
- package/src/Db.ts +98 -12
- package/src/DuplicateKeyError.ts +26 -0
- package/src/TransactionRetryExhaustedError.ts +37 -0
- package/src/transaction/TransactionRetryPolicy.ts +31 -0
- package/test/DuplicateKeyError.test.ts +179 -0
- 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.
|
|
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": "
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|