@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/src/Columns.d.ts +12 -6
  3. package/dist/src/Columns.d.ts.map +1 -1
  4. package/dist/src/Columns.js +40 -65
  5. package/dist/src/Columns.js.map +1 -1
  6. package/dist/src/Db.d.ts.map +1 -1
  7. package/dist/src/Db.js +2 -5
  8. package/dist/src/Db.js.map +1 -1
  9. package/dist/src/Record.d.ts +35 -0
  10. package/dist/src/Record.d.ts.map +1 -1
  11. package/dist/src/Record.js +177 -45
  12. package/dist/src/Record.js.map +1 -1
  13. package/dist/src/Table.d.ts +18 -1
  14. package/dist/src/Table.d.ts.map +1 -1
  15. package/dist/src/encryption/EncryptionRecordHooks.d.ts +9 -4
  16. package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -1
  17. package/dist/src/encryption/EncryptionRecordHooks.js +65 -42
  18. package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -1
  19. package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts +2 -0
  20. package/dist/test/DateTimeFieldReadOnFirstRead.test.d.ts.map +1 -0
  21. package/dist/test/DateTimeFieldReadOnFirstRead.test.js +238 -0
  22. package/dist/test/DateTimeFieldReadOnFirstRead.test.js.map +1 -0
  23. package/dist/test/HydrationBenchmark.test.d.ts +2 -0
  24. package/dist/test/HydrationBenchmark.test.d.ts.map +1 -0
  25. package/dist/test/HydrationBenchmark.test.js +299 -0
  26. package/dist/test/HydrationBenchmark.test.js.map +1 -0
  27. package/dist/test/LegacyCellHydration.test.d.ts +2 -0
  28. package/dist/test/LegacyCellHydration.test.d.ts.map +1 -0
  29. package/dist/test/LegacyCellHydration.test.js +321 -0
  30. package/dist/test/LegacyCellHydration.test.js.map +1 -0
  31. package/dist/test/SyncRowHydration.test.d.ts +2 -0
  32. package/dist/test/SyncRowHydration.test.d.ts.map +1 -0
  33. package/dist/test/SyncRowHydration.test.js +344 -0
  34. package/dist/test/SyncRowHydration.test.js.map +1 -0
  35. package/dist/test/reusable/ColumnTypesTests.d.ts.map +1 -1
  36. package/dist/test/reusable/ColumnTypesTests.js +55 -0
  37. package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/Columns.ts +14 -7
  40. package/src/Db.ts +3 -4
  41. package/src/Record.ts +202 -16
  42. package/src/Table.ts +21 -1
  43. package/src/encryption/EncryptionRecordHooks.ts +48 -17
  44. package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
  45. package/test/HydrationBenchmark.test.ts +209 -0
  46. package/test/LegacyCellHydration.test.ts +234 -0
  47. package/test/SyncRowHydration.test.ts +249 -0
  48. 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
- const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
104
- const prepared = await new EncryptionRecordHooks().onDeserialize(this.table, serializedRecord);
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
- const deserialized: any = {};
107
- const fieldSerializer = new FieldSerializer(this.table);
108
- const omittedFields: string[] = [];
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 serializedFieldValue = prepared[columnName];
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
- const { fieldPropertyName, fieldValue } = await fieldSerializer.deserialize(
113
- columnName,
114
- serializedFieldValue,
115
- prepared
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
- deserialized[fieldPropertyName] = fieldValue;
118
- } catch (MissingFieldError) {
119
- omittedFields.push(columnName);
208
+ continue;
120
209
  }
210
+
211
+ record[propertyName] = fieldValue;
121
212
  }
122
213
 
123
- if (omittedFields.length > 0) {
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
- return deserialized;
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
- 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
 
@@ -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
+ });