@proteinjs/db 1.33.0 → 1.34.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/CHANGELOG.md +24 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.d.ts.map +1 -1
  5. package/dist/generated/test/index.js +15 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/src/Db.d.ts +2 -0
  12. package/dist/src/Db.d.ts.map +1 -1
  13. package/dist/src/Db.js +33 -62
  14. package/dist/src/Db.js.map +1 -1
  15. package/dist/src/MigrationRunner.d.ts +10 -0
  16. package/dist/src/MigrationRunner.d.ts.map +1 -1
  17. package/dist/src/MigrationRunner.js +21 -2
  18. package/dist/src/MigrationRunner.js.map +1 -1
  19. package/dist/src/ReverseCascadeEdgeIndex.d.ts +51 -0
  20. package/dist/src/ReverseCascadeEdgeIndex.d.ts.map +1 -0
  21. package/dist/src/ReverseCascadeEdgeIndex.js +108 -0
  22. package/dist/src/ReverseCascadeEdgeIndex.js.map +1 -0
  23. package/dist/src/Table.d.ts +34 -7
  24. package/dist/src/Table.d.ts.map +1 -1
  25. package/dist/src/Table.js +2 -7
  26. package/dist/src/Table.js.map +1 -1
  27. package/dist/src/schema/TableManager.d.ts +26 -0
  28. package/dist/src/schema/TableManager.d.ts.map +1 -1
  29. package/dist/src/schema/TableManager.js +154 -13
  30. package/dist/src/schema/TableManager.js.map +1 -1
  31. package/dist/src/source/SourceRecordLoader.d.ts +18 -1
  32. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  33. package/dist/src/source/SourceRecordLoader.js +187 -68
  34. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  35. package/dist/src/tables/MigrationTable.d.ts +1 -6
  36. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  37. package/dist/src/tables/MigrationTable.js +2 -1
  38. package/dist/src/tables/MigrationTable.js.map +1 -1
  39. package/dist/test/index.d.ts +2 -0
  40. package/dist/test/index.d.ts.map +1 -1
  41. package/dist/test/index.js +2 -0
  42. package/dist/test/index.js.map +1 -1
  43. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  44. package/dist/test/reusable/CascadeDeleteTests.js +59 -0
  45. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  46. package/dist/test/reusable/SourceRecordSyncTests.d.ts +11 -0
  47. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -0
  48. package/dist/test/reusable/SourceRecordSyncTests.js +483 -0
  49. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -0
  50. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  51. package/dist/test/util/DbTestEnvironment.js +2 -1
  52. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  53. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +46 -0
  54. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
  55. package/dist/test/util/tables/cascadeDeleteTestTables.js +51 -1
  56. package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
  57. package/dist/test/util/tables/crudTestTables.d.ts +1 -1
  58. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +82 -0
  59. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -0
  60. package/dist/test/util/tables/sourceRecordSyncTestTables.js +90 -0
  61. package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -0
  62. package/dist/test/util/tables/transactionTestTables.d.ts +1 -1
  63. package/generated/index.ts +1 -1
  64. package/generated/test/index.ts +15 -1
  65. package/index.ts +4 -0
  66. package/package.json +2 -2
  67. package/src/Db.ts +32 -57
  68. package/src/MigrationRunner.ts +21 -2
  69. package/src/ReverseCascadeEdgeIndex.ts +126 -0
  70. package/src/Table.ts +32 -9
  71. package/src/schema/TableManager.ts +98 -1
  72. package/src/source/SourceRecordLoader.ts +133 -23
  73. package/src/tables/MigrationTable.ts +3 -2
  74. package/test/index.ts +2 -0
  75. package/test/reusable/CascadeDeleteTests.ts +46 -0
  76. package/test/reusable/SourceRecordSyncTests.ts +288 -0
  77. package/test/util/DbTestEnvironment.ts +2 -0
  78. package/test/util/tables/cascadeDeleteTestTables.ts +48 -0
  79. package/test/util/tables/sourceRecordSyncTestTables.ts +84 -0
package/index.ts CHANGED
@@ -13,6 +13,10 @@ export * from './src/RecordIterator';
13
13
  export * from './src/CursorWindowPager';
14
14
  export * from './src/source/SourceRecord';
15
15
  export * from './src/source/SourceRecordRepo';
16
+ // The sync runner behind Db.init's source-record leg, aliased: its class name is taken in this
17
+ // index by the SourceRecordLoader declaration interface. Public so consumer test harnesses can
18
+ // run one boot of the sync directly (the exports map blocks deep dist imports).
19
+ export { SourceRecordLoader as SourceRecordSyncRunner } from './src/source/SourceRecordLoader';
16
20
  export * from './src/MigrationRunner';
17
21
  export * from './src/QueryBuilderFactory';
18
22
  export * from './src/TableWatcher';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.33.0",
3
+ "version": "1.34.1",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "23b168ca8f82ba392500b545230adc6e8c761b4e"
69
+ "gitHead": "a5a56218a5757e47576cbe79858c9aacc6026816"
70
70
  }
package/src/Db.ts CHANGED
@@ -8,8 +8,8 @@ import {
8
8
  tableByName,
9
9
  addDefaultFieldValues,
10
10
  addUpdateFieldValues,
11
- getTables,
12
11
  } from './Table';
12
+ import { ReverseCascadeEdgeIndex } from './ReverseCascadeEdgeIndex';
13
13
  import { Record, RecordSerializer, SerializedRecord } from './Record';
14
14
  import { Logger } from '@proteinjs/logger';
15
15
  import { SourceRecordLoader } from './source/SourceRecordLoader';
@@ -380,6 +380,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
380
380
 
381
381
  /**
382
382
  * Reverse cascades driven by column-level flags on reference columns only.
383
+ * Edges come from the `ReverseCascadeEdgeIndex` (derived once per process from the static
384
+ * table registry) instead of a per-delete scan over every registered table's columns.
383
385
  * Supports:
384
386
  * - ReferenceColumn
385
387
  * - DynamicReferenceColumn
@@ -392,68 +394,41 @@ export class Db<R extends Record = Record> implements DbService<R> {
392
394
  }
393
395
 
394
396
  const deletedIdSet = new Set<string>(deletedIds);
395
- const allTables = getTables();
397
+ for (const edge of ReverseCascadeEdgeIndex.get().getEdges(table.name)) {
398
+ const { referencingTable, columnPropertyName } = edge;
396
399
 
397
- for (const referencingTable of allTables) {
398
- for (const colPropName in referencingTable.columns) {
399
- const col = referencingTable.columns[colPropName] as any;
400
+ if (edge.refKind === 'dynamicReference') {
401
+ const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
402
+ await this.addColumnQueries(referencingTable, qb, 'read');
400
403
 
401
- // Only act if the column explicitly opted in
402
- if (!col || col.reverseCascadeDelete !== true) {
403
- continue;
404
- }
405
-
406
- // DynamicReferenceColumn: has dynamicRefTableColName
407
- if (typeof col.dynamicRefTableColName === 'string' && col.dynamicRefTableColName.length > 0) {
408
- const dynTableProp = getColumnPropertyName(referencingTable, col.dynamicRefTableColName);
409
- if (!dynTableProp) {
410
- continue;
411
- }
412
-
413
- const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
414
- await this.addColumnQueries(referencingTable, qb, 'read');
404
+ qb.condition({ field: edge.dynamicRefTableColumnPropertyName as any, operator: '=', value: table.name as any });
405
+ qb.condition({ field: columnPropertyName as any, operator: 'IN', value: deletedIds as any });
415
406
 
416
- qb.condition({ field: dynTableProp as any, operator: '=', value: table.name as any });
417
- qb.condition({ field: colPropName as any, operator: 'IN', value: deletedIds as any });
418
-
419
- this.logger.info({
420
- message: `Executing reverse cascade (dynamic) for table: ${table.name}`,
421
- obj: { referencingTable: referencingTable.name, columnPropertyName: colPropName, deletedIds },
422
- });
407
+ this.logger.info({
408
+ message: `Executing reverse cascade (dynamic) for table: ${table.name}`,
409
+ obj: { referencingTable: referencingTable.name, columnPropertyName, deletedIds },
410
+ });
423
411
 
424
- const deleteCount = await this.delete(referencingTable, qb);
425
- this.logger.info({
426
- message: `Reverse cascade (dynamic) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
427
- });
428
- continue;
429
- }
412
+ const deleteCount = await this.delete(referencingTable, qb);
413
+ this.logger.info({
414
+ message: `Reverse cascade (dynamic) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
415
+ });
416
+ } else if (edge.refKind === 'reference') {
417
+ const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
418
+ await this.addColumnQueries(referencingTable, qb, 'read');
419
+ qb.condition({ field: columnPropertyName as any, operator: 'IN', value: deletedIds as any });
430
420
 
431
- // ReferenceColumn/ReferenceArrayColumn must match the target table
432
- if (col.referenceTable !== table.name) {
433
- continue;
434
- }
421
+ this.logger.info({
422
+ message: `Executing reverse cascade (ReferenceColumn) for table: ${table.name}`,
423
+ obj: { referencingTable: referencingTable.name, columnPropertyName, deletedIds },
424
+ });
435
425
 
436
- const ctorName = col.constructor?.name;
437
-
438
- if (ctorName === 'ReferenceColumn') {
439
- const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
440
- await this.addColumnQueries(referencingTable, qb, 'read');
441
- qb.condition({ field: colPropName as any, operator: 'IN', value: deletedIds as any });
442
-
443
- this.logger.info({
444
- message: `Executing reverse cascade (ReferenceColumn) for table: ${table.name}`,
445
- obj: { referencingTable: referencingTable.name, columnPropertyName: colPropName, deletedIds },
446
- });
447
-
448
- const deleteCount = await this.delete(referencingTable, qb);
449
- this.logger.info({
450
- message: `Reverse cascade (ReferenceColumn) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
451
- });
452
- } else if (ctorName === 'ReferenceArrayColumn') {
453
- await this.reverseDeleteReferenceArrayHolders(referencingTable, colPropName, deletedIds, deletedIdSet);
454
- } else {
455
- continue;
456
- }
426
+ const deleteCount = await this.delete(referencingTable, qb);
427
+ this.logger.info({
428
+ message: `Reverse cascade (ReferenceColumn) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
429
+ });
430
+ } else {
431
+ await this.reverseDeleteReferenceArrayHolders(referencingTable, columnPropertyName, deletedIds, deletedIdSet);
457
432
  }
458
433
  }
459
434
  }
@@ -206,7 +206,7 @@ export class MigrationRunner implements MigrationRunnerService {
206
206
  const db = getRunDb();
207
207
  migration.status = 'running';
208
208
  migration.startTime = moment();
209
- await db.update(migrationTable, migration);
209
+ await db.update(migrationTable, this.definedFields(migration));
210
210
  this.logger.info({ message: `Running migration (${migration.id}) ${migration.description}` });
211
211
  try {
212
212
  migration.output = await migration.run();
@@ -222,12 +222,31 @@ export class MigrationRunner implements MigrationRunnerService {
222
222
  migration.endTime = moment();
223
223
  }
224
224
  migration.duration = this.duration(migration.startTime, migration.endTime);
225
- await db.update(migrationTable, migration);
225
+ await db.update(migrationTable, this.definedFields(migration));
226
226
  this.logger.info({
227
227
  message: `[${migration.status}] (${migration.duration}) Finished running migration (${migration.id}) ${migration.description}`,
228
228
  });
229
229
  }
230
230
 
231
+ /**
232
+ * The run-state payload with `undefined`-valued fields OMITTED. Several of the record's fields
233
+ * are legitimately absent depending on the run (`output` for a void `run()`, `failureMessage`/
234
+ * `failureStack` for a non-Error throw), but the migration object carries them as explicit
235
+ * `undefined` assignments — and `RecordSerializer` rejects any payload field holding `undefined`
236
+ * (never a partial write), which would strand the row at 'running' status with the run's real
237
+ * outcome lost. Absent means omitted, never undefined — for EVERY optional field of the payload,
238
+ * not per-field.
239
+ */
240
+ private definedFields(migration: Migration): Partial<Migration> {
241
+ const payload: Partial<Migration> = {};
242
+ for (const [field, value] of Object.entries(migration)) {
243
+ if (value !== undefined) {
244
+ (payload as any)[field] = value;
245
+ }
246
+ }
247
+ return payload;
248
+ }
249
+
231
250
  private resolveMigration(migrationTable: Table<Migration>, id: string): Migration {
232
251
  const migration = new SourceRecordRepo().getSourceRecord<Migration>(migrationTable.name, id);
233
252
  if (!migration) {
@@ -0,0 +1,126 @@
1
+ import { Table, getColumnPropertyName, getTables } from './Table';
2
+
3
+ /**
4
+ * One reverse-cascade edge: a column flagged `reverseCascadeDelete: true` whose rows die when
5
+ * the record they point at is deleted. `dynamicReference` edges carry the resolved property
6
+ * name of the sibling column that stores the target table's name per row.
7
+ */
8
+ export type ReverseCascadeEdge = {
9
+ /** The table holding the flagged column (its rows are the reverse-cascade victims). */
10
+ referencingTable: Table<any>;
11
+ /** Property name of the flagged column on `referencingTable.columns`. */
12
+ columnPropertyName: string;
13
+ /** Position in the registry walk (table order, then column order) — lookups preserve it. */
14
+ order: number;
15
+ } & (
16
+ | { refKind: 'reference' | 'referenceArray' }
17
+ | { refKind: 'dynamicReference'; dynamicRefTableColumnPropertyName: string }
18
+ );
19
+
20
+ /**
21
+ * Derived in-memory reverse-cascade edge index over the static table registry.
22
+ *
23
+ * Maps the referenced-target dimension (`referencedTableName → [(referencingTable,
24
+ * columnProperty, refKind)]`) for every column flagged `reverseCascadeDelete: true`, so
25
+ * `Db.delete` consults an O(edges-for-target) lookup instead of re-scanning every registered
26
+ * table's columns on every delete. Table definitions are the source of truth and are static
27
+ * per process, so the index is built once — lazily on first use — and never invalidated.
28
+ *
29
+ * Edge derivation mirrors the three reference column shapes:
30
+ * - `ReferenceColumn` / `ReferenceArrayColumn` edges are keyed under their declared
31
+ * `referenceTable` — they can only ever point at that one target.
32
+ * - `DynamicReferenceColumn` edges are target-agnostic (the target table name lives in a
33
+ * sibling column per row), so they apply to every target table and are returned for all
34
+ * lookups; the per-row table-name condition stays in the delete query, exactly as the
35
+ * pre-index scan issued it.
36
+ */
37
+ export class ReverseCascadeEdgeIndex {
38
+ private static instance: ReverseCascadeEdgeIndex | undefined;
39
+ private edgesByTargetTable = new Map<string, ReverseCascadeEdge[]>();
40
+ private dynamicEdges: ReverseCascadeEdge[] = [];
41
+
42
+ static get(): ReverseCascadeEdgeIndex {
43
+ if (!ReverseCascadeEdgeIndex.instance) {
44
+ ReverseCascadeEdgeIndex.instance = new ReverseCascadeEdgeIndex();
45
+ }
46
+
47
+ return ReverseCascadeEdgeIndex.instance;
48
+ }
49
+
50
+ /**
51
+ * All reverse-cascade edges that can point at `targetTableName`: the edges keyed under it
52
+ * plus every dynamic (target-agnostic) edge, in the same registry-walk order the pre-index
53
+ * all-tables scan visited them.
54
+ */
55
+ getEdges(targetTableName: string): ReverseCascadeEdge[] {
56
+ const exactEdges = this.edgesByTargetTable.get(targetTableName) ?? [];
57
+ if (exactEdges.length === 0) {
58
+ return this.dynamicEdges;
59
+ }
60
+
61
+ if (this.dynamicEdges.length === 0) {
62
+ return exactEdges;
63
+ }
64
+
65
+ return [...exactEdges, ...this.dynamicEdges].sort((a, b) => a.order - b.order);
66
+ }
67
+
68
+ private constructor() {
69
+ this.build();
70
+ }
71
+
72
+ private build(): void {
73
+ let order = 0;
74
+ for (const referencingTable of getTables()) {
75
+ for (const columnPropertyName in referencingTable.columns) {
76
+ const column = referencingTable.columns[columnPropertyName] as any;
77
+ if (!column || column.reverseCascadeDelete !== true) {
78
+ continue;
79
+ }
80
+
81
+ // DynamicReferenceColumn: identified by its sibling table-name column pointer
82
+ if (typeof column.dynamicRefTableColName === 'string' && column.dynamicRefTableColName.length > 0) {
83
+ const dynamicRefTableColumnPropertyName = getColumnPropertyName(
84
+ referencingTable,
85
+ column.dynamicRefTableColName
86
+ );
87
+ if (!dynamicRefTableColumnPropertyName) {
88
+ continue;
89
+ }
90
+
91
+ this.dynamicEdges.push({
92
+ referencingTable,
93
+ columnPropertyName,
94
+ refKind: 'dynamicReference',
95
+ dynamicRefTableColumnPropertyName,
96
+ order: order++,
97
+ });
98
+ continue;
99
+ }
100
+
101
+ if (typeof column.referenceTable !== 'string') {
102
+ continue;
103
+ }
104
+
105
+ const ctorName = column.constructor?.name;
106
+ const refKind =
107
+ ctorName === 'ReferenceColumn'
108
+ ? 'reference'
109
+ : ctorName === 'ReferenceArrayColumn'
110
+ ? 'referenceArray'
111
+ : undefined;
112
+ if (!refKind) {
113
+ continue;
114
+ }
115
+
116
+ let targetEdges = this.edgesByTargetTable.get(column.referenceTable);
117
+ if (!targetEdges) {
118
+ targetEdges = [];
119
+ this.edgesByTargetTable.set(column.referenceTable, targetEdges);
120
+ }
121
+
122
+ targetEdges.push({ referencingTable, columnPropertyName, refKind, order: order++ });
123
+ }
124
+ }
125
+ }
126
+ }
package/src/Table.ts CHANGED
@@ -87,13 +87,8 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
87
87
  public indexes: { columns: (keyof T)[]; name?: string; unique?: boolean }[] = [];
88
88
  /** When records are deleted, delete records having references pointing to deleted records */
89
89
  public cascadeDeleteReferences: () => { table: string; referenceColumn: string }[] = () => [];
90
- /**
91
- * Options for configuring SourceRecords
92
- * @param doNotDeleteSourceRecordsFromDb if true, the SourceRecordLoader will not delete source records from the db if they no longer exist on the file system
93
- */
94
- public sourceRecordOptions: SourceRecordOptions = {
95
- doNotDeleteSourceRecordsFromDb: false,
96
- };
90
+ /** Options for configuring SourceRecords (see {@link SourceRecordOptions}) */
91
+ public sourceRecordOptions: SourceRecordOptions<T> = {};
97
92
  public auth?: {
98
93
  db?: TableOperationsAuth;
99
94
  service?: TableOperationsAuth;
@@ -180,8 +175,36 @@ export type ColumnOptions = {
180
175
  };
181
176
  };
182
177
 
183
- export type SourceRecordOptions = {
184
- doNotDeleteSourceRecordsFromDb?: boolean;
178
+ export type SourceRecordOptions<T = any> = {
179
+ /**
180
+ * What the source-record sync does with rows it previously loaded from source
181
+ * (`is_loaded_from_source = true`) whose declaration no longer exists:
182
+ * - `'delete'` (default): delete the rows.
183
+ * - `'keep'`: leave the rows untouched (e.g. the migration ledger — run history outlives the
184
+ * migration class).
185
+ * - `{ update }`: apply the patch to the removed rows — e.g. a machine-account table flagging
186
+ * removed accounts `{ update: { status: 'deactivated' } }` instead of deleting them. The
187
+ * patch is applied only to rows whose fields actually differ (idempotent boots), through
188
+ * `Db.update`, so table watchers observe the write. Re-declaring the record reverts the
189
+ * patch via normal drift reversion — removal is reversible in source.
190
+ *
191
+ * Rows never loaded from source are structurally untouchable by every policy: the removed
192
+ * reconcile only ever matches `is_loaded_from_source = true`.
193
+ */
194
+ onSourceRemoved?: 'delete' | 'keep' | { update: Partial<T> };
195
+ /**
196
+ * When set, the sync keys records on this column instead of `id` — matching, adoption, and
197
+ * the removed reconcile all use it. An existing row matched by natural key is ADOPTED in
198
+ * place: it keeps its id (the declared id is used only for fresh inserts — existing ids may
199
+ * be referenced from other tables), gets stamped `is_loaded_from_source = true`, and has its
200
+ * declared fields reverted to source. Drift comparison excludes `id`, so adoption converges.
201
+ *
202
+ * Preconditions, validated at boot by the loader (loud failures):
203
+ * - the column is declared unique (`ColumnOptions.unique` or a single-column unique index in
204
+ * {@link Table.indexes});
205
+ * - every declaration provides the natural key, and no two declarations share a value.
206
+ */
207
+ naturalKey?: keyof T & string;
185
208
  ui?: {
186
209
  hideColumns?: boolean;
187
210
  };
@@ -1,9 +1,33 @@
1
1
  import { Logger } from '@proteinjs/logger';
2
- import { Column, Table, getTables } from '../Table';
2
+ import { QueryBuilder } from '@proteinjs/db-query';
3
+ import { Column, Table, getColumnPropertyName, getTables } from '../Table';
3
4
  import { SchemaOperations, TableChanges } from './SchemaOperations';
4
5
  import { SchemaMetadata } from './SchemaMetadata';
5
6
  import { DbDriver } from '../Db';
6
7
  import { DynamicReferenceColumn, DynamicReferenceTableNameColumn } from '../Columns';
8
+ import { StatementConfigFactory } from '../StatementConfigFactory';
9
+
10
+ /**
11
+ * Thrown by the pre-sync duplicate check when a unique index is about to be added to an existing
12
+ * table whose data already violates it. Without the preflight the backend's index backfill fails
13
+ * with an opaque driver error mid-boot; this names the table, the columns, and sample offending
14
+ * values so the operator can resolve the duplicates and boot again.
15
+ */
16
+ export class DuplicateValuesForUniqueIndexError extends Error {
17
+ constructor(tableName: string, columnNames: string[], duplicateValues: unknown[]) {
18
+ super(
19
+ `(${tableName}) Cannot create a unique index on (${columnNames.join(', ')}): existing rows hold duplicate ` +
20
+ `values. Resolve the duplicates, then boot again. Duplicate values (up to ` +
21
+ `${DuplicateValuesForUniqueIndexError.MAX_REPORTED_VALUES} shown): ${JSON.stringify(duplicateValues)}`
22
+ );
23
+ this.name = 'DuplicateValuesForUniqueIndexError';
24
+ // ES5 down-leveled `extends Error` loses the subclass prototype — restore it so
25
+ // `instanceof DuplicateValuesForUniqueIndexError` holds for catchers.
26
+ Object.setPrototypeOf(this, DuplicateValuesForUniqueIndexError.prototype);
27
+ }
28
+
29
+ static readonly MAX_REPORTED_VALUES = 20;
30
+ }
7
31
 
8
32
  const getEnvVar = (key: string): string | undefined =>
9
33
  typeof process !== 'undefined' && process.env ? process.env[key] : undefined;
@@ -22,6 +46,7 @@ export class TableManager {
22
46
  private static readonly RECONCILE_VERIFY_ATTEMPTS = 5;
23
47
  private static readonly RECONCILE_VERIFY_RETRY_DELAY_MS = 200;
24
48
  private logger = new Logger({ name: this.constructor.name, logLevel: getEnvVar('DB_LOG_LEVEL') as any });
49
+ private dbDriver: DbDriver;
25
50
  public columnTypeFactory: ColumnTypeFactory;
26
51
  public schemaOperations: SchemaOperations;
27
52
  public schemaMetadata: SchemaMetadata;
@@ -32,6 +57,7 @@ export class TableManager {
32
57
  schemaOperations: SchemaOperations,
33
58
  schemaMetadata?: SchemaMetadata
34
59
  ) {
60
+ this.dbDriver = dbDriver;
35
61
  this.columnTypeFactory = columnTypeFactory;
36
62
  this.schemaOperations = schemaOperations;
37
63
  this.schemaMetadata = schemaMetadata ? schemaMetadata : new SchemaMetadata(dbDriver);
@@ -95,6 +121,7 @@ export class TableManager {
95
121
  private async alterTableIfChanged(table: Table<any>): Promise<void> {
96
122
  const tableChanges = await this.getTableChanges(table);
97
123
  if (this.shouldAlterTable(tableChanges)) {
124
+ await this.preflightUniqueIndexAdditions(table, tableChanges);
98
125
  this.logger.info({ message: `Altering table: ${table.name}` });
99
126
  try {
100
127
  await this.schemaOperations.alterTable(table, tableChanges);
@@ -431,4 +458,74 @@ export class TableManager {
431
458
 
432
459
  return { indexesToCreate, indexesToDrop };
433
460
  }
461
+
462
+ /**
463
+ * Pre-sync duplicate check: before an alter adds a unique index (via `ColumnOptions.unique` or
464
+ * a unique entry in `Table.indexes`) to an EXISTING table, verify the data can satisfy it.
465
+ * Existing duplicates would otherwise surface as an opaque backend backfill failure mid-boot;
466
+ * this throws {@link DuplicateValuesForUniqueIndexError} naming the table, columns, and sample
467
+ * offending values instead. Runs only on the alter path (a freshly created table has no rows),
468
+ * and only when a unique index is actually being added — steady-state boots never pay for it.
469
+ */
470
+ private async preflightUniqueIndexAdditions(table: Table<any>, tableChanges: TableChanges): Promise<void> {
471
+ const uniqueAdditions: string[][] = tableChanges.columnsWithUniqueConstraintsToCreate.map((columnName) => [
472
+ columnName,
473
+ ]);
474
+ for (const index of tableChanges.indexesToCreate) {
475
+ if (index.unique) {
476
+ uniqueAdditions.push(Array.isArray(index.columns) ? index.columns : [index.columns]);
477
+ }
478
+ }
479
+
480
+ for (const columnNames of uniqueAdditions) {
481
+ await this.assertNoDuplicateValues(table, columnNames);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Read the key column(s) of every row (NULLs excluded — a unique index admits them) and group
487
+ * in memory. A full-column read is acceptable here: it runs once, at boot, only when a NEW
488
+ * unique index is being added over existing data.
489
+ */
490
+ private async assertNoDuplicateValues(table: Table<any>, columnNames: string[]): Promise<void> {
491
+ const propertyNames = columnNames.map((columnName) => {
492
+ const propertyName = getColumnPropertyName(table, columnName);
493
+ if (!propertyName) {
494
+ throw new Error(`(${table.name}) Unable to resolve a column property for column: ${columnName}`);
495
+ }
496
+
497
+ return propertyName;
498
+ });
499
+
500
+ const qb = new QueryBuilder(table.name).select({ fields: propertyNames });
501
+ for (const propertyName of propertyNames) {
502
+ qb.condition({ field: propertyName, operator: 'IS NOT NULL' });
503
+ }
504
+
505
+ const statementConfigFactory = new StatementConfigFactory(this.dbDriver.getDbName());
506
+ const rows = await this.dbDriver.runQuery((config) => qb.toSql(statementConfigFactory.getStatementConfig(config)));
507
+
508
+ const counts = new Map<string, { count: number; values: unknown[] }>();
509
+ for (const row of rows) {
510
+ const values = columnNames.map((columnName) => (row as any)[columnName]);
511
+ const key = JSON.stringify(values);
512
+ const entry = counts.get(key) ?? { count: 0, values };
513
+ entry.count += 1;
514
+ counts.set(key, entry);
515
+ }
516
+
517
+ const duplicates: unknown[] = [];
518
+ for (const entry of Array.from(counts.values())) {
519
+ if (entry.count > 1) {
520
+ duplicates.push(entry.values.length === 1 ? entry.values[0] : entry.values);
521
+ if (duplicates.length >= DuplicateValuesForUniqueIndexError.MAX_REPORTED_VALUES) {
522
+ break;
523
+ }
524
+ }
525
+ }
526
+
527
+ if (duplicates.length > 0) {
528
+ throw new DuplicateValuesForUniqueIndexError(table.name, columnNames, duplicates);
529
+ }
530
+ }
434
531
  }