@proteinjs/db 1.54.1 → 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 (39) hide show
  1. package/CHANGELOG.md +11 -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 +19 -0
  10. package/dist/src/Record.d.ts.map +1 -1
  11. package/dist/src/Record.js +127 -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/SyncRowHydration.test.d.ts +2 -0
  28. package/dist/test/SyncRowHydration.test.d.ts.map +1 -0
  29. package/dist/test/SyncRowHydration.test.js +344 -0
  30. package/dist/test/SyncRowHydration.test.js.map +1 -0
  31. package/package.json +2 -2
  32. package/src/Columns.ts +14 -7
  33. package/src/Db.ts +3 -4
  34. package/src/Record.ts +151 -17
  35. package/src/Table.ts +21 -1
  36. package/src/encryption/EncryptionRecordHooks.ts +48 -17
  37. package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
  38. package/test/HydrationBenchmark.test.ts +209 -0
  39. package/test/SyncRowHydration.test.ts +249 -0
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
+ });
@@ -0,0 +1,209 @@
1
+ import { createHook } from 'async_hooks';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { Db, DbDriver } from '../src/Db';
4
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
5
+ import { Table } from '../src/Table';
6
+ import { Record, SerializedRecord, withRecordColumns } from '../src/Record';
7
+ import { DateTimeColumn, IntegerColumn, ObjectColumn, StringColumn } from '../src/Columns';
8
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
9
+ import type { Moment } from 'moment';
10
+
11
+ /**
12
+ * A MEASURE, never a gate: hydrating 1,000 rows with ten DateTime columns through `Db.query`, read
13
+ * as wall time, promises created (async_hooks) and microtask rounds — the numbers are printed for
14
+ * the person reading the run and nothing here asserts a threshold (CI's machines vary). The one
15
+ * assertion is that the rows came back.
16
+ */
17
+
18
+ interface Wide extends Record {
19
+ title: string;
20
+ body: string;
21
+ count: number;
22
+ payload: { tags: string[]; n: number } | null;
23
+ t0: Moment | null;
24
+ t1: Moment | null;
25
+ t2: Moment | null;
26
+ t3: Moment | null;
27
+ t4: Moment | null;
28
+ t5: Moment | null;
29
+ t6: Moment | null;
30
+ t7: Moment | null;
31
+ t8: Moment | null;
32
+ t9: Moment | null;
33
+ }
34
+
35
+ class WideTable extends Table<Wide> {
36
+ public name = 'hydration_wide';
37
+ public columns = withRecordColumns<Wide>({
38
+ title: new StringColumn('title'),
39
+ body: new StringColumn('body', undefined, 'MAX'),
40
+ count: new IntegerColumn('count'),
41
+ payload: new ObjectColumn('payload'),
42
+ t0: new DateTimeColumn('t0'),
43
+ t1: new DateTimeColumn('t1'),
44
+ t2: new DateTimeColumn('t2'),
45
+ t3: new DateTimeColumn('t3'),
46
+ t4: new DateTimeColumn('t4'),
47
+ t5: new DateTimeColumn('t5'),
48
+ t6: new DateTimeColumn('t6'),
49
+ t7: new DateTimeColumn('t7'),
50
+ t8: new DateTimeColumn('t8'),
51
+ t9: new DateTimeColumn('t9'),
52
+ });
53
+ }
54
+
55
+ const wideTable = new WideTable() as Table<Wide>;
56
+
57
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
58
+ private context: TransactionContextData = {};
59
+
60
+ getTransactionContext(): TransactionContextData {
61
+ return this.context;
62
+ }
63
+
64
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
65
+ const previous = this.context;
66
+ this.context = context;
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ this.context = previous;
71
+ }
72
+ }
73
+ }
74
+
75
+ class RowsDriver implements DbDriver {
76
+ constructor(private readonly rows: SerializedRecord[]) {}
77
+
78
+ getDbName(): string {
79
+ return 'test';
80
+ }
81
+
82
+ async createDbIfNotExists(): Promise<void> {}
83
+
84
+ async createDb(): Promise<void> {}
85
+
86
+ async dropDb(): Promise<void> {}
87
+
88
+ getTableManager(): never {
89
+ throw new Error('not used by this measure');
90
+ }
91
+
92
+ async runQuery(): Promise<SerializedRecord[]> {
93
+ return this.rows;
94
+ }
95
+
96
+ async runDml(): Promise<number> {
97
+ return 0;
98
+ }
99
+
100
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
101
+ return await fn({ id: 'transaction' });
102
+ }
103
+
104
+ getOperationDeadlineMs(): number {
105
+ return 1000;
106
+ }
107
+ }
108
+
109
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
110
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
111
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
112
+ let originalWatchers: unknown[] | undefined;
113
+
114
+ // No reflection graph in a unit test: the watcher registry is empty by declaration.
115
+ beforeAll(() => {
116
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
117
+ objectCache()['@proteinjs/db/TableWatcher'] = [];
118
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
119
+ });
120
+
121
+ afterAll(() => {
122
+ if (originalWatchers) {
123
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
124
+ } else {
125
+ delete objectCache()['@proteinjs/db/TableWatcher'];
126
+ }
127
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
128
+ });
129
+
130
+ const storedRows = (count: number): SerializedRecord[] =>
131
+ Array.from({ length: count }, (_, row) => {
132
+ const stored: SerializedRecord = {
133
+ id: `row-${row}`,
134
+ created: new Date(1_600_000_000_000 + row),
135
+ updated: new Date(1_600_000_000_000 + row),
136
+ title: `title ${row}`,
137
+ body: 'x'.repeat(400),
138
+ count: row,
139
+ payload: JSON.stringify({ tags: ['a', 'b', 'c'], n: row }),
140
+ };
141
+ for (let i = 0; i < 10; i++) {
142
+ stored[`t${i}`] = new Date(1_700_000_000_000 + row * 100 + i);
143
+ }
144
+ return stored;
145
+ });
146
+
147
+ type Measure = { ms: number; promises: number; microtaskRounds: number };
148
+
149
+ /** Wall time, promises created and microtask rounds while `act` runs. */
150
+ const measure = async (act: () => Promise<unknown>): Promise<Measure> => {
151
+ let promises = 0;
152
+ const hook = createHook({
153
+ init(_asyncId, type) {
154
+ if (type === 'PROMISE') {
155
+ promises++;
156
+ }
157
+ },
158
+ });
159
+ let microtaskRounds = 0;
160
+ let running = true;
161
+ const round = () => {
162
+ if (!running) {
163
+ return;
164
+ }
165
+ microtaskRounds++;
166
+ queueMicrotask(round);
167
+ };
168
+ hook.enable();
169
+ const started = process.hrtime.bigint();
170
+ queueMicrotask(round);
171
+ await act();
172
+ running = false;
173
+ const ms = Number(process.hrtime.bigint() - started) / 1_000_000;
174
+ hook.disable();
175
+ return { ms: Math.round(ms * 10) / 10, promises, microtaskRounds };
176
+ };
177
+
178
+ describe('hydration measure', () => {
179
+ it('reports 1,000 rows with ten DateTime columns through Db.query (wall ms, promises, microtask rounds)', async () => {
180
+ const rows = storedRows(1000);
181
+ const db = new Db(new RowsDriver(rows), () => wideTable, new TestTransactionContextFactory(), true);
182
+ // one warm-up pass, then the measured passes
183
+ await db.query(wideTable, {});
184
+
185
+ const passes: Measure[] = [];
186
+ let records: Wide[] = [];
187
+ for (let i = 0; i < 5; i++) {
188
+ passes.push(await measure(async () => (records = await db.query(wideTable, {}))));
189
+ }
190
+ const readAll = await measure(async () => {
191
+ for (const record of records) {
192
+ record.t0!.valueOf();
193
+ record.created.valueOf();
194
+ }
195
+ });
196
+
197
+ process.stdout.write(
198
+ `hydration measure — 1,000 rows × (10 DateTime + created/updated + 4 plain columns), Db.query, 5 passes:\n` +
199
+ passes
200
+ .map(
201
+ (pass, i) =>
202
+ ` pass ${i + 1}: ${pass.ms} ms · ${pass.promises} promises · ${pass.microtaskRounds} microtask rounds`
203
+ )
204
+ .join('\n') +
205
+ `\n then reading t0 + created on every record: ${readAll.ms} ms · ${readAll.promises} promises\n`
206
+ );
207
+ expect(records).toHaveLength(1000);
208
+ });
209
+ });