@proteinjs/db 1.55.1 → 1.57.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.
Files changed (47) 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.d.ts.map +1 -1
  5. package/dist/generated/test/index.js +5 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/src/Db.d.ts +22 -6
  8. package/dist/src/Db.d.ts.map +1 -1
  9. package/dist/src/Db.js +35 -15
  10. package/dist/src/Db.js.map +1 -1
  11. package/dist/src/TableWatcher.d.ts +15 -6
  12. package/dist/src/TableWatcher.d.ts.map +1 -1
  13. package/dist/src/TableWatcher.js.map +1 -1
  14. package/dist/src/TableWatcherRunner.d.ts +14 -6
  15. package/dist/src/TableWatcherRunner.d.ts.map +1 -1
  16. package/dist/src/TableWatcherRunner.js +19 -12
  17. package/dist/src/TableWatcherRunner.js.map +1 -1
  18. package/dist/test/DbIsSystem.test.d.ts +2 -0
  19. package/dist/test/DbIsSystem.test.d.ts.map +1 -0
  20. package/dist/test/DbIsSystem.test.js +280 -0
  21. package/dist/test/DbIsSystem.test.js.map +1 -0
  22. package/dist/test/index.d.ts +2 -0
  23. package/dist/test/index.d.ts.map +1 -1
  24. package/dist/test/index.js +2 -0
  25. package/dist/test/index.js.map +1 -1
  26. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts +13 -0
  27. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts.map +1 -0
  28. package/dist/test/reusable/TableWatcherAuthorityTests.js +392 -0
  29. package/dist/test/reusable/TableWatcherAuthorityTests.js.map +1 -0
  30. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  31. package/dist/test/util/DbTestEnvironment.js +3 -2
  32. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  33. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts +35 -0
  34. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts.map +1 -0
  35. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js +113 -0
  36. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js.map +1 -0
  37. package/generated/index.ts +1 -1
  38. package/generated/test/index.ts +5 -1
  39. package/package.json +2 -2
  40. package/src/Db.ts +50 -21
  41. package/src/TableWatcher.ts +17 -6
  42. package/src/TableWatcherRunner.ts +25 -12
  43. package/test/DbIsSystem.test.ts +173 -0
  44. package/test/index.ts +2 -0
  45. package/test/reusable/TableWatcherAuthorityTests.ts +204 -0
  46. package/test/util/DbTestEnvironment.ts +2 -0
  47. package/test/util/tables/tableWatcherAuthorityTestTables.ts +61 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.55.1",
3
+ "version": "1.57.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": "195e4d8a6aae026ad6c6b8b0fbfa831a57ecb501"
69
+ "gitHead": "c33ab303e81e366f73bb2cfac13f818567c37306"
70
70
  }
package/src/Db.ts CHANGED
@@ -167,6 +167,16 @@ export class Db<R extends Record = Record> implements DbService<R> {
167
167
  : this.getDefaultTransactionContextFactory();
168
168
  }
169
169
 
170
+ /**
171
+ * Whether this Db writes as the SYSTEM (`getDbAsSystem()`: no table-auth gate, no scope) rather
172
+ * than as the session's user. The Db a write hands its hooks ({@link newHookDb}) answers for the
173
+ * invoking write, so a watcher that must tell a user's write from a system one (archive on a
174
+ * user's delete; stand aside on a purge) reads the write's mode here instead of the session.
175
+ */
176
+ isSystem(): boolean {
177
+ return this.runAsSystem;
178
+ }
179
+
170
180
  static getDefaultDbDriver(): DbDriver {
171
181
  if (!getGlobal()[DEFAULT_DB_DRIVER_GLOBAL_KEY]) {
172
182
  const defaultDbDriverFactory = SourceRepository.get().object<DefaultDbDriverFactory>(
@@ -241,7 +251,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
241
251
 
242
252
  let recordCopy = Object.assign({}, record);
243
253
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
244
- recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
254
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy, this.newHookDb());
245
255
  await this.addColumnInsertHooks(table, recordCopy);
246
256
  const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
247
257
  const recordSerializer = new RecordSerializer(table, encryptionContext);
@@ -269,7 +279,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
269
279
  );
270
280
  }
271
281
  await this.runColumnAfterInsertHooks(table, recordCopy);
272
- await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
282
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T, this.newHookDb());
273
283
  return recordCopy as T;
274
284
  }
275
285
 
@@ -311,7 +321,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
311
321
  for (const record of records) {
312
322
  let recordCopy = Object.assign({}, record);
313
323
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
314
- recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
324
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy, this.newHookDb());
315
325
  await this.addColumnInsertHooks(table, recordCopy);
316
326
  const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
317
327
  const serializedRecord = await new RecordSerializer(table, encryptionContext).serialize(recordCopy);
@@ -343,7 +353,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
343
353
  );
344
354
  }
345
355
  await this.runColumnAfterInsertHooks(table, recordCopy);
346
- await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
356
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T, this.newHookDb());
347
357
  }
348
358
  return prepared.map((row) => row.recordCopy as T);
349
359
  }
@@ -389,7 +399,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
389
399
  qb.condition({ field: 'id', operator: '=', value: recordCopy.id as T[keyof T] });
390
400
  }
391
401
 
392
- recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
402
+ recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb, this.newHookDb());
393
403
  let recordUpdateCount: number;
394
404
  if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
395
405
  recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb, driverOptions);
@@ -412,7 +422,13 @@ export class Db<R extends Record = Record> implements DbService<R> {
412
422
  await this.runZeroRowFilteredWriteHooks(table, id, 'write');
413
423
  }
414
424
  }
415
- await this.tableWatcherRunner.runAfterUpdateTableWatchers(table, recordUpdateCount, recordCopy, qb);
425
+ await this.tableWatcherRunner.runAfterUpdateTableWatchers(
426
+ table,
427
+ recordUpdateCount,
428
+ recordCopy,
429
+ qb,
430
+ this.newHookDb()
431
+ );
416
432
  return recordUpdateCount;
417
433
  }
418
434
 
@@ -542,34 +558,31 @@ export class Db<R extends Record = Record> implements DbService<R> {
542
558
  const generateDelete = (config: DbDriverDmlStatementConfig) =>
543
559
  new StatementFactory<T>().delete(table.name, deleteQb, this.statementConfigFactory.getStatementConfig(config));
544
560
  await this.runColumnBeforeDeletes(table, recordsToDelete);
545
- await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb);
561
+ await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb, this.newHookDb());
546
562
  const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
547
563
  await this.runCascadeDeletions(table, recordsToDelete);
548
564
  await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
549
565
  await this.deleteEncryptionTokenRows(table, recordsToDeleteIds as string[]);
550
- await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
566
+ await this.tableWatcherRunner.runAfterDeleteTableWatchers(
567
+ table,
568
+ recordDeleteCount,
569
+ recordsToDelete,
570
+ qb,
571
+ deleteQb,
572
+ this.newHookDb()
573
+ );
551
574
  return recordDeleteCount;
552
575
  }
553
576
 
554
577
  /**
555
- * A column's `beforeDelete` hook runs with the authority of the write that fired it: the Db it
556
- * is handed rides this instance's driver, tables and transaction context AND its system mode.
557
- * The hooks cascade a delete through reference columns, and a system delete's cascade must stay
558
- * system — handed a default (non-system) Db, the cascade ran as whoever the session held, which
559
- * narrowed it to that user's rows inside a request and refused it outright in a background
560
- * context, where a scoped table finds no user to scope the query to.
578
+ * A column's `beforeDelete` hook cascades the delete through reference columns, with the
579
+ * authority of the write that fired it ({@link newHookDb}).
561
580
  */
562
581
  private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
563
582
  for (const columnPropertyName in table.columns) {
564
583
  const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
565
584
  if (typeof column.beforeDelete !== 'undefined') {
566
- await column.beforeDelete(
567
- table,
568
- columnPropertyName,
569
- recordsToDelete,
570
- this.getTable,
571
- new Db(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem)
572
- );
585
+ await column.beforeDelete(table, columnPropertyName, recordsToDelete, this.getTable, this.newHookDb());
573
586
  }
574
587
  }
575
588
  }
@@ -1171,6 +1184,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
1171
1184
  return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, true);
1172
1185
  }
1173
1186
 
1187
+ /**
1188
+ * The Db a hook is handed — a column's `beforeDelete`, a table watcher's six hooks: an instance
1189
+ * riding THIS instance's driver, tables and transaction context AND its system mode, so the hook
1190
+ * runs with the authority of the write that fired it. A hook that opened a Db of its own
1191
+ * (`getDb()`, a session-scoped Db) ran as whoever the session held: a system write's follow-on
1192
+ * narrowed to that user's rows inside a request, and refused outright in a background context
1193
+ * (an account purge, a sweep, a deploy gate), where a scoped table finds no user to scope the
1194
+ * query to — the cascade left orphans, the watcher left its rows (a message's topic links, a
1195
+ * media row's file) and either swallowed or raised the "No user in scope context" error.
1196
+ * Transaction state is instance state ({@link newSelfWrapDb}), so the hook rides the ambient
1197
+ * transaction through the shared context and never this instance's own.
1198
+ */
1199
+ private newHookDb(): Db<any> {
1200
+ return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem);
1201
+ }
1202
+
1174
1203
  /**
1175
1204
  * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
1176
1205
  * in a transaction. `runTransaction` carries the open transaction as INSTANCE state, and these
@@ -2,6 +2,7 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
2
  import { QueryBuilder } from '@proteinjs/db-query';
3
3
  import { Record } from './Record';
4
4
  import { Table } from './Table';
5
+ import { Db } from './Db';
5
6
 
6
7
  type RequiredProps<T, K extends keyof T> = Omit<T, K> & {
7
8
  [P in K]-?: T[P];
@@ -65,22 +66,32 @@ export const getTableWatcherMap = <R extends Record = any>(): TableWatcherMap<R>
65
66
  export const getTableWatchers = <R extends Record = any>() =>
66
67
  SourceRepository.get().objects<TableWatcher<R>>('@proteinjs/db/TableWatcher');
67
68
 
69
+ /**
70
+ * Hooks around a table's writes. Every hook's trailing `db` is the Db the invoking write made
71
+ * for its hooks — the write's driver, tables and transaction context AND its system mode — so a
72
+ * watcher's own reads and writes carry the authority of the write that fired it: a system write's
73
+ * follow-on runs as system (a background purge with no session user), a request's as its session.
74
+ * The Db hands it to every hook; a watcher never opens a Db of its own for the write's follow-on.
75
+ * Optional in the type so an implementer that ignores it still conforms.
76
+ */
68
77
  export interface TableWatcher<R extends Record = Record> extends Loadable {
69
78
  name(): string;
70
79
  table(): Table<R>;
71
- beforeInsert?<T extends R>(record: Omit<T, keyof R>): Promise<Omit<T, keyof R>>;
72
- afterInsert?<T extends R>(record: T): Promise<void>;
73
- beforeUpdate?<T extends R>(record: Partial<T>, qb: QueryBuilder<T>): Promise<Partial<T>>;
74
- afterUpdate?<T extends R>(recordUpdateCount: number, record: Partial<T>, qb: QueryBuilder<T>): Promise<void>;
80
+ beforeInsert?<T extends R>(record: Omit<T, keyof R>, db?: Db): Promise<Omit<T, keyof R>>;
81
+ afterInsert?<T extends R>(record: T, db?: Db): Promise<void>;
82
+ beforeUpdate?<T extends R>(record: Partial<T>, qb: QueryBuilder<T>, db?: Db): Promise<Partial<T>>;
83
+ afterUpdate?<T extends R>(recordUpdateCount: number, record: Partial<T>, qb: QueryBuilder<T>, db?: Db): Promise<void>;
75
84
  beforeDelete?<T extends R>(
76
85
  recordsToDelete: T[],
77
86
  initialQb: QueryBuilder<T>,
78
- deleteQb: QueryBuilder<T>
87
+ deleteQb: QueryBuilder<T>,
88
+ db?: Db
79
89
  ): Promise<void>;
80
90
  afterDelete?<T extends R>(
81
91
  recordDeleteCount: number,
82
92
  deletedRecords: T[],
83
93
  initialQb: QueryBuilder<T>,
84
- deleteQb: QueryBuilder<T>
94
+ deleteQb: QueryBuilder<T>,
95
+ db?: Db
85
96
  ): Promise<void>;
86
97
  }
@@ -2,11 +2,19 @@ import { Logger } from '@proteinjs/logger';
2
2
  import { TableWatcherMap, getTableWatcherMap } from './TableWatcher';
3
3
  import { Record } from './Record';
4
4
  import { Table } from './Table';
5
+ import { Db } from './Db';
5
6
  import { QueryBuilder } from '@proteinjs/db-query';
6
7
 
7
8
  const getEnvVar = (key: string): string | undefined =>
8
9
  typeof process !== 'undefined' && process.env ? process.env[key] : undefined;
9
10
 
11
+ /**
12
+ * Runs a table's watchers around a write. Every hook is handed `db`, the Db the invoking write
13
+ * made for its hooks (`Db.newHookDb`): the write's driver, tables and transaction context AND its
14
+ * system mode, so a watcher's own reads and writes carry the authority of the write that fired
15
+ * it. A watcher never opens a Db of its own for the write's follow-on — a session-scoped Db ran
16
+ * as whoever the session held, which refused a system write's follow-on in a background context.
17
+ */
10
18
  export class TableWatcherRunner<R extends Record = Record> {
11
19
  private static tableWatcherMap: TableWatcherMap;
12
20
  private logger = new Logger({ name: this.constructor.name, logLevel: getEnvVar('DB_LOG_LEVEL') as any });
@@ -19,7 +27,8 @@ export class TableWatcherRunner<R extends Record = Record> {
19
27
 
20
28
  async runBeforeInsertTableWatchers<T extends R>(
21
29
  table: Table<T>,
22
- record: Omit<T, keyof R>
30
+ record: Omit<T, keyof R>,
31
+ db: Db
23
32
  ): Promise<Omit<T, keyof R>> {
24
33
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
25
34
  if (!tableWatcherMap) {
@@ -35,7 +44,7 @@ export class TableWatcherRunner<R extends Record = Record> {
35
44
  let updatedRecord: any = record;
36
45
  for (const tableWatcher of tableWatchers) {
37
46
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.beforeInsert` });
38
- updatedRecord = await tableWatcher.beforeInsert(updatedRecord);
47
+ updatedRecord = await tableWatcher.beforeInsert(updatedRecord, db);
39
48
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.beforeInsert` });
40
49
  }
41
50
  this.logger.info({ message: `(${table.name}) Finished running before-insert table watchers` });
@@ -43,7 +52,7 @@ export class TableWatcherRunner<R extends Record = Record> {
43
52
  return updatedRecord;
44
53
  }
45
54
 
46
- async runAfterInsertTableWatchers<T extends R>(table: Table<T>, record: T): Promise<void> {
55
+ async runAfterInsertTableWatchers<T extends R>(table: Table<T>, record: T, db: Db): Promise<void> {
47
56
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
48
57
  if (!tableWatcherMap) {
49
58
  return;
@@ -57,7 +66,7 @@ export class TableWatcherRunner<R extends Record = Record> {
57
66
  this.logger.info({ message: `(${table.name}) Running after-insert table watchers` });
58
67
  for (const tableWatcher of tableWatchers) {
59
68
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.afterInsert` });
60
- await tableWatcher.afterInsert(record);
69
+ await tableWatcher.afterInsert(record, db);
61
70
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.afterInsert` });
62
71
  }
63
72
  this.logger.info({ message: `(${table.name}) Finished running after-insert table watchers` });
@@ -66,7 +75,8 @@ export class TableWatcherRunner<R extends Record = Record> {
66
75
  async runBeforeUpdateTableWatchers<T extends R>(
67
76
  table: Table<T>,
68
77
  record: Partial<T>,
69
- qb: QueryBuilder<T>
78
+ qb: QueryBuilder<T>,
79
+ db: Db
70
80
  ): Promise<Partial<T>> {
71
81
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
72
82
  if (!tableWatcherMap) {
@@ -83,7 +93,7 @@ export class TableWatcherRunner<R extends Record = Record> {
83
93
  let updatedRecord: any = record;
84
94
  for (const tableWatcher of tableWatchers) {
85
95
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.beforeUpdate` });
86
- updatedRecord = await tableWatcher.beforeUpdate(updatedRecord, qbCopy);
96
+ updatedRecord = await tableWatcher.beforeUpdate(updatedRecord, qbCopy, db);
87
97
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.beforeUpdate` });
88
98
  }
89
99
  this.logger.info({ message: `(${table.name}) Finished running before-update table watchers` });
@@ -95,7 +105,8 @@ export class TableWatcherRunner<R extends Record = Record> {
95
105
  table: Table<T>,
96
106
  recordUpdateCount: number,
97
107
  record: Partial<T>,
98
- qb: QueryBuilder<T>
108
+ qb: QueryBuilder<T>,
109
+ db: Db
99
110
  ): Promise<void> {
100
111
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
101
112
  if (!tableWatcherMap) {
@@ -111,7 +122,7 @@ export class TableWatcherRunner<R extends Record = Record> {
111
122
  const qbCopy = QueryBuilder.fromQueryBuilder(qb, table.name);
112
123
  for (const tableWatcher of tableWatchers) {
113
124
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.afterUpdate` });
114
- await tableWatcher.afterUpdate(recordUpdateCount, record, qbCopy);
125
+ await tableWatcher.afterUpdate(recordUpdateCount, record, qbCopy, db);
115
126
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.afterUpdate` });
116
127
  }
117
128
  this.logger.info({ message: `(${table.name}) Finished running after-update table watchers` });
@@ -121,7 +132,8 @@ export class TableWatcherRunner<R extends Record = Record> {
121
132
  table: Table<T>,
122
133
  recordsToDelete: T[],
123
134
  initialQb: QueryBuilder<T>,
124
- deleteQb: QueryBuilder<T>
135
+ deleteQb: QueryBuilder<T>,
136
+ db: Db
125
137
  ): Promise<void> {
126
138
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
127
139
  if (!tableWatcherMap) {
@@ -137,7 +149,7 @@ export class TableWatcherRunner<R extends Record = Record> {
137
149
  const initialQbCopy = QueryBuilder.fromQueryBuilder(initialQb, table.name);
138
150
  for (const tableWatcher of tableWatchers) {
139
151
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.beforeDelete` });
140
- await tableWatcher.beforeDelete(recordsToDelete, initialQbCopy, deleteQb);
152
+ await tableWatcher.beforeDelete(recordsToDelete, initialQbCopy, deleteQb, db);
141
153
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.beforeDelete` });
142
154
  }
143
155
  this.logger.info({ message: `(${table.name}) Finished running before-delete table watchers` });
@@ -148,7 +160,8 @@ export class TableWatcherRunner<R extends Record = Record> {
148
160
  recordDeleteCount: number,
149
161
  deletedRecords: T[],
150
162
  initialQb: QueryBuilder<T>,
151
- deleteQb: QueryBuilder<T>
163
+ deleteQb: QueryBuilder<T>,
164
+ db: Db
152
165
  ): Promise<void> {
153
166
  const tableWatcherMap = TableWatcherRunner.tableWatcherMap[table.name];
154
167
  if (!tableWatcherMap) {
@@ -164,7 +177,7 @@ export class TableWatcherRunner<R extends Record = Record> {
164
177
  const initialQbCopy = QueryBuilder.fromQueryBuilder(initialQb, table.name);
165
178
  for (const tableWatcher of tableWatchers) {
166
179
  this.logger.info({ message: `(${table.name}) Running ${tableWatcher.name()}.afterDelete` });
167
- await tableWatcher.afterDelete(recordDeleteCount, deletedRecords, initialQbCopy, deleteQb);
180
+ await tableWatcher.afterDelete(recordDeleteCount, deletedRecords, initialQbCopy, deleteQb, db);
168
181
  this.logger.info({ message: `(${table.name}) Finished running ${tableWatcher.name()}.afterDelete` });
169
182
  }
170
183
  this.logger.info({ message: `(${table.name}) Finished running after-delete table watchers` });
@@ -0,0 +1,173 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { Statement } from '@proteinjs/db-query';
3
+ import { Db, DbDriver, DbDriverDmlStatementConfig, getDb, getDbAsSystem } from '../src/Db';
4
+ import { Table } from '../src/Table';
5
+ import { Record, withRecordColumns } from '../src/Record';
6
+ import { StringColumn } from '../src/Columns';
7
+ import { TableWatcher } from '../src/TableWatcher';
8
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
9
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
10
+
11
+ /**
12
+ * `Db.isSystem()` — the public read of a write's MODE. A hook that must tell a user's write from
13
+ * the system's (a watcher that archives on a user's delete and stands aside on a purge) reads it
14
+ * off the Db the write hands it, instead of guessing from the session. Pinned both ways on the
15
+ * public factories, and — the pin that matters — on the Db the hook runner hands each watcher:
16
+ * that Db answers for the INVOKING write, whatever the session holds.
17
+ */
18
+
19
+ interface Doc extends Record {
20
+ title: string;
21
+ }
22
+
23
+ class DocTable extends Table<Doc> {
24
+ public name = 'is_system_doc';
25
+ /** Every door open to everyone: a session Db reaches the write with no user to resolve. */
26
+ public auth: Table<Doc>['auth'] = { db: { all: 'public' } };
27
+ public columns = withRecordColumns<Doc>({
28
+ title: new StringColumn('title'),
29
+ });
30
+ }
31
+
32
+ const docTable = new DocTable() as Table<Doc>;
33
+
34
+ /** What each hook was handed: the mode of its trailing Db. */
35
+ const handed: { phase: string; mode: boolean | undefined }[] = [];
36
+
37
+ class ModeRecordingWatcher implements TableWatcher<Doc> {
38
+ name(): string {
39
+ return 'ModeRecordingWatcher';
40
+ }
41
+
42
+ table(): Table<Doc> {
43
+ return docTable;
44
+ }
45
+
46
+ async beforeInsert<T extends Doc>(record: Omit<T, keyof Doc>, db?: Db): Promise<Omit<T, keyof Doc>> {
47
+ handed.push({ phase: 'beforeInsert', mode: db?.isSystem() });
48
+ return record;
49
+ }
50
+
51
+ async afterInsert<T extends Doc>(_record: T, db?: Db): Promise<void> {
52
+ handed.push({ phase: 'afterInsert', mode: db?.isSystem() });
53
+ }
54
+ }
55
+
56
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
57
+ private context: TransactionContextData = {};
58
+
59
+ getTransactionContext(): TransactionContextData {
60
+ return this.context;
61
+ }
62
+
63
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
64
+ const previous = this.context;
65
+ this.context = context;
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ this.context = previous;
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Accepts every statement; no database behind it. */
75
+ class AcceptingDriver implements DbDriver {
76
+ getDbName(): string {
77
+ return 'test';
78
+ }
79
+
80
+ async createDbIfNotExists(): Promise<void> {}
81
+
82
+ async createDb(): Promise<void> {}
83
+
84
+ async dropDb(): Promise<void> {}
85
+
86
+ getTableManager(): never {
87
+ throw new Error('not used by these tests');
88
+ }
89
+
90
+ async runQuery(): Promise<never[]> {
91
+ return [];
92
+ }
93
+
94
+ async runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement): Promise<number> {
95
+ generateStatement({
96
+ useParams: true,
97
+ useNamedParams: true,
98
+ prefixTablesWithDb: false,
99
+ getDriverColumnType: () => 'STRING(MAX)',
100
+ });
101
+ return 1;
102
+ }
103
+
104
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
105
+ return await fn({ id: 'transaction' });
106
+ }
107
+
108
+ getOperationDeadlineMs(): number {
109
+ return 1000;
110
+ }
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
+ const DEFAULT_DRIVER_GLOBAL = '__proteinjs_db_defaultDbDriver';
117
+
118
+ const driver = new AcceptingDriver();
119
+ const transactions = new TestTransactionContextFactory();
120
+ const modesHandedBy = async (db: Db<Doc>): Promise<(boolean | undefined)[]> => {
121
+ handed.length = 0;
122
+ await db.insert(docTable, { title: 'row' });
123
+ expect(handed.map(({ phase }) => phase)).toEqual(['beforeInsert', 'afterInsert']);
124
+ return handed.map(({ mode }) => mode);
125
+ };
126
+
127
+ describe('Db.isSystem', () => {
128
+ let originalWatchers: unknown[] | undefined;
129
+ let originalFactories: unknown[] | undefined;
130
+ let originalTransactionFactories: unknown[] | undefined;
131
+
132
+ beforeAll(() => {
133
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
134
+ originalFactories = objectCache()['@proteinjs/db/DefaultDbDriverFactory'];
135
+ originalTransactionFactories = objectCache()['@proteinjs/db/DefaultTransactionContextFactory'];
136
+ objectCache()['@proteinjs/db/TableWatcher'] = [new ModeRecordingWatcher()];
137
+ // The public factories resolve their driver and transaction context through reflection.
138
+ objectCache()['@proteinjs/db/DefaultDbDriverFactory'] = [{ getDbDriver: () => driver }];
139
+ objectCache()['@proteinjs/db/DefaultTransactionContextFactory'] = [transactions];
140
+ delete (globalThis as { [key: string]: unknown })[DEFAULT_DRIVER_GLOBAL];
141
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
142
+ });
143
+
144
+ afterAll(() => {
145
+ const restore = (name: string, original: unknown[] | undefined) => {
146
+ if (original) {
147
+ objectCache()[name] = original;
148
+ } else {
149
+ delete objectCache()[name];
150
+ }
151
+ };
152
+ restore('@proteinjs/db/TableWatcher', originalWatchers);
153
+ restore('@proteinjs/db/DefaultDbDriverFactory', originalFactories);
154
+ restore('@proteinjs/db/DefaultTransactionContextFactory', originalTransactionFactories);
155
+ delete (globalThis as { [key: string]: unknown })[DEFAULT_DRIVER_GLOBAL];
156
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
157
+ });
158
+
159
+ test('a system Db answers true, a session Db false — on the public factories and on the constructor', () => {
160
+ expect(getDbAsSystem().isSystem()).toBe(true);
161
+ expect(getDb().isSystem()).toBe(false);
162
+ expect(new Db<Doc>(driver, () => docTable, transactions, true).isSystem()).toBe(true);
163
+ expect(new Db<Doc>(driver, () => docTable, transactions).isSystem()).toBe(false);
164
+ });
165
+
166
+ test("the Db a write hands its watchers answers for THAT write: a system write's hooks read true", async () => {
167
+ expect(await modesHandedBy(getDbAsSystem<Doc>())).toEqual([true, true]);
168
+ });
169
+
170
+ test("the Db a write hands its watchers answers for THAT write: a session write's hooks read false", async () => {
171
+ expect(await modesHandedBy(getDb<Doc>())).toEqual([false, false]);
172
+ });
173
+ });
package/test/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './reusable/PreloadReferencesTests';
8
8
  export * from './reusable/RecordIteratorTests';
9
9
  export * from './reusable/SourceRecordSyncTests';
10
10
  export * from './reusable/StoredValueContractTests';
11
+ export * from './reusable/TableWatcherAuthorityTests';
11
12
 
12
13
  export * from './util/testUser';
13
14
 
@@ -19,4 +20,5 @@ export * from './util/tables/recordIteratorTestTables';
19
20
  export * from './util/tables/sourceRecordSyncTestTables';
20
21
  export * from './util/tables/storedValueContractTestTables';
21
22
  export * from './util/tables/tableManagerTestTables';
23
+ export * from './util/tables/tableWatcherAuthorityTestTables';
22
24
  export * from './util/tables/transactionTestTables';