@proteinjs/db 1.55.0 → 1.55.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.55.0",
3
+ "version": "1.55.1",
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": "195e4d8a6aae026ad6c6b8b0fbfa831a57ecb501"
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
@@ -551,6 +551,14 @@ export class Db<R extends Record = Record> implements DbService<R> {
551
551
  return recordDeleteCount;
552
552
  }
553
553
 
554
+ /**
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.
561
+ */
554
562
  private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
555
563
  for (const columnPropertyName in table.columns) {
556
564
  const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
@@ -560,7 +568,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
560
568
  columnPropertyName,
561
569
  recordsToDelete,
562
570
  this.getTable,
563
- new Db(this.dbDriver, this.getTable, this.transactionContextFactory)
571
+ new Db(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem)
564
572
  );
565
573
  }
566
574
  }
@@ -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
  };
@@ -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
  };