@proteinjs/db 1.54.0 → 1.54.2

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 (53) 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.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Columns.d.ts +12 -6
  7. package/dist/src/Columns.d.ts.map +1 -1
  8. package/dist/src/Columns.js +40 -65
  9. package/dist/src/Columns.js.map +1 -1
  10. package/dist/src/Db.d.ts.map +1 -1
  11. package/dist/src/Db.js +2 -5
  12. package/dist/src/Db.js.map +1 -1
  13. package/dist/src/Record.d.ts +19 -0
  14. package/dist/src/Record.d.ts.map +1 -1
  15. package/dist/src/Record.js +127 -45
  16. package/dist/src/Record.js.map +1 -1
  17. package/dist/src/Table.d.ts +18 -1
  18. package/dist/src/Table.d.ts.map +1 -1
  19. package/dist/src/encryption/EncryptionRecordHooks.d.ts +9 -4
  20. package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -1
  21. package/dist/src/encryption/EncryptionRecordHooks.js +65 -42
  22. package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -1
  23. package/dist/src/services/TransactionRunnerService.d.ts +3 -1
  24. package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
  25. package/dist/src/transaction/TransactionRunner.d.ts +13 -5
  26. package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
  27. package/dist/src/transaction/TransactionRunner.js +48 -12
  28. package/dist/src/transaction/TransactionRunner.js.map +1 -1
  29. package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts +2 -0
  30. package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts.map +1 -0
  31. package/dist/test/DateTimeFieldReadOnFirstRead.test.js +238 -0
  32. package/dist/test/DateTimeFieldReadOnFirstRead.test.js.map +1 -0
  33. package/dist/test/HydrationBenchmark.test.d.ts +2 -0
  34. package/dist/test/HydrationBenchmark.test.d.ts.map +1 -0
  35. package/dist/test/HydrationBenchmark.test.js +299 -0
  36. package/dist/test/HydrationBenchmark.test.js.map +1 -0
  37. package/dist/test/SyncRowHydration.test.d.ts +2 -0
  38. package/dist/test/SyncRowHydration.test.d.ts.map +1 -0
  39. package/dist/test/SyncRowHydration.test.js +344 -0
  40. package/dist/test/SyncRowHydration.test.js.map +1 -0
  41. package/generated/index.ts +1 -1
  42. package/generated/test/index.ts +1 -1
  43. package/package.json +2 -2
  44. package/src/Columns.ts +14 -7
  45. package/src/Db.ts +3 -4
  46. package/src/Record.ts +151 -17
  47. package/src/Table.ts +21 -1
  48. package/src/encryption/EncryptionRecordHooks.ts +48 -17
  49. package/src/services/TransactionRunnerService.ts +3 -1
  50. package/src/transaction/TransactionRunner.ts +53 -13
  51. package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
  52. package/test/HydrationBenchmark.test.ts +209 -0
  53. package/test/SyncRowHydration.test.ts +249 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.54.0",
3
+ "version": "1.54.2",
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": "856ac58e8bb61e5d46261ffb57434994fb808390"
69
+ "gitHead": "d90bd35b32b628c2b9cf9d8ddb16b4a2b9140f11"
70
70
  }
package/src/Columns.ts CHANGED
@@ -74,7 +74,7 @@ export class BooleanColumn implements Column<boolean | null, boolean | null> {
74
74
  return null;
75
75
  }
76
76
 
77
- async deserialize(serializedFieldValue: boolean | null): Promise<boolean | null> {
77
+ deserialize(serializedFieldValue: boolean | null): boolean | null {
78
78
  if (serializedFieldValue) {
79
79
  return true;
80
80
  }
@@ -95,6 +95,13 @@ export class DateColumn implements Column<Date, Date> {
95
95
  }
96
96
 
97
97
  export class DateTimeColumn implements Column<moment.Moment | null, Date | null> {
98
+ /**
99
+ * The moment is built on the field's first read, not at hydration (`Column.deserializeOnFirstRead`):
100
+ * a row carries several of these and most are never read, and a moment costs more to build than
101
+ * the stored Date it is built from.
102
+ */
103
+ readonly deserializeOnFirstRead = true;
104
+
98
105
  constructor(
99
106
  public name: string,
100
107
  public options?: ColumnOptions
@@ -112,7 +119,7 @@ export class DateTimeColumn implements Column<moment.Moment | null, Date | null>
112
119
  return fieldValue.toDate();
113
120
  }
114
121
 
115
- async deserialize(serializedFieldValue: Date | null): Promise<moment.Moment | null> {
122
+ deserialize(serializedFieldValue: Date | null): moment.Moment | null {
116
123
  if (serializedFieldValue === undefined || serializedFieldValue === null) {
117
124
  return null;
118
125
  }
@@ -186,7 +193,7 @@ export class ObjectColumn<T> extends StringColumn<T> {
186
193
  return Serializer.serialize(fieldValue);
187
194
  }
188
195
 
189
- async deserialize(serializedFieldValue: string): Promise<T | null> {
196
+ deserialize(serializedFieldValue: string): T | null {
190
197
  if (serializedFieldValue === undefined || serializedFieldValue == null) {
191
198
  return null;
192
199
  }
@@ -247,8 +254,8 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
247
254
  return await super.serialize(returnIds as any);
248
255
  }
249
256
 
250
- async deserialize(serializedFieldValue: string): Promise<ReferenceArray<T> | null> {
251
- let ids = (await super.deserialize(serializedFieldValue)) as string[] | null;
257
+ deserialize(serializedFieldValue: string): ReferenceArray<T> | null {
258
+ let ids = super.deserialize(serializedFieldValue) as string[] | null;
252
259
  if (ids === null) {
253
260
  ids = [];
254
261
  }
@@ -315,7 +322,7 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
315
322
  return fieldValue._id;
316
323
  }
317
324
 
318
- async deserialize(serializedFieldValue: string): Promise<Reference<T> | null> {
325
+ deserialize(serializedFieldValue: string): Reference<T> | null {
319
326
  const reference = new Reference(this.referenceTable, serializedFieldValue);
320
327
  if (reference._id === null) {
321
328
  return null;
@@ -487,7 +494,7 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
487
494
  return fieldValue._id;
488
495
  }
489
496
 
490
- async deserialize(serializedFieldValue: string, serializedRecord: any): Promise<Reference<T> | null> {
497
+ deserialize(serializedFieldValue: string, serializedRecord: any): Reference<T> | null {
491
498
  const reference = new Reference('', serializedFieldValue);
492
499
  if (reference._id === null) {
493
500
  return null;
package/src/Db.ts CHANGED
@@ -750,10 +750,9 @@ export class Db<R extends Record = Record> implements DbService<R> {
750
750
  const generateQuery = (config: DbDriverQueryStatementConfig) =>
751
751
  qb.toSql(this.statementConfigFactory.getStatementConfig(config));
752
752
  const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
753
- const recordSerializer = new RecordSerializer(table);
754
- const records = await Promise.all(
755
- serializedRecords.map(async (serializedRecord) => recordSerializer.deserialize(serializedRecord))
756
- );
753
+ // One pass over the result set — synchronous when no row needs an await (see
754
+ // `RecordSerializer.deserializeAll`): no promise per row, no yield of the loop between rows.
755
+ const records = await new RecordSerializer<T>(table).deserializeAll(serializedRecords);
757
756
  await this.preloadReferences(records, options);
758
757
  return records;
759
758
  }
package/src/Record.ts CHANGED
@@ -2,6 +2,7 @@ import { Logger } from '@proteinjs/logger';
2
2
  import { DateTimeColumn, UuidColumn } from './Columns';
3
3
  import { Column, Table, Columns } from './Table';
4
4
  import { moment } from './opt/moment';
5
+ import type { EncryptionRecordHooks } from './encryption/EncryptionRecordHooks';
5
6
 
6
7
  export interface Record {
7
8
  id: string;
@@ -51,10 +52,26 @@ export type SerializedRecord = { [columnName: string]: any };
51
52
  * (see `EncryptionRecordHooks`). Resolved by `Db` for writes touching encrypted columns. */
52
53
  export type RecordEncryptionContext = { keyOwner: string };
53
54
 
55
+ /** One column's seat in a hydration: the property the record carries it under, and the column. */
56
+ interface HydrationSlot {
57
+ propertyName: string;
58
+ column: Column<any, any>;
59
+ }
60
+
61
+ /**
62
+ * What a hydration resolves ONCE per serializer, never per row: the decryption hook and every
63
+ * column's seat, keyed by the name a stored row carries it under.
64
+ */
65
+ interface HydrationPlan {
66
+ hooks: EncryptionRecordHooks;
67
+ slotsByColumnName: { [columnName: string]: HydrationSlot | undefined };
68
+ }
69
+
54
70
  export class RecordSerializer<T extends Record> {
55
71
  private logger = new Logger({ name: this.constructor.name });
56
72
  private table: Table<T>;
57
73
  private encryptionContext?: RecordEncryptionContext;
74
+ private plan?: HydrationPlan;
58
75
 
59
76
  constructor(table: Table<T>, encryptionContext?: RecordEncryptionContext) {
60
77
  this.table = table;
@@ -97,35 +114,152 @@ export class RecordSerializer<T extends Record> {
97
114
  return serialized;
98
115
  }
99
116
 
117
+ /**
118
+ * A result set's rows → records, in ONE pass. A row whose hook and columns all answer directly
119
+ * is hydrated synchronously, and when every row did the array returns synchronously: no promise
120
+ * per row, no yield of the event loop between rows. A row that needs an await — a ciphertext
121
+ * envelope to decrypt, a column whose `deserialize` answers with a promise — settles through one
122
+ * promise for the whole set. The hook and the column seats are resolved once per serializer.
123
+ */
124
+ deserializeAll(serializedRecords: SerializedRecord[]): T[] | Promise<T[]> {
125
+ const plan = this.hydrationPlan();
126
+ const records: (T | Promise<T>)[] = new Array(serializedRecords.length);
127
+ let settles = false;
128
+ for (let i = 0; i < serializedRecords.length; i++) {
129
+ const record = this.hydrate(plan, serializedRecords[i]);
130
+ if (this.isPromise(record)) {
131
+ settles = true;
132
+ }
133
+ records[i] = record;
134
+ }
135
+
136
+ return settles ? Promise.all(records) : (records as T[]);
137
+ }
138
+
139
+ /** One row → one record (`deserializeAll` is the door for a result set). */
100
140
  async deserialize(serializedRecord: SerializedRecord): Promise<T> {
141
+ return await this.hydrate(this.hydrationPlan(), serializedRecord);
142
+ }
143
+
144
+ private hydrate(plan: HydrationPlan, serializedRecord: SerializedRecord): T | Promise<T> {
101
145
  // The transparent decryption seam: ciphertext envelopes decrypt (self-describing — the
102
- // envelope names its key), framework companion columns drop out of the result.
103
- const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
104
- const prepared = await new EncryptionRecordHooks().onDeserialize(this.table, serializedRecord);
146
+ // envelope names its key), framework companion columns drop out of the result. The hook
147
+ // answers directly for a row holding no envelope.
148
+ const prepared = plan.hooks.onDeserialize(this.table, serializedRecord);
149
+ if (this.isPromise(prepared)) {
150
+ return prepared.then((row) => this.hydratePrepared(plan, row));
151
+ }
105
152
 
106
- const deserialized: any = {};
107
- const fieldSerializer = new FieldSerializer(this.table);
108
- const omittedFields: string[] = [];
153
+ return this.hydratePrepared(plan, prepared);
154
+ }
155
+
156
+ private hydratePrepared(plan: HydrationPlan, prepared: SerializedRecord): T | Promise<T> {
157
+ const record: any = {};
158
+ let settling: Promise<void>[] | undefined;
159
+ let omittedFields: string[] | undefined;
109
160
  for (const columnName in prepared) {
110
- const serializedFieldValue = prepared[columnName];
111
- try {
112
- const { fieldPropertyName, fieldValue } = await fieldSerializer.deserialize(
113
- columnName,
114
- serializedFieldValue,
115
- prepared
161
+ const slot = plan.slotsByColumnName[columnName];
162
+ if (!slot) {
163
+ // a column exists in the db that is no longer defined in Table.columns
164
+ (omittedFields ??= []).push(columnName);
165
+ continue;
166
+ }
167
+
168
+ const { propertyName, column } = slot;
169
+ const storedValue = prepared[columnName];
170
+ if (!column.deserialize) {
171
+ record[propertyName] = storedValue;
172
+ continue;
173
+ }
174
+
175
+ if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
176
+ this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
177
+ continue;
178
+ }
179
+
180
+ const fieldValue = column.deserialize(storedValue, prepared);
181
+ if (this.isPromise(fieldValue)) {
182
+ record[propertyName] = undefined; // seats the property in column order; the value lands when it settles
183
+ (settling ??= []).push(
184
+ fieldValue.then((value) => {
185
+ record[propertyName] = value;
186
+ })
116
187
  );
117
- deserialized[fieldPropertyName] = fieldValue;
118
- } catch (MissingFieldError) {
119
- omittedFields.push(columnName);
188
+ continue;
120
189
  }
190
+
191
+ record[propertyName] = fieldValue;
121
192
  }
122
193
 
123
- if (omittedFields.length > 0) {
194
+ if (omittedFields) {
124
195
  // expected when passing a base table into the query api
125
196
  this.logger.debug({ message: `Fields were omitted during deserialization`, obj: { omittedFields } });
126
197
  }
127
198
 
128
- return deserialized;
199
+ if (settling) {
200
+ return Promise.all(settling).then(() => record as T);
201
+ }
202
+
203
+ return record as T;
204
+ }
205
+
206
+ /**
207
+ * The field as an own, enumerable property whose value is deserialized on its first read, once
208
+ * (`Column.deserializeOnFirstRead`); a write replaces it like any other field.
209
+ */
210
+ private defineFieldReadOnFirstRead(
211
+ record: any,
212
+ propertyName: string,
213
+ column: Column<any, any>,
214
+ storedValue: unknown
215
+ ): void {
216
+ let value: unknown;
217
+ let read = false;
218
+ Object.defineProperty(record, propertyName, {
219
+ enumerable: true,
220
+ configurable: true,
221
+ get() {
222
+ if (!read) {
223
+ value = column.deserialize!(storedValue, undefined);
224
+ read = true;
225
+ }
226
+
227
+ return value;
228
+ },
229
+ set(next: unknown) {
230
+ value = next;
231
+ read = true;
232
+ },
233
+ });
234
+ }
235
+
236
+ private hydrationPlan(): HydrationPlan {
237
+ if (this.plan) {
238
+ return this.plan;
239
+ }
240
+
241
+ // Required at call time — this module sits below the encryption machinery in the package's
242
+ // module graph (the seam `Table.ts` uses for the same reason).
243
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
244
+ const { EncryptionRecordHooks } =
245
+ require('./encryption/EncryptionRecordHooks') as typeof import('./encryption/EncryptionRecordHooks');
246
+ const slotsByColumnName: HydrationPlan['slotsByColumnName'] = {};
247
+ const columns: { [propertyName: string]: Column<any, any> } = this.table.columns;
248
+ // Seated by column name, then by property name — a stored key that is a property name
249
+ // resolves to that property's column first, as it always has.
250
+ for (const propertyName in columns) {
251
+ slotsByColumnName[columns[propertyName].name] = { propertyName, column: columns[propertyName] };
252
+ }
253
+ for (const propertyName in columns) {
254
+ slotsByColumnName[propertyName] = { propertyName, column: columns[propertyName] };
255
+ }
256
+
257
+ this.plan = { hooks: new EncryptionRecordHooks(), slotsByColumnName };
258
+ return this.plan;
259
+ }
260
+
261
+ private isPromise<V>(value: V | Promise<V>): value is Promise<V> {
262
+ return typeof (value as { then?: unknown } | null | undefined)?.then === 'function';
129
263
  }
130
264
  }
131
265
 
package/src/Table.ts CHANGED
@@ -263,7 +263,27 @@ export type Column<T, Serialized> = {
263
263
  oldName?: string;
264
264
  options?: ColumnOptions;
265
265
  serialize?: (fieldValue: T | null | undefined) => Promise<Serialized | null | undefined>;
266
- deserialize?: (serializedFieldValue: Serialized | null, serializedRecord: any) => Promise<T | null | void>;
266
+ /**
267
+ * The stored value → the field's value. Answers directly when building the value needs no
268
+ * await (every column this package ships does), or with a promise when it does: record
269
+ * hydration (`RecordSerializer.deserializeAll`) awaits only what comes back as a promise, so a
270
+ * result set whose rows all answer directly hydrates synchronously — no promise per row, no
271
+ * yield of the event loop between rows.
272
+ */
273
+ deserialize?: (
274
+ serializedFieldValue: Serialized | null,
275
+ serializedRecord: any
276
+ ) => T | null | void | Promise<T | null | void>;
277
+ /**
278
+ * When true, a hydrated record builds this column's field on the FIRST READ of the field, not
279
+ * at hydration: `deserialize` runs then, once, with the stored value alone (the row is not
280
+ * kept), so a deserializer under this flag reads nothing but its value and answers directly,
281
+ * never with a promise. For a pure, synchronous deserializer whose value costs more to build
282
+ * than to store and is often never read on a row — a DateTime column's moment. The record's
283
+ * shape is unchanged: the field is an own, enumerable property that reads as the deserialized
284
+ * value and takes a write like any other; a stored null reads null, undeferred.
285
+ */
286
+ deserializeOnFirstRead?: boolean;
267
287
  /**
268
288
  * The column's QUERY-side contract, paralleling `serialize`/`deserialize` on the storage
269
289
  * side: how uses of this column in a query (conditions, ORDER BY, aggregation, GROUP BY)
@@ -9,6 +9,9 @@ import { SearchTokenizer } from './SearchTokenizer';
9
9
  /** Who a row being written belongs to — the data key its values encrypt under. */
10
10
  export type EncryptionWriteContext = { keyOwner: string };
11
11
 
12
+ /** A stored cell holding a ciphertext envelope, and the key the envelope names. */
13
+ type EnvelopeCell = { columnName: string; owner: string; version: number };
14
+
12
15
  /**
13
16
  * The transparent encrypt/decrypt seam, invoked by `RecordSerializer` on every write's
14
17
  * serialize and every read's deserialize — the same per-column hook layer every caller
@@ -31,6 +34,7 @@ export class EncryptionRecordHooks {
31
34
  private encryptedColumns = new EncryptedColumns();
32
35
  private envelope = new EncryptionEnvelope();
33
36
  private tokenizer = new SearchTokenizer();
37
+ private internalNames?: { table: Table<any>; names: Set<string> };
34
38
 
35
39
  /** Encrypt encrypted-column values in `serialized` (column-name-keyed) and add companions. */
36
40
  async onSerialize(table: Table<any>, serialized: SerializedRecord, context?: EncryptionWriteContext): Promise<void> {
@@ -88,34 +92,46 @@ export class EncryptionRecordHooks {
88
92
  }
89
93
 
90
94
  /**
91
- * A copy of `serializedRecord` with framework companion columns dropped and every
92
- * envelope value decrypted (any column — self-describing ciphertext also covers the
93
- * decrypt-out transition, where the config no longer marks the column).
95
+ * `serializedRecord` with framework companion columns dropped and every envelope value
96
+ * decrypted (any column — self-describing ciphertext also covers the decrypt-out transition,
97
+ * where the config no longer marks the column). Answers directly — the row itself when
98
+ * nothing drops or decrypts, else a copy — unless an envelope must be decrypted: its key is
99
+ * read asynchronously, so the copy settles through a promise.
94
100
  */
95
- async onDeserialize(table: Table<any>, serializedRecord: SerializedRecord): Promise<SerializedRecord> {
101
+ onDeserialize(table: Table<any>, serializedRecord: SerializedRecord): SerializedRecord | Promise<SerializedRecord> {
96
102
  this.encryptedColumns.ensureSchema(table);
97
- const prepared: SerializedRecord = {};
98
103
  const internalColumnNames = this.internalColumnNames(table);
99
- let decrypted = false;
100
- for (const columnName of Object.keys(serializedRecord)) {
104
+ let dropsInternal = false;
105
+ let envelopes: EnvelopeCell[] | undefined;
106
+ for (const columnName in serializedRecord) {
101
107
  if (internalColumnNames.has(columnName)) {
108
+ dropsInternal = true;
102
109
  continue;
103
110
  }
104
111
 
105
- const value = serializedRecord[columnName];
106
- const parsed = this.envelope.parse(value);
107
- if (!parsed) {
108
- prepared[columnName] = value;
109
- continue;
112
+ const parsed = this.envelope.parse(serializedRecord[columnName]);
113
+ if (parsed) {
114
+ (envelopes ??= []).push({ columnName, owner: parsed.owner, version: parsed.version });
110
115
  }
116
+ }
111
117
 
112
- this.assertServerSide();
113
- const key = await new DataKeyStore().getKeyByVersion(parsed.owner, parsed.version);
114
- prepared[columnName] = this.envelope.decrypt(value as string, key);
115
- decrypted = true;
118
+ if (!dropsInternal && !envelopes) {
119
+ return serializedRecord;
116
120
  }
117
121
 
118
- return decrypted || internalColumnNames.size > 0 ? prepared : serializedRecord;
122
+ const prepared: SerializedRecord = {};
123
+ for (const columnName in serializedRecord) {
124
+ if (!internalColumnNames.has(columnName)) {
125
+ prepared[columnName] = serializedRecord[columnName];
126
+ }
127
+ }
128
+
129
+ if (!envelopes) {
130
+ return prepared;
131
+ }
132
+
133
+ this.assertServerSide();
134
+ return this.decryptInto(prepared, envelopes);
119
135
  }
120
136
 
121
137
  /**
@@ -144,7 +160,21 @@ export class EncryptionRecordHooks {
144
160
  );
145
161
  }
146
162
 
163
+ private async decryptInto(prepared: SerializedRecord, envelopes: EnvelopeCell[]): Promise<SerializedRecord> {
164
+ for (const cell of envelopes) {
165
+ const key = await new DataKeyStore().getKeyByVersion(cell.owner, cell.version);
166
+ prepared[cell.columnName] = this.envelope.decrypt(prepared[cell.columnName] as string, key);
167
+ }
168
+
169
+ return prepared;
170
+ }
171
+
172
+ /** The table's framework-derived companion column names — resolved once per table per instance, not per row. */
147
173
  private internalColumnNames(table: Table<any>): Set<string> {
174
+ if (this.internalNames?.table === table) {
175
+ return this.internalNames.names;
176
+ }
177
+
148
178
  const names = new Set<string>();
149
179
  for (const prop of Object.keys(table.columns)) {
150
180
  const column = (table.columns as any)[prop];
@@ -153,6 +183,7 @@ export class EncryptionRecordHooks {
153
183
  }
154
184
  }
155
185
 
186
+ this.internalNames = { table, names };
156
187
  return names;
157
188
  }
158
189
 
@@ -17,7 +17,9 @@ export type TransactionRunOptions = {
17
17
  * Ids of rows the operations depend on that may not exist yet: the row an update or a
18
18
  * membership update targets while its insert is still in flight, the permission source a
19
19
  * child row attaches to while the root's own insert (and the owner grant that commits with
20
- * it) is still landing. Every id must be a row the operations themselves reference. The
20
+ * it) is still landing, any row an insert names through one of its table's reference columns
21
+ * while that row is still being born. Every id must be a row the operations themselves
22
+ * reference (an insert references every row its reference columns name). The
21
23
  * runner waits, bounded, for each to exist AND be visible to the caller before running the
22
24
  * operations; a row that never appears fails the request with a plain clause — never a
23
25
  * silent zero-row write, never a hang.
@@ -1,6 +1,11 @@
1
1
  import { Logger } from '@proteinjs/logger';
2
+ import { isInstanceOf } from '@proteinjs/util';
2
3
  import { Db, getDb } from '../Db';
3
- import { Table, tableByName } from '../Table';
4
+ import { Column, Table, getColumnPropertyName, tableByName } from '../Table';
5
+ // After Table: Columns ↔ Table ↔ Record load in a cycle that resolves only when Record is entered
6
+ // before Columns (Record constructs its columns at module load); a module that enters Columns
7
+ // first — as this one would when it is the first module loaded — leaves those constructors undefined.
8
+ import { DynamicReferenceColumn, ReferenceColumn } from '../Columns';
4
9
  import { getDefaultTransactionContextFactory } from './TransactionContextFactory';
5
10
  import {
6
11
  TransactionRunnerService,
@@ -135,10 +140,11 @@ export class TransactionRunner implements TransactionRunnerService {
135
140
  }
136
141
 
137
142
  /**
138
- * The rows the operations reference, by id → the table(s) they are read from: the permission
139
- * source a row attaches to (an insert whose `permissionSource` is another row), the row an
140
- * update / preserving update / membership update / delete targets. The rows the operations
141
- * insert themselves are not dependencies.
143
+ * The rows the operations reference, by id → the table(s) they are read from. An insert
144
+ * references every row its table's reference columns name (`rowsTheInsertReferences`) — the
145
+ * permission source a row attaches to is one of these, a row named through any other reference
146
+ * column another. An update / preserving update / membership update / delete references the row
147
+ * it targets. The rows the operations insert themselves are not dependencies.
142
148
  */
143
149
  private static rowsTheOperationsReference(ops: Operation<any>[]): Map<string, Table<any>[]> {
144
150
  const inserted: string[] = [];
@@ -161,16 +167,12 @@ export class TransactionRunner implements TransactionRunnerService {
161
167
  const args = op.args as unknown[];
162
168
  const table = args[0] as Table<any> | undefined;
163
169
  if (op.name === 'insert') {
164
- const record = args[1] as
165
- | { id?: string; permissionSource?: { _table?: string; _id?: string }; permissionSourceTable?: string }
166
- | undefined;
170
+ const record = args[1] as { id?: string } | undefined;
167
171
  if (record?.id) {
168
172
  inserted.push(record.id);
169
173
  }
170
- const source = record?.permissionSource;
171
- if (source?._id && source._id !== record?.id) {
172
- const sourceTableName = record?.permissionSourceTable ?? source._table;
173
- reference(sourceTableName ? TransactionRunner.tableNamed(sourceTableName, table) : table, source._id);
174
+ for (const row of TransactionRunner.rowsTheInsertReferences(table, record)) {
175
+ reference(TransactionRunner.tableNamed(row.tableName, table), row.id);
174
176
  }
175
177
  } else if (op.name === 'update' || op.name === 'updatePreserving') {
176
178
  const record = args[1] as { id?: string } | undefined;
@@ -188,7 +190,45 @@ export class TransactionRunner implements TransactionRunnerService {
188
190
  return referenced;
189
191
  }
190
192
 
191
- /** The table a permission source lives in: the insert's own table when it names the same one. */
193
+ /**
194
+ * The rows an inserted record names through its table's reference columns: for each column
195
+ * whose value carries an id, that id and the table it lives in — a `ReferenceColumn`'s declared
196
+ * table; for a `DynamicReferenceColumn`, the table its paired table-name column carries on the
197
+ * record (a client's queued insert ran the column's default), else the reference's own.
198
+ */
199
+ private static rowsTheInsertReferences(
200
+ table: Table<any> | undefined,
201
+ record: { [field: string]: unknown } | undefined
202
+ ): { id: string; tableName: string }[] {
203
+ if (!table || !record) {
204
+ return [];
205
+ }
206
+
207
+ const rows: { id: string; tableName: string }[] = [];
208
+ for (const [field, column] of Object.entries(table.columns as { [field: string]: Column<any, any> })) {
209
+ const value = record[field] as { _id?: unknown; _table?: unknown } | null | undefined;
210
+ if (!value || typeof value._id !== 'string') {
211
+ continue;
212
+ }
213
+ let tableName: unknown;
214
+ if (isInstanceOf(column, ReferenceColumn)) {
215
+ tableName = (column as ReferenceColumn<any>).referenceTable;
216
+ } else if (isInstanceOf(column, DynamicReferenceColumn)) {
217
+ const tableNameField = getColumnPropertyName(
218
+ table,
219
+ (column as DynamicReferenceColumn<any>).dynamicRefTableColName
220
+ );
221
+ tableName = (tableNameField && record[tableNameField]) || value._table;
222
+ }
223
+ if (typeof tableName === 'string' && tableName) {
224
+ rows.push({ id: value._id, tableName });
225
+ }
226
+ }
227
+
228
+ return rows;
229
+ }
230
+
231
+ /** The table a referenced row lives in: the insert's own table when it names the same one. */
192
232
  private static tableNamed(tableName: string, inserting: Table<any> | undefined): Table<any> {
193
233
  return inserting && inserting.name === tableName ? inserting : tableByName(tableName);
194
234
  }