@proteinjs/db 1.54.1 → 1.54.3
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/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 +35 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +177 -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/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/LegacyCellHydration.test.d.ts +2 -0
- package/dist/test/LegacyCellHydration.test.d.ts.map +1 -0
- package/dist/test/LegacyCellHydration.test.js +321 -0
- package/dist/test/LegacyCellHydration.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/dist/test/reusable/ColumnTypesTests.d.ts.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.js +55 -0
- package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
- package/package.json +2 -2
- package/src/Columns.ts +14 -7
- package/src/Db.ts +3 -4
- package/src/Record.ts +202 -16
- package/src/Table.ts +21 -1
- package/src/encryption/EncryptionRecordHooks.ts +48 -17
- package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
- package/test/HydrationBenchmark.test.ts +209 -0
- package/test/LegacyCellHydration.test.ts +234 -0
- package/test/SyncRowHydration.test.ts +249 -0
- package/test/reusable/ColumnTypesTests.ts +47 -1
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,33 @@ 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> {
|
|
71
|
+
/**
|
|
72
|
+
* The table-and-column pairs whose stored cells have failed to deserialize in this process
|
|
73
|
+
* (`omitCellNotDeserialized`): each is warned once. A legacy cell is read again by every query
|
|
74
|
+
* that touches its row — a background sweep reads some rows every few seconds — and the warning
|
|
75
|
+
* is a schema fact, not a per-read event.
|
|
76
|
+
*/
|
|
77
|
+
private static legacyCellsWarned = new Set<string>();
|
|
55
78
|
private logger = new Logger({ name: this.constructor.name });
|
|
56
79
|
private table: Table<T>;
|
|
57
80
|
private encryptionContext?: RecordEncryptionContext;
|
|
81
|
+
private plan?: HydrationPlan;
|
|
58
82
|
|
|
59
83
|
constructor(table: Table<T>, encryptionContext?: RecordEncryptionContext) {
|
|
60
84
|
this.table = table;
|
|
@@ -97,35 +121,197 @@ export class RecordSerializer<T extends Record> {
|
|
|
97
121
|
return serialized;
|
|
98
122
|
}
|
|
99
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A result set's rows → records, in ONE pass. A row whose hook and columns all answer directly
|
|
126
|
+
* is hydrated synchronously, and when every row did the array returns synchronously: no promise
|
|
127
|
+
* per row, no yield of the event loop between rows. A row that needs an await — a ciphertext
|
|
128
|
+
* envelope to decrypt, a column whose `deserialize` answers with a promise — settles through one
|
|
129
|
+
* promise for the whole set. The hook and the column seats are resolved once per serializer.
|
|
130
|
+
*/
|
|
131
|
+
deserializeAll(serializedRecords: SerializedRecord[]): T[] | Promise<T[]> {
|
|
132
|
+
const plan = this.hydrationPlan();
|
|
133
|
+
const records: (T | Promise<T>)[] = new Array(serializedRecords.length);
|
|
134
|
+
let settles = false;
|
|
135
|
+
for (let i = 0; i < serializedRecords.length; i++) {
|
|
136
|
+
const record = this.hydrate(plan, serializedRecords[i]);
|
|
137
|
+
if (this.isPromise(record)) {
|
|
138
|
+
settles = true;
|
|
139
|
+
}
|
|
140
|
+
records[i] = record;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return settles ? Promise.all(records) : (records as T[]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One row → one record (`deserializeAll` is the door for a result set). */
|
|
100
147
|
async deserialize(serializedRecord: SerializedRecord): Promise<T> {
|
|
148
|
+
return await this.hydrate(this.hydrationPlan(), serializedRecord);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private hydrate(plan: HydrationPlan, serializedRecord: SerializedRecord): T | Promise<T> {
|
|
101
152
|
// 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 =
|
|
153
|
+
// envelope names its key), framework companion columns drop out of the result. The hook
|
|
154
|
+
// answers directly for a row holding no envelope.
|
|
155
|
+
const prepared = plan.hooks.onDeserialize(this.table, serializedRecord);
|
|
156
|
+
if (this.isPromise(prepared)) {
|
|
157
|
+
return prepared.then((row) => this.hydratePrepared(plan, row));
|
|
158
|
+
}
|
|
105
159
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
return this.hydratePrepared(plan, prepared);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private hydratePrepared(plan: HydrationPlan, prepared: SerializedRecord): T | Promise<T> {
|
|
164
|
+
const record: any = {};
|
|
165
|
+
let settling: Promise<void>[] | undefined;
|
|
166
|
+
let omittedFields: string[] | undefined;
|
|
109
167
|
for (const columnName in prepared) {
|
|
110
|
-
const
|
|
168
|
+
const slot = plan.slotsByColumnName[columnName];
|
|
169
|
+
if (!slot) {
|
|
170
|
+
// a column exists in the db that is no longer defined in Table.columns
|
|
171
|
+
(omittedFields ??= []).push(columnName);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { propertyName, column } = slot;
|
|
176
|
+
const storedValue = prepared[columnName];
|
|
177
|
+
if (!column.deserialize) {
|
|
178
|
+
record[propertyName] = storedValue;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
|
|
183
|
+
this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let fieldValue: unknown;
|
|
111
188
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
189
|
+
fieldValue = column.deserialize(storedValue, prepared);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.omitCellNotDeserialized(columnName, prepared, error);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (this.isPromise(fieldValue)) {
|
|
196
|
+
record[propertyName] = undefined; // seats the property in column order; the value lands when it settles
|
|
197
|
+
(settling ??= []).push(
|
|
198
|
+
fieldValue.then(
|
|
199
|
+
(value) => {
|
|
200
|
+
record[propertyName] = value;
|
|
201
|
+
},
|
|
202
|
+
(error) => {
|
|
203
|
+
delete record[propertyName];
|
|
204
|
+
this.omitCellNotDeserialized(columnName, prepared, error);
|
|
205
|
+
}
|
|
206
|
+
)
|
|
116
207
|
);
|
|
117
|
-
|
|
118
|
-
} catch (MissingFieldError) {
|
|
119
|
-
omittedFields.push(columnName);
|
|
208
|
+
continue;
|
|
120
209
|
}
|
|
210
|
+
|
|
211
|
+
record[propertyName] = fieldValue;
|
|
121
212
|
}
|
|
122
213
|
|
|
123
|
-
if (omittedFields
|
|
214
|
+
if (omittedFields) {
|
|
124
215
|
// expected when passing a base table into the query api
|
|
125
216
|
this.logger.debug({ message: `Fields were omitted during deserialization`, obj: { omittedFields } });
|
|
126
217
|
}
|
|
127
218
|
|
|
128
|
-
|
|
219
|
+
if (settling) {
|
|
220
|
+
return Promise.all(settling).then(() => record as T);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return record as T;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The field as an own, enumerable property whose value is deserialized on its first read, once
|
|
228
|
+
* (`Column.deserializeOnFirstRead`); a write replaces it like any other field.
|
|
229
|
+
*/
|
|
230
|
+
private defineFieldReadOnFirstRead(
|
|
231
|
+
record: any,
|
|
232
|
+
propertyName: string,
|
|
233
|
+
column: Column<any, any>,
|
|
234
|
+
storedValue: unknown
|
|
235
|
+
): void {
|
|
236
|
+
let value: unknown;
|
|
237
|
+
let read = false;
|
|
238
|
+
const omit = (error: unknown) => this.omitCellNotDeserialized(column.name, record, error);
|
|
239
|
+
Object.defineProperty(record, propertyName, {
|
|
240
|
+
enumerable: true,
|
|
241
|
+
configurable: true,
|
|
242
|
+
get() {
|
|
243
|
+
if (!read) {
|
|
244
|
+
read = true;
|
|
245
|
+
try {
|
|
246
|
+
value = column.deserialize!(storedValue, undefined);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
delete record[propertyName];
|
|
249
|
+
omit(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return value;
|
|
254
|
+
},
|
|
255
|
+
set(next: unknown) {
|
|
256
|
+
value = next;
|
|
257
|
+
read = true;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private hydrationPlan(): HydrationPlan {
|
|
263
|
+
if (this.plan) {
|
|
264
|
+
return this.plan;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Required at call time — this module sits below the encryption machinery in the package's
|
|
268
|
+
// module graph (the seam `Table.ts` uses for the same reason).
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
270
|
+
const { EncryptionRecordHooks } =
|
|
271
|
+
require('./encryption/EncryptionRecordHooks') as typeof import('./encryption/EncryptionRecordHooks');
|
|
272
|
+
const slotsByColumnName: HydrationPlan['slotsByColumnName'] = {};
|
|
273
|
+
const columns: { [propertyName: string]: Column<any, any> } = this.table.columns;
|
|
274
|
+
// Seated by column name, then by property name — a stored key that is a property name
|
|
275
|
+
// resolves to that property's column first, as it always has.
|
|
276
|
+
for (const propertyName in columns) {
|
|
277
|
+
slotsByColumnName[columns[propertyName].name] = { propertyName, column: columns[propertyName] };
|
|
278
|
+
}
|
|
279
|
+
for (const propertyName in columns) {
|
|
280
|
+
slotsByColumnName[propertyName] = { propertyName, column: columns[propertyName] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.plan = { hooks: new EncryptionRecordHooks(), slotsByColumnName };
|
|
284
|
+
return this.plan;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A stored cell the column cannot deserialize — a value written past the serializer, the way a
|
|
289
|
+
* hand-run statement or an older writer leaves it — never takes the row, the result set or the
|
|
290
|
+
* process with it. The field is omitted from the record (the law the per-row hydration held:
|
|
291
|
+
* `try { deserialize } catch { omit }`; what a reader of an optional field already takes as
|
|
292
|
+
* absent), and one warning per table and column per process names the table, the column and the
|
|
293
|
+
* first row seen — never the stored value, which the parser's own message quotes.
|
|
294
|
+
*/
|
|
295
|
+
private omitCellNotDeserialized(columnName: string, row: SerializedRecord, error: unknown): void {
|
|
296
|
+
const cell = `${this.table.name}.${columnName}`;
|
|
297
|
+
if (RecordSerializer.legacyCellsWarned.has(cell)) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
RecordSerializer.legacyCellsWarned.add(cell);
|
|
302
|
+
this.logger.warn({
|
|
303
|
+
message: `A stored cell could not be deserialized; the field is omitted from the record`,
|
|
304
|
+
obj: {
|
|
305
|
+
table: this.table.name,
|
|
306
|
+
column: columnName,
|
|
307
|
+
id: row['id'],
|
|
308
|
+
error: error instanceof Error ? error.name : typeof error,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private isPromise<V>(value: V | Promise<V>): value is Promise<V> {
|
|
314
|
+
return typeof (value as { then?: unknown } | null | undefined)?.then === 'function';
|
|
129
315
|
}
|
|
130
316
|
}
|
|
131
317
|
|
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
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DateTime column's moment is built on the field's FIRST READ, not at hydration
|
|
3
|
+
* (`Column.deserializeOnFirstRead`): a row carries many such columns and most are never read, and
|
|
4
|
+
* every moment built at hydration was event-loop time under a large read. What a reader sees is
|
|
5
|
+
* unchanged — an own, enumerable field holding a moment at the stored instant, the same instance on
|
|
6
|
+
* every read, replaceable by a write, present to Object.keys, spread and JSON alike.
|
|
7
|
+
*/
|
|
8
|
+
jest.mock('moment', () => {
|
|
9
|
+
const actual = jest.requireActual('moment');
|
|
10
|
+
const factory = jest.fn((...args: unknown[]) => actual(...args));
|
|
11
|
+
Object.assign(factory, actual);
|
|
12
|
+
return factory;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
import '../src/Table';
|
|
16
|
+
import moment from 'moment';
|
|
17
|
+
import { Table } from '../src/Table';
|
|
18
|
+
import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
|
|
19
|
+
import { DateTimeColumn, StringColumn } from '../src/Columns';
|
|
20
|
+
|
|
21
|
+
const momentFactory = moment as unknown as jest.Mock;
|
|
22
|
+
|
|
23
|
+
interface Stamped extends Record {
|
|
24
|
+
title: string;
|
|
25
|
+
t0: moment.Moment | null;
|
|
26
|
+
t1: moment.Moment | null;
|
|
27
|
+
t2: moment.Moment | null;
|
|
28
|
+
t3: moment.Moment | null;
|
|
29
|
+
t4: moment.Moment | null;
|
|
30
|
+
t5: moment.Moment | null;
|
|
31
|
+
t6: moment.Moment | null;
|
|
32
|
+
t7: moment.Moment | null;
|
|
33
|
+
t8: moment.Moment | null;
|
|
34
|
+
t9: moment.Moment | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class StampedTable extends Table<Stamped> {
|
|
38
|
+
public name = 'stamped';
|
|
39
|
+
public columns = withRecordColumns<Stamped>({
|
|
40
|
+
title: new StringColumn('title'),
|
|
41
|
+
t0: new DateTimeColumn('t0'),
|
|
42
|
+
t1: new DateTimeColumn('t1'),
|
|
43
|
+
t2: new DateTimeColumn('t2'),
|
|
44
|
+
t3: new DateTimeColumn('t3'),
|
|
45
|
+
t4: new DateTimeColumn('t4'),
|
|
46
|
+
t5: new DateTimeColumn('t5'),
|
|
47
|
+
t6: new DateTimeColumn('t6'),
|
|
48
|
+
t7: new DateTimeColumn('t7'),
|
|
49
|
+
t8: new DateTimeColumn('t8'),
|
|
50
|
+
t9: new DateTimeColumn('t9'),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stampedTable = new StampedTable() as Table<Stamped>;
|
|
55
|
+
|
|
56
|
+
/** What a driver hands the column: a Date (Spanner's is a Date subclass) — the instant is what the column takes. */
|
|
57
|
+
const stampsOf = (row: number) => Array.from({ length: 10 }, (_, i) => new Date(1_700_000_000_000 + row * 100 + i));
|
|
58
|
+
|
|
59
|
+
const storedRows = (count: number): SerializedRecord[] =>
|
|
60
|
+
Array.from({ length: count }, (_, row) => {
|
|
61
|
+
const stamps = stampsOf(row);
|
|
62
|
+
const stored: SerializedRecord = {
|
|
63
|
+
id: `row-${row}`,
|
|
64
|
+
created: new Date(1_600_000_000_000 + row),
|
|
65
|
+
updated: new Date(1_600_000_000_000 + row),
|
|
66
|
+
title: `title ${row}`,
|
|
67
|
+
};
|
|
68
|
+
stamps.forEach((stamp, i) => {
|
|
69
|
+
stored[`t${i}`] = i === 9 ? null : stamp;
|
|
70
|
+
});
|
|
71
|
+
return stored;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Through the per-row door; the result-set door shares the hydrator (SyncRowHydration proves its one pass). */
|
|
75
|
+
const hydrate = (rows: SerializedRecord[]): Promise<Stamped[]> => {
|
|
76
|
+
const serializer = new RecordSerializer<Stamped>(stampedTable);
|
|
77
|
+
return Promise.all(rows.map((row) => serializer.deserialize(row)));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe('a DateTime field builds its moment on the first read', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
momentFactory.mockClear();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('hydrating rows with ten DateTime columns builds no moment', async () => {
|
|
86
|
+
const records = await hydrate(storedRows(50));
|
|
87
|
+
|
|
88
|
+
expect(records).toHaveLength(50);
|
|
89
|
+
expect(momentFactory).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('the first read builds the moment at the stored instant, once; every read after returns that instance', async () => {
|
|
93
|
+
const [record] = await hydrate(storedRows(1));
|
|
94
|
+
const stamps = stampsOf(0);
|
|
95
|
+
|
|
96
|
+
const first = record.t3!;
|
|
97
|
+
|
|
98
|
+
expect(moment.isMoment(first)).toBe(true);
|
|
99
|
+
expect(first.valueOf()).toBe(stamps[3].getTime());
|
|
100
|
+
expect(momentFactory).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(record.t3).toBe(first);
|
|
102
|
+
expect(momentFactory).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(record.created.valueOf()).toBe(1_600_000_000_000);
|
|
104
|
+
expect(momentFactory).toHaveBeenCalledTimes(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('a stored null reads null; a write replaces the field', async () => {
|
|
108
|
+
const [record] = await hydrate(storedRows(1));
|
|
109
|
+
|
|
110
|
+
expect(record.t9).toBeNull();
|
|
111
|
+
expect(momentFactory).not.toHaveBeenCalled();
|
|
112
|
+
|
|
113
|
+
const assigned = moment(0);
|
|
114
|
+
record.t4 = assigned;
|
|
115
|
+
expect(record.t4).toBe(assigned);
|
|
116
|
+
expect(momentFactory).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the record keeps its shape: own enumerable fields in column order, under spread and JSON alike', async () => {
|
|
120
|
+
const [record] = await hydrate(storedRows(1));
|
|
121
|
+
const stamps = stampsOf(0);
|
|
122
|
+
|
|
123
|
+
expect(Object.keys(record)).toEqual([
|
|
124
|
+
'id',
|
|
125
|
+
'created',
|
|
126
|
+
'updated',
|
|
127
|
+
'title',
|
|
128
|
+
't0',
|
|
129
|
+
't1',
|
|
130
|
+
't2',
|
|
131
|
+
't3',
|
|
132
|
+
't4',
|
|
133
|
+
't5',
|
|
134
|
+
't6',
|
|
135
|
+
't7',
|
|
136
|
+
't8',
|
|
137
|
+
't9',
|
|
138
|
+
]);
|
|
139
|
+
expect(Object.prototype.hasOwnProperty.call(record, 't5')).toBe(true);
|
|
140
|
+
expect(momentFactory).not.toHaveBeenCalled();
|
|
141
|
+
|
|
142
|
+
const copy = { ...record };
|
|
143
|
+
expect(moment.isMoment(copy.t5)).toBe(true);
|
|
144
|
+
expect(copy.t5).toBe(record.t5);
|
|
145
|
+
|
|
146
|
+
const json = JSON.parse(JSON.stringify(record));
|
|
147
|
+
expect(json.t7).toBe(stamps[7].toISOString());
|
|
148
|
+
expect(json.t9).toBeNull();
|
|
149
|
+
expect(json.title).toBe('title 0');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('a column read directly still answers the moment itself', () => {
|
|
153
|
+
const column = stampedTable.columns.t0 as DateTimeColumn;
|
|
154
|
+
const stamp = new Date(1_700_000_000_123);
|
|
155
|
+
|
|
156
|
+
const value = column.deserialize(stamp);
|
|
157
|
+
|
|
158
|
+
expect(moment.isMoment(value)).toBe(true);
|
|
159
|
+
expect(value!.valueOf()).toBe(1_700_000_000_123);
|
|
160
|
+
expect(column.deserialize(null)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|