@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.
Files changed (72) hide show
  1. package/.eslintrc.js +15 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/generated/test/index.d.ts.map +1 -1
  4. package/dist/generated/test/index.js +7 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Record.d.ts +31 -0
  7. package/dist/src/Record.d.ts.map +1 -1
  8. package/dist/src/Record.js +159 -58
  9. package/dist/src/Record.js.map +1 -1
  10. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  11. package/dist/src/schema/TableManager.d.ts +8 -0
  12. package/dist/src/schema/TableManager.d.ts.map +1 -1
  13. package/dist/src/schema/TableManager.js +22 -0
  14. package/dist/src/schema/TableManager.js.map +1 -1
  15. package/dist/src/source/SourceRecordSyncRunner.js +1 -1
  16. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
  17. package/dist/test/LegacyCellHydration.test.d.ts +2 -0
  18. package/dist/test/LegacyCellHydration.test.d.ts.map +1 -0
  19. package/dist/test/LegacyCellHydration.test.js +321 -0
  20. package/dist/test/LegacyCellHydration.test.js.map +1 -0
  21. package/dist/test/SettleThenFail.test.d.ts +2 -0
  22. package/dist/test/SettleThenFail.test.d.ts.map +1 -0
  23. package/dist/test/SettleThenFail.test.js +237 -0
  24. package/dist/test/SettleThenFail.test.js.map +1 -0
  25. package/dist/test/StoredValueSeating.test.d.ts +2 -0
  26. package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
  27. package/dist/test/StoredValueSeating.test.js +243 -0
  28. package/dist/test/StoredValueSeating.test.js.map +1 -0
  29. package/dist/test/SyncRowHydration.test.js.map +1 -1
  30. package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
  31. package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
  32. package/dist/test/UndeclaredColumnCensus.test.js +139 -0
  33. package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
  34. package/dist/test/index.d.ts +2 -0
  35. package/dist/test/index.d.ts.map +1 -1
  36. package/dist/test/index.js +2 -0
  37. package/dist/test/index.js.map +1 -1
  38. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  39. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  40. package/dist/test/reusable/ColumnTypesTests.d.ts.map +1 -1
  41. package/dist/test/reusable/ColumnTypesTests.js +55 -0
  42. package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
  43. package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
  44. package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
  45. package/dist/test/reusable/StoredValueContractTests.js +666 -0
  46. package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
  47. package/dist/test/reusable/TransactionTests.js +8 -8
  48. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  49. package/dist/test/util/DbTestEnvironment.js +5 -1
  50. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  51. package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
  52. package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
  53. package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
  54. package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
  55. package/generated/test/index.ts +7 -1
  56. package/package.json +3 -3
  57. package/src/Record.ts +166 -55
  58. package/src/auth/TableServiceAuth.ts +1 -1
  59. package/src/schema/TableManager.ts +25 -0
  60. package/src/source/SourceRecordSyncRunner.ts +1 -1
  61. package/test/LegacyCellHydration.test.ts +234 -0
  62. package/test/SettleThenFail.test.ts +167 -0
  63. package/test/StoredValueSeating.test.ts +169 -0
  64. package/test/SyncRowHydration.test.ts +1 -1
  65. package/test/UndeclaredColumnCensus.test.ts +87 -0
  66. package/test/index.ts +2 -0
  67. package/test/reusable/CascadeDeleteTests.ts +1 -15
  68. package/test/reusable/ColumnTypesTests.ts +47 -1
  69. package/test/reusable/StoredValueContractTests.ts +419 -0
  70. package/test/reusable/TransactionTests.ts +1 -1
  71. package/test/util/DbTestEnvironment.ts +3 -0
  72. 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.54.2",
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.0",
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": "d90bd35b32b628c2b9cf9d8ddb16b4a2b9140f11"
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
- class MissingFieldError extends Error {
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 (MissingFieldError) {
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
- for (let i = 0; i < serializedRecords.length; i++) {
129
- const record = this.hydrate(plan, serializedRecords[i]);
130
- if (this.isPromise(record)) {
131
- settles = true;
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
- records[i] = record;
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
- for (const columnName in prepared) {
161
- const slot = plan.slotsByColumnName[columnName];
162
- if (!slot) {
163
- // a column exists in the db that is no longer defined in Table.columns
164
- (omittedFields ??= []).push(columnName);
165
- continue;
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
- const { propertyName, column } = slot;
169
- const storedValue = prepared[columnName];
170
- if (!column.deserialize) {
171
- record[propertyName] = storedValue;
172
- continue;
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
- if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
176
- this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
177
- continue;
178
- }
234
+ if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
235
+ this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
236
+ continue;
237
+ }
179
238
 
180
- const fieldValue = column.deserialize(storedValue, prepared);
181
- if (this.isPromise(fieldValue)) {
182
- record[propertyName] = undefined; // seats the property in column order; the value lands when it settles
183
- (settling ??= []).push(
184
- fieldValue.then((value) => {
185
- record[propertyName] = value;
186
- })
187
- );
188
- continue;
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
- record[propertyName] = fieldValue;
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 column name, then by property name — a stored key that is a property name
249
- // resolves to that property's column first, as it always has.
250
- for (const propertyName in columns) {
251
- slotsByColumnName[columns[propertyName].name] = { propertyName, column: columns[propertyName] };
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
- slotsByColumnName[propertyName] = { propertyName, column: columns[propertyName] };
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
- let fieldPropertyName = columnName;
287
- let column = columns[columnName]; // the scenario that the column name is the same as the property name
288
- if (!column) {
289
- for (const columnPropertyName in columns) {
290
- const checkColumn = (this.table.columns as any)[columnPropertyName];
291
- if (checkColumn && columnName == checkColumn.name) {
292
- fieldPropertyName = columnPropertyName;
293
- column = checkColumn;
294
- break;
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 is no longer defined in Table.columns
301
- throw new MissingFieldError(this.table.name, fieldPropertyName);
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, TableAuthError } from './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]) {
@@ -662,7 +662,7 @@ export class SourceRecordSyncRunner {
662
662
  break;
663
663
  }
664
664
  }
665
- } catch (error) {
665
+ } catch {
666
666
  // Unresolvable from cwd — reported below.
667
667
  }
668
668
  }
@@ -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
+ });