@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/Db.d.ts +45 -5
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +45 -11
  13. package/dist/src/Db.js.map +1 -1
  14. package/dist/src/TransactionRetryExhaustedError.d.ts +25 -0
  15. package/dist/src/TransactionRetryExhaustedError.d.ts.map +1 -0
  16. package/dist/src/TransactionRetryExhaustedError.js +54 -0
  17. package/dist/src/TransactionRetryExhaustedError.js.map +1 -0
  18. package/dist/src/services/TransactionRunnerService.d.ts +3 -1
  19. package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
  20. package/dist/src/transaction/TransactionRetryPolicy.d.ts +34 -0
  21. package/dist/src/transaction/TransactionRetryPolicy.d.ts.map +1 -0
  22. package/dist/src/transaction/TransactionRetryPolicy.js +3 -0
  23. package/dist/src/transaction/TransactionRetryPolicy.js.map +1 -0
  24. package/dist/src/transaction/TransactionRunner.d.ts +13 -5
  25. package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
  26. package/dist/src/transaction/TransactionRunner.js +48 -12
  27. package/dist/src/transaction/TransactionRunner.js.map +1 -1
  28. package/dist/test/TransactionRetryOptions.test.d.ts +2 -0
  29. package/dist/test/TransactionRetryOptions.test.d.ts.map +1 -0
  30. package/dist/test/TransactionRetryOptions.test.js +293 -0
  31. package/dist/test/TransactionRetryOptions.test.js.map +1 -0
  32. package/generated/index.ts +1 -1
  33. package/generated/test/index.ts +1 -1
  34. package/index.ts +2 -0
  35. package/package.json +2 -2
  36. package/src/Db.ts +72 -11
  37. package/src/TransactionRetryExhaustedError.ts +37 -0
  38. package/src/services/TransactionRunnerService.ts +3 -1
  39. package/src/transaction/TransactionRetryPolicy.ts +31 -0
  40. package/src/transaction/TransactionRunner.ts +53 -13
  41. 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.53.0",
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": "480320d415088dd0d378bd0e9863dc70353fa92f"
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
- runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: any): Promise<number>;
98
- runTransaction<T>(fn: (transaction: any) => Promise<T>): Promise<T>;
98
+ /**
99
+ * Run one write statement. On `transaction` it rides that transaction; without one it is a
100
+ * transaction of its own, and `options.retry` (`TransactionRetryPolicy`) is how the driver
101
+ * re-runs it when the database aborts it — the driver's own road when omitted, else the bound
102
+ * named, past which the driver throws `TransactionRetryExhaustedError`. A driver that never
103
+ * re-runs an aborted statement (the abort reaches the caller at once) already satisfies every
104
+ * policy.
105
+ */
106
+ runDml(
107
+ generateStatement: (config: DbDriverDmlStatementConfig) => Statement,
108
+ transaction?: any,
109
+ options?: TransactionOptions
110
+ ): Promise<number>;
111
+ /**
112
+ * Run `fn` as one transaction. `options.retry` (`TransactionRetryPolicy`) is how the driver
113
+ * re-runs the whole body when the database aborts the transaction — see `runDml`.
114
+ */
115
+ runTransaction<T>(fn: (transaction: any) => Promise<T>, options?: TransactionOptions): Promise<T>;
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
- async insert<T extends R>(table: Table<T>, record: Omit<T, keyof R>): Promise<T> {
229
+ /**
230
+ * Insert one row. Outside a transaction the insert is a transaction of its own, and
231
+ * `options.retry` names how it is re-run when the database aborts it (`TransactionRetryPolicy`:
232
+ * the driver's own road when omitted; a bounded policy for a best-effort write, past which the
233
+ * caller takes `TransactionRetryExhaustedError`). Inside a transaction the transaction's policy
234
+ * governs, and an insert naming one of its own is refused.
235
+ */
236
+ async insert<T extends R>(table: Table<T>, record: Omit<T, keyof R>, options?: TransactionOptions): Promise<T> {
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
- async update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number> {
351
+ /**
352
+ * Update the rows `query` selects (the one row `record.id` names when there is no query).
353
+ * Outside a transaction the update is a transaction of its own, and `options.retry` names how
354
+ * it is re-run when the database aborts it (`TransactionRetryPolicy`: the driver's own road when
355
+ * omitted; a bounded policy for a best-effort write — a live progress row the next step
356
+ * rewrites anyway — past which the caller takes `TransactionRetryExhaustedError`). Inside a
357
+ * transaction the transaction's policy governs, and an update naming one of its own is refused.
358
+ */
359
+ async update<T extends R>(
360
+ table: Table<T>,
361
+ record: Partial<T>,
362
+ query?: Query<T>,
363
+ options?: TransactionOptions
364
+ ): Promise<number> {
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>): 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. Every id must be a row the operations themselves reference. The
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: the permission
139
- * source a row attaches to (an insert whose `permissionSource` is another row), the row an
140
- * update / preserving update / membership update / delete targets. The rows the operations
141
- * insert themselves are not dependencies.
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 source = record?.permissionSource;
171
- if (source?._id && source._id !== record?.id) {
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
- /** The table a permission source lives in: the insert's own table when it names the same one. */
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
+ });