@proteinjs/db 1.53.0 → 1.54.1
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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +45 -5
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +45 -11
- package/dist/src/Db.js.map +1 -1
- 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/services/TransactionRunnerService.d.ts +3 -1
- package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
- 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/src/transaction/TransactionRunner.d.ts +13 -5
- package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.js +48 -12
- package/dist/src/transaction/TransactionRunner.js.map +1 -1
- 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 +2 -0
- package/package.json +2 -2
- package/src/Db.ts +72 -11
- package/src/TransactionRetryExhaustedError.ts +37 -0
- package/src/services/TransactionRunnerService.ts +3 -1
- package/src/transaction/TransactionRetryPolicy.ts +31 -0
- package/src/transaction/TransactionRunner.ts +53 -13
- package/test/TransactionRetryOptions.test.ts +195 -0
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './src/DetachedDbOps';
|
|
|
3
3
|
export * from './src/Table';
|
|
4
4
|
export * from './src/RecordAccessError';
|
|
5
5
|
export * from './src/DuplicateKeyError';
|
|
6
|
+
export * from './src/TransactionRetryExhaustedError';
|
|
6
7
|
export * from './src/auth/TableAuth';
|
|
7
8
|
export * from './src/auth/TableServiceAuth';
|
|
8
9
|
export * from './src/Columns';
|
|
@@ -25,6 +26,7 @@ export * from './src/TableWatcher';
|
|
|
25
26
|
export * from './src/transaction/Transaction';
|
|
26
27
|
export * from './src/transaction/TransactionRunner';
|
|
27
28
|
export * from './src/transaction/TransactionContextFactory';
|
|
29
|
+
export * from './src/transaction/TransactionRetryPolicy';
|
|
28
30
|
|
|
29
31
|
export * from './src/schema/SchemaOperations';
|
|
30
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.1",
|
|
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": "9c87e1ee2e65c4b2ffcf5308cbab21b88d53365c"
|
|
70
70
|
}
|
package/src/Db.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { ReferenceCache } from './reference/ReferenceCache';
|
|
|
33
33
|
import { ArrayMembershipUpdate, applyArrayMembershipOps } from './reference/ArrayMembershipOps';
|
|
34
34
|
import { PreservedPath, overlayPreservedPaths } from './UpdatePreserving';
|
|
35
35
|
import { DuplicateKeyError } from './DuplicateKeyError';
|
|
36
|
+
import { TransactionOptions } from './transaction/TransactionRetryPolicy';
|
|
36
37
|
|
|
37
38
|
/** get `Db` if on server, and `DbService` if on browser */
|
|
38
39
|
export const getDb = <R extends Record = Record>() =>
|
|
@@ -94,8 +95,24 @@ export interface DbDriver {
|
|
|
94
95
|
generateStatement: (config: DbDriverQueryStatementConfig) => Statement,
|
|
95
96
|
transaction?: any
|
|
96
97
|
): Promise<SerializedRecord[]>;
|
|
97
|
-
|
|
98
|
-
|
|
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>;
|
|
99
116
|
/**
|
|
100
117
|
* The driver's per-operation deadline, in milliseconds: the longest one statement it runs may
|
|
101
118
|
* take before the driver fails it — the bound the driver enforces, as configured. Not a whole
|
|
@@ -209,10 +226,18 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
209
226
|
return (await this.query(table, query, options))[0];
|
|
210
227
|
}
|
|
211
228
|
|
|
212
|
-
|
|
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> {
|
|
213
237
|
if (!this.runAsSystem) {
|
|
214
238
|
this.auth.canInsert(table);
|
|
215
239
|
}
|
|
240
|
+
const driverOptions = this.ownTransactionOptions(options);
|
|
216
241
|
|
|
217
242
|
let recordCopy = Object.assign({}, record);
|
|
218
243
|
await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
|
|
@@ -228,7 +253,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
228
253
|
this.statementConfigFactory.getStatementConfig(config)
|
|
229
254
|
);
|
|
230
255
|
try {
|
|
231
|
-
await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
|
|
256
|
+
await this.dbDriver.runDml(generateInsert, this.transactionForDriver(), driverOptions);
|
|
232
257
|
} catch (error) {
|
|
233
258
|
throw this.insertFailure(table, error);
|
|
234
259
|
}
|
|
@@ -323,7 +348,20 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
323
348
|
return prepared.map((row) => row.recordCopy as T);
|
|
324
349
|
}
|
|
325
350
|
|
|
326
|
-
|
|
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> {
|
|
327
365
|
if (!this.runAsSystem) {
|
|
328
366
|
this.auth.canUpdate(table);
|
|
329
367
|
}
|
|
@@ -331,6 +369,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
331
369
|
if (!query && !record.id) {
|
|
332
370
|
throw new Error(`Update must be called with either a Query or a record with an id property`);
|
|
333
371
|
}
|
|
372
|
+
const driverOptions = this.ownTransactionOptions(options);
|
|
334
373
|
|
|
335
374
|
let recordCopy = Object.assign({}, record);
|
|
336
375
|
// Immutable columns can never be rewritten through an update — strip them from the payload
|
|
@@ -353,7 +392,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
353
392
|
recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
|
|
354
393
|
let recordUpdateCount: number;
|
|
355
394
|
if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
|
|
356
|
-
recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb);
|
|
395
|
+
recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb, driverOptions);
|
|
357
396
|
} else {
|
|
358
397
|
const recordSerializer = new RecordSerializer<T>(table);
|
|
359
398
|
const serializedRecord = await recordSerializer.serialize(recordCopy);
|
|
@@ -365,7 +404,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
365
404
|
qb,
|
|
366
405
|
this.statementConfigFactory.getStatementConfig(config)
|
|
367
406
|
);
|
|
368
|
-
recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
|
|
407
|
+
recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver(), driverOptions);
|
|
369
408
|
}
|
|
370
409
|
if (!this.runAsSystem && recordUpdateCount === 0) {
|
|
371
410
|
const id = this.singleRowIdTarget(record, query);
|
|
@@ -928,6 +967,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
928
967
|
*
|
|
929
968
|
* Note: Nested transactions are not supported; will throw.
|
|
930
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
|
+
*
|
|
931
975
|
* Note: work spawned inside the body but NOT awaited by it escapes the transaction's
|
|
932
976
|
* lifetime while still holding its context — such work fails loudly on its next db
|
|
933
977
|
* operation (see TransactionContextData.ended). Await everything inside the body, or run
|
|
@@ -946,7 +990,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
946
990
|
* });
|
|
947
991
|
* ```
|
|
948
992
|
*/
|
|
949
|
-
async runTransaction<T>(fn: () => Promise<T
|
|
993
|
+
async runTransaction<T>(fn: () => Promise<T>, options?: TransactionOptions): Promise<T> {
|
|
950
994
|
if (this.transactionContextFactory.getTransactionContext().currentTransaction) {
|
|
951
995
|
throw new Error(`Nested transactions are not supported. A transaction is already running in this context.`);
|
|
952
996
|
}
|
|
@@ -969,7 +1013,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
969
1013
|
// silent op on a finished transaction (see transactionForDriver).
|
|
970
1014
|
contextData.ended = true;
|
|
971
1015
|
}
|
|
972
|
-
});
|
|
1016
|
+
}, options);
|
|
973
1017
|
|
|
974
1018
|
// COMMIT BOUNDARY: the driver resolves only after the transaction is durably committed (and
|
|
975
1019
|
// rejects on rollback, in which case the queue above is never drained). Hook failures are
|
|
@@ -1062,7 +1106,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
1062
1106
|
private async updateEncrypted<T extends R>(
|
|
1063
1107
|
table: Table<T>,
|
|
1064
1108
|
recordCopy: Partial<T>,
|
|
1065
|
-
qb: QueryBuilder<T
|
|
1109
|
+
qb: QueryBuilder<T>,
|
|
1110
|
+
options?: TransactionOptions
|
|
1066
1111
|
): Promise<number> {
|
|
1067
1112
|
const targetRows = await this._query(table, qb);
|
|
1068
1113
|
if (targetRows.length === 0) {
|
|
@@ -1096,7 +1141,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
1096
1141
|
groupQb,
|
|
1097
1142
|
this.statementConfigFactory.getStatementConfig(config)
|
|
1098
1143
|
);
|
|
1099
|
-
recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
|
|
1144
|
+
recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver(), options);
|
|
1100
1145
|
await tokenMaintenance.afterUpdate(table, ids, recordCopy, owner, this.newSystemDb());
|
|
1101
1146
|
}
|
|
1102
1147
|
|
|
@@ -1149,6 +1194,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
1149
1194
|
return context.currentTransaction;
|
|
1150
1195
|
}
|
|
1151
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
|
+
|
|
1152
1213
|
/**
|
|
1153
1214
|
* The rows of one multi-row insert split into the fewest statements the driver's statement
|
|
1154
1215
|
* parameter bound admits (`insertMany`): each statement binds `columns × rows` parameters,
|
|
@@ -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';
|
|
@@ -17,7 +17,9 @@ export type TransactionRunOptions = {
|
|
|
17
17
|
* Ids of rows the operations depend on that may not exist yet: the row an update or a
|
|
18
18
|
* membership update targets while its insert is still in flight, the permission source a
|
|
19
19
|
* child row attaches to while the root's own insert (and the owner grant that commits with
|
|
20
|
-
* it) is still landing
|
|
20
|
+
* it) is still landing, any row an insert names through one of its table's reference columns
|
|
21
|
+
* while that row is still being born. Every id must be a row the operations themselves
|
|
22
|
+
* reference (an insert references every row its reference columns name). The
|
|
21
23
|
* runner waits, bounded, for each to exist AND be visible to the caller before running the
|
|
22
24
|
* operations; a row that never appears fails the request with a plain clause — never a
|
|
23
25
|
* silent zero-row write, never a hang.
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Logger } from '@proteinjs/logger';
|
|
2
|
+
import { isInstanceOf } from '@proteinjs/util';
|
|
2
3
|
import { Db, getDb } from '../Db';
|
|
3
|
-
import { Table, tableByName } from '../Table';
|
|
4
|
+
import { Column, Table, getColumnPropertyName, tableByName } from '../Table';
|
|
5
|
+
// After Table: Columns ↔ Table ↔ Record load in a cycle that resolves only when Record is entered
|
|
6
|
+
// before Columns (Record constructs its columns at module load); a module that enters Columns
|
|
7
|
+
// first — as this one would when it is the first module loaded — leaves those constructors undefined.
|
|
8
|
+
import { DynamicReferenceColumn, ReferenceColumn } from '../Columns';
|
|
4
9
|
import { getDefaultTransactionContextFactory } from './TransactionContextFactory';
|
|
5
10
|
import {
|
|
6
11
|
TransactionRunnerService,
|
|
@@ -135,10 +140,11 @@ export class TransactionRunner implements TransactionRunnerService {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
/**
|
|
138
|
-
* The rows the operations reference, by id → the table(s) they are read from
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
143
|
+
* The rows the operations reference, by id → the table(s) they are read from. An insert
|
|
144
|
+
* references every row its table's reference columns name (`rowsTheInsertReferences`) — the
|
|
145
|
+
* permission source a row attaches to is one of these, a row named through any other reference
|
|
146
|
+
* column another. An update / preserving update / membership update / delete references the row
|
|
147
|
+
* it targets. The rows the operations insert themselves are not dependencies.
|
|
142
148
|
*/
|
|
143
149
|
private static rowsTheOperationsReference(ops: Operation<any>[]): Map<string, Table<any>[]> {
|
|
144
150
|
const inserted: string[] = [];
|
|
@@ -161,16 +167,12 @@ export class TransactionRunner implements TransactionRunnerService {
|
|
|
161
167
|
const args = op.args as unknown[];
|
|
162
168
|
const table = args[0] as Table<any> | undefined;
|
|
163
169
|
if (op.name === 'insert') {
|
|
164
|
-
const record = args[1] as
|
|
165
|
-
| { id?: string; permissionSource?: { _table?: string; _id?: string }; permissionSourceTable?: string }
|
|
166
|
-
| undefined;
|
|
170
|
+
const record = args[1] as { id?: string } | undefined;
|
|
167
171
|
if (record?.id) {
|
|
168
172
|
inserted.push(record.id);
|
|
169
173
|
}
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const sourceTableName = record?.permissionSourceTable ?? source._table;
|
|
173
|
-
reference(sourceTableName ? TransactionRunner.tableNamed(sourceTableName, table) : table, source._id);
|
|
174
|
+
for (const row of TransactionRunner.rowsTheInsertReferences(table, record)) {
|
|
175
|
+
reference(TransactionRunner.tableNamed(row.tableName, table), row.id);
|
|
174
176
|
}
|
|
175
177
|
} else if (op.name === 'update' || op.name === 'updatePreserving') {
|
|
176
178
|
const record = args[1] as { id?: string } | undefined;
|
|
@@ -188,7 +190,45 @@ export class TransactionRunner implements TransactionRunnerService {
|
|
|
188
190
|
return referenced;
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* The rows an inserted record names through its table's reference columns: for each column
|
|
195
|
+
* whose value carries an id, that id and the table it lives in — a `ReferenceColumn`'s declared
|
|
196
|
+
* table; for a `DynamicReferenceColumn`, the table its paired table-name column carries on the
|
|
197
|
+
* record (a client's queued insert ran the column's default), else the reference's own.
|
|
198
|
+
*/
|
|
199
|
+
private static rowsTheInsertReferences(
|
|
200
|
+
table: Table<any> | undefined,
|
|
201
|
+
record: { [field: string]: unknown } | undefined
|
|
202
|
+
): { id: string; tableName: string }[] {
|
|
203
|
+
if (!table || !record) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rows: { id: string; tableName: string }[] = [];
|
|
208
|
+
for (const [field, column] of Object.entries(table.columns as { [field: string]: Column<any, any> })) {
|
|
209
|
+
const value = record[field] as { _id?: unknown; _table?: unknown } | null | undefined;
|
|
210
|
+
if (!value || typeof value._id !== 'string') {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let tableName: unknown;
|
|
214
|
+
if (isInstanceOf(column, ReferenceColumn)) {
|
|
215
|
+
tableName = (column as ReferenceColumn<any>).referenceTable;
|
|
216
|
+
} else if (isInstanceOf(column, DynamicReferenceColumn)) {
|
|
217
|
+
const tableNameField = getColumnPropertyName(
|
|
218
|
+
table,
|
|
219
|
+
(column as DynamicReferenceColumn<any>).dynamicRefTableColName
|
|
220
|
+
);
|
|
221
|
+
tableName = (tableNameField && record[tableNameField]) || value._table;
|
|
222
|
+
}
|
|
223
|
+
if (typeof tableName === 'string' && tableName) {
|
|
224
|
+
rows.push({ id: value._id, tableName });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The table a referenced row lives in: the insert's own table when it names the same one. */
|
|
192
232
|
private static tableNamed(tableName: string, inserting: Table<any> | undefined): Table<any> {
|
|
193
233
|
return inserting && inserting.name === tableName ? inserting : tableByName(tableName);
|
|
194
234
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { Statement } from '@proteinjs/db-query';
|
|
3
|
+
import { Db, DbDriver, DbDriverDmlStatementConfig } from '../src/Db';
|
|
4
|
+
import { Table } from '../src/Table';
|
|
5
|
+
import { Record, withRecordColumns } from '../src/Record';
|
|
6
|
+
import { StringColumn } from '../src/Columns';
|
|
7
|
+
import { TableWatcherRunner } from '../src/TableWatcherRunner';
|
|
8
|
+
import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
|
|
9
|
+
import { TransactionOptions } from '../src/transaction/TransactionRetryPolicy';
|
|
10
|
+
import {
|
|
11
|
+
TransactionRetryExhaustedError,
|
|
12
|
+
isTransactionRetryExhaustedError,
|
|
13
|
+
} from '../src/TransactionRetryExhaustedError';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The retry policy a caller names at the call reaches the driver on the write's own transaction —
|
|
17
|
+
* `insert` and `update` outside a transaction hand it to `runDml` beside the (absent) transaction,
|
|
18
|
+
* `runTransaction` hands it to the driver's `runTransaction` — and a write inside a transaction
|
|
19
|
+
* naming a policy of its own is refused by name: the transaction's policy governs every write in
|
|
20
|
+
* it, and a policy nothing would honour is never dropped on the floor. What the driver does with
|
|
21
|
+
* the policy — the bounded attempts, the typed refusal — is the driver suites' (the Spanner
|
|
22
|
+
* driver's BoundedTransactionRetry suite, on the emulator); the typed refusal's shape is held
|
|
23
|
+
* here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface Doc extends Record {
|
|
27
|
+
title: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class DocTable extends Table<Doc> {
|
|
31
|
+
public name = 'retry_options_doc';
|
|
32
|
+
public columns = withRecordColumns<Doc>({
|
|
33
|
+
title: new StringColumn('title'),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const docTable = new DocTable() as Table<Doc>;
|
|
38
|
+
|
|
39
|
+
class TestTransactionContextFactory implements DefaultTransactionContextFactory {
|
|
40
|
+
private context: TransactionContextData = {};
|
|
41
|
+
|
|
42
|
+
getTransactionContext(): TransactionContextData {
|
|
43
|
+
return this.context;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
const previous = this.context;
|
|
48
|
+
this.context = context;
|
|
49
|
+
try {
|
|
50
|
+
return await fn();
|
|
51
|
+
} finally {
|
|
52
|
+
this.context = previous;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A driver that records the transaction and options every write and transaction arrived with. */
|
|
58
|
+
class RecordingDriver implements DbDriver {
|
|
59
|
+
readonly dmlCalls: { transaction: unknown; options: TransactionOptions | undefined }[] = [];
|
|
60
|
+
readonly transactionCalls: { options: TransactionOptions | undefined }[] = [];
|
|
61
|
+
|
|
62
|
+
getDbName(): string {
|
|
63
|
+
return 'test';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async createDbIfNotExists(): Promise<void> {}
|
|
67
|
+
|
|
68
|
+
async createDb(): Promise<void> {}
|
|
69
|
+
|
|
70
|
+
async dropDb(): Promise<void> {}
|
|
71
|
+
|
|
72
|
+
getTableManager(): never {
|
|
73
|
+
throw new Error('not used by these tests');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async runQuery(): Promise<never[]> {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async runDml(
|
|
81
|
+
generateStatement: (config: DbDriverDmlStatementConfig) => Statement,
|
|
82
|
+
transaction?: unknown,
|
|
83
|
+
options?: TransactionOptions
|
|
84
|
+
): Promise<number> {
|
|
85
|
+
generateStatement({
|
|
86
|
+
useParams: true,
|
|
87
|
+
useNamedParams: true,
|
|
88
|
+
prefixTablesWithDb: false,
|
|
89
|
+
getDriverColumnType: () => 'STRING(MAX)',
|
|
90
|
+
});
|
|
91
|
+
this.dmlCalls.push({ transaction, options });
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async runTransaction<T>(fn: (transaction: unknown) => Promise<T>, options?: TransactionOptions): Promise<T> {
|
|
96
|
+
this.transactionCalls.push({ options });
|
|
97
|
+
return await fn({ handle: 'the transaction' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getOperationDeadlineMs(): number {
|
|
101
|
+
return 60_000;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
106
|
+
type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
|
|
107
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
108
|
+
|
|
109
|
+
describe('A write names its retry policy at the call; it reaches the driver on the write`s own transaction', () => {
|
|
110
|
+
let driver: RecordingDriver;
|
|
111
|
+
let db: Db<Doc>;
|
|
112
|
+
let originalWatchers: unknown[] | undefined;
|
|
113
|
+
|
|
114
|
+
// No reflection graph in a unit test: the watcher registry is empty by declaration.
|
|
115
|
+
beforeAll(() => {
|
|
116
|
+
originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
|
|
117
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [];
|
|
118
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(() => {
|
|
122
|
+
if (originalWatchers) {
|
|
123
|
+
objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
|
|
124
|
+
} else {
|
|
125
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
126
|
+
}
|
|
127
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
driver = new RecordingDriver();
|
|
132
|
+
db = new Db<Doc>(driver, () => docTable, new TestTransactionContextFactory(), true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('an update outside a transaction carries its policy to the driver, with no transaction', async () => {
|
|
136
|
+
await db.update(docTable, { id: 'doc-1', title: 'a title' }, undefined, { retry: { attempts: 2, maxMs: 1500 } });
|
|
137
|
+
|
|
138
|
+
expect(driver.dmlCalls).toEqual([{ transaction: undefined, options: { retry: { attempts: 2, maxMs: 1500 } } }]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an insert outside a transaction carries its policy to the driver, with no transaction', async () => {
|
|
142
|
+
await db.insert(docTable, { title: 'a title' }, { retry: 'none' });
|
|
143
|
+
|
|
144
|
+
expect(driver.dmlCalls).toEqual([{ transaction: undefined, options: { retry: 'none' } }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a write with no policy reaches the driver with none — the driver`s own road', async () => {
|
|
148
|
+
await db.update(docTable, { id: 'doc-1', title: 'a title' });
|
|
149
|
+
await db.insert(docTable, { title: 'a title' });
|
|
150
|
+
|
|
151
|
+
expect(driver.dmlCalls.map((call) => call.options)).toEqual([undefined, undefined]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a transaction carries its policy to the driver, and the writes inside it ride the transaction with no policy of their own', async () => {
|
|
155
|
+
await db.runTransaction(
|
|
156
|
+
async () => {
|
|
157
|
+
await db.update(docTable, { id: 'doc-1', title: 'a title' });
|
|
158
|
+
await db.insert(docTable, { title: 'a title' });
|
|
159
|
+
},
|
|
160
|
+
{ retry: { attempts: 3 } }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(driver.transactionCalls).toEqual([{ options: { retry: { attempts: 3 } } }]);
|
|
164
|
+
expect(driver.dmlCalls).toEqual([
|
|
165
|
+
{ transaction: { handle: 'the transaction' }, options: undefined },
|
|
166
|
+
{ transaction: { handle: 'the transaction' }, options: undefined },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a write inside a transaction naming a policy of its own is refused by name, and never reaches the driver', async () => {
|
|
171
|
+
const refusal = db.runTransaction(async () => {
|
|
172
|
+
await db.update(docTable, { id: 'doc-1', title: 'a title' }, undefined, { retry: 'none' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await expect(refusal).rejects.toThrow('A write inside a transaction cannot name a retry policy of its own');
|
|
176
|
+
expect(driver.dmlCalls).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('the typed refusal: name-tagged, carrying the attempts and the elapsed time, its cause never enumerable', () => {
|
|
180
|
+
const abort = new Error('10 ABORTED: the backend quotes a row value here');
|
|
181
|
+
const refusal = new TransactionRetryExhaustedError('spanner dml transaction', 2, 312.4, abort);
|
|
182
|
+
|
|
183
|
+
expect(isTransactionRetryExhaustedError(refusal)).toBe(true);
|
|
184
|
+
expect(isTransactionRetryExhaustedError(new Error('other'))).toBe(false);
|
|
185
|
+
expect(refusal.name).toBe('TransactionRetryExhaustedError');
|
|
186
|
+
expect(refusal.attempts).toBe(2);
|
|
187
|
+
expect(refusal.elapsedMs).toBe(312.4);
|
|
188
|
+
expect(refusal.cause).toBe(abort);
|
|
189
|
+
expect(Object.keys(refusal)).not.toContain('cause');
|
|
190
|
+
expect(JSON.stringify(refusal)).not.toContain('row value');
|
|
191
|
+
expect(refusal.message).toBe(
|
|
192
|
+
'Transaction aborted and not retried further: its retry policy allowed 2 attempts, made in 312ms (spanner dml transaction)'
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|