@proteinjs/db 1.54.2 → 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 +22 -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 +31 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +159 -58
- 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/LegacyCellHydration.test.d.ts +2 -0
- package/dist/test/LegacyCellHydration.test.d.ts.map +1 -0
- package/dist/test/LegacyCellHydration.test.js +321 -0
- package/dist/test/LegacyCellHydration.test.js.map +1 -0
- 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/ColumnTypesTests.d.ts.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.js +55 -0
- package/dist/test/reusable/ColumnTypesTests.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 +166 -55
- package/src/auth/TableServiceAuth.ts +1 -1
- package/src/schema/TableManager.ts +25 -0
- package/src/source/SourceRecordSyncRunner.ts +1 -1
- package/test/LegacyCellHydration.test.ts +234 -0
- 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/ColumnTypesTests.ts +47 -1
- 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
|
|
|
@@ -68,6 +84,13 @@ interface HydrationPlan {
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export class RecordSerializer<T extends Record> {
|
|
87
|
+
/**
|
|
88
|
+
* The table-and-column pairs whose stored cells have failed to deserialize in this process
|
|
89
|
+
* (`omitCellNotDeserialized`): each is warned once. A legacy cell is read again by every query
|
|
90
|
+
* that touches its row — a background sweep reads some rows every few seconds — and the warning
|
|
91
|
+
* is a schema fact, not a per-read event.
|
|
92
|
+
*/
|
|
93
|
+
private static legacyCellsWarned = new Set<string>();
|
|
71
94
|
private logger = new Logger({ name: this.constructor.name });
|
|
72
95
|
private table: Table<T>;
|
|
73
96
|
private encryptionContext?: RecordEncryptionContext;
|
|
@@ -95,7 +118,13 @@ export class RecordSerializer<T extends Record> {
|
|
|
95
118
|
try {
|
|
96
119
|
const { columnName, serializedFieldValue } = await fieldSerializer.serialize(fieldPropertyName, fieldValue);
|
|
97
120
|
serialized[columnName] = serializedFieldValue;
|
|
98
|
-
} 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
|
+
|
|
99
128
|
omittedFields.push(fieldPropertyName);
|
|
100
129
|
}
|
|
101
130
|
}
|
|
@@ -125,17 +154,45 @@ export class RecordSerializer<T extends Record> {
|
|
|
125
154
|
const plan = this.hydrationPlan();
|
|
126
155
|
const records: (T | Promise<T>)[] = new Array(serializedRecords.length);
|
|
127
156
|
let settles = false;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
132
164
|
}
|
|
133
|
-
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return this.failAfterSettling(records, error);
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
return settles ? Promise.all(records) : (records as T[]);
|
|
137
170
|
}
|
|
138
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
|
+
|
|
139
196
|
/** One row → one record (`deserializeAll` is the door for a result set). */
|
|
140
197
|
async deserialize(serializedRecord: SerializedRecord): Promise<T> {
|
|
141
198
|
return await this.hydrate(this.hydrationPlan(), serializedRecord);
|
|
@@ -157,38 +214,56 @@ export class RecordSerializer<T extends Record> {
|
|
|
157
214
|
const record: any = {};
|
|
158
215
|
let settling: Promise<void>[] | undefined;
|
|
159
216
|
let omittedFields: string[] | undefined;
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}
|
|
167
226
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
227
|
+
const { propertyName, column } = slot;
|
|
228
|
+
const storedValue = prepared[columnName];
|
|
229
|
+
if (!column.deserialize) {
|
|
230
|
+
record[propertyName] = storedValue;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
174
233
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
|
|
235
|
+
this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
179
238
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
239
|
+
let fieldValue: unknown;
|
|
240
|
+
try {
|
|
241
|
+
fieldValue = column.deserialize(storedValue, prepared);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
this.omitCellNotDeserialized(columnName, prepared, error);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
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
|
+
}
|
|
190
262
|
|
|
191
|
-
|
|
263
|
+
record[propertyName] = fieldValue;
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return this.failAfterSettling(settling ?? [], error);
|
|
192
267
|
}
|
|
193
268
|
|
|
194
269
|
if (omittedFields) {
|
|
@@ -215,13 +290,19 @@ export class RecordSerializer<T extends Record> {
|
|
|
215
290
|
): void {
|
|
216
291
|
let value: unknown;
|
|
217
292
|
let read = false;
|
|
293
|
+
const omit = (error: unknown) => this.omitCellNotDeserialized(column.name, record, error);
|
|
218
294
|
Object.defineProperty(record, propertyName, {
|
|
219
295
|
enumerable: true,
|
|
220
296
|
configurable: true,
|
|
221
297
|
get() {
|
|
222
298
|
if (!read) {
|
|
223
|
-
value = column.deserialize!(storedValue, undefined);
|
|
224
299
|
read = true;
|
|
300
|
+
try {
|
|
301
|
+
value = column.deserialize!(storedValue, undefined);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
delete record[propertyName];
|
|
304
|
+
omit(error);
|
|
305
|
+
}
|
|
225
306
|
}
|
|
226
307
|
|
|
227
308
|
return value;
|
|
@@ -245,19 +326,50 @@ export class RecordSerializer<T extends Record> {
|
|
|
245
326
|
require('./encryption/EncryptionRecordHooks') as typeof import('./encryption/EncryptionRecordHooks');
|
|
246
327
|
const slotsByColumnName: HydrationPlan['slotsByColumnName'] = {};
|
|
247
328
|
const columns: { [propertyName: string]: Column<any, any> } = this.table.columns;
|
|
248
|
-
// Seated by
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
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.
|
|
253
333
|
for (const propertyName in columns) {
|
|
254
|
-
|
|
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] };
|
|
255
341
|
}
|
|
256
342
|
|
|
257
343
|
this.plan = { hooks: new EncryptionRecordHooks(), slotsByColumnName };
|
|
258
344
|
return this.plan;
|
|
259
345
|
}
|
|
260
346
|
|
|
347
|
+
/**
|
|
348
|
+
* A stored cell the column cannot deserialize — a value written past the serializer, the way a
|
|
349
|
+
* hand-run statement or an older writer leaves it — never takes the row, the result set or the
|
|
350
|
+
* process with it. The field is omitted from the record (the law the per-row hydration held:
|
|
351
|
+
* `try { deserialize } catch { omit }`; what a reader of an optional field already takes as
|
|
352
|
+
* absent), and one warning per table and column per process names the table, the column and the
|
|
353
|
+
* first row seen — never the stored value, which the parser's own message quotes.
|
|
354
|
+
*/
|
|
355
|
+
private omitCellNotDeserialized(columnName: string, row: SerializedRecord, error: unknown): void {
|
|
356
|
+
const cell = `${this.table.name}.${columnName}`;
|
|
357
|
+
if (RecordSerializer.legacyCellsWarned.has(cell)) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
RecordSerializer.legacyCellsWarned.add(cell);
|
|
362
|
+
this.logger.warn({
|
|
363
|
+
message: `A stored cell could not be deserialized; the field is omitted from the record`,
|
|
364
|
+
obj: {
|
|
365
|
+
table: this.table.name,
|
|
366
|
+
column: columnName,
|
|
367
|
+
id: row['id'],
|
|
368
|
+
error: error instanceof Error ? error.name : typeof error,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
261
373
|
private isPromise<V>(value: V | Promise<V>): value is Promise<V> {
|
|
262
374
|
return typeof (value as { then?: unknown } | null | undefined)?.then === 'function';
|
|
263
375
|
}
|
|
@@ -283,22 +395,21 @@ export class FieldSerializer<T extends Record> {
|
|
|
283
395
|
|
|
284
396
|
async deserialize(columnName: string, serializedFieldValue: any, serializedRecord: SerializedRecord) {
|
|
285
397
|
const columns: { [prop: string]: Column<any, any> } = this.table.columns;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
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;
|
|
296
407
|
}
|
|
297
408
|
}
|
|
298
409
|
|
|
299
|
-
if (!column) {
|
|
300
|
-
// this is the case where a column exists in the db that
|
|
301
|
-
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);
|
|
302
413
|
}
|
|
303
414
|
|
|
304
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,234 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { Db, DbDriver } from '../src/Db';
|
|
3
|
+
import { TableWatcherRunner } from '../src/TableWatcherRunner';
|
|
4
|
+
import { Column, Table } from '../src/Table';
|
|
5
|
+
import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
|
|
6
|
+
import { ArrayColumn, ObjectColumn, StringColumn } from '../src/Columns';
|
|
7
|
+
import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A stored cell a column cannot deserialize — a legacy value written outside the serializer, say a
|
|
11
|
+
* bare word in a column that stores JSON — never takes the row, the result set or the process with
|
|
12
|
+
* it. The law the per-row hydration held (`try { deserialize } catch { omit }`) and the one-pass
|
|
13
|
+
* hydration dropped: the field is OMITTED from the record, every other field lands, the query's
|
|
14
|
+
* promise resolves, and ONE warning per table and column per process names the table, the column
|
|
15
|
+
* and the first row seen — never the stored value (the parser's own message quotes it).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface Doc extends Record {
|
|
19
|
+
title: string;
|
|
20
|
+
tags: string[];
|
|
21
|
+
settings: { theme: string };
|
|
22
|
+
after: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class DocTable extends Table<Doc> {
|
|
26
|
+
public name = 'legacy_cell_doc';
|
|
27
|
+
public columns = withRecordColumns<Doc>({
|
|
28
|
+
title: new StringColumn('title'),
|
|
29
|
+
tags: new ArrayColumn<string>('tag_list'),
|
|
30
|
+
settings: new ObjectColumn<{ theme: string }>('settings'),
|
|
31
|
+
after: new StringColumn('after'),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const docTable = new DocTable() as Table<Doc>;
|
|
36
|
+
|
|
37
|
+
class TestTransactionContextFactory implements DefaultTransactionContextFactory {
|
|
38
|
+
private context: TransactionContextData = {};
|
|
39
|
+
|
|
40
|
+
getTransactionContext(): TransactionContextData {
|
|
41
|
+
return this.context;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
|
|
45
|
+
const previous = this.context;
|
|
46
|
+
this.context = context;
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally {
|
|
50
|
+
this.context = previous;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A driver that answers every query with the rows it was handed — the stored cells as the database holds them. */
|
|
56
|
+
class RowsDriver implements DbDriver {
|
|
57
|
+
constructor(private readonly rows: SerializedRecord[]) {}
|
|
58
|
+
|
|
59
|
+
getDbName(): string {
|
|
60
|
+
return 'test';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createDbIfNotExists(): Promise<void> {}
|
|
64
|
+
|
|
65
|
+
async createDb(): Promise<void> {}
|
|
66
|
+
|
|
67
|
+
async dropDb(): Promise<void> {}
|
|
68
|
+
|
|
69
|
+
getTableManager(): never {
|
|
70
|
+
throw new Error('not used by these tests');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async runQuery(): Promise<SerializedRecord[]> {
|
|
74
|
+
return this.rows;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async runDml(): Promise<number> {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
|
|
82
|
+
return await fn({ id: 'transaction' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getOperationDeadlineMs(): number {
|
|
86
|
+
return 1000;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dbOver = (table: Table<any>, rows: SerializedRecord[]) =>
|
|
91
|
+
new Db(new RowsDriver(rows), () => table, new TestTransactionContextFactory(), true);
|
|
92
|
+
|
|
93
|
+
const storedRow = (id: string, cells: Partial<SerializedRecord>): SerializedRecord => ({
|
|
94
|
+
id,
|
|
95
|
+
created: new Date(1_700_000_000_000),
|
|
96
|
+
updated: new Date(1_700_000_000_000),
|
|
97
|
+
title: `title ${id}`,
|
|
98
|
+
tag_list: '["a","b"]',
|
|
99
|
+
settings: '{"theme":"dark"}',
|
|
100
|
+
after: `after ${id}`,
|
|
101
|
+
...cells,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** The stored value no warning may ever carry — distinctive, so a leak is unmistakable. */
|
|
105
|
+
const LEGACY_WORD = 'legacyword7';
|
|
106
|
+
|
|
107
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
108
|
+
type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
|
|
109
|
+
type SerializerInternals = { logger: { warn: (...args: unknown[]) => void; debug: (...args: unknown[]) => void } };
|
|
110
|
+
/** The process-wide once-per-table-and-column registry behind the warning (reset per test). */
|
|
111
|
+
type SerializerStatics = { legacyCellsWarned: Set<string> };
|
|
112
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
113
|
+
let originalWatchers: unknown[] | undefined;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
(RecordSerializer as unknown as SerializerStatics).legacyCellsWarned?.clear();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// No reflection graph in a unit test: the watcher registry is empty by declaration.
|
|
120
|
+
beforeAll(() => {
|
|
121
|
+
originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
|
|
122
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [];
|
|
123
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(() => {
|
|
127
|
+
if (originalWatchers) {
|
|
128
|
+
objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
|
|
129
|
+
} else {
|
|
130
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
131
|
+
}
|
|
132
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Every warning a serializer logs, as the logger received it. */
|
|
136
|
+
const warningsOf = (serializer: RecordSerializer<any>): unknown[][] => {
|
|
137
|
+
const calls: unknown[][] = [];
|
|
138
|
+
const logger = (serializer as unknown as SerializerInternals).logger;
|
|
139
|
+
logger.warn = (...args: unknown[]) => {
|
|
140
|
+
calls.push(args);
|
|
141
|
+
};
|
|
142
|
+
return calls;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
describe('a stored cell a column cannot deserialize', () => {
|
|
146
|
+
it('a query over a row holding a legacy value resolves: the field is omitted, every other field lands', async () => {
|
|
147
|
+
const rows = [storedRow('r1', {}), storedRow('r2', { tag_list: LEGACY_WORD }), storedRow('r3', {})];
|
|
148
|
+
|
|
149
|
+
const records = await dbOver(docTable, rows).query(docTable, {});
|
|
150
|
+
|
|
151
|
+
expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
|
|
152
|
+
expect(records[0].tags).toEqual(['a', 'b']);
|
|
153
|
+
expect(records[2].tags).toEqual(['a', 'b']);
|
|
154
|
+
expect(records[1]).not.toHaveProperty('tags');
|
|
155
|
+
expect(records[1]).toMatchObject({
|
|
156
|
+
id: 'r2',
|
|
157
|
+
title: 'title r2',
|
|
158
|
+
settings: { theme: 'dark' },
|
|
159
|
+
after: 'after r2',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('a get of the row answers the record, not a rejection', async () => {
|
|
164
|
+
const rows = [storedRow('r2', { settings: LEGACY_WORD })];
|
|
165
|
+
|
|
166
|
+
const record = await dbOver(docTable, rows).get(docTable, { id: 'r2' });
|
|
167
|
+
|
|
168
|
+
expect(record).toMatchObject({ id: 'r2', title: 'title r2', tags: ['a', 'b'], after: 'after r2' });
|
|
169
|
+
expect(record).not.toHaveProperty('settings');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('warns once per table and column per process, naming the table, the column and the first row — never the value', () => {
|
|
173
|
+
const serializer = new RecordSerializer<Doc>(docTable);
|
|
174
|
+
const warnings = warningsOf(serializer);
|
|
175
|
+
const rows = [
|
|
176
|
+
storedRow('r1', { tag_list: LEGACY_WORD }),
|
|
177
|
+
storedRow('r2', { tag_list: LEGACY_WORD }),
|
|
178
|
+
storedRow('r3', { settings: LEGACY_WORD }),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const records = serializer.deserializeAll(rows) as Doc[];
|
|
182
|
+
|
|
183
|
+
expect(Array.isArray(records)).toBe(true);
|
|
184
|
+
expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
|
|
185
|
+
expect(warnings).toHaveLength(2);
|
|
186
|
+
expect(warnings[0][0]).toMatchObject({
|
|
187
|
+
obj: { table: 'legacy_cell_doc', column: 'tag_list', id: 'r1', error: 'SyntaxError' },
|
|
188
|
+
});
|
|
189
|
+
expect(warnings[1][0]).toMatchObject({
|
|
190
|
+
obj: { table: 'legacy_cell_doc', column: 'settings', id: 'r3', error: 'SyntaxError' },
|
|
191
|
+
});
|
|
192
|
+
expect(JSON.stringify(warnings)).not.toContain(LEGACY_WORD);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('a column whose deserialize answers with a rejecting promise is omitted the same way', async () => {
|
|
196
|
+
class LaterColumn implements Column<number, number> {
|
|
197
|
+
constructor(public name: string) {}
|
|
198
|
+
|
|
199
|
+
deserialize(storedValue: number | null): Promise<number | null> {
|
|
200
|
+
return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
interface Later extends Record {
|
|
204
|
+
title: string;
|
|
205
|
+
later: number;
|
|
206
|
+
after: string;
|
|
207
|
+
}
|
|
208
|
+
class LaterTable extends Table<Later> {
|
|
209
|
+
public name = 'legacy_cell_later';
|
|
210
|
+
public columns = withRecordColumns<Later>({
|
|
211
|
+
title: new StringColumn('title'),
|
|
212
|
+
later: new LaterColumn('later'),
|
|
213
|
+
after: new StringColumn('after'),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const laterTable = new LaterTable() as Table<Later>;
|
|
217
|
+
const serializer = new RecordSerializer<Later>(laterTable);
|
|
218
|
+
const warnings = warningsOf(serializer);
|
|
219
|
+
const rows: SerializedRecord[] = [
|
|
220
|
+
{ id: 'l1', title: 'one', later: 1, after: 'x' },
|
|
221
|
+
{ id: 'l2', title: 'two', later: -1, after: 'y' },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const records = await serializer.deserializeAll(rows);
|
|
225
|
+
|
|
226
|
+
expect(records[0]).toMatchObject({ id: 'l1', title: 'one', later: 1, after: 'x' });
|
|
227
|
+
expect(records[1]).toMatchObject({ id: 'l2', title: 'two', after: 'y' });
|
|
228
|
+
expect(records[1]).not.toHaveProperty('later');
|
|
229
|
+
expect(warnings).toHaveLength(1);
|
|
230
|
+
expect(warnings[0][0]).toMatchObject({
|
|
231
|
+
obj: { table: 'legacy_cell_later', column: 'later', id: 'l2', error: 'RangeError' },
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|