@proteinjs/db 1.54.3 → 1.55.1
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 +22 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +15 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Columns.d.ts +20 -2
- package/dist/src/Columns.d.ts.map +1 -1
- package/dist/src/Columns.js +46 -45
- package/dist/src/Columns.js.map +1 -1
- package/dist/src/Db.d.ts +8 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +9 -1
- package/dist/src/Db.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 +132 -0
- 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/cascadeDeleteTestTables.d.ts +38 -0
- package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.js +119 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.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 +15 -1
- package/package.json +3 -3
- package/src/Columns.ts +50 -27
- package/src/Db.ts +9 -1
- 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 +91 -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/cascadeDeleteTestTables.ts +85 -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.1",
|
|
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": "195e4d8a6aae026ad6c6b8b0fbfa831a57ecb501"
|
|
70
70
|
}
|
package/src/Columns.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import moment from 'moment';
|
|
2
2
|
import { v1 as uuidv1 } from 'uuid';
|
|
3
|
-
import { Column, ColumnOptions, getColumnPropertyName, Table
|
|
3
|
+
import { Column, ColumnOptions, getColumnPropertyName, Table } from './Table';
|
|
4
4
|
import { Record } from './Record';
|
|
5
5
|
import { ReferenceArray } from './reference/ReferenceArray';
|
|
6
6
|
import { Db } from './Db';
|
|
@@ -263,17 +263,29 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
|
|
|
263
263
|
return new ReferenceArray(this.referenceTable, ids);
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Cascade: the referenced records go with the holder. Runs through the Db the firing write
|
|
268
|
+
* hands it — its driver, its transaction context, its authority — and off the ids the column
|
|
269
|
+
* stores; nothing here opens a Db of its own or re-reads the referenced rows, either of which
|
|
270
|
+
* would run as the session's user rather than as the write (a system delete's cascade must stay
|
|
271
|
+
* system: in a background context there is no session user at all).
|
|
272
|
+
*/
|
|
273
|
+
async beforeDelete(
|
|
274
|
+
table: Table<any>,
|
|
275
|
+
columnPropertyName: string,
|
|
276
|
+
records: any[],
|
|
277
|
+
getTable?: (tableName: string) => Table<any>,
|
|
278
|
+
db?: Db
|
|
279
|
+
): Promise<void> {
|
|
267
280
|
if (!this.cascadeDelete) {
|
|
268
281
|
return;
|
|
269
282
|
}
|
|
270
283
|
|
|
271
284
|
const recordIdsToDelete: string[] = [];
|
|
272
285
|
for (const record of records) {
|
|
273
|
-
const referenceArray = record[columnPropertyName] as ReferenceArray<Record
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
recordIdsToDelete.push(referenceRecord.id);
|
|
286
|
+
const referenceArray = record[columnPropertyName] as ReferenceArray<Record> | null | undefined;
|
|
287
|
+
if (referenceArray) {
|
|
288
|
+
recordIdsToDelete.push(...referenceArray._ids);
|
|
277
289
|
}
|
|
278
290
|
}
|
|
279
291
|
|
|
@@ -281,11 +293,12 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
|
|
|
281
293
|
return;
|
|
282
294
|
}
|
|
283
295
|
|
|
284
|
-
|
|
296
|
+
// Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
|
|
297
|
+
const referenceTable = getTable!(this.referenceTable);
|
|
285
298
|
const qb = new QueryBuilderFactory()
|
|
286
299
|
.getQueryBuilder(referenceTable)
|
|
287
300
|
.condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
|
|
288
|
-
await
|
|
301
|
+
await db!.delete(referenceTable, qb);
|
|
289
302
|
}
|
|
290
303
|
}
|
|
291
304
|
|
|
@@ -331,7 +344,18 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
|
|
|
331
344
|
return reference as Reference<T>;
|
|
332
345
|
}
|
|
333
346
|
|
|
334
|
-
|
|
347
|
+
/**
|
|
348
|
+
* Cascade: the referenced record goes with the holder. Runs through the Db the firing write
|
|
349
|
+
* hands it (its driver, transaction context and authority) off the stored id — see
|
|
350
|
+
* `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of its own.
|
|
351
|
+
*/
|
|
352
|
+
async beforeDelete(
|
|
353
|
+
table: Table<any>,
|
|
354
|
+
columnPropertyName: string,
|
|
355
|
+
records: any[],
|
|
356
|
+
getTable?: (tableName: string) => Table<any>,
|
|
357
|
+
db?: Db
|
|
358
|
+
): Promise<void> {
|
|
335
359
|
if (!this.cascadeDelete) {
|
|
336
360
|
return;
|
|
337
361
|
}
|
|
@@ -348,11 +372,12 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
|
|
|
348
372
|
return;
|
|
349
373
|
}
|
|
350
374
|
|
|
351
|
-
|
|
375
|
+
// Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
|
|
376
|
+
const referenceTable = getTable!(this.referenceTable);
|
|
352
377
|
const qb = new QueryBuilderFactory()
|
|
353
378
|
.getQueryBuilder(referenceTable)
|
|
354
379
|
.condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
|
|
355
|
-
await
|
|
380
|
+
await db!.delete(referenceTable, qb);
|
|
356
381
|
}
|
|
357
382
|
}
|
|
358
383
|
|
|
@@ -508,6 +533,12 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
|
|
|
508
533
|
return new Reference<T>(tableName, serializedFieldValue);
|
|
509
534
|
}
|
|
510
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Cascade: the referenced record goes with the holder, whichever table the row names. Runs
|
|
538
|
+
* through the Db the firing write hands it (its driver, transaction context and authority) off
|
|
539
|
+
* the stored ids — see `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of
|
|
540
|
+
* its own.
|
|
541
|
+
*/
|
|
511
542
|
async beforeDelete(
|
|
512
543
|
table: Table<any>,
|
|
513
544
|
columnPropertyName: string,
|
|
@@ -519,12 +550,8 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
|
|
|
519
550
|
return;
|
|
520
551
|
}
|
|
521
552
|
|
|
522
|
-
|
|
523
|
-
const dbInstance = db ? db : new Db();
|
|
524
|
-
|
|
525
|
-
// Get all referenced record IDs grouped by table
|
|
553
|
+
// The referenced ids, grouped by the table each row names
|
|
526
554
|
const recordsToDelete = new Map<string, string[]>();
|
|
527
|
-
|
|
528
555
|
for (const record of records) {
|
|
529
556
|
const reference = record[columnPropertyName] as Reference<Record>;
|
|
530
557
|
if (!reference?._id || !reference._table) {
|
|
@@ -537,17 +564,13 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
|
|
|
537
564
|
recordsToDelete.get(reference._table)!.push(reference._id);
|
|
538
565
|
}
|
|
539
566
|
|
|
540
|
-
//
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
.getQueryBuilder(referenceTable)
|
|
548
|
-
.condition({ field: 'id', operator: 'IN', value: ids });
|
|
549
|
-
await dbInstance.delete(referenceTable, qb);
|
|
550
|
-
}
|
|
567
|
+
// Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
|
|
568
|
+
for (const [tableName, ids] of Array.from(recordsToDelete.entries())) {
|
|
569
|
+
const referenceTable = getTable!(tableName);
|
|
570
|
+
const qb = new QueryBuilderFactory()
|
|
571
|
+
.getQueryBuilder(referenceTable)
|
|
572
|
+
.condition({ field: 'id', operator: 'IN', value: ids });
|
|
573
|
+
await db!.delete(referenceTable, qb);
|
|
551
574
|
}
|
|
552
575
|
}
|
|
553
576
|
}
|
package/src/Db.ts
CHANGED
|
@@ -551,6 +551,14 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
551
551
|
return recordDeleteCount;
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
+
/**
|
|
555
|
+
* A column's `beforeDelete` hook runs with the authority of the write that fired it: the Db it
|
|
556
|
+
* is handed rides this instance's driver, tables and transaction context AND its system mode.
|
|
557
|
+
* The hooks cascade a delete through reference columns, and a system delete's cascade must stay
|
|
558
|
+
* system — handed a default (non-system) Db, the cascade ran as whoever the session held, which
|
|
559
|
+
* narrowed it to that user's rows inside a request and refused it outright in a background
|
|
560
|
+
* context, where a scoped table finds no user to scope the query to.
|
|
561
|
+
*/
|
|
554
562
|
private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
|
|
555
563
|
for (const columnPropertyName in table.columns) {
|
|
556
564
|
const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
|
|
@@ -560,7 +568,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
560
568
|
columnPropertyName,
|
|
561
569
|
recordsToDelete,
|
|
562
570
|
this.getTable,
|
|
563
|
-
new Db(this.dbDriver, this.getTable, this.transactionContextFactory)
|
|
571
|
+
new Db(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem)
|
|
564
572
|
);
|
|
565
573
|
}
|
|
566
574
|
}
|
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
|
+
});
|