@proteinjs/db 1.54.3 → 1.55.0
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/.eslintrc.js +15 -1
- package/CHANGELOG.md +11 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +7 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Record.d.ts +15 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +118 -67
- package/dist/src/Record.js.map +1 -1
- package/dist/src/auth/TableServiceAuth.js.map +1 -1
- package/dist/src/schema/TableManager.d.ts +8 -0
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +22 -0
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/src/source/SourceRecordSyncRunner.js +1 -1
- package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
- package/dist/test/SettleThenFail.test.d.ts +2 -0
- package/dist/test/SettleThenFail.test.d.ts.map +1 -0
- package/dist/test/SettleThenFail.test.js +237 -0
- package/dist/test/SettleThenFail.test.js.map +1 -0
- package/dist/test/StoredValueSeating.test.d.ts +2 -0
- package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
- package/dist/test/StoredValueSeating.test.js +243 -0
- package/dist/test/StoredValueSeating.test.js.map +1 -0
- package/dist/test/SyncRowHydration.test.js.map +1 -1
- package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
- package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
- package/dist/test/UndeclaredColumnCensus.test.js +139 -0
- package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
- package/dist/test/index.d.ts +2 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
- package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
- package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
- package/dist/test/reusable/StoredValueContractTests.js +666 -0
- package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
- package/dist/test/reusable/TransactionTests.js +8 -8
- package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
- package/dist/test/util/DbTestEnvironment.js +5 -1
- package/dist/test/util/DbTestEnvironment.js.map +1 -1
- package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
- package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
- package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
- package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
- package/generated/test/index.ts +7 -1
- package/package.json +3 -3
- package/src/Record.ts +125 -66
- package/src/auth/TableServiceAuth.ts +1 -1
- package/src/schema/TableManager.ts +25 -0
- package/src/source/SourceRecordSyncRunner.ts +1 -1
- package/test/SettleThenFail.test.ts +167 -0
- package/test/StoredValueSeating.test.ts +169 -0
- package/test/SyncRowHydration.test.ts +1 -1
- package/test/UndeclaredColumnCensus.test.ts +87 -0
- package/test/index.ts +2 -0
- package/test/reusable/CascadeDeleteTests.ts +1 -15
- package/test/reusable/StoredValueContractTests.ts +419 -0
- package/test/reusable/TransactionTests.ts +1 -1
- package/test/util/DbTestEnvironment.ts +3 -0
- package/test/util/tables/storedValueContractTestTables.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.55.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.11.
|
|
44
|
+
"@proteinjs/db-query": "^1.11.1",
|
|
45
45
|
"@proteinjs/logger": "^1.2.0",
|
|
46
46
|
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "e2cb123d846e60e8cddccbca06b5076a009817ed"
|
|
70
70
|
}
|
package/src/Record.ts
CHANGED
|
@@ -25,10 +25,26 @@ const recordColumns: Columns<Record> = {
|
|
|
25
25
|
}),
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** A record field no declared column owns — the one serialize failure a write omits instead of failing on. */
|
|
29
|
+
export class MissingFieldError extends Error {
|
|
29
30
|
constructor(tableName: string, fieldName: string) {
|
|
30
31
|
super(`Table ${tableName} is missing field: ${fieldName}`);
|
|
31
32
|
this.name = 'MissingFieldError';
|
|
33
|
+
// ES5 down-leveled `extends Error` loses the subclass prototype — restore it so
|
|
34
|
+
// `instanceof MissingFieldError` holds for the write-side catch.
|
|
35
|
+
Object.setPrototypeOf(this, MissingFieldError.prototype);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Two declared columns of one table share a stored name: the second would silently take the first's seat. */
|
|
40
|
+
export class DuplicateColumnNameError extends Error {
|
|
41
|
+
constructor(tableName: string, columnName: string, propertyNames: [string, string]) {
|
|
42
|
+
super(
|
|
43
|
+
`(${tableName}) Columns ${propertyNames[0]} and ${propertyNames[1]} both declare the column name ` +
|
|
44
|
+
`'${columnName}'; a stored cell can have only one seat`
|
|
45
|
+
);
|
|
46
|
+
this.name = 'DuplicateColumnNameError';
|
|
47
|
+
Object.setPrototypeOf(this, DuplicateColumnNameError.prototype);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
34
50
|
|
|
@@ -102,7 +118,13 @@ export class RecordSerializer<T extends Record> {
|
|
|
102
118
|
try {
|
|
103
119
|
const { columnName, serializedFieldValue } = await fieldSerializer.serialize(fieldPropertyName, fieldValue);
|
|
104
120
|
serialized[columnName] = serializedFieldValue;
|
|
105
|
-
} catch (
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Only a field no column owns is omitted; a column that cannot serialize its value fails
|
|
123
|
+
// the write — nothing is stored for a cell the column could not make, and the caller hears it.
|
|
124
|
+
if (!(error instanceof MissingFieldError)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
omittedFields.push(fieldPropertyName);
|
|
107
129
|
}
|
|
108
130
|
}
|
|
@@ -132,17 +154,45 @@ export class RecordSerializer<T extends Record> {
|
|
|
132
154
|
const plan = this.hydrationPlan();
|
|
133
155
|
const records: (T | Promise<T>)[] = new Array(serializedRecords.length);
|
|
134
156
|
let settles = false;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
try {
|
|
158
|
+
for (let i = 0; i < serializedRecords.length; i++) {
|
|
159
|
+
const record = this.hydrate(plan, serializedRecords[i]);
|
|
160
|
+
if (this.isPromise(record)) {
|
|
161
|
+
settles = true;
|
|
162
|
+
}
|
|
163
|
+
records[i] = record;
|
|
139
164
|
}
|
|
140
|
-
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return this.failAfterSettling(records, error);
|
|
141
167
|
}
|
|
142
168
|
|
|
143
169
|
return settles ? Promise.all(records) : (records as T[]);
|
|
144
170
|
}
|
|
145
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Settle, then fail: a row (or cell) that threw synchronously fails the set — but never before
|
|
174
|
+
* every promise an earlier row or cell already created has settled. A promise left behind by a
|
|
175
|
+
* synchronous throw is observed by nobody: its rejection is an `unhandledRejection` no caller
|
|
176
|
+
* can catch. So the throw becomes the set's rejection, after the created promises settle.
|
|
177
|
+
*/
|
|
178
|
+
private failAfterSettling(created: unknown[], error: unknown): Promise<never> {
|
|
179
|
+
const settling: Promise<void>[] = [];
|
|
180
|
+
for (const value of created) {
|
|
181
|
+
if (this.isPromise(value)) {
|
|
182
|
+
settling.push(
|
|
183
|
+
value.then(
|
|
184
|
+
() => undefined,
|
|
185
|
+
() => undefined
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Promise.all(settling).then(() => {
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
146
196
|
/** One row → one record (`deserializeAll` is the door for a result set). */
|
|
147
197
|
async deserialize(serializedRecord: SerializedRecord): Promise<T> {
|
|
148
198
|
return await this.hydrate(this.hydrationPlan(), serializedRecord);
|
|
@@ -164,51 +214,56 @@ export class RecordSerializer<T extends Record> {
|
|
|
164
214
|
const record: any = {};
|
|
165
215
|
let settling: Promise<void>[] | undefined;
|
|
166
216
|
let omittedFields: string[] | undefined;
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
try {
|
|
218
|
+
for (const columnName in prepared) {
|
|
219
|
+
const slot = plan.slotsByColumnName[columnName];
|
|
220
|
+
if (!slot) {
|
|
221
|
+
// a column exists in the db that no declared column owns (a column the table no longer
|
|
222
|
+
// declares, or one a rename left behind under the property's old name)
|
|
223
|
+
(omittedFields ??= []).push(columnName);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
174
226
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
227
|
+
const { propertyName, column } = slot;
|
|
228
|
+
const storedValue = prepared[columnName];
|
|
229
|
+
if (!column.deserialize) {
|
|
230
|
+
record[propertyName] = storedValue;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
181
233
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
234
|
+
if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
|
|
235
|
+
this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
186
238
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
239
|
+
let fieldValue: unknown;
|
|
240
|
+
try {
|
|
241
|
+
fieldValue = column.deserialize(storedValue, prepared);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
this.omitCellNotDeserialized(columnName, prepared, error);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
194
246
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
247
|
+
if (this.isPromise(fieldValue)) {
|
|
248
|
+
record[propertyName] = undefined; // seats the property in column order; the value lands when it settles
|
|
249
|
+
(settling ??= []).push(
|
|
250
|
+
fieldValue.then(
|
|
251
|
+
(value) => {
|
|
252
|
+
record[propertyName] = value;
|
|
253
|
+
},
|
|
254
|
+
(error) => {
|
|
255
|
+
delete record[propertyName];
|
|
256
|
+
this.omitCellNotDeserialized(columnName, prepared, error);
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
210
262
|
|
|
211
|
-
|
|
263
|
+
record[propertyName] = fieldValue;
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return this.failAfterSettling(settling ?? [], error);
|
|
212
267
|
}
|
|
213
268
|
|
|
214
269
|
if (omittedFields) {
|
|
@@ -271,13 +326,18 @@ export class RecordSerializer<T extends Record> {
|
|
|
271
326
|
require('./encryption/EncryptionRecordHooks') as typeof import('./encryption/EncryptionRecordHooks');
|
|
272
327
|
const slotsByColumnName: HydrationPlan['slotsByColumnName'] = {};
|
|
273
328
|
const columns: { [propertyName: string]: Column<any, any> } = this.table.columns;
|
|
274
|
-
// Seated by
|
|
275
|
-
//
|
|
329
|
+
// Seated by the declared COLUMN name alone — the name a stored row carries a cell under (a
|
|
330
|
+
// driver's rows are keyed by column name, always). A stored key that is only a property's name
|
|
331
|
+
// — a column a rename retired and left physically present — names no seat: it is omitted like
|
|
332
|
+
// any other undeclared column and never reaches a declared column's parser.
|
|
276
333
|
for (const propertyName in columns) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
334
|
+
const columnName = columns[propertyName].name;
|
|
335
|
+
const seated = slotsByColumnName[columnName];
|
|
336
|
+
if (seated) {
|
|
337
|
+
throw new DuplicateColumnNameError(this.table.name, columnName, [seated.propertyName, propertyName]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
slotsByColumnName[columnName] = { propertyName, column: columns[propertyName] };
|
|
281
341
|
}
|
|
282
342
|
|
|
283
343
|
this.plan = { hooks: new EncryptionRecordHooks(), slotsByColumnName };
|
|
@@ -335,22 +395,21 @@ export class FieldSerializer<T extends Record> {
|
|
|
335
395
|
|
|
336
396
|
async deserialize(columnName: string, serializedFieldValue: any, serializedRecord: SerializedRecord) {
|
|
337
397
|
const columns: { [prop: string]: Column<any, any> } = this.table.columns;
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
398
|
+
// One seat per stored cell: the declared column whose NAME the cell is stored under (the law
|
|
399
|
+
// `RecordSerializer` hydrates by) — never a property that merely shares the stored key's name.
|
|
400
|
+
let fieldPropertyName: string | undefined;
|
|
401
|
+
let column: Column<any, any> | undefined;
|
|
402
|
+
for (const columnPropertyName in columns) {
|
|
403
|
+
if (columnName === columns[columnPropertyName].name) {
|
|
404
|
+
fieldPropertyName = columnPropertyName;
|
|
405
|
+
column = columns[columnPropertyName];
|
|
406
|
+
break;
|
|
348
407
|
}
|
|
349
408
|
}
|
|
350
409
|
|
|
351
|
-
if (!column) {
|
|
352
|
-
// this is the case where a column exists in the db that
|
|
353
|
-
throw new MissingFieldError(this.table.name,
|
|
410
|
+
if (!column || !fieldPropertyName) {
|
|
411
|
+
// this is the case where a column exists in the db that no declared column owns
|
|
412
|
+
throw new MissingFieldError(this.table.name, columnName);
|
|
354
413
|
}
|
|
355
414
|
|
|
356
415
|
let fieldValue = serializedFieldValue;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Logger } from '@proteinjs/logger';
|
|
2
2
|
import { ServiceError, ServiceRefusal } from '@proteinjs/service';
|
|
3
3
|
import { Table, isTable } from '../Table';
|
|
4
|
-
import { TableAuth
|
|
4
|
+
import { TableAuth } from './TableAuth';
|
|
5
5
|
|
|
6
6
|
export class TableServiceAuth {
|
|
7
7
|
private logger = new Logger({ name: this.constructor.name });
|
|
@@ -419,6 +419,30 @@ export class TableManager {
|
|
|
419
419
|
return true;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The census, at every schema sync of an existing table: the physical columns no declared column
|
|
424
|
+
* of the table owns — a column a rename retired (`tags` beside the `tag_list` that replaced it),
|
|
425
|
+
* a column the table dropped from its declaration. Such a column is never read or altered by the
|
|
426
|
+
* schema sync and never seated by the row reader; it rides every row of the table until a
|
|
427
|
+
* migration retires it. One WARN per table naming the columns — names only, never a value.
|
|
428
|
+
*/
|
|
429
|
+
private warnUndeclaredColumns(table: Table<any>, physicalColumnNames: string[]): void {
|
|
430
|
+
const declared = new Set<string>();
|
|
431
|
+
for (const columnPropertyName in table.columns) {
|
|
432
|
+
declared.add(table.columns[columnPropertyName].name);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const undeclared = physicalColumnNames.filter((columnName) => !declared.has(columnName));
|
|
436
|
+
if (undeclared.length === 0) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.logger.warn({
|
|
441
|
+
message: `Physical columns no declared column owns; a migration retires them`,
|
|
442
|
+
obj: { table: table.name, columns: undeclared },
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
422
446
|
private async getTableChanges(table: Table<any>) {
|
|
423
447
|
const { indexesToCreate, indexesToDrop } = await this.getIndexOperations(table);
|
|
424
448
|
const tableChanges: TableChanges = {
|
|
@@ -440,6 +464,7 @@ export class TableManager {
|
|
|
440
464
|
const columnMetadata = await this.schemaMetadata.getColumnMetadata(table);
|
|
441
465
|
const uniqueColumns = await this.schemaMetadata.getUniqueColumns(table);
|
|
442
466
|
const foreignKeys = await this.schemaMetadata.getForeignKeys(table);
|
|
467
|
+
this.warnUndeclaredColumns(table, Object.keys(columnMetadata));
|
|
443
468
|
for (const columnPropertyName in table.columns) {
|
|
444
469
|
const column = table.columns[columnPropertyName];
|
|
445
470
|
if (columnMetadata[column.name]) {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Column, Table } from '../src/Table';
|
|
2
|
+
import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
|
|
3
|
+
import { StringColumn } from '../src/Columns';
|
|
4
|
+
import { EncryptionRecordHooks } from '../src/encryption/EncryptionRecordHooks';
|
|
5
|
+
import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
|
|
6
|
+
import { DataKeyStore, DataKeyMaterial } from '../src/encryption/DataKeyStore';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Settle, then fail. The set reader hydrates rows in order and hands them to ONE promise after the
|
|
10
|
+
* loop; a row that needs an await (a ciphertext envelope whose key is read asynchronously) is a
|
|
11
|
+
* promise in the set from the moment its hook answers. If a LATER row then throws synchronously,
|
|
12
|
+
* the loop ends before that one promise exists — and the earlier row's promise is observed by
|
|
13
|
+
* nobody: its rejection is an `unhandledRejection`, which no caller can catch and a fatal boundary
|
|
14
|
+
* turns into an exit. The law: a synchronous throw fails the set only after every promise the set
|
|
15
|
+
* already created has settled — one rejection, carrying the throw, and no unhandled rejection. The
|
|
16
|
+
* same inside one row, for its cells.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Doc extends Record {
|
|
20
|
+
title: string;
|
|
21
|
+
later: number;
|
|
22
|
+
after: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A column whose `deserialize` answers with a promise: resolves the number, rejects for -1. */
|
|
26
|
+
class LaterColumn implements Column<number, number> {
|
|
27
|
+
constructor(public name: string) {}
|
|
28
|
+
|
|
29
|
+
deserialize(storedValue: number | null): Promise<number | null> {
|
|
30
|
+
return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class DocTable extends Table<Doc> {
|
|
35
|
+
public name = 'settle_doc';
|
|
36
|
+
public columns = withRecordColumns<Doc>({
|
|
37
|
+
title: new StringColumn('title'),
|
|
38
|
+
later: new LaterColumn('later'),
|
|
39
|
+
after: new StringColumn('after'),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const docTable = new DocTable() as Table<Doc>;
|
|
44
|
+
|
|
45
|
+
const keyFor = (owner: string, version: number): DataKeyMaterial => ({
|
|
46
|
+
owner,
|
|
47
|
+
version,
|
|
48
|
+
cipherKey: Buffer.alloc(32, 7),
|
|
49
|
+
indexKey: Buffer.alloc(32, 9),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const storedRow = (id: string, cells: Partial<SerializedRecord> = {}): SerializedRecord => ({
|
|
53
|
+
id,
|
|
54
|
+
created: new Date(1_700_000_000_000),
|
|
55
|
+
updated: new Date(1_700_000_000_000),
|
|
56
|
+
title: `title ${id}`,
|
|
57
|
+
later: 1,
|
|
58
|
+
after: `after ${id}`,
|
|
59
|
+
...cells,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The unhandled rejections Node reported while `run` ran, counted after the loop drained twice. */
|
|
63
|
+
const unhandledRejectionsDuring = async (
|
|
64
|
+
run: () => Promise<unknown>
|
|
65
|
+
): Promise<{ unhandled: number; outcome: unknown }> => {
|
|
66
|
+
let unhandled = 0;
|
|
67
|
+
const listener = () => {
|
|
68
|
+
unhandled++;
|
|
69
|
+
};
|
|
70
|
+
process.on('unhandledRejection', listener);
|
|
71
|
+
try {
|
|
72
|
+
const outcome = await run().then(
|
|
73
|
+
(value) => ({ resolved: value }),
|
|
74
|
+
(error) => ({ rejected: error })
|
|
75
|
+
);
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
return { unhandled, outcome };
|
|
79
|
+
} finally {
|
|
80
|
+
process.off('unhandledRejection', listener);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe('settle, then fail', () => {
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
jest.restoreAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an envelope row whose key read rejects, beside a later row that throws synchronously: one rejection, nothing unhandled', async () => {
|
|
90
|
+
const key = keyFor('owner-1', 4);
|
|
91
|
+
jest.spyOn(DataKeyStore.prototype, 'getKeyByVersion').mockRejectedValue(new Error('key unavailable'));
|
|
92
|
+
const onDeserialize = EncryptionRecordHooks.prototype.onDeserialize;
|
|
93
|
+
jest.spyOn(EncryptionRecordHooks.prototype, 'onDeserialize').mockImplementation(function (
|
|
94
|
+
this: EncryptionRecordHooks,
|
|
95
|
+
table,
|
|
96
|
+
row
|
|
97
|
+
) {
|
|
98
|
+
if (row.id === 'r2') {
|
|
99
|
+
throw new Error('hook failed');
|
|
100
|
+
}
|
|
101
|
+
return onDeserialize.call(this, table, row);
|
|
102
|
+
});
|
|
103
|
+
const rows = [
|
|
104
|
+
storedRow('r1', { title: new EncryptionEnvelope().encrypt('secret', key) }),
|
|
105
|
+
storedRow('r2'),
|
|
106
|
+
storedRow('r3'),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
110
|
+
new RecordSerializer<Doc>(docTable).deserializeAll(rows)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(outcome).toEqual({ rejected: new Error('hook failed') });
|
|
114
|
+
expect(unhandled).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('inside one row: a cell whose promise rejects, beside a later cell that throws synchronously', async () => {
|
|
118
|
+
// A stored value whose `then` getter throws — the one synchronous throw the duck-typed
|
|
119
|
+
// promise check can meet after a column has answered.
|
|
120
|
+
const throwingThenable = {
|
|
121
|
+
get then(): never {
|
|
122
|
+
throw new Error('not a promise after all');
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
class ThenableColumn implements Column<unknown, unknown> {
|
|
126
|
+
constructor(public name: string) {}
|
|
127
|
+
|
|
128
|
+
deserialize(): unknown {
|
|
129
|
+
return throwingThenable;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
interface Cells extends Record {
|
|
133
|
+
later: number;
|
|
134
|
+
odd: unknown;
|
|
135
|
+
}
|
|
136
|
+
class CellsTable extends Table<Cells> {
|
|
137
|
+
public name = 'settle_cells';
|
|
138
|
+
public columns = withRecordColumns<Cells>({
|
|
139
|
+
later: new LaterColumn('later'),
|
|
140
|
+
odd: new ThenableColumn('odd'),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const rows = [storedRow('c1', { later: -1, odd: 'x' })];
|
|
144
|
+
|
|
145
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
146
|
+
new RecordSerializer<Cells>(new CellsTable() as Table<Cells>).deserializeAll(rows)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(outcome).toEqual({ rejected: new Error('not a promise after all') });
|
|
150
|
+
expect(unhandled).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a set that mixes the async road with a rejecting cell and a synchronous cell resolves, every row served', async () => {
|
|
154
|
+
const rows = [storedRow('r1'), storedRow('r2', { later: -1 }), storedRow('r3', { later: 3 })];
|
|
155
|
+
|
|
156
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
157
|
+
new RecordSerializer<Doc>(docTable).deserializeAll(rows)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(unhandled).toBe(0);
|
|
161
|
+
const records = (outcome as { resolved: Doc[] }).resolved;
|
|
162
|
+
expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
|
|
163
|
+
expect(records[0].later).toBe(1);
|
|
164
|
+
expect(records[1]).not.toHaveProperty('later');
|
|
165
|
+
expect(records[2].later).toBe(3);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Column, Table } from '../src/Table';
|
|
2
|
+
import {
|
|
3
|
+
DuplicateColumnNameError,
|
|
4
|
+
FieldSerializer,
|
|
5
|
+
MissingFieldError,
|
|
6
|
+
Record,
|
|
7
|
+
RecordSerializer,
|
|
8
|
+
SerializedRecord,
|
|
9
|
+
withRecordColumns,
|
|
10
|
+
} from '../src/Record';
|
|
11
|
+
import { ArrayColumn, StringColumn } from '../src/Columns';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a stored cell is seated: by the declared COLUMN name alone. A stored key that is only a
|
|
15
|
+
* PROPERTY's name — the text column `tags` a cutover retired beside the array column `tag_list`
|
|
16
|
+
* that replaced it, property `tags` both times — names no seat: it never reaches the array
|
|
17
|
+
* column's parser, never displaces the array, whichever side of the real cell it lands on, and
|
|
18
|
+
* whatever it holds (a bare word, an empty string, NULL). The row reader used to seat by property
|
|
19
|
+
* name as a second pass, which is the only way an undeclared cell could reach a declared column's
|
|
20
|
+
* parser — and, iterated after the real cell, a NULL in it silently replaced the array.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Member extends Record {
|
|
24
|
+
name: string;
|
|
25
|
+
tags: string[] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MemberTable extends Table<Member> {
|
|
29
|
+
public name = 'seating_member';
|
|
30
|
+
public columns = withRecordColumns<Member>({
|
|
31
|
+
name: new StringColumn('name'),
|
|
32
|
+
tags: new ArrayColumn<string>('tag_list'),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const memberTable = new MemberTable() as Table<Member>;
|
|
37
|
+
|
|
38
|
+
type SerializerInternals = { logger: { warn: (...args: unknown[]) => void } };
|
|
39
|
+
type SerializerStatics = { legacyCellsWarned: Set<string> };
|
|
40
|
+
|
|
41
|
+
const LEGACY_WORD = 'legacyword7';
|
|
42
|
+
|
|
43
|
+
/** Every warning a serializer logs, as the logger received it. */
|
|
44
|
+
const warningsOf = (serializer: RecordSerializer<any>): unknown[][] => {
|
|
45
|
+
const calls: unknown[][] = [];
|
|
46
|
+
(serializer as unknown as SerializerInternals).logger.warn = (...args: unknown[]) => {
|
|
47
|
+
calls.push(args);
|
|
48
|
+
};
|
|
49
|
+
return calls;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A stored row in the given key order — the order a driver's row carries its cells in. */
|
|
53
|
+
const rowOf = (cells: { [columnName: string]: unknown }): SerializedRecord => ({
|
|
54
|
+
id: 'm1',
|
|
55
|
+
created: new Date(1_700_000_000_000),
|
|
56
|
+
updated: new Date(1_700_000_000_000),
|
|
57
|
+
...cells,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
(RecordSerializer as unknown as SerializerStatics).legacyCellsWarned.clear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('a stored key that is a property name, not a column name', () => {
|
|
65
|
+
test.each([
|
|
66
|
+
['a bare word', LEGACY_WORD],
|
|
67
|
+
['an empty string', ''],
|
|
68
|
+
['NULL', null],
|
|
69
|
+
])('iterated AFTER the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
|
|
70
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
71
|
+
const warnings = warningsOf(serializer);
|
|
72
|
+
|
|
73
|
+
const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tag_list: '["a","b"]', tags: legacy })]);
|
|
74
|
+
|
|
75
|
+
expect(record.tags).toEqual(['a', 'b']);
|
|
76
|
+
expect(record).toMatchObject({ id: 'm1', name: 'm' });
|
|
77
|
+
// The legacy cell never reached the array column's parser: no cell-omitted warning.
|
|
78
|
+
expect(warnings).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test.each([
|
|
82
|
+
['a bare word', LEGACY_WORD],
|
|
83
|
+
['an empty string', ''],
|
|
84
|
+
['NULL', null],
|
|
85
|
+
])('iterated BEFORE the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
|
|
86
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
87
|
+
const warnings = warningsOf(serializer);
|
|
88
|
+
|
|
89
|
+
const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tags: legacy, tag_list: '["a","b"]' })]);
|
|
90
|
+
|
|
91
|
+
expect(record.tags).toEqual(['a', 'b']);
|
|
92
|
+
expect(warnings).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a stored key equal to a column name is that column; the property name alone is nobody', async () => {
|
|
96
|
+
const fieldSerializer = new FieldSerializer<Member>(memberTable);
|
|
97
|
+
|
|
98
|
+
const seated = await fieldSerializer.deserialize('tag_list', '["a"]', {});
|
|
99
|
+
expect(seated).toEqual({ fieldPropertyName: 'tags', fieldValue: ['a'] });
|
|
100
|
+
|
|
101
|
+
await expect(fieldSerializer.deserialize('tags', LEGACY_WORD, {})).rejects.toBeInstanceOf(MissingFieldError);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('two declared columns sharing one stored name', () => {
|
|
106
|
+
test('is refused at the first hydration, naming the table and both properties', () => {
|
|
107
|
+
interface Twice extends Record {
|
|
108
|
+
first: string;
|
|
109
|
+
second: string;
|
|
110
|
+
}
|
|
111
|
+
class TwiceTable extends Table<Twice> {
|
|
112
|
+
public name = 'seating_twice';
|
|
113
|
+
public columns = withRecordColumns<Twice>({
|
|
114
|
+
first: new StringColumn('shared'),
|
|
115
|
+
second: new StringColumn('shared'),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const serializer = new RecordSerializer<Twice>(new TwiceTable() as Table<Twice>);
|
|
119
|
+
|
|
120
|
+
expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(DuplicateColumnNameError);
|
|
121
|
+
expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(
|
|
122
|
+
`(seating_twice) Columns first and second both declare the column name 'shared'`
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('the write side', () => {
|
|
128
|
+
class RefusingColumn implements Column<string, string> {
|
|
129
|
+
constructor(public name: string) {}
|
|
130
|
+
|
|
131
|
+
async serialize(): Promise<string> {
|
|
132
|
+
throw new RangeError('cannot be stored');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
interface Note extends Record {
|
|
136
|
+
title: string;
|
|
137
|
+
body: string;
|
|
138
|
+
}
|
|
139
|
+
class NoteTable extends Table<Note> {
|
|
140
|
+
public name = 'seating_note';
|
|
141
|
+
public columns = withRecordColumns<Note>({
|
|
142
|
+
title: new StringColumn('title'),
|
|
143
|
+
body: new RefusingColumn('body'),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const noteTable = new NoteTable() as Table<Note>;
|
|
147
|
+
|
|
148
|
+
test('a column that cannot serialize its value fails the write with that error', async () => {
|
|
149
|
+
const serializer = new RecordSerializer<Note>(noteTable);
|
|
150
|
+
const warnings = warningsOf(serializer);
|
|
151
|
+
|
|
152
|
+
await expect(serializer.serialize({ id: 'n1', title: 't', body: 'b' })).rejects.toThrow(
|
|
153
|
+
new RangeError('cannot be stored')
|
|
154
|
+
);
|
|
155
|
+
expect(warnings).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a field no column owns is omitted from the write, and warned', async () => {
|
|
159
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
160
|
+
const warnings = warningsOf(serializer);
|
|
161
|
+
|
|
162
|
+
const serialized = await serializer.serialize({ id: 'm1', name: 'm', tags: ['a'], retired: 'gone' });
|
|
163
|
+
|
|
164
|
+
expect(Object.keys(serialized)).toEqual(['id', 'name', 'tag_list']);
|
|
165
|
+
expect(JSON.parse(serialized.tag_list)).toEqual(['a']);
|
|
166
|
+
expect(warnings).toHaveLength(1);
|
|
167
|
+
expect(warnings[0][0]).toMatchObject({ obj: { omittedFields: ['retired'] } });
|
|
168
|
+
});
|
|
169
|
+
});
|