@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.
- package/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +5 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Db.d.ts +15 -6
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +26 -15
- package/dist/src/Db.js.map +1 -1
- package/dist/src/TableWatcher.d.ts +15 -6
- package/dist/src/TableWatcher.d.ts.map +1 -1
- package/dist/src/TableWatcher.js.map +1 -1
- package/dist/src/TableWatcherRunner.d.ts +14 -6
- package/dist/src/TableWatcherRunner.d.ts.map +1 -1
- package/dist/src/TableWatcherRunner.js +19 -12
- package/dist/src/TableWatcherRunner.js.map +1 -1
- package/dist/test/index.d.ts +2 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/reusable/TableWatcherAuthorityTests.d.ts +13 -0
- package/dist/test/reusable/TableWatcherAuthorityTests.d.ts.map +1 -0
- package/dist/test/reusable/TableWatcherAuthorityTests.js +392 -0
- package/dist/test/reusable/TableWatcherAuthorityTests.js.map +1 -0
- package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
- package/dist/test/util/DbTestEnvironment.js +3 -2
- package/dist/test/util/DbTestEnvironment.js.map +1 -1
- package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts +35 -0
- package/dist/test/util/tables/tableWatcherAuthorityTestTables.d.ts.map +1 -0
- package/dist/test/util/tables/tableWatcherAuthorityTestTables.js +113 -0
- package/dist/test/util/tables/tableWatcherAuthorityTestTables.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +5 -1
- package/package.json +2 -2
- package/src/Db.ts +40 -21
- package/src/TableWatcher.ts +17 -6
- package/src/TableWatcherRunner.ts +25 -12
- package/test/index.ts +2 -0
- package/test/reusable/TableWatcherAuthorityTests.ts +204 -0
- package/test/util/DbTestEnvironment.ts +2 -0
- 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.
|
|
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": "
|
|
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(
|
|
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(
|
|
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
|
|
556
|
-
*
|
|
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
|
package/src/TableWatcher.ts
CHANGED
|
@@ -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
|
|
72
|
-
afterInsert?<T extends R>(record: T): Promise<void>;
|
|
73
|
-
beforeUpdate?<T extends R>(record: Partial<T>, qb: QueryBuilder<T
|
|
74
|
-
afterUpdate?<T extends R>(recordUpdateCount: number, record: Partial<T>, qb: QueryBuilder<T
|
|
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
|
+
};
|