@proteinjs/db 1.55.0 → 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 (56) 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 +13 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/src/Columns.d.ts +20 -2
  8. package/dist/src/Columns.d.ts.map +1 -1
  9. package/dist/src/Columns.js +46 -45
  10. package/dist/src/Columns.js.map +1 -1
  11. package/dist/src/Db.d.ts +17 -0
  12. package/dist/src/Db.d.ts.map +1 -1
  13. package/dist/src/Db.js +28 -9
  14. package/dist/src/Db.js.map +1 -1
  15. package/dist/src/TableWatcher.d.ts +15 -6
  16. package/dist/src/TableWatcher.d.ts.map +1 -1
  17. package/dist/src/TableWatcher.js.map +1 -1
  18. package/dist/src/TableWatcherRunner.d.ts +14 -6
  19. package/dist/src/TableWatcherRunner.d.ts.map +1 -1
  20. package/dist/src/TableWatcherRunner.js +19 -12
  21. package/dist/src/TableWatcherRunner.js.map +1 -1
  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/CascadeDeleteTests.d.ts.map +1 -1
  27. package/dist/test/reusable/CascadeDeleteTests.js +132 -0
  28. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  29. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts +13 -0
  30. package/dist/test/reusable/TableWatcherAuthorityTests.d.ts.map +1 -0
  31. package/dist/test/reusable/TableWatcherAuthorityTests.js +392 -0
  32. package/dist/test/reusable/TableWatcherAuthorityTests.js.map +1 -0
  33. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  34. package/dist/test/util/DbTestEnvironment.js +3 -2
  35. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  36. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +38 -0
  37. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
  38. package/dist/test/util/tables/cascadeDeleteTestTables.js +119 -1
  39. package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
  40. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts +35 -0
  41. package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts.map +1 -0
  42. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js +113 -0
  43. package/dist/test/util/tables/tableWatcherAuthorityTestTables.js.map +1 -0
  44. package/generated/index.ts +1 -1
  45. package/generated/test/index.ts +13 -1
  46. package/package.json +2 -2
  47. package/src/Columns.ts +50 -27
  48. package/src/Db.ts +42 -15
  49. package/src/TableWatcher.ts +17 -6
  50. package/src/TableWatcherRunner.ts +25 -12
  51. package/test/index.ts +2 -0
  52. package/test/reusable/CascadeDeleteTests.ts +90 -0
  53. package/test/reusable/TableWatcherAuthorityTests.ts +204 -0
  54. package/test/util/DbTestEnvironment.ts +2 -0
  55. package/test/util/tables/cascadeDeleteTestTables.ts +85 -0
  56. 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.0",
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": "e2cb123d846e60e8cddccbca06b5076a009817ed"
69
+ "gitHead": "469b64193ef5d4fc0faa74ebe1f6c2af04dfd6cb"
70
70
  }
package/src/Columns.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import moment from 'moment';
2
2
  import { v1 as uuidv1 } from 'uuid';
3
- import { Column, ColumnOptions, getColumnPropertyName, Table, tableByName } from './Table';
3
+ import { Column, ColumnOptions, getColumnPropertyName, Table } from './Table';
4
4
  import { Record } from './Record';
5
5
  import { ReferenceArray } from './reference/ReferenceArray';
6
6
  import { Db } from './Db';
@@ -263,17 +263,29 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
263
263
  return new ReferenceArray(this.referenceTable, ids);
264
264
  }
265
265
 
266
- async beforeDelete(table: Table<any>, columnPropertyName: string, records: any[]) {
266
+ /**
267
+ * Cascade: the referenced records go with the holder. Runs through the Db the firing write
268
+ * hands it — its driver, its transaction context, its authority — and off the ids the column
269
+ * stores; nothing here opens a Db of its own or re-reads the referenced rows, either of which
270
+ * would run as the session's user rather than as the write (a system delete's cascade must stay
271
+ * system: in a background context there is no session user at all).
272
+ */
273
+ async beforeDelete(
274
+ table: Table<any>,
275
+ columnPropertyName: string,
276
+ records: any[],
277
+ getTable?: (tableName: string) => Table<any>,
278
+ db?: Db
279
+ ): Promise<void> {
267
280
  if (!this.cascadeDelete) {
268
281
  return;
269
282
  }
270
283
 
271
284
  const recordIdsToDelete: string[] = [];
272
285
  for (const record of records) {
273
- const referenceArray = record[columnPropertyName] as ReferenceArray<Record>;
274
- const referenceRecords = await referenceArray.get();
275
- for (const referenceRecord of referenceRecords) {
276
- recordIdsToDelete.push(referenceRecord.id);
286
+ const referenceArray = record[columnPropertyName] as ReferenceArray<Record> | null | undefined;
287
+ if (referenceArray) {
288
+ recordIdsToDelete.push(...referenceArray._ids);
277
289
  }
278
290
  }
279
291
 
@@ -281,11 +293,12 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
281
293
  return;
282
294
  }
283
295
 
284
- const referenceTable = tableByName(this.referenceTable);
296
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
297
+ const referenceTable = getTable!(this.referenceTable);
285
298
  const qb = new QueryBuilderFactory()
286
299
  .getQueryBuilder(referenceTable)
287
300
  .condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
288
- await new Db().delete(referenceTable, qb);
301
+ await db!.delete(referenceTable, qb);
289
302
  }
290
303
  }
291
304
 
@@ -331,7 +344,18 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
331
344
  return reference as Reference<T>;
332
345
  }
333
346
 
334
- async beforeDelete(table: Table<any>, columnPropertyName: string, records: any[]) {
347
+ /**
348
+ * Cascade: the referenced record goes with the holder. Runs through the Db the firing write
349
+ * hands it (its driver, transaction context and authority) off the stored id — see
350
+ * `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of its own.
351
+ */
352
+ async beforeDelete(
353
+ table: Table<any>,
354
+ columnPropertyName: string,
355
+ records: any[],
356
+ getTable?: (tableName: string) => Table<any>,
357
+ db?: Db
358
+ ): Promise<void> {
335
359
  if (!this.cascadeDelete) {
336
360
  return;
337
361
  }
@@ -348,11 +372,12 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
348
372
  return;
349
373
  }
350
374
 
351
- const referenceTable = tableByName(this.referenceTable);
375
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
376
+ const referenceTable = getTable!(this.referenceTable);
352
377
  const qb = new QueryBuilderFactory()
353
378
  .getQueryBuilder(referenceTable)
354
379
  .condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
355
- await new Db().delete(referenceTable, qb);
380
+ await db!.delete(referenceTable, qb);
356
381
  }
357
382
  }
358
383
 
@@ -508,6 +533,12 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
508
533
  return new Reference<T>(tableName, serializedFieldValue);
509
534
  }
510
535
 
536
+ /**
537
+ * Cascade: the referenced record goes with the holder, whichever table the row names. Runs
538
+ * through the Db the firing write hands it (its driver, transaction context and authority) off
539
+ * the stored ids — see `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of
540
+ * its own.
541
+ */
511
542
  async beforeDelete(
512
543
  table: Table<any>,
513
544
  columnPropertyName: string,
@@ -519,12 +550,8 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
519
550
  return;
520
551
  }
521
552
 
522
- const getTableFn = getTable ? getTable : tableByName;
523
- const dbInstance = db ? db : new Db();
524
-
525
- // Get all referenced record IDs grouped by table
553
+ // The referenced ids, grouped by the table each row names
526
554
  const recordsToDelete = new Map<string, string[]>();
527
-
528
555
  for (const record of records) {
529
556
  const reference = record[columnPropertyName] as Reference<Record>;
530
557
  if (!reference?._id || !reference._table) {
@@ -537,17 +564,13 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
537
564
  recordsToDelete.get(reference._table)!.push(reference._id);
538
565
  }
539
566
 
540
- // Delete records from each referenced table using Promise.all to properly await all deletions
541
- const entries = Array.from(recordsToDelete.entries());
542
- console.log(`entries to delete: ${JSON.stringify(entries)}`);
543
- for (const [tableName, ids] of entries) {
544
- if (ids.length > 0) {
545
- const referenceTable = getTableFn(tableName);
546
- const qb = new QueryBuilderFactory()
547
- .getQueryBuilder(referenceTable)
548
- .condition({ field: 'id', operator: 'IN', value: ids });
549
- await dbInstance.delete(referenceTable, qb);
550
- }
567
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
568
+ for (const [tableName, ids] of Array.from(recordsToDelete.entries())) {
569
+ const referenceTable = getTable!(tableName);
570
+ const qb = new QueryBuilderFactory()
571
+ .getQueryBuilder(referenceTable)
572
+ .condition({ field: 'id', operator: 'IN', value: ids });
573
+ await db!.delete(referenceTable, qb);
551
574
  }
552
575
  }
553
576
  }
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,26 +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
 
567
+ /**
568
+ * A column's `beforeDelete` hook cascades the delete through reference columns, with the
569
+ * authority of the write that fired it ({@link newHookDb}).
570
+ */
554
571
  private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
555
572
  for (const columnPropertyName in table.columns) {
556
573
  const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
557
574
  if (typeof column.beforeDelete !== 'undefined') {
558
- await column.beforeDelete(
559
- table,
560
- columnPropertyName,
561
- recordsToDelete,
562
- this.getTable,
563
- new Db(this.dbDriver, this.getTable, this.transactionContextFactory)
564
- );
575
+ await column.beforeDelete(table, columnPropertyName, recordsToDelete, this.getTable, this.newHookDb());
565
576
  }
566
577
  }
567
578
  }
@@ -1163,6 +1174,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
1163
1174
  return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, true);
1164
1175
  }
1165
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
+
1166
1193
  /**
1167
1194
  * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
1168
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';
@@ -6,6 +6,10 @@ import {
6
6
  GroupArr,
7
7
  GroupDyn,
8
8
  GroupRef,
9
+ GuardedHolderArr,
10
+ GuardedHolderDyn,
11
+ GuardedHolderRef,
12
+ GuardedMember,
9
13
  MemberArr,
10
14
  MemberArrRev,
11
15
  MemberDyn,
@@ -224,5 +228,91 @@ export const cascadeDeleteTests = (
224
228
  expect(missionsAfterPilot.length).toBe(0);
225
229
  });
226
230
  });
231
+
232
+ /**
233
+ * -------------------- Cascade Delete as system (holder → guarded referenced) --------------------
234
+ * A system delete's cascade runs with the authority of the write that fired it. The children's
235
+ * table refuses every non-system query (a scoped table's shape), and the delete runs in a fresh
236
+ * async context with no session — a background sweep's shape. Before the fix the cascade hooks
237
+ * ran as a non-system Db, and every case below failed with the guarded table's refusal.
238
+ */
239
+ describe('Cascade Delete as system', () => {
240
+ const systemDb = new Db(driver, undefined, transactionContextFactory, true);
241
+ const memberTable = cascadeDeleteTestTables.GuardedMember;
242
+
243
+ /** A fresh async context with nothing inherited from the test's own — a background sweep's shape. */
244
+ const inFreshContext = <T>(fn: () => Promise<T>): Promise<T> =>
245
+ new Promise<T>((resolve, reject) => {
246
+ setImmediate(() => {
247
+ fn().then(resolve, reject);
248
+ });
249
+ });
250
+
251
+ test('ReferenceColumn: a system delete of the holder deletes the guarded referenced record', async () => {
252
+ const holderTable = cascadeDeleteTestTables.GuardedHolderRef;
253
+
254
+ const m = await systemDb.insert(memberTable, { name: 'Gail' });
255
+ const h = await systemDb.insert(holderTable, {
256
+ holderName: 'H-Ref',
257
+ memberRef: new Reference<GuardedMember>(memberTable.name, m.id),
258
+ });
259
+
260
+ const delQb = new QueryBuilder<GuardedHolderRef>(holderTable.name).condition({
261
+ field: 'id',
262
+ operator: '=',
263
+ value: h.id,
264
+ });
265
+ const deleted = await inFreshContext(() => systemDb.delete(holderTable, delQb));
266
+ expect(deleted).toBe(1);
267
+
268
+ expect(await systemDb.query(memberTable, { id: m.id })).toHaveLength(0);
269
+ });
270
+
271
+ test('ReferenceArrayColumn: a system delete of the holder deletes every guarded referenced record', async () => {
272
+ const holderTable = cascadeDeleteTestTables.GuardedHolderArr;
273
+
274
+ const m1 = await systemDb.insert(memberTable, { name: 'Gus' });
275
+ const m2 = await systemDb.insert(memberTable, { name: 'Greta' });
276
+ const m3 = await systemDb.insert(memberTable, { name: 'Glen' });
277
+ const h = await systemDb.insert(holderTable, {
278
+ holderName: 'H-Arr',
279
+ memberRefs: new ReferenceArray<GuardedMember>(memberTable.name, [m1.id, m2.id]),
280
+ });
281
+
282
+ const delQb = new QueryBuilder<GuardedHolderArr>(holderTable.name).condition({
283
+ field: 'id',
284
+ operator: '=',
285
+ value: h.id,
286
+ });
287
+ const deleted = await inFreshContext(() => systemDb.delete(holderTable, delQb));
288
+ expect(deleted).toBe(1);
289
+
290
+ expect(await systemDb.query(memberTable, { id: m1.id })).toHaveLength(0);
291
+ expect(await systemDb.query(memberTable, { id: m2.id })).toHaveLength(0);
292
+ // The unreferenced member stands: the cascade is the stored ids, nothing wider.
293
+ expect(await systemDb.query(memberTable, { id: m3.id })).toHaveLength(1);
294
+ });
295
+
296
+ test('DynamicReferenceColumn: a system delete of the holder deletes the guarded dynamically referenced record', async () => {
297
+ const holderTable = cascadeDeleteTestTables.GuardedHolderDyn;
298
+
299
+ const m = await systemDb.insert(memberTable, { name: 'Gwen' });
300
+ const h = await systemDb.insert(holderTable, {
301
+ holderName: 'H-Dyn',
302
+ memberDynTableName: memberTable.name,
303
+ memberDynRef: new Reference<GuardedMember>(memberTable.name, m.id),
304
+ });
305
+
306
+ const delQb = new QueryBuilder<GuardedHolderDyn>(holderTable.name).condition({
307
+ field: 'id',
308
+ operator: '=',
309
+ value: h.id,
310
+ });
311
+ const deleted = await inFreshContext(() => systemDb.delete(holderTable, delQb));
312
+ expect(deleted).toBe(1);
313
+
314
+ expect(await systemDb.query(memberTable, { id: m.id })).toHaveLength(0);
315
+ });
316
+ });
227
317
  };
228
318
  };