@proteinjs/db 1.55.1 → 1.56.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 (42) hide show
  1. package/CHANGELOG.md +11 -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 +15 -6
  8. package/dist/src/Db.d.ts.map +1 -1
  9. package/dist/src/Db.js +26 -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/index.d.ts +2 -0
  19. package/dist/test/index.d.ts.map +1 -1
  20. package/dist/test/index.js +2 -0
  21. package/dist/test/index.js.map +1 -1
  22. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts +13 -0
  23. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts.map +1 -0
  24. package/dist/test/reusable/TableWatcherAuthorityTests.js +392 -0
  25. package/dist/test/reusable/TableWatcherAuthorityTests.js.map +1 -0
  26. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  27. package/dist/test/util/DbTestEnvironment.js +3 -2
  28. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  29. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts +35 -0
  30. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts.map +1 -0
  31. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js +113 -0
  32. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js.map +1 -0
  33. package/generated/index.ts +1 -1
  34. package/generated/test/index.ts +5 -1
  35. package/package.json +2 -2
  36. package/src/Db.ts +40 -21
  37. package/src/TableWatcher.ts +17 -6
  38. package/src/TableWatcherRunner.ts +25 -12
  39. package/test/index.ts +2 -0
  40. package/test/reusable/TableWatcherAuthorityTests.ts +204 -0
  41. package/test/util/DbTestEnvironment.ts +2 -0
  42. 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.56.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": "469b64193ef5d4fc0faa74ebe1f6c2af04dfd6cb"
70
70
  }
package/src/Db.ts CHANGED
@@ -241,7 +241,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
241
241
 
242
242
  let recordCopy = Object.assign({}, record);
243
243
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
244
- recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
244
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy, this.newHookDb());
245
245
  await this.addColumnInsertHooks(table, recordCopy);
246
246
  const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
247
247
  const recordSerializer = new RecordSerializer(table, encryptionContext);
@@ -269,7 +269,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
269
269
  );
270
270
  }
271
271
  await this.runColumnAfterInsertHooks(table, recordCopy);
272
- await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
272
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T, this.newHookDb());
273
273
  return recordCopy as T;
274
274
  }
275
275
 
@@ -311,7 +311,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
311
311
  for (const record of records) {
312
312
  let recordCopy = Object.assign({}, record);
313
313
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
314
- recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
314
+ recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy, this.newHookDb());
315
315
  await this.addColumnInsertHooks(table, recordCopy);
316
316
  const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
317
317
  const serializedRecord = await new RecordSerializer(table, encryptionContext).serialize(recordCopy);
@@ -343,7 +343,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
343
343
  );
344
344
  }
345
345
  await this.runColumnAfterInsertHooks(table, recordCopy);
346
- await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
346
+ await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T, this.newHookDb());
347
347
  }
348
348
  return prepared.map((row) => row.recordCopy as T);
349
349
  }
@@ -389,7 +389,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
389
389
  qb.condition({ field: 'id', operator: '=', value: recordCopy.id as T[keyof T] });
390
390
  }
391
391
 
392
- recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
392
+ recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb, this.newHookDb());
393
393
  let recordUpdateCount: number;
394
394
  if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
395
395
  recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb, driverOptions);
@@ -412,7 +412,13 @@ export class Db<R extends Record = Record> implements DbService<R> {
412
412
  await this.runZeroRowFilteredWriteHooks(table, id, 'write');
413
413
  }
414
414
  }
415
- await this.tableWatcherRunner.runAfterUpdateTableWatchers(table, recordUpdateCount, recordCopy, qb);
415
+ await this.tableWatcherRunner.runAfterUpdateTableWatchers(
416
+ table,
417
+ recordUpdateCount,
418
+ recordCopy,
419
+ qb,
420
+ this.newHookDb()
421
+ );
416
422
  return recordUpdateCount;
417
423
  }
418
424
 
@@ -542,34 +548,31 @@ export class Db<R extends Record = Record> implements DbService<R> {
542
548
  const generateDelete = (config: DbDriverDmlStatementConfig) =>
543
549
  new StatementFactory<T>().delete(table.name, deleteQb, this.statementConfigFactory.getStatementConfig(config));
544
550
  await this.runColumnBeforeDeletes(table, recordsToDelete);
545
- await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb);
551
+ await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb, this.newHookDb());
546
552
  const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
547
553
  await this.runCascadeDeletions(table, recordsToDelete);
548
554
  await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
549
555
  await this.deleteEncryptionTokenRows(table, recordsToDeleteIds as string[]);
550
- await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
556
+ await this.tableWatcherRunner.runAfterDeleteTableWatchers(
557
+ table,
558
+ recordDeleteCount,
559
+ recordsToDelete,
560
+ qb,
561
+ deleteQb,
562
+ this.newHookDb()
563
+ );
551
564
  return recordDeleteCount;
552
565
  }
553
566
 
554
567
  /**
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.
568
+ * A column's `beforeDelete` hook cascades the delete through reference columns, with the
569
+ * authority of the write that fired it ({@link newHookDb}).
561
570
  */
562
571
  private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
563
572
  for (const columnPropertyName in table.columns) {
564
573
  const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
565
574
  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
- );
575
+ await column.beforeDelete(table, columnPropertyName, recordsToDelete, this.getTable, this.newHookDb());
573
576
  }
574
577
  }
575
578
  }
@@ -1171,6 +1174,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
1171
1174
  return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, true);
1172
1175
  }
1173
1176
 
1177
+ /**
1178
+ * The Db a hook is handed — a column's `beforeDelete`, a table watcher's six hooks: an instance
1179
+ * riding THIS instance's driver, tables and transaction context AND its system mode, so the hook
1180
+ * runs with the authority of the write that fired it. A hook that opened a Db of its own
1181
+ * (`getDb()`, a session-scoped Db) ran as whoever the session held: a system write's follow-on
1182
+ * narrowed to that user's rows inside a request, and refused outright in a background context
1183
+ * (an account purge, a sweep, a deploy gate), where a scoped table finds no user to scope the
1184
+ * query to — the cascade left orphans, the watcher left its rows (a message's topic links, a
1185
+ * media row's file) and either swallowed or raised the "No user in scope context" error.
1186
+ * Transaction state is instance state ({@link newSelfWrapDb}), so the hook rides the ambient
1187
+ * transaction through the shared context and never this instance's own.
1188
+ */
1189
+ private newHookDb(): Db<any> {
1190
+ return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem);
1191
+ }
1192
+
1174
1193
  /**
1175
1194
  * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
1176
1195
  * 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` });
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';
@@ -0,0 +1,204 @@
1
+ import { Db, DbDriver, Table, TableWatcher, DefaultTransactionContextFactory } from '@proteinjs/db';
2
+ import { QueryBuilder } from '@proteinjs/db-query';
3
+ import { SourceRepository } from '@proteinjs/reflection';
4
+ import { DbTestEnvironment } from '../util/DbTestEnvironment';
5
+ import { TableWatcherRunner } from '../../src/TableWatcherRunner';
6
+ import {
7
+ GuardedJournal,
8
+ tableWatcherAuthorityTestTables,
9
+ WATCHER_GUARDED_REFUSAL,
10
+ WatchedNote,
11
+ } from '../util/tables/tableWatcherAuthorityTestTables';
12
+
13
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
14
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
15
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
16
+ const resetTableWatcherMap = () => {
17
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
18
+ };
19
+
20
+ /** The watcher's refusal when a hook arrives without the write's Db — the contract's first half. */
21
+ export const HOOK_HANDED_NO_DB = 'the hook was handed no Db';
22
+
23
+ const HOOKS = ['beforeInsert', 'afterInsert', 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete'];
24
+
25
+ /**
26
+ * Journals every hook run through the HANDED Db and nothing else — the way a watcher is written
27
+ * once the Db hands one (chat's topic watcher deleting a deleted message's topic links, the media
28
+ * watcher deleting a deleted row's file, the activity watcher bumping the chat a message landed
29
+ * in). The journal is a guarded table, so the hook's write and read succeed only with a system
30
+ * Db: the one the write made, carrying its own authority.
31
+ */
32
+ class JournalingWatcher implements TableWatcher<WatchedNote> {
33
+ name(): string {
34
+ return 'JournalingWatcher';
35
+ }
36
+
37
+ table(): Table<WatchedNote> {
38
+ return tableWatcherAuthorityTestTables.WatchedNote;
39
+ }
40
+
41
+ async beforeInsert<T extends WatchedNote>(
42
+ record: Omit<T, keyof WatchedNote>,
43
+ db?: Db
44
+ ): Promise<Omit<T, keyof WatchedNote>> {
45
+ await this.journal(db, (record as unknown as WatchedNote).id, 'beforeInsert');
46
+ return record;
47
+ }
48
+
49
+ async afterInsert<T extends WatchedNote>(record: T, db?: Db): Promise<void> {
50
+ await this.journal(db, record.id, 'afterInsert');
51
+ }
52
+
53
+ async beforeUpdate<T extends WatchedNote>(record: Partial<T>, qb: QueryBuilder<T>, db?: Db): Promise<Partial<T>> {
54
+ for (const row of await this.handed(db).query(this.table(), qb as unknown as QueryBuilder<WatchedNote>)) {
55
+ await this.journal(db, row.id, 'beforeUpdate');
56
+ }
57
+ return record;
58
+ }
59
+
60
+ async afterUpdate<T extends WatchedNote>(
61
+ recordUpdateCount: number,
62
+ record: Partial<T>,
63
+ qb: QueryBuilder<T>,
64
+ db?: Db
65
+ ): Promise<void> {
66
+ for (const row of await this.handed(db).query(this.table(), qb as unknown as QueryBuilder<WatchedNote>)) {
67
+ await this.journal(db, row.id, 'afterUpdate');
68
+ }
69
+ }
70
+
71
+ async beforeDelete<T extends WatchedNote>(
72
+ recordsToDelete: T[],
73
+ _initialQb: QueryBuilder<T>,
74
+ _deleteQb: QueryBuilder<T>,
75
+ db?: Db
76
+ ): Promise<void> {
77
+ for (const row of recordsToDelete) {
78
+ await this.journal(db, row.id, 'beforeDelete');
79
+ }
80
+ }
81
+
82
+ async afterDelete<T extends WatchedNote>(
83
+ _recordDeleteCount: number,
84
+ deletedRecords: T[],
85
+ _initialQb: QueryBuilder<T>,
86
+ _deleteQb: QueryBuilder<T>,
87
+ db?: Db
88
+ ): Promise<void> {
89
+ for (const row of deletedRecords) {
90
+ await this.journal(db, row.id, 'afterDelete');
91
+ // The read half of the contract: a query through the handed Db over the guarded table.
92
+ await this.handed(db).query(tableWatcherAuthorityTestTables.GuardedJournal, { note: row.id });
93
+ }
94
+ }
95
+
96
+ private async journal(db: Db | undefined, note: string, hook: string): Promise<void> {
97
+ await this.handed(db).insert(tableWatcherAuthorityTestTables.GuardedJournal, {
98
+ note,
99
+ hook,
100
+ principal: 'the write',
101
+ });
102
+ }
103
+
104
+ private handed(db: Db | undefined): Db {
105
+ if (!db) {
106
+ throw new Error(HOOK_HANDED_NO_DB);
107
+ }
108
+ return db;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * A table watcher's hooks run with the authority of the write that fired them: the Db hands every
114
+ * hook a Db carrying its driver, tables, transaction context and system mode. Before this the
115
+ * hooks were handed nothing and each watcher opened a session-scoped Db of its own, which a system
116
+ * write in a session-less context (an account purge, a sweep) could not use — the topic links of a
117
+ * purged message and the file of a system-deleted media row stayed behind, and the activity bump
118
+ * of a system-inserted message threw.
119
+ */
120
+ export const tableWatcherAuthorityTests = (
121
+ driver: DbDriver,
122
+ transactionContextFactory: DefaultTransactionContextFactory,
123
+ dropTable: (table: Table<any>) => Promise<void>
124
+ ) => {
125
+ return () => {
126
+ const testEnv = new DbTestEnvironment(driver, dropTable);
127
+ const notes = tableWatcherAuthorityTestTables.WatchedNote;
128
+ const journal = tableWatcherAuthorityTestTables.GuardedJournal;
129
+ // Constructed in beforeAll, AFTER the watcher registration below: a Db's construction builds
130
+ // the runner's static watcher map, and the map must be built from the registered watcher.
131
+ let systemDb: Db;
132
+ let sessionDb: Db;
133
+ let originalWatchers: unknown[] | undefined;
134
+
135
+ /** A fresh async hop — the shape of a background context that inherits no caller's state. */
136
+ const inFreshContext = <T>(fn: () => Promise<T>): Promise<T> =>
137
+ new Promise<T>((resolve, reject) => {
138
+ setImmediate(() => {
139
+ fn().then(resolve, reject);
140
+ });
141
+ });
142
+
143
+ const journalHooksFor = async (noteId: string): Promise<string[]> =>
144
+ (await systemDb.query(journal, { note: noteId })).map((row: GuardedJournal) => row.hook).sort();
145
+
146
+ beforeAll(async () => {
147
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
148
+ objectCache()['@proteinjs/db/TableWatcher'] = [new JournalingWatcher()];
149
+ resetTableWatcherMap();
150
+ systemDb = new Db(driver, undefined, transactionContextFactory, true);
151
+ sessionDb = new Db(driver, undefined, transactionContextFactory);
152
+ await testEnv.beforeAll();
153
+ }, 120000);
154
+
155
+ afterAll(async () => {
156
+ await testEnv.afterAll();
157
+ if (originalWatchers) {
158
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
159
+ } else {
160
+ delete objectCache()['@proteinjs/db/TableWatcher'];
161
+ }
162
+ resetTableWatcherMap();
163
+ }, 120000);
164
+
165
+ beforeEach(async () => {
166
+ await systemDb.delete(journal, {});
167
+ await systemDb.delete(notes, {});
168
+ });
169
+
170
+ describe('Table watchers carry the write’s authority', () => {
171
+ test('a system insert, update and delete in a session-less context: all six hooks write and read the guarded journal through the handed Db', async () => {
172
+ const noteId = await inFreshContext(async () => {
173
+ const note = await systemDb.insert(notes, { title: 'Kept' });
174
+ await systemDb.update(notes, { title: 'Kept, renamed' }, { id: note.id });
175
+ await systemDb.delete(notes, { id: note.id });
176
+ return note.id;
177
+ });
178
+
179
+ expect(await systemDb.query(notes, { id: noteId })).toHaveLength(0);
180
+ expect(await journalHooksFor(noteId)).toEqual([...HOOKS].sort());
181
+ });
182
+
183
+ test('a session write’s hooks run as the session, never as system: the guarded journal refuses the first hook and the write with it', async () => {
184
+ await expect(sessionDb.insert(notes, { title: 'Refused' })).rejects.toThrow(WATCHER_GUARDED_REFUSAL);
185
+ expect(await systemDb.query(notes, { title: 'Refused' })).toHaveLength(0);
186
+ });
187
+
188
+ test('the handed Db rides the write’s transaction: a rolled-back system insert leaves no journal row', async () => {
189
+ let noteId: string | undefined;
190
+ await expect(
191
+ systemDb.runTransaction(async () => {
192
+ const note = await systemDb.insert(notes, { title: 'Rolled back' });
193
+ noteId = note.id;
194
+ throw new Error('roll back');
195
+ })
196
+ ).rejects.toThrow('roll back');
197
+
198
+ expect(noteId).toBeDefined();
199
+ expect(await systemDb.query(notes, { id: noteId })).toHaveLength(0);
200
+ expect(await journalHooksFor(noteId!)).toEqual([]);
201
+ });
202
+ });
203
+ };
204
+ };
@@ -8,6 +8,7 @@ import { recordIteratorTestTables } from './tables/recordIteratorTestTables';
8
8
  import { sourceRecordSyncTestTables } from './tables/sourceRecordSyncTestTables';
9
9
  import { storedValueContractTestTables } from './tables/storedValueContractTestTables';
10
10
  import { tableManagerTestTables } from './tables/tableManagerTestTables';
11
+ import { tableWatcherAuthorityTestTables } from './tables/tableWatcherAuthorityTestTables';
11
12
  import { transactionTestTables } from './tables/transactionTestTables';
12
13
 
13
14
  const TABLES: Table<any>[] = [
@@ -20,6 +21,7 @@ const TABLES: Table<any>[] = [
20
21
  // The member tables are the stored-value suite's own: it builds each physical column order itself.
21
22
  storedValueContractTestTables.Mixed,
22
23
  ...Object.values(tableManagerTestTables),
24
+ ...Object.values(tableWatcherAuthorityTestTables),
23
25
  ...Object.values(transactionTestTables),
24
26
  ];
25
27
 
@@ -0,0 +1,61 @@
1
+ import { StringColumn, Table, withRecordColumns, Record } from '@proteinjs/db';
2
+
3
+ // --- Table watchers run with the authority of the write that fired them (WatchedNote -> GuardedJournal)
4
+
5
+ /** The watched table: a plain row whose writes fire the suite's journaling watcher. */
6
+ export interface WatchedNote extends Record {
7
+ title: string;
8
+ }
9
+
10
+ /** The watcher's journal: one row per hook run, writable and readable as system only. */
11
+ export interface GuardedJournal extends Record {
12
+ /** The watched row's id. */
13
+ note: string;
14
+ /** The hook that wrote the row (`beforeInsert` … `afterDelete`). */
15
+ hook: string;
16
+ /** The scoped-table contract in miniature — see {@link GuardedJournalTable}. */
17
+ principal: string;
18
+ }
19
+
20
+ const WATCHED_NOTE_TABLE = 'db_test_tw_notes';
21
+ const GUARDED_JOURNAL_TABLE = 'db_test_tw_guarded_journal';
22
+
23
+ /** The journal's refusal of a non-system write or query, by name. */
24
+ export const WATCHER_GUARDED_REFUSAL = 'no user in scope (journal)';
25
+
26
+ export class WatchedNoteTable extends Table<WatchedNote> {
27
+ name = WATCHED_NOTE_TABLE;
28
+ columns: Table<WatchedNote>['columns'] = withRecordColumns<WatchedNote>({
29
+ title: new StringColumn('title'),
30
+ });
31
+ }
32
+
33
+ /**
34
+ * A scoped table's shape without a dependency on one: a non-system write needs the context's
35
+ * user to stamp the row with, a non-system query needs it to scope the query to — there is none
36
+ * in a session-less context, so both refuse by name. A system write supplies the principal itself
37
+ * and a system query is not scoped, exactly as `@proteinjs/user`'s ScopedRecord columns behave.
38
+ */
39
+ export class GuardedJournalTable extends Table<GuardedJournal> {
40
+ name = GUARDED_JOURNAL_TABLE;
41
+ columns: Table<GuardedJournal>['columns'] = withRecordColumns<GuardedJournal>({
42
+ note: new StringColumn('note'),
43
+ hook: new StringColumn('hook'),
44
+ principal: new StringColumn('principal', {
45
+ defaultValue: async () => {
46
+ throw new Error(WATCHER_GUARDED_REFUSAL);
47
+ },
48
+ forceDefaultValue: (runAsSystem) => !runAsSystem,
49
+ addToQuery: async (qb, runAsSystem) => {
50
+ if (!runAsSystem) {
51
+ throw new Error(WATCHER_GUARDED_REFUSAL);
52
+ }
53
+ },
54
+ }),
55
+ });
56
+ }
57
+
58
+ export const tableWatcherAuthorityTestTables = {
59
+ WatchedNote: new WatchedNoteTable() as Table<WatchedNote>,
60
+ GuardedJournal: new GuardedJournalTable() as Table<GuardedJournal>,
61
+ };