@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
@@ -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
 
@@ -80,6 +80,28 @@ export interface Mission extends Record {
80
80
  assigneeRef?: Reference<Pilot | Robot> | null;
81
81
  }
82
82
 
83
+ // --- Cascade under the write's authority: a guarded child (GuardedHolder* -> GuardedMember)
84
+ // The child's table refuses every non-system query, the shape of a scoped table (its column adds
85
+ // the caller's scope to each query and throws when the context carries no user). A holder deleted
86
+ // AS SYSTEM must take its children with it: the cascade runs with the authority of the write that
87
+ // fired it, never as whoever happens to be in the session.
88
+ export interface GuardedMember extends Record {
89
+ name: string;
90
+ }
91
+ export interface GuardedHolderRef extends Record {
92
+ holderName: string;
93
+ memberRef?: Reference<GuardedMember> | null;
94
+ }
95
+ export interface GuardedHolderArr extends Record {
96
+ holderName: string;
97
+ memberRefs?: ReferenceArray<GuardedMember> | null;
98
+ }
99
+ export interface GuardedHolderDyn extends Record {
100
+ holderName: string;
101
+ memberDynTableName?: string | null;
102
+ memberDynRef?: Reference<GuardedMember> | null;
103
+ }
104
+
83
105
  /**
84
106
  * ---------- Table Names ----------
85
107
  */
@@ -112,6 +134,15 @@ const PILOT_TABLE = 'db_test_cd_pilots';
112
134
  const ROBOT_TABLE = 'db_test_cd_robots';
113
135
  const MISSION_TABLE = 'db_test_cd_missions';
114
136
 
137
+ // Cascade under the write's authority (guarded child)
138
+ const GUARDED_MEMBER_TABLE = 'db_test_cd_guarded_members';
139
+ const GUARDED_HOLDER_REF_TABLE = 'db_test_cd_guarded_holders_ref';
140
+ const GUARDED_HOLDER_ARR_TABLE = 'db_test_cd_guarded_holders_arr';
141
+ const GUARDED_HOLDER_DYN_TABLE = 'db_test_cd_guarded_holders_dyn';
142
+
143
+ /** The guarded table's refusal of a non-system query — the words a suite asserts on. */
144
+ export const GUARDED_QUERY_REFUSAL = 'no user in scope';
145
+
115
146
  /**
116
147
  * ---------- Table Classes ----------
117
148
  */
@@ -261,6 +292,56 @@ export class MissionTable extends Table<Mission> {
261
292
  });
262
293
  }
263
294
 
295
+ // Cascade under the write's authority: the guarded child and its three holders
296
+ export class GuardedMemberTable extends Table<GuardedMember> {
297
+ name = GUARDED_MEMBER_TABLE;
298
+ columns: Table<GuardedMember>['columns'] = withRecordColumns<GuardedMember>({
299
+ name: new StringColumn('name', {
300
+ // The scoped-table contract without a dependency on it: a non-system query of this table
301
+ // would need the context's user to scope it to, and there is none — refuse it by name.
302
+ addToQuery: async (qb, runAsSystem) => {
303
+ if (!runAsSystem) {
304
+ throw new Error(GUARDED_QUERY_REFUSAL);
305
+ }
306
+ },
307
+ }),
308
+ });
309
+ }
310
+ export class GuardedHolderRefTable extends Table<GuardedHolderRef> {
311
+ name = GUARDED_HOLDER_REF_TABLE;
312
+ columns: Table<GuardedHolderRef>['columns'] = withRecordColumns<GuardedHolderRef>({
313
+ holderName: new StringColumn('holder_name'),
314
+ memberRef: new ReferenceColumn<GuardedMember>(
315
+ 'member_id',
316
+ GUARDED_MEMBER_TABLE,
317
+ true // cascade: deleting the holder deletes the referenced GuardedMember
318
+ ),
319
+ });
320
+ }
321
+ export class GuardedHolderArrTable extends Table<GuardedHolderArr> {
322
+ name = GUARDED_HOLDER_ARR_TABLE;
323
+ columns: Table<GuardedHolderArr>['columns'] = withRecordColumns<GuardedHolderArr>({
324
+ holderName: new StringColumn('holder_name'),
325
+ memberRefs: new ReferenceArrayColumn<GuardedMember>(
326
+ 'member_ids',
327
+ GUARDED_MEMBER_TABLE,
328
+ true // cascade: deleting the holder deletes every referenced GuardedMember
329
+ ),
330
+ });
331
+ }
332
+ export class GuardedHolderDynTable extends Table<GuardedHolderDyn> {
333
+ name = GUARDED_HOLDER_DYN_TABLE;
334
+ columns: Table<GuardedHolderDyn>['columns'] = withRecordColumns<GuardedHolderDyn>({
335
+ holderName: new StringColumn('holder_name'),
336
+ memberDynTableName: new DynamicReferenceTableNameColumn('member_dyn_table_name', 'member_dyn_ref'),
337
+ memberDynRef: new DynamicReferenceColumn<GuardedMember>(
338
+ 'member_dyn_ref',
339
+ 'member_dyn_table_name',
340
+ true // cascade: deleting the holder deletes the dynamically referenced GuardedMember
341
+ ),
342
+ });
343
+ }
344
+
264
345
  export const cascadeDeleteTestTables = {
265
346
  MemberRef: new MemberRefTable() as Table<MemberRef>,
266
347
  GroupRef: new GroupRefTable() as Table<GroupRef>,
@@ -277,4 +358,8 @@ export const cascadeDeleteTestTables = {
277
358
  Pilot: new PilotTable() as Table<Pilot>,
278
359
  Robot: new RobotTable() as Table<Robot>,
279
360
  Mission: new MissionTable() as Table<Mission>,
361
+ GuardedMember: new GuardedMemberTable() as Table<GuardedMember>,
362
+ GuardedHolderRef: new GuardedHolderRefTable() as Table<GuardedHolderRef>,
363
+ GuardedHolderArr: new GuardedHolderArrTable() as Table<GuardedHolderArr>,
364
+ GuardedHolderDyn: new GuardedHolderDynTable() as Table<GuardedHolderDyn>,
280
365
  };
@@ -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
+ };