@proteinjs/db 1.33.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +15 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +2 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +33 -62
- package/dist/src/Db.js.map +1 -1
- package/dist/src/ReverseCascadeEdgeIndex.d.ts +51 -0
- package/dist/src/ReverseCascadeEdgeIndex.d.ts.map +1 -0
- package/dist/src/ReverseCascadeEdgeIndex.js +108 -0
- package/dist/src/ReverseCascadeEdgeIndex.js.map +1 -0
- package/dist/src/Table.d.ts +34 -7
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js +2 -7
- package/dist/src/Table.js.map +1 -1
- package/dist/src/schema/TableManager.d.ts +26 -0
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +154 -13
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +18 -1
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +187 -68
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +1 -6
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +2 -1
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/index.d.ts +2 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.js +59 -0
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts +11 -0
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -0
- package/dist/test/reusable/SourceRecordSyncTests.js +483 -0
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -0
- package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
- package/dist/test/util/DbTestEnvironment.js +2 -1
- package/dist/test/util/DbTestEnvironment.js.map +1 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +46 -0
- package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.js +51 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
- package/dist/test/util/tables/crudTestTables.d.ts +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +82 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +90 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -0
- package/dist/test/util/tables/transactionTestTables.d.ts +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +15 -1
- package/index.ts +4 -0
- package/package.json +2 -2
- package/src/Db.ts +32 -57
- package/src/ReverseCascadeEdgeIndex.ts +126 -0
- package/src/Table.ts +32 -9
- package/src/schema/TableManager.ts +98 -1
- package/src/source/SourceRecordLoader.ts +133 -23
- package/src/tables/MigrationTable.ts +3 -2
- package/test/index.ts +2 -0
- package/test/reusable/CascadeDeleteTests.ts +46 -0
- package/test/reusable/SourceRecordSyncTests.ts +288 -0
- package/test/util/DbTestEnvironment.ts +2 -0
- package/test/util/tables/cascadeDeleteTestTables.ts +48 -0
- 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.
|
|
3
|
+
"version": "1.34.0",
|
|
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": "
|
|
69
|
+
"gitHead": "7a301275151aee8040fd52e15e3fe950a6863783"
|
|
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
|
|
397
|
+
for (const edge of ReverseCascadeEdgeIndex.get().getEdges(table.name)) {
|
|
398
|
+
const { referencingTable, columnPropertyName } = edge;
|
|
396
399
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
+
if (edge.refKind === 'dynamicReference') {
|
|
401
|
+
const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
|
|
402
|
+
await this.addColumnQueries(referencingTable, qb, 'read');
|
|
400
403
|
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|