@proteinjs/db 1.54.3 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.eslintrc.js +15 -1
  2. package/CHANGELOG.md +11 -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 +15 -0
  7. package/dist/src/Record.d.ts.map +1 -1
  8. package/dist/src/Record.js +118 -67
  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/SettleThenFail.test.d.ts +2 -0
  18. package/dist/test/SettleThenFail.test.d.ts.map +1 -0
  19. package/dist/test/SettleThenFail.test.js +237 -0
  20. package/dist/test/SettleThenFail.test.js.map +1 -0
  21. package/dist/test/StoredValueSeating.test.d.ts +2 -0
  22. package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
  23. package/dist/test/StoredValueSeating.test.js +243 -0
  24. package/dist/test/StoredValueSeating.test.js.map +1 -0
  25. package/dist/test/SyncRowHydration.test.js.map +1 -1
  26. package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
  27. package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
  28. package/dist/test/UndeclaredColumnCensus.test.js +139 -0
  29. package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
  30. package/dist/test/index.d.ts +2 -0
  31. package/dist/test/index.d.ts.map +1 -1
  32. package/dist/test/index.js +2 -0
  33. package/dist/test/index.js.map +1 -1
  34. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  35. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  36. package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
  37. package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
  38. package/dist/test/reusable/StoredValueContractTests.js +666 -0
  39. package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
  40. package/dist/test/reusable/TransactionTests.js +8 -8
  41. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  42. package/dist/test/util/DbTestEnvironment.js +5 -1
  43. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  44. package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
  45. package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
  46. package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
  47. package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
  48. package/generated/test/index.ts +7 -1
  49. package/package.json +3 -3
  50. package/src/Record.ts +125 -66
  51. package/src/auth/TableServiceAuth.ts +1 -1
  52. package/src/schema/TableManager.ts +25 -0
  53. package/src/source/SourceRecordSyncRunner.ts +1 -1
  54. package/test/SettleThenFail.test.ts +167 -0
  55. package/test/StoredValueSeating.test.ts +169 -0
  56. package/test/SyncRowHydration.test.ts +1 -1
  57. package/test/UndeclaredColumnCensus.test.ts +87 -0
  58. package/test/index.ts +2 -0
  59. package/test/reusable/CascadeDeleteTests.ts +1 -15
  60. package/test/reusable/StoredValueContractTests.ts +419 -0
  61. package/test/reusable/TransactionTests.ts +1 -1
  62. package/test/util/DbTestEnvironment.ts +3 -0
  63. 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.3",
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": "351cc984b09d074bf0306a82750efd99ec00654a"
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
 
@@ -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 (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
+
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
- for (let i = 0; i < serializedRecords.length; i++) {
136
- const record = this.hydrate(plan, serializedRecords[i]);
137
- if (this.isPromise(record)) {
138
- 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;
139
164
  }
140
- records[i] = record;
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
- for (const columnName in prepared) {
168
- const slot = plan.slotsByColumnName[columnName];
169
- if (!slot) {
170
- // a column exists in the db that is no longer defined in Table.columns
171
- (omittedFields ??= []).push(columnName);
172
- continue;
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
- const { propertyName, column } = slot;
176
- const storedValue = prepared[columnName];
177
- if (!column.deserialize) {
178
- record[propertyName] = storedValue;
179
- continue;
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
- if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
183
- this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
184
- continue;
185
- }
234
+ if (column.deserializeOnFirstRead && storedValue !== null && storedValue !== undefined) {
235
+ this.defineFieldReadOnFirstRead(record, propertyName, column, storedValue);
236
+ continue;
237
+ }
186
238
 
187
- let fieldValue: unknown;
188
- try {
189
- fieldValue = column.deserialize(storedValue, prepared);
190
- } catch (error) {
191
- this.omitCellNotDeserialized(columnName, prepared, error);
192
- continue;
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
- if (this.isPromise(fieldValue)) {
196
- record[propertyName] = undefined; // seats the property in column order; the value lands when it settles
197
- (settling ??= []).push(
198
- fieldValue.then(
199
- (value) => {
200
- record[propertyName] = value;
201
- },
202
- (error) => {
203
- delete record[propertyName];
204
- this.omitCellNotDeserialized(columnName, prepared, error);
205
- }
206
- )
207
- );
208
- continue;
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
- record[propertyName] = fieldValue;
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 column name, then by property name — a stored key that is a property name
275
- // resolves to that property's column first, as it always has.
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
- slotsByColumnName[columns[propertyName].name] = { propertyName, column: columns[propertyName] };
278
- }
279
- for (const propertyName in columns) {
280
- 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] };
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
- let fieldPropertyName = columnName;
339
- let column = columns[columnName]; // the scenario that the column name is the same as the property name
340
- if (!column) {
341
- for (const columnPropertyName in columns) {
342
- const checkColumn = (this.table.columns as any)[columnPropertyName];
343
- if (checkColumn && columnName == checkColumn.name) {
344
- fieldPropertyName = columnPropertyName;
345
- column = checkColumn;
346
- break;
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 is no longer defined in Table.columns
353
- 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);
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, 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,167 @@
1
+ import { Column, Table } from '../src/Table';
2
+ import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
3
+ import { StringColumn } from '../src/Columns';
4
+ import { EncryptionRecordHooks } from '../src/encryption/EncryptionRecordHooks';
5
+ import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
6
+ import { DataKeyStore, DataKeyMaterial } from '../src/encryption/DataKeyStore';
7
+
8
+ /**
9
+ * Settle, then fail. The set reader hydrates rows in order and hands them to ONE promise after the
10
+ * loop; a row that needs an await (a ciphertext envelope whose key is read asynchronously) is a
11
+ * promise in the set from the moment its hook answers. If a LATER row then throws synchronously,
12
+ * the loop ends before that one promise exists — and the earlier row's promise is observed by
13
+ * nobody: its rejection is an `unhandledRejection`, which no caller can catch and a fatal boundary
14
+ * turns into an exit. The law: a synchronous throw fails the set only after every promise the set
15
+ * already created has settled — one rejection, carrying the throw, and no unhandled rejection. The
16
+ * same inside one row, for its cells.
17
+ */
18
+
19
+ interface Doc extends Record {
20
+ title: string;
21
+ later: number;
22
+ after: string;
23
+ }
24
+
25
+ /** A column whose `deserialize` answers with a promise: resolves the number, rejects for -1. */
26
+ class LaterColumn implements Column<number, number> {
27
+ constructor(public name: string) {}
28
+
29
+ deserialize(storedValue: number | null): Promise<number | null> {
30
+ return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
31
+ }
32
+ }
33
+
34
+ class DocTable extends Table<Doc> {
35
+ public name = 'settle_doc';
36
+ public columns = withRecordColumns<Doc>({
37
+ title: new StringColumn('title'),
38
+ later: new LaterColumn('later'),
39
+ after: new StringColumn('after'),
40
+ });
41
+ }
42
+
43
+ const docTable = new DocTable() as Table<Doc>;
44
+
45
+ const keyFor = (owner: string, version: number): DataKeyMaterial => ({
46
+ owner,
47
+ version,
48
+ cipherKey: Buffer.alloc(32, 7),
49
+ indexKey: Buffer.alloc(32, 9),
50
+ });
51
+
52
+ const storedRow = (id: string, cells: Partial<SerializedRecord> = {}): SerializedRecord => ({
53
+ id,
54
+ created: new Date(1_700_000_000_000),
55
+ updated: new Date(1_700_000_000_000),
56
+ title: `title ${id}`,
57
+ later: 1,
58
+ after: `after ${id}`,
59
+ ...cells,
60
+ });
61
+
62
+ /** The unhandled rejections Node reported while `run` ran, counted after the loop drained twice. */
63
+ const unhandledRejectionsDuring = async (
64
+ run: () => Promise<unknown>
65
+ ): Promise<{ unhandled: number; outcome: unknown }> => {
66
+ let unhandled = 0;
67
+ const listener = () => {
68
+ unhandled++;
69
+ };
70
+ process.on('unhandledRejection', listener);
71
+ try {
72
+ const outcome = await run().then(
73
+ (value) => ({ resolved: value }),
74
+ (error) => ({ rejected: error })
75
+ );
76
+ await new Promise((resolve) => setTimeout(resolve, 0));
77
+ await new Promise((resolve) => setTimeout(resolve, 0));
78
+ return { unhandled, outcome };
79
+ } finally {
80
+ process.off('unhandledRejection', listener);
81
+ }
82
+ };
83
+
84
+ describe('settle, then fail', () => {
85
+ afterEach(() => {
86
+ jest.restoreAllMocks();
87
+ });
88
+
89
+ test('an envelope row whose key read rejects, beside a later row that throws synchronously: one rejection, nothing unhandled', async () => {
90
+ const key = keyFor('owner-1', 4);
91
+ jest.spyOn(DataKeyStore.prototype, 'getKeyByVersion').mockRejectedValue(new Error('key unavailable'));
92
+ const onDeserialize = EncryptionRecordHooks.prototype.onDeserialize;
93
+ jest.spyOn(EncryptionRecordHooks.prototype, 'onDeserialize').mockImplementation(function (
94
+ this: EncryptionRecordHooks,
95
+ table,
96
+ row
97
+ ) {
98
+ if (row.id === 'r2') {
99
+ throw new Error('hook failed');
100
+ }
101
+ return onDeserialize.call(this, table, row);
102
+ });
103
+ const rows = [
104
+ storedRow('r1', { title: new EncryptionEnvelope().encrypt('secret', key) }),
105
+ storedRow('r2'),
106
+ storedRow('r3'),
107
+ ];
108
+
109
+ const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
110
+ new RecordSerializer<Doc>(docTable).deserializeAll(rows)
111
+ );
112
+
113
+ expect(outcome).toEqual({ rejected: new Error('hook failed') });
114
+ expect(unhandled).toBe(0);
115
+ });
116
+
117
+ test('inside one row: a cell whose promise rejects, beside a later cell that throws synchronously', async () => {
118
+ // A stored value whose `then` getter throws — the one synchronous throw the duck-typed
119
+ // promise check can meet after a column has answered.
120
+ const throwingThenable = {
121
+ get then(): never {
122
+ throw new Error('not a promise after all');
123
+ },
124
+ };
125
+ class ThenableColumn implements Column<unknown, unknown> {
126
+ constructor(public name: string) {}
127
+
128
+ deserialize(): unknown {
129
+ return throwingThenable;
130
+ }
131
+ }
132
+ interface Cells extends Record {
133
+ later: number;
134
+ odd: unknown;
135
+ }
136
+ class CellsTable extends Table<Cells> {
137
+ public name = 'settle_cells';
138
+ public columns = withRecordColumns<Cells>({
139
+ later: new LaterColumn('later'),
140
+ odd: new ThenableColumn('odd'),
141
+ });
142
+ }
143
+ const rows = [storedRow('c1', { later: -1, odd: 'x' })];
144
+
145
+ const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
146
+ new RecordSerializer<Cells>(new CellsTable() as Table<Cells>).deserializeAll(rows)
147
+ );
148
+
149
+ expect(outcome).toEqual({ rejected: new Error('not a promise after all') });
150
+ expect(unhandled).toBe(0);
151
+ });
152
+
153
+ test('a set that mixes the async road with a rejecting cell and a synchronous cell resolves, every row served', async () => {
154
+ const rows = [storedRow('r1'), storedRow('r2', { later: -1 }), storedRow('r3', { later: 3 })];
155
+
156
+ const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
157
+ new RecordSerializer<Doc>(docTable).deserializeAll(rows)
158
+ );
159
+
160
+ expect(unhandled).toBe(0);
161
+ const records = (outcome as { resolved: Doc[] }).resolved;
162
+ expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
163
+ expect(records[0].later).toBe(1);
164
+ expect(records[1]).not.toHaveProperty('later');
165
+ expect(records[2].later).toBe(3);
166
+ });
167
+ });
@@ -0,0 +1,169 @@
1
+ import { Column, Table } from '../src/Table';
2
+ import {
3
+ DuplicateColumnNameError,
4
+ FieldSerializer,
5
+ MissingFieldError,
6
+ Record,
7
+ RecordSerializer,
8
+ SerializedRecord,
9
+ withRecordColumns,
10
+ } from '../src/Record';
11
+ import { ArrayColumn, StringColumn } from '../src/Columns';
12
+
13
+ /**
14
+ * Where a stored cell is seated: by the declared COLUMN name alone. A stored key that is only a
15
+ * PROPERTY's name — the text column `tags` a cutover retired beside the array column `tag_list`
16
+ * that replaced it, property `tags` both times — names no seat: it never reaches the array
17
+ * column's parser, never displaces the array, whichever side of the real cell it lands on, and
18
+ * whatever it holds (a bare word, an empty string, NULL). The row reader used to seat by property
19
+ * name as a second pass, which is the only way an undeclared cell could reach a declared column's
20
+ * parser — and, iterated after the real cell, a NULL in it silently replaced the array.
21
+ */
22
+
23
+ interface Member extends Record {
24
+ name: string;
25
+ tags: string[] | null;
26
+ }
27
+
28
+ class MemberTable extends Table<Member> {
29
+ public name = 'seating_member';
30
+ public columns = withRecordColumns<Member>({
31
+ name: new StringColumn('name'),
32
+ tags: new ArrayColumn<string>('tag_list'),
33
+ });
34
+ }
35
+
36
+ const memberTable = new MemberTable() as Table<Member>;
37
+
38
+ type SerializerInternals = { logger: { warn: (...args: unknown[]) => void } };
39
+ type SerializerStatics = { legacyCellsWarned: Set<string> };
40
+
41
+ const LEGACY_WORD = 'legacyword7';
42
+
43
+ /** Every warning a serializer logs, as the logger received it. */
44
+ const warningsOf = (serializer: RecordSerializer<any>): unknown[][] => {
45
+ const calls: unknown[][] = [];
46
+ (serializer as unknown as SerializerInternals).logger.warn = (...args: unknown[]) => {
47
+ calls.push(args);
48
+ };
49
+ return calls;
50
+ };
51
+
52
+ /** A stored row in the given key order — the order a driver's row carries its cells in. */
53
+ const rowOf = (cells: { [columnName: string]: unknown }): SerializedRecord => ({
54
+ id: 'm1',
55
+ created: new Date(1_700_000_000_000),
56
+ updated: new Date(1_700_000_000_000),
57
+ ...cells,
58
+ });
59
+
60
+ beforeEach(() => {
61
+ (RecordSerializer as unknown as SerializerStatics).legacyCellsWarned.clear();
62
+ });
63
+
64
+ describe('a stored key that is a property name, not a column name', () => {
65
+ test.each([
66
+ ['a bare word', LEGACY_WORD],
67
+ ['an empty string', ''],
68
+ ['NULL', null],
69
+ ])('iterated AFTER the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
70
+ const serializer = new RecordSerializer<Member>(memberTable);
71
+ const warnings = warningsOf(serializer);
72
+
73
+ const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tag_list: '["a","b"]', tags: legacy })]);
74
+
75
+ expect(record.tags).toEqual(['a', 'b']);
76
+ expect(record).toMatchObject({ id: 'm1', name: 'm' });
77
+ // The legacy cell never reached the array column's parser: no cell-omitted warning.
78
+ expect(warnings).toHaveLength(0);
79
+ });
80
+
81
+ test.each([
82
+ ['a bare word', LEGACY_WORD],
83
+ ['an empty string', ''],
84
+ ['NULL', null],
85
+ ])('iterated BEFORE the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
86
+ const serializer = new RecordSerializer<Member>(memberTable);
87
+ const warnings = warningsOf(serializer);
88
+
89
+ const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tags: legacy, tag_list: '["a","b"]' })]);
90
+
91
+ expect(record.tags).toEqual(['a', 'b']);
92
+ expect(warnings).toHaveLength(0);
93
+ });
94
+
95
+ test('a stored key equal to a column name is that column; the property name alone is nobody', async () => {
96
+ const fieldSerializer = new FieldSerializer<Member>(memberTable);
97
+
98
+ const seated = await fieldSerializer.deserialize('tag_list', '["a"]', {});
99
+ expect(seated).toEqual({ fieldPropertyName: 'tags', fieldValue: ['a'] });
100
+
101
+ await expect(fieldSerializer.deserialize('tags', LEGACY_WORD, {})).rejects.toBeInstanceOf(MissingFieldError);
102
+ });
103
+ });
104
+
105
+ describe('two declared columns sharing one stored name', () => {
106
+ test('is refused at the first hydration, naming the table and both properties', () => {
107
+ interface Twice extends Record {
108
+ first: string;
109
+ second: string;
110
+ }
111
+ class TwiceTable extends Table<Twice> {
112
+ public name = 'seating_twice';
113
+ public columns = withRecordColumns<Twice>({
114
+ first: new StringColumn('shared'),
115
+ second: new StringColumn('shared'),
116
+ });
117
+ }
118
+ const serializer = new RecordSerializer<Twice>(new TwiceTable() as Table<Twice>);
119
+
120
+ expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(DuplicateColumnNameError);
121
+ expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(
122
+ `(seating_twice) Columns first and second both declare the column name 'shared'`
123
+ );
124
+ });
125
+ });
126
+
127
+ describe('the write side', () => {
128
+ class RefusingColumn implements Column<string, string> {
129
+ constructor(public name: string) {}
130
+
131
+ async serialize(): Promise<string> {
132
+ throw new RangeError('cannot be stored');
133
+ }
134
+ }
135
+ interface Note extends Record {
136
+ title: string;
137
+ body: string;
138
+ }
139
+ class NoteTable extends Table<Note> {
140
+ public name = 'seating_note';
141
+ public columns = withRecordColumns<Note>({
142
+ title: new StringColumn('title'),
143
+ body: new RefusingColumn('body'),
144
+ });
145
+ }
146
+ const noteTable = new NoteTable() as Table<Note>;
147
+
148
+ test('a column that cannot serialize its value fails the write with that error', async () => {
149
+ const serializer = new RecordSerializer<Note>(noteTable);
150
+ const warnings = warningsOf(serializer);
151
+
152
+ await expect(serializer.serialize({ id: 'n1', title: 't', body: 'b' })).rejects.toThrow(
153
+ new RangeError('cannot be stored')
154
+ );
155
+ expect(warnings).toHaveLength(0);
156
+ });
157
+
158
+ test('a field no column owns is omitted from the write, and warned', async () => {
159
+ const serializer = new RecordSerializer<Member>(memberTable);
160
+ const warnings = warningsOf(serializer);
161
+
162
+ const serialized = await serializer.serialize({ id: 'm1', name: 'm', tags: ['a'], retired: 'gone' });
163
+
164
+ expect(Object.keys(serialized)).toEqual(['id', 'name', 'tag_list']);
165
+ expect(JSON.parse(serialized.tag_list)).toEqual(['a']);
166
+ expect(warnings).toHaveLength(1);
167
+ expect(warnings[0][0]).toMatchObject({ obj: { omittedFields: ['retired'] } });
168
+ });
169
+ });