@proteinjs/db 1.54.0 → 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.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- 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 +19 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +127 -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/src/services/TransactionRunnerService.d.ts +3 -1
- package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.d.ts +13 -5
- package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.js +48 -12
- package/dist/src/transaction/TransactionRunner.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/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/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +2 -2
- package/src/Columns.ts +14 -7
- package/src/Db.ts +3 -4
- package/src/Record.ts +151 -17
- package/src/Table.ts +21 -1
- package/src/encryption/EncryptionRecordHooks.ts +48 -17
- package/src/services/TransactionRunnerService.ts +3 -1
- package/src/transaction/TransactionRunner.ts +53 -13
- package/test/DateTimeFieldReadOnFirstRead.test.ts +162 -0
- package/test/HydrationBenchmark.test.ts +209 -0
- package/test/SyncRowHydration.test.ts +249 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|