@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
@@ -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
+ });
@@ -0,0 +1,234 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { Db, DbDriver } from '../src/Db';
3
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
4
+ import { Column, Table } from '../src/Table';
5
+ import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
6
+ import { ArrayColumn, ObjectColumn, StringColumn } from '../src/Columns';
7
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
8
+
9
+ /**
10
+ * A stored cell a column cannot deserialize — a legacy value written outside the serializer, say a
11
+ * bare word in a column that stores JSON — never takes the row, the result set or the process with
12
+ * it. The law the per-row hydration held (`try { deserialize } catch { omit }`) and the one-pass
13
+ * hydration dropped: the field is OMITTED from the record, every other field lands, the query's
14
+ * promise resolves, and ONE warning per table and column per process names the table, the column
15
+ * and the first row seen — never the stored value (the parser's own message quotes it).
16
+ */
17
+
18
+ interface Doc extends Record {
19
+ title: string;
20
+ tags: string[];
21
+ settings: { theme: string };
22
+ after: string;
23
+ }
24
+
25
+ class DocTable extends Table<Doc> {
26
+ public name = 'legacy_cell_doc';
27
+ public columns = withRecordColumns<Doc>({
28
+ title: new StringColumn('title'),
29
+ tags: new ArrayColumn<string>('tag_list'),
30
+ settings: new ObjectColumn<{ theme: string }>('settings'),
31
+ after: new StringColumn('after'),
32
+ });
33
+ }
34
+
35
+ const docTable = new DocTable() as Table<Doc>;
36
+
37
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
38
+ private context: TransactionContextData = {};
39
+
40
+ getTransactionContext(): TransactionContextData {
41
+ return this.context;
42
+ }
43
+
44
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
45
+ const previous = this.context;
46
+ this.context = context;
47
+ try {
48
+ return await fn();
49
+ } finally {
50
+ this.context = previous;
51
+ }
52
+ }
53
+ }
54
+
55
+ /** A driver that answers every query with the rows it was handed — the stored cells as the database holds them. */
56
+ class RowsDriver implements DbDriver {
57
+ constructor(private readonly rows: SerializedRecord[]) {}
58
+
59
+ getDbName(): string {
60
+ return 'test';
61
+ }
62
+
63
+ async createDbIfNotExists(): Promise<void> {}
64
+
65
+ async createDb(): Promise<void> {}
66
+
67
+ async dropDb(): Promise<void> {}
68
+
69
+ getTableManager(): never {
70
+ throw new Error('not used by these tests');
71
+ }
72
+
73
+ async runQuery(): Promise<SerializedRecord[]> {
74
+ return this.rows;
75
+ }
76
+
77
+ async runDml(): Promise<number> {
78
+ return 0;
79
+ }
80
+
81
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
82
+ return await fn({ id: 'transaction' });
83
+ }
84
+
85
+ getOperationDeadlineMs(): number {
86
+ return 1000;
87
+ }
88
+ }
89
+
90
+ const dbOver = (table: Table<any>, rows: SerializedRecord[]) =>
91
+ new Db(new RowsDriver(rows), () => table, new TestTransactionContextFactory(), true);
92
+
93
+ const storedRow = (id: string, cells: Partial<SerializedRecord>): SerializedRecord => ({
94
+ id,
95
+ created: new Date(1_700_000_000_000),
96
+ updated: new Date(1_700_000_000_000),
97
+ title: `title ${id}`,
98
+ tag_list: '["a","b"]',
99
+ settings: '{"theme":"dark"}',
100
+ after: `after ${id}`,
101
+ ...cells,
102
+ });
103
+
104
+ /** The stored value no warning may ever carry — distinctive, so a leak is unmistakable. */
105
+ const LEGACY_WORD = 'legacyword7';
106
+
107
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
108
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
109
+ type SerializerInternals = { logger: { warn: (...args: unknown[]) => void; debug: (...args: unknown[]) => void } };
110
+ /** The process-wide once-per-table-and-column registry behind the warning (reset per test). */
111
+ type SerializerStatics = { legacyCellsWarned: Set<string> };
112
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
113
+ let originalWatchers: unknown[] | undefined;
114
+
115
+ beforeEach(() => {
116
+ (RecordSerializer as unknown as SerializerStatics).legacyCellsWarned?.clear();
117
+ });
118
+
119
+ // No reflection graph in a unit test: the watcher registry is empty by declaration.
120
+ beforeAll(() => {
121
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
122
+ objectCache()['@proteinjs/db/TableWatcher'] = [];
123
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
124
+ });
125
+
126
+ afterAll(() => {
127
+ if (originalWatchers) {
128
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
129
+ } else {
130
+ delete objectCache()['@proteinjs/db/TableWatcher'];
131
+ }
132
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
133
+ });
134
+
135
+ /** Every warning a serializer logs, as the logger received it. */
136
+ const warningsOf = (serializer: RecordSerializer<any>): unknown[][] => {
137
+ const calls: unknown[][] = [];
138
+ const logger = (serializer as unknown as SerializerInternals).logger;
139
+ logger.warn = (...args: unknown[]) => {
140
+ calls.push(args);
141
+ };
142
+ return calls;
143
+ };
144
+
145
+ describe('a stored cell a column cannot deserialize', () => {
146
+ it('a query over a row holding a legacy value resolves: the field is omitted, every other field lands', async () => {
147
+ const rows = [storedRow('r1', {}), storedRow('r2', { tag_list: LEGACY_WORD }), storedRow('r3', {})];
148
+
149
+ const records = await dbOver(docTable, rows).query(docTable, {});
150
+
151
+ expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
152
+ expect(records[0].tags).toEqual(['a', 'b']);
153
+ expect(records[2].tags).toEqual(['a', 'b']);
154
+ expect(records[1]).not.toHaveProperty('tags');
155
+ expect(records[1]).toMatchObject({
156
+ id: 'r2',
157
+ title: 'title r2',
158
+ settings: { theme: 'dark' },
159
+ after: 'after r2',
160
+ });
161
+ });
162
+
163
+ it('a get of the row answers the record, not a rejection', async () => {
164
+ const rows = [storedRow('r2', { settings: LEGACY_WORD })];
165
+
166
+ const record = await dbOver(docTable, rows).get(docTable, { id: 'r2' });
167
+
168
+ expect(record).toMatchObject({ id: 'r2', title: 'title r2', tags: ['a', 'b'], after: 'after r2' });
169
+ expect(record).not.toHaveProperty('settings');
170
+ });
171
+
172
+ it('warns once per table and column per process, naming the table, the column and the first row — never the value', () => {
173
+ const serializer = new RecordSerializer<Doc>(docTable);
174
+ const warnings = warningsOf(serializer);
175
+ const rows = [
176
+ storedRow('r1', { tag_list: LEGACY_WORD }),
177
+ storedRow('r2', { tag_list: LEGACY_WORD }),
178
+ storedRow('r3', { settings: LEGACY_WORD }),
179
+ ];
180
+
181
+ const records = serializer.deserializeAll(rows) as Doc[];
182
+
183
+ expect(Array.isArray(records)).toBe(true);
184
+ expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
185
+ expect(warnings).toHaveLength(2);
186
+ expect(warnings[0][0]).toMatchObject({
187
+ obj: { table: 'legacy_cell_doc', column: 'tag_list', id: 'r1', error: 'SyntaxError' },
188
+ });
189
+ expect(warnings[1][0]).toMatchObject({
190
+ obj: { table: 'legacy_cell_doc', column: 'settings', id: 'r3', error: 'SyntaxError' },
191
+ });
192
+ expect(JSON.stringify(warnings)).not.toContain(LEGACY_WORD);
193
+ });
194
+
195
+ it('a column whose deserialize answers with a rejecting promise is omitted the same way', async () => {
196
+ class LaterColumn implements Column<number, number> {
197
+ constructor(public name: string) {}
198
+
199
+ deserialize(storedValue: number | null): Promise<number | null> {
200
+ return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
201
+ }
202
+ }
203
+ interface Later extends Record {
204
+ title: string;
205
+ later: number;
206
+ after: string;
207
+ }
208
+ class LaterTable extends Table<Later> {
209
+ public name = 'legacy_cell_later';
210
+ public columns = withRecordColumns<Later>({
211
+ title: new StringColumn('title'),
212
+ later: new LaterColumn('later'),
213
+ after: new StringColumn('after'),
214
+ });
215
+ }
216
+ const laterTable = new LaterTable() as Table<Later>;
217
+ const serializer = new RecordSerializer<Later>(laterTable);
218
+ const warnings = warningsOf(serializer);
219
+ const rows: SerializedRecord[] = [
220
+ { id: 'l1', title: 'one', later: 1, after: 'x' },
221
+ { id: 'l2', title: 'two', later: -1, after: 'y' },
222
+ ];
223
+
224
+ const records = await serializer.deserializeAll(rows);
225
+
226
+ expect(records[0]).toMatchObject({ id: 'l1', title: 'one', later: 1, after: 'x' });
227
+ expect(records[1]).toMatchObject({ id: 'l2', title: 'two', after: 'y' });
228
+ expect(records[1]).not.toHaveProperty('later');
229
+ expect(warnings).toHaveLength(1);
230
+ expect(warnings[0][0]).toMatchObject({
231
+ obj: { table: 'legacy_cell_later', column: 'later', id: 'l2', error: 'RangeError' },
232
+ });
233
+ });
234
+ });
@@ -0,0 +1,249 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { Db, DbDriver } from '../src/Db';
4
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
5
+ import { Column, Table } from '../src/Table';
6
+ import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
7
+ import { IntegerColumn, StringColumn } from '../src/Columns';
8
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
9
+ import { DataKeyMaterial, DataKeyStore } from '../src/encryption/DataKeyStore';
10
+ import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
11
+
12
+ /**
13
+ * How a result set becomes records: in ONE synchronous pass when no row needs an await — no promise
14
+ * per row, no yield of the event loop between rows (the per-row async chain was the loop's holder
15
+ * under a large read: one awaited generator per row, per hook and per column) — while a row that
16
+ * does need an await (a ciphertext envelope whose key is read asynchronously, a column whose
17
+ * `deserialize` answers with a promise) still settles, through one promise for the set.
18
+ */
19
+
20
+ interface Doc extends Record {
21
+ title: string;
22
+ first: number;
23
+ second: number;
24
+ }
25
+
26
+ /** Where the loop stood when each cell was read: the row, and whether the sentinel had fired by then. */
27
+ const cellsRead: { column: string; row: number; sentinelFired: boolean }[] = [];
28
+ let sentinelFired = false;
29
+
30
+ /**
31
+ * A column that notes, at each read, whether a microtask armed by the FIRST cell read has fired yet:
32
+ * a hydration that yields the loop between rows lets it fire mid-way; a synchronous one never does.
33
+ */
34
+ class ObservingColumn implements Column<number, number> {
35
+ constructor(public name: string) {}
36
+
37
+ deserialize(storedValue: number | null): number | null {
38
+ if (cellsRead.length === 0) {
39
+ queueMicrotask(() => {
40
+ sentinelFired = true;
41
+ });
42
+ }
43
+
44
+ cellsRead.push({ column: this.name, row: storedValue as number, sentinelFired });
45
+ return storedValue;
46
+ }
47
+ }
48
+
49
+ class DocTable extends Table<Doc> {
50
+ public name = 'hydration_doc';
51
+ public columns = withRecordColumns<Doc>({
52
+ title: new StringColumn('title'),
53
+ first: new ObservingColumn('first'),
54
+ second: new ObservingColumn('second'),
55
+ });
56
+ }
57
+
58
+ const docTable = new DocTable() as Table<Doc>;
59
+
60
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
61
+ private context: TransactionContextData = {};
62
+
63
+ getTransactionContext(): TransactionContextData {
64
+ return this.context;
65
+ }
66
+
67
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
68
+ const previous = this.context;
69
+ this.context = context;
70
+ try {
71
+ return await fn();
72
+ } finally {
73
+ this.context = previous;
74
+ }
75
+ }
76
+ }
77
+
78
+ /** A driver that answers every query with the rows it was handed. */
79
+ class RowsDriver implements DbDriver {
80
+ constructor(private readonly rows: SerializedRecord[]) {}
81
+
82
+ getDbName(): string {
83
+ return 'test';
84
+ }
85
+
86
+ async createDbIfNotExists(): Promise<void> {}
87
+
88
+ async createDb(): Promise<void> {}
89
+
90
+ async dropDb(): Promise<void> {}
91
+
92
+ getTableManager(): never {
93
+ throw new Error('not used by these tests');
94
+ }
95
+
96
+ async runQuery(): Promise<SerializedRecord[]> {
97
+ return this.rows;
98
+ }
99
+
100
+ async runDml(): Promise<number> {
101
+ return 0;
102
+ }
103
+
104
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
105
+ return await fn({ id: 'transaction' });
106
+ }
107
+
108
+ getOperationDeadlineMs(): number {
109
+ return 1000;
110
+ }
111
+ }
112
+
113
+ const dbOver = (rows: SerializedRecord[]) =>
114
+ new Db(new RowsDriver(rows), () => docTable, new TestTransactionContextFactory(), true);
115
+
116
+ const storedRows = (count: number): SerializedRecord[] =>
117
+ Array.from({ length: count }, (_, i) => ({
118
+ id: `row-${i}`,
119
+ created: new Date(1_700_000_000_000 + i),
120
+ updated: new Date(1_700_000_000_000 + i),
121
+ title: `title ${i}`,
122
+ first: i,
123
+ second: i,
124
+ }));
125
+
126
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
127
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
128
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
129
+ let originalWatchers: unknown[] | undefined;
130
+
131
+ // No reflection graph in a unit test: the watcher registry is empty by declaration.
132
+ beforeAll(() => {
133
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
134
+ objectCache()['@proteinjs/db/TableWatcher'] = [];
135
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
136
+ });
137
+
138
+ afterAll(() => {
139
+ if (originalWatchers) {
140
+ objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
141
+ } else {
142
+ delete objectCache()['@proteinjs/db/TableWatcher'];
143
+ }
144
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
145
+ });
146
+
147
+ const keyFor = (owner: string, version: number): DataKeyMaterial => ({
148
+ owner,
149
+ version,
150
+ cipherKey: randomBytes(32),
151
+ indexKey: randomBytes(32),
152
+ });
153
+
154
+ describe('a result set hydrates in one synchronous pass when no row needs an await', () => {
155
+ beforeEach(() => {
156
+ cellsRead.length = 0;
157
+ sentinelFired = false;
158
+ });
159
+
160
+ it('a query over plain rows never yields the event loop between rows', async () => {
161
+ const rows = storedRows(200);
162
+
163
+ const records = await dbOver(rows).query(docTable, {});
164
+
165
+ expect(records).toHaveLength(200);
166
+ expect(records[199]).toMatchObject({ id: 'row-199', title: 'title 199', first: 199, second: 199 });
167
+ // Every cell of every row was read before the microtask armed by the first cell could fire.
168
+ expect(cellsRead).toHaveLength(400);
169
+ expect(cellsRead.filter((cell) => cell.sentinelFired).map((cell) => `${cell.column}:${cell.row}`)).toEqual([]);
170
+ expect(sentinelFired).toBe(true);
171
+ });
172
+
173
+ it('the serializer answers a plain result set with the array itself, not a promise', () => {
174
+ const rows = storedRows(3);
175
+
176
+ const records = new RecordSerializer<Doc>(docTable).deserializeAll(rows);
177
+
178
+ expect(Array.isArray(records)).toBe(true);
179
+ expect((records as Doc[]).map((record) => record.title)).toEqual(['title 0', 'title 1', 'title 2']);
180
+ });
181
+
182
+ it('a column whose deserialize answers with a promise still settles, in column order', async () => {
183
+ class LaterColumn implements Column<number, number> {
184
+ constructor(public name: string) {}
185
+
186
+ deserialize(storedValue: number | null): Promise<number | null> {
187
+ return Promise.resolve(storedValue === null ? null : storedValue * 2);
188
+ }
189
+ }
190
+ interface Later extends Record {
191
+ title: string;
192
+ later: number;
193
+ after: string;
194
+ }
195
+ class LaterTable extends Table<Later> {
196
+ public name = 'hydration_later';
197
+ public columns = withRecordColumns<Later>({
198
+ title: new StringColumn('title'),
199
+ later: new LaterColumn('later'),
200
+ after: new StringColumn('after'),
201
+ });
202
+ }
203
+ const laterTable = new LaterTable() as Table<Later>;
204
+ const rows: SerializedRecord[] = [
205
+ { id: 'a', created: new Date(), updated: new Date(), title: 'one', later: 21, after: 'x' },
206
+ { id: 'b', created: new Date(), updated: new Date(), title: 'two', later: null, after: 'y' },
207
+ ];
208
+
209
+ const settling = new RecordSerializer<Later>(laterTable).deserializeAll(rows);
210
+
211
+ expect(Array.isArray(settling)).toBe(false);
212
+ const records = await settling;
213
+ expect(records.map((record) => record.later)).toEqual([42, null]);
214
+ expect(Object.keys(records[0])).toEqual(['id', 'created', 'updated', 'title', 'later', 'after']);
215
+ });
216
+
217
+ it('a row holding a ciphertext envelope takes the async road and decrypts; its plain neighbors ride along', async () => {
218
+ const key = keyFor('owner-1', 4);
219
+ const envelope = new EncryptionEnvelope();
220
+ const keyRead = jest.spyOn(DataKeyStore.prototype, 'getKeyByVersion').mockResolvedValue(key);
221
+ try {
222
+ const rows = storedRows(3);
223
+ rows[1].title = envelope.encrypt('the plaintext', key);
224
+
225
+ const settling = new RecordSerializer<Doc>(docTable).deserializeAll(rows);
226
+
227
+ expect(Array.isArray(settling)).toBe(false);
228
+ const records = await settling;
229
+ expect(records.map((record) => record.title)).toEqual(['title 0', 'the plaintext', 'title 2']);
230
+ expect(keyRead).toHaveBeenCalledTimes(1);
231
+ expect(keyRead).toHaveBeenCalledWith('owner-1', 4);
232
+ // The plain rows were hydrated in the one pass; the envelope row landed when its key read settled.
233
+ expect(cellsRead.map((cell) => cell.row)).toEqual([0, 0, 2, 2, 1, 1]);
234
+ } finally {
235
+ keyRead.mockRestore();
236
+ }
237
+ });
238
+
239
+ it('a stored column the table no longer declares is omitted; a column without deserialize passes its value through', async () => {
240
+ const rows: SerializedRecord[] = [
241
+ { id: 'a', created: new Date(), updated: new Date(), title: 'one', first: 1, second: 1, retired: 'gone' },
242
+ ];
243
+
244
+ const [record] = (await new RecordSerializer<Doc>(docTable).deserializeAll(rows)) as Doc[];
245
+
246
+ expect(record).toMatchObject({ id: 'a', title: 'one', first: 1, second: 1 });
247
+ expect('retired' in record).toBe(false);
248
+ });
249
+ });