@proteinjs/db 1.54.3 → 1.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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 +15 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Columns.d.ts +20 -2
  7. package/dist/src/Columns.d.ts.map +1 -1
  8. package/dist/src/Columns.js +46 -45
  9. package/dist/src/Columns.js.map +1 -1
  10. package/dist/src/Db.d.ts +8 -0
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +9 -1
  13. package/dist/src/Db.js.map +1 -1
  14. package/dist/src/Record.d.ts +15 -0
  15. package/dist/src/Record.d.ts.map +1 -1
  16. package/dist/src/Record.js +118 -67
  17. package/dist/src/Record.js.map +1 -1
  18. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  19. package/dist/src/schema/TableManager.d.ts +8 -0
  20. package/dist/src/schema/TableManager.d.ts.map +1 -1
  21. package/dist/src/schema/TableManager.js +22 -0
  22. package/dist/src/schema/TableManager.js.map +1 -1
  23. package/dist/src/source/SourceRecordSyncRunner.js +1 -1
  24. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
  25. package/dist/test/SettleThenFail.test.d.ts +2 -0
  26. package/dist/test/SettleThenFail.test.d.ts.map +1 -0
  27. package/dist/test/SettleThenFail.test.js +237 -0
  28. package/dist/test/SettleThenFail.test.js.map +1 -0
  29. package/dist/test/StoredValueSeating.test.d.ts +2 -0
  30. package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
  31. package/dist/test/StoredValueSeating.test.js +243 -0
  32. package/dist/test/StoredValueSeating.test.js.map +1 -0
  33. package/dist/test/SyncRowHydration.test.js.map +1 -1
  34. package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
  35. package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
  36. package/dist/test/UndeclaredColumnCensus.test.js +139 -0
  37. package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
  38. package/dist/test/index.d.ts +2 -0
  39. package/dist/test/index.d.ts.map +1 -1
  40. package/dist/test/index.js +2 -0
  41. package/dist/test/index.js.map +1 -1
  42. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  43. package/dist/test/reusable/CascadeDeleteTests.js +132 -0
  44. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  45. package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
  46. package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
  47. package/dist/test/reusable/StoredValueContractTests.js +666 -0
  48. package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
  49. package/dist/test/reusable/TransactionTests.js +8 -8
  50. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  51. package/dist/test/util/DbTestEnvironment.js +5 -1
  52. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  53. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +38 -0
  54. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
  55. package/dist/test/util/tables/cascadeDeleteTestTables.js +119 -1
  56. package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
  57. package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
  58. package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
  59. package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
  60. package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
  61. package/generated/test/index.ts +15 -1
  62. package/package.json +3 -3
  63. package/src/Columns.ts +50 -27
  64. package/src/Db.ts +9 -1
  65. package/src/Record.ts +125 -66
  66. package/src/auth/TableServiceAuth.ts +1 -1
  67. package/src/schema/TableManager.ts +25 -0
  68. package/src/source/SourceRecordSyncRunner.ts +1 -1
  69. package/test/SettleThenFail.test.ts +167 -0
  70. package/test/StoredValueSeating.test.ts +169 -0
  71. package/test/SyncRowHydration.test.ts +1 -1
  72. package/test/UndeclaredColumnCensus.test.ts +87 -0
  73. package/test/index.ts +2 -0
  74. package/test/reusable/CascadeDeleteTests.ts +91 -15
  75. package/test/reusable/StoredValueContractTests.ts +419 -0
  76. package/test/reusable/TransactionTests.ts +1 -1
  77. package/test/util/DbTestEnvironment.ts +3 -0
  78. package/test/util/tables/cascadeDeleteTestTables.ts +85 -0
  79. 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.1",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.11.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": "195e4d8a6aae026ad6c6b8b0fbfa831a57ecb501"
70
70
  }
package/src/Columns.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import moment from 'moment';
2
2
  import { v1 as uuidv1 } from 'uuid';
3
- import { Column, ColumnOptions, getColumnPropertyName, Table, tableByName } from './Table';
3
+ import { Column, ColumnOptions, getColumnPropertyName, Table } from './Table';
4
4
  import { Record } from './Record';
5
5
  import { ReferenceArray } from './reference/ReferenceArray';
6
6
  import { Db } from './Db';
@@ -263,17 +263,29 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
263
263
  return new ReferenceArray(this.referenceTable, ids);
264
264
  }
265
265
 
266
- async beforeDelete(table: Table<any>, columnPropertyName: string, records: any[]) {
266
+ /**
267
+ * Cascade: the referenced records go with the holder. Runs through the Db the firing write
268
+ * hands it — its driver, its transaction context, its authority — and off the ids the column
269
+ * stores; nothing here opens a Db of its own or re-reads the referenced rows, either of which
270
+ * would run as the session's user rather than as the write (a system delete's cascade must stay
271
+ * system: in a background context there is no session user at all).
272
+ */
273
+ async beforeDelete(
274
+ table: Table<any>,
275
+ columnPropertyName: string,
276
+ records: any[],
277
+ getTable?: (tableName: string) => Table<any>,
278
+ db?: Db
279
+ ): Promise<void> {
267
280
  if (!this.cascadeDelete) {
268
281
  return;
269
282
  }
270
283
 
271
284
  const recordIdsToDelete: string[] = [];
272
285
  for (const record of records) {
273
- const referenceArray = record[columnPropertyName] as ReferenceArray<Record>;
274
- const referenceRecords = await referenceArray.get();
275
- for (const referenceRecord of referenceRecords) {
276
- recordIdsToDelete.push(referenceRecord.id);
286
+ const referenceArray = record[columnPropertyName] as ReferenceArray<Record> | null | undefined;
287
+ if (referenceArray) {
288
+ recordIdsToDelete.push(...referenceArray._ids);
277
289
  }
278
290
  }
279
291
 
@@ -281,11 +293,12 @@ export class ReferenceArrayColumn<T extends Record> extends ObjectColumn<Referen
281
293
  return;
282
294
  }
283
295
 
284
- const referenceTable = tableByName(this.referenceTable);
296
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
297
+ const referenceTable = getTable!(this.referenceTable);
285
298
  const qb = new QueryBuilderFactory()
286
299
  .getQueryBuilder(referenceTable)
287
300
  .condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
288
- await new Db().delete(referenceTable, qb);
301
+ await db!.delete(referenceTable, qb);
289
302
  }
290
303
  }
291
304
 
@@ -331,7 +344,18 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
331
344
  return reference as Reference<T>;
332
345
  }
333
346
 
334
- async beforeDelete(table: Table<any>, columnPropertyName: string, records: any[]) {
347
+ /**
348
+ * Cascade: the referenced record goes with the holder. Runs through the Db the firing write
349
+ * hands it (its driver, transaction context and authority) off the stored id — see
350
+ * `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of its own.
351
+ */
352
+ async beforeDelete(
353
+ table: Table<any>,
354
+ columnPropertyName: string,
355
+ records: any[],
356
+ getTable?: (tableName: string) => Table<any>,
357
+ db?: Db
358
+ ): Promise<void> {
335
359
  if (!this.cascadeDelete) {
336
360
  return;
337
361
  }
@@ -348,11 +372,12 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
348
372
  return;
349
373
  }
350
374
 
351
- const referenceTable = tableByName(this.referenceTable);
375
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
376
+ const referenceTable = getTable!(this.referenceTable);
352
377
  const qb = new QueryBuilderFactory()
353
378
  .getQueryBuilder(referenceTable)
354
379
  .condition({ field: 'id', operator: 'IN', value: recordIdsToDelete });
355
- await new Db().delete(referenceTable, qb);
380
+ await db!.delete(referenceTable, qb);
356
381
  }
357
382
  }
358
383
 
@@ -508,6 +533,12 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
508
533
  return new Reference<T>(tableName, serializedFieldValue);
509
534
  }
510
535
 
536
+ /**
537
+ * Cascade: the referenced record goes with the holder, whichever table the row names. Runs
538
+ * through the Db the firing write hands it (its driver, transaction context and authority) off
539
+ * the stored ids — see `ReferenceArrayColumn.beforeDelete` for why the hook never opens a Db of
540
+ * its own.
541
+ */
511
542
  async beforeDelete(
512
543
  table: Table<any>,
513
544
  columnPropertyName: string,
@@ -519,12 +550,8 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
519
550
  return;
520
551
  }
521
552
 
522
- const getTableFn = getTable ? getTable : tableByName;
523
- const dbInstance = db ? db : new Db();
524
-
525
- // Get all referenced record IDs grouped by table
553
+ // The referenced ids, grouped by the table each row names
526
554
  const recordsToDelete = new Map<string, string[]>();
527
-
528
555
  for (const record of records) {
529
556
  const reference = record[columnPropertyName] as Reference<Record>;
530
557
  if (!reference?._id || !reference._table) {
@@ -537,17 +564,13 @@ export class DynamicReferenceColumn<T extends Record> extends StringColumn<Refer
537
564
  recordsToDelete.get(reference._table)!.push(reference._id);
538
565
  }
539
566
 
540
- // Delete records from each referenced table using Promise.all to properly await all deletions
541
- const entries = Array.from(recordsToDelete.entries());
542
- console.log(`entries to delete: ${JSON.stringify(entries)}`);
543
- for (const [tableName, ids] of entries) {
544
- if (ids.length > 0) {
545
- const referenceTable = getTableFn(tableName);
546
- const qb = new QueryBuilderFactory()
547
- .getQueryBuilder(referenceTable)
548
- .condition({ field: 'id', operator: 'IN', value: ids });
549
- await dbInstance.delete(referenceTable, qb);
550
- }
567
+ // Optional in the hook contract for implementers that need neither; the Db hands both to every hook.
568
+ for (const [tableName, ids] of Array.from(recordsToDelete.entries())) {
569
+ const referenceTable = getTable!(tableName);
570
+ const qb = new QueryBuilderFactory()
571
+ .getQueryBuilder(referenceTable)
572
+ .condition({ field: 'id', operator: 'IN', value: ids });
573
+ await db!.delete(referenceTable, qb);
551
574
  }
552
575
  }
553
576
  }
package/src/Db.ts CHANGED
@@ -551,6 +551,14 @@ export class Db<R extends Record = Record> implements DbService<R> {
551
551
  return recordDeleteCount;
552
552
  }
553
553
 
554
+ /**
555
+ * A column's `beforeDelete` hook runs with the authority of the write that fired it: the Db it
556
+ * is handed rides this instance's driver, tables and transaction context AND its system mode.
557
+ * The hooks cascade a delete through reference columns, and a system delete's cascade must stay
558
+ * system — handed a default (non-system) Db, the cascade ran as whoever the session held, which
559
+ * narrowed it to that user's rows inside a request and refused it outright in a background
560
+ * context, where a scoped table finds no user to scope the query to.
561
+ */
554
562
  private async runColumnBeforeDeletes(table: Table<any>, recordsToDelete: Record[]) {
555
563
  for (const columnPropertyName in table.columns) {
556
564
  const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
@@ -560,7 +568,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
560
568
  columnPropertyName,
561
569
  recordsToDelete,
562
570
  this.getTable,
563
- new Db(this.dbDriver, this.getTable, this.transactionContextFactory)
571
+ new Db(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem)
564
572
  );
565
573
  }
566
574
  }
package/src/Record.ts CHANGED
@@ -25,10 +25,26 @@ const recordColumns: Columns<Record> = {
25
25
  }),
26
26
  };
27
27
 
28
- 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
+ });