@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.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Columns.d.ts +12 -6
- package/dist/src/Columns.d.ts.map +1 -1
- package/dist/src/Columns.js +40 -65
- package/dist/src/Columns.js.map +1 -1
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +2 -5
- package/dist/src/Db.js.map +1 -1
- package/dist/src/Record.d.ts +19 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +127 -45
- package/dist/src/Record.js.map +1 -1
- package/dist/src/Table.d.ts +18 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/encryption/EncryptionRecordHooks.d.ts +9 -4
- package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -1
- package/dist/src/encryption/EncryptionRecordHooks.js +65 -42
- package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -1
- package/dist/src/services/TransactionRunnerService.d.ts +3 -1
- package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.d.ts +13 -5
- package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.js +48 -12
- package/dist/src/transaction/TransactionRunner.js.map +1 -1
- package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts +2 -0
- package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts.map +1 -0
- package/dist/test/DateTimeFieldReadOnFirstRead.test.js +238 -0
- package/dist/test/DateTimeFieldReadOnFirstRead.test.js.map +1 -0
- package/dist/test/HydrationBenchmark.test.d.ts +2 -0
- package/dist/test/HydrationBenchmark.test.d.ts.map +1 -0
- package/dist/test/HydrationBenchmark.test.js +299 -0
- package/dist/test/HydrationBenchmark.test.js.map +1 -0
- package/dist/test/SyncRowHydration.test.d.ts +2 -0
- package/dist/test/SyncRowHydration.test.d.ts.map +1 -0
- package/dist/test/SyncRowHydration.test.js +344 -0
- package/dist/test/SyncRowHydration.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +2 -2
- package/src/Columns.ts +14 -7
- package/src/Db.ts +3 -4
- package/src/Record.ts +151 -17
- package/src/Table.ts +21 -1
- package/src/encryption/EncryptionRecordHooks.ts +48 -17
- package/src/services/TransactionRunnerService.ts +3 -1
- package/src/transaction/TransactionRunner.ts +53 -13
- package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
- package/test/HydrationBenchmark.test.ts +209 -0
- 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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
let ids =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
104
|
-
const prepared =
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
} catch (MissingFieldError) {
|
|
119
|
-
omittedFields.push(columnName);
|
|
188
|
+
continue;
|
|
120
189
|
}
|
|
190
|
+
|
|
191
|
+
record[propertyName] = fieldValue;
|
|
121
192
|
}
|
|
122
193
|
|
|
123
|
-
if (omittedFields
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
171
|
-
|
|
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
|
-
/**
|
|
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
|
}
|