@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
@@ -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
+ });