@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
|
@@ -2,12 +2,12 @@ import { Logger } from '@proteinjs/logger';
|
|
|
2
2
|
import { QueryBuilder } from '@proteinjs/db-query';
|
|
3
3
|
import { getSourceRecordLoaders, SourceRecord, getSourceRecordTables } from './SourceRecord';
|
|
4
4
|
import { Table } from '../Table';
|
|
5
|
-
import { getDbAsSystem } from '../Db';
|
|
5
|
+
import { Db, getDbAsSystem } from '../Db';
|
|
6
6
|
import { SourceRecordRepo } from './SourceRecordRepo';
|
|
7
7
|
import { RecordSerializer } from '../Record';
|
|
8
8
|
|
|
9
9
|
type SourceRecordsMap = {
|
|
10
|
-
[tableName: string]: { table: Table<any>; records: Omit<SourceRecord, 'created' | 'updated'>[]
|
|
10
|
+
[tableName: string]: { table: Table<any>; records: Omit<SourceRecord, 'created' | 'updated'>[] };
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export class SourceRecordLoader {
|
|
@@ -17,26 +17,37 @@ export class SourceRecordLoader {
|
|
|
17
17
|
const sourceRecordsMap = await this.getSourceRecordsMap();
|
|
18
18
|
const db = getDbAsSystem();
|
|
19
19
|
for (const tableName in sourceRecordsMap) {
|
|
20
|
+
const { table, records } = sourceRecordsMap[tableName];
|
|
21
|
+
// 'id' unless the table declares a natural key (validated: unique-indexed, present and
|
|
22
|
+
// unambiguous across declarations).
|
|
23
|
+
const keyProperty = this.validateSyncKey(table, records);
|
|
24
|
+
const declaredKeys = records.map((record) => (record as any)[keyProperty]);
|
|
25
|
+
const { deleteCount, removedUpdateCount } = await this.reconcileRemoved(db, table, keyProperty, declaredKeys);
|
|
26
|
+
|
|
20
27
|
let insertCount = 0;
|
|
21
28
|
let updateCount = 0;
|
|
22
29
|
let unchangedCount = 0;
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
const sourceRecordIds = sourceRecordsMap[tableName].recordIds;
|
|
26
|
-
if (!table.sourceRecordOptions.doNotDeleteSourceRecordsFromDb) {
|
|
27
|
-
const qb = QueryBuilder.fromObject<SourceRecord>({ isLoadedFromSource: true }, table.name);
|
|
28
|
-
if (sourceRecordIds.length > 0) {
|
|
29
|
-
qb.condition({ field: 'id', operator: 'NOT IN', value: sourceRecordIds });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
deleteCount = await db.delete(table, qb);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const sourceRecords = sourceRecordsMap[tableName].records;
|
|
36
|
-
for (let sourceRecord of sourceRecords) {
|
|
30
|
+
let adoptedCount = 0;
|
|
31
|
+
for (let sourceRecord of records) {
|
|
37
32
|
sourceRecord.isLoadedFromSource = true;
|
|
38
|
-
const existingRecord = await db.get(table, {
|
|
33
|
+
const existingRecord = await db.get(table, { [keyProperty]: (sourceRecord as any)[keyProperty] });
|
|
39
34
|
if (existingRecord) {
|
|
35
|
+
if (existingRecord.id !== sourceRecord.id) {
|
|
36
|
+
// Adopt in place: the existing row keeps its id — other tables may reference it.
|
|
37
|
+
// The declared id is only ever used for fresh inserts.
|
|
38
|
+
sourceRecord = { ...sourceRecord, id: existingRecord.id };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (existingRecord.isLoadedFromSource !== true) {
|
|
42
|
+
// A pre-existing (runtime-created) row is being taken over by a declaration —
|
|
43
|
+
// deliberate, but loud: a declaration asserts ownership of the row's identity.
|
|
44
|
+
adoptedCount += 1;
|
|
45
|
+
this.logger.info({
|
|
46
|
+
message: `(${table.name}) Adopting existing record into source ownership`,
|
|
47
|
+
obj: { [keyProperty]: (sourceRecord as any)[keyProperty], id: existingRecord.id },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
if (await this.hasChanges(table, sourceRecord, existingRecord)) {
|
|
41
52
|
await db.update(table, sourceRecord);
|
|
42
53
|
updateCount += 1;
|
|
@@ -49,24 +60,125 @@ export class SourceRecordLoader {
|
|
|
49
60
|
insertCount += 1;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
// Registered under the DB id (= the adopted id when an existing row matched by natural key).
|
|
52
64
|
new SourceRecordRepo().loadSourceRecord(table.name, sourceRecord as any);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
this.logger.info({
|
|
56
|
-
message: `(${table.name}) Loaded ${
|
|
68
|
+
message: `(${table.name}) Loaded ${records.length} ${records.length == 1 ? 'record' : 'records'} from source`,
|
|
57
69
|
obj: {
|
|
58
70
|
inserts: insertCount,
|
|
59
71
|
updates: updateCount,
|
|
60
72
|
unchanged: unchangedCount,
|
|
73
|
+
adopted: adoptedCount,
|
|
61
74
|
deletes: deleteCount,
|
|
75
|
+
removedUpdates: removedUpdateCount,
|
|
62
76
|
},
|
|
63
77
|
});
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The removed-reconcile leg: rows previously loaded from source whose declaration no longer
|
|
83
|
+
* exists (`is_loaded_from_source = true AND <key> NOT IN declared`), handled per the table's
|
|
84
|
+
* `onSourceRemoved` policy — delete (default), keep, or update with a patch. The update leg
|
|
85
|
+
* applies the patch only to rows whose fields actually differ (idempotent boots), through
|
|
86
|
+
* `Db.update` so table watchers observe each write.
|
|
87
|
+
*/
|
|
88
|
+
private async reconcileRemoved(
|
|
89
|
+
db: Db,
|
|
90
|
+
table: Table<any>,
|
|
91
|
+
keyProperty: string,
|
|
92
|
+
declaredKeys: unknown[]
|
|
93
|
+
): Promise<{ deleteCount: number; removedUpdateCount: number }> {
|
|
94
|
+
const policy = table.sourceRecordOptions.onSourceRemoved ?? 'delete';
|
|
95
|
+
if (policy === 'keep') {
|
|
96
|
+
return { deleteCount: 0, removedUpdateCount: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const qb = QueryBuilder.fromObject<SourceRecord>({ isLoadedFromSource: true }, table.name);
|
|
100
|
+
if (declaredKeys.length > 0) {
|
|
101
|
+
qb.condition({ field: keyProperty as any, operator: 'NOT IN', value: declaredKeys as any });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (policy === 'delete') {
|
|
105
|
+
return { deleteCount: await db.delete(table, qb), removedUpdateCount: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let removedUpdateCount = 0;
|
|
109
|
+
const removedRecords = await db.query(table, qb);
|
|
110
|
+
for (const removedRecord of removedRecords) {
|
|
111
|
+
if (await this.hasChanges(table, policy.update, removedRecord)) {
|
|
112
|
+
await db.update(table, { id: removedRecord.id, ...policy.update });
|
|
113
|
+
removedUpdateCount += 1;
|
|
114
|
+
this.logger.info({
|
|
115
|
+
message: `(${table.name}) Applied onSourceRemoved update to record removed from source`,
|
|
116
|
+
obj: { id: removedRecord.id, [keyProperty]: (removedRecord as any)[keyProperty] },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { deleteCount: 0, removedUpdateCount };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve and validate the property the sync keys on: `id` unless the table declares
|
|
126
|
+
* `sourceRecordOptions.naturalKey`. A natural key must be schema-unique (a `ColumnOptions.unique`
|
|
127
|
+
* column or a single-column unique index in `Table.indexes`), present on every declaration, and
|
|
128
|
+
* unambiguous across declarations — each violation fails boot loudly by name.
|
|
129
|
+
*/
|
|
130
|
+
private validateSyncKey(table: Table<any>, records: Omit<SourceRecord, 'created' | 'updated'>[]): string {
|
|
131
|
+
const naturalKey = table.sourceRecordOptions.naturalKey;
|
|
132
|
+
if (!naturalKey) {
|
|
133
|
+
return 'id';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const column = (table.columns as any)[naturalKey];
|
|
137
|
+
if (!column) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`(${table.name}) sourceRecordOptions.naturalKey '${naturalKey}' is not a column property on the table`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const uniqueByColumn = column.options?.unique?.unique === true;
|
|
144
|
+
const uniqueByIndex = (table.indexes ?? []).some(
|
|
145
|
+
(index) => index.unique === true && index.columns.length === 1 && String(index.columns[0]) === naturalKey
|
|
146
|
+
);
|
|
147
|
+
if (!uniqueByColumn && !uniqueByIndex) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`(${table.name}) sourceRecordOptions.naturalKey '${naturalKey}' requires the column to be unique — ` +
|
|
150
|
+
`declare ColumnOptions.unique on it (or a single-column unique index in Table.indexes) so ` +
|
|
151
|
+
`natural-key adoption cannot match ambiguously`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const seen = new Map<unknown, true>();
|
|
156
|
+
for (const record of records) {
|
|
157
|
+
const value = (record as any)[naturalKey];
|
|
158
|
+
if (value === undefined || value === null) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`(${table.name}) A source record declaration is missing its natural key '${naturalKey}' (declared id: '${record.id}')`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (seen.has(value)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`(${table.name}) Two source record declarations share the natural key '${naturalKey}' = '${value}' — ` +
|
|
167
|
+
`declarations must be unique by natural key`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
seen.set(value, true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return naturalKey;
|
|
175
|
+
}
|
|
176
|
+
|
|
67
177
|
/**
|
|
68
178
|
* Compare source record fields against the existing DB record to detect actual changes.
|
|
69
|
-
* Only fields present on the source record are compared
|
|
179
|
+
* Only fields present on the source record are compared, ignoring `id`, `created`, `updated`
|
|
180
|
+
* (`id` because natural-key adoption keeps the existing row's id — the declared id must not
|
|
181
|
+
* register as perpetual drift; `Db.update` never writes id anyway).
|
|
70
182
|
* Uses serialization to normalize values (e.g. Reference objects, Moment, JSON) before
|
|
71
183
|
* comparison, then delegates to {@link findMismatchPath}.
|
|
72
184
|
*
|
|
@@ -79,7 +191,7 @@ export class SourceRecordLoader {
|
|
|
79
191
|
const serializedSource = await serializer.serialize(sourceRecord);
|
|
80
192
|
const serializedExisting = await serializer.serialize(existingRecord);
|
|
81
193
|
for (const columnName in serializedSource) {
|
|
82
|
-
if (columnName === 'created' || columnName === 'updated') {
|
|
194
|
+
if (columnName === 'id' || columnName === 'created' || columnName === 'updated') {
|
|
83
195
|
continue;
|
|
84
196
|
}
|
|
85
197
|
|
|
@@ -98,7 +210,7 @@ export class SourceRecordLoader {
|
|
|
98
210
|
const sourceRecordTables = getSourceRecordTables();
|
|
99
211
|
for (const sourceRecordTable of sourceRecordTables) {
|
|
100
212
|
if (!sourceRecordsMap[sourceRecordTable.name]) {
|
|
101
|
-
sourceRecordsMap[sourceRecordTable.name] = { table: sourceRecordTable, records: []
|
|
213
|
+
sourceRecordsMap[sourceRecordTable.name] = { table: sourceRecordTable, records: [] };
|
|
102
214
|
}
|
|
103
215
|
}
|
|
104
216
|
|
|
@@ -108,12 +220,10 @@ export class SourceRecordLoader {
|
|
|
108
220
|
sourceRecordsMap[sourceRecordLoader.table.name] = {
|
|
109
221
|
table: sourceRecordLoader.table,
|
|
110
222
|
records: [],
|
|
111
|
-
recordIds: [],
|
|
112
223
|
};
|
|
113
224
|
}
|
|
114
225
|
|
|
115
226
|
sourceRecordsMap[sourceRecordLoader.table.name].records.push(sourceRecordLoader.record);
|
|
116
|
-
sourceRecordsMap[sourceRecordLoader.table.name].recordIds.push(sourceRecordLoader.record.id);
|
|
117
227
|
}
|
|
118
228
|
|
|
119
229
|
return sourceRecordsMap;
|
|
@@ -56,8 +56,9 @@ export class MigrationTable extends Table<Migration> {
|
|
|
56
56
|
duration: new StringColumn('duration'),
|
|
57
57
|
output: new ObjectColumn('output'),
|
|
58
58
|
});
|
|
59
|
-
public sourceRecordOptions = {
|
|
60
|
-
|
|
59
|
+
public sourceRecordOptions: Table<Migration>['sourceRecordOptions'] = {
|
|
60
|
+
// The ledger outlives the migration class: run history stays when the source is deleted.
|
|
61
|
+
onSourceRemoved: 'keep',
|
|
61
62
|
ui: {
|
|
62
63
|
hideColumns: true,
|
|
63
64
|
},
|
package/test/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './reusable/TransactionTests';
|
|
|
6
6
|
export * from './reusable/CascadeDeleteTests';
|
|
7
7
|
export * from './reusable/PreloadReferencesTests';
|
|
8
8
|
export * from './reusable/RecordIteratorTests';
|
|
9
|
+
export * from './reusable/SourceRecordSyncTests';
|
|
9
10
|
|
|
10
11
|
export * from './util/testUser';
|
|
11
12
|
|
|
@@ -14,5 +15,6 @@ export * from './util/tables/columnTypesTestTables';
|
|
|
14
15
|
export * from './util/tables/crudTestTables';
|
|
15
16
|
export * from './util/tables/dynamicReferenceColumnTestTables';
|
|
16
17
|
export * from './util/tables/recordIteratorTestTables';
|
|
18
|
+
export * from './util/tables/sourceRecordSyncTestTables';
|
|
17
19
|
export * from './util/tables/tableManagerTestTables';
|
|
18
20
|
export * from './util/tables/transactionTestTables';
|
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
MemberArrRev,
|
|
25
25
|
MemberDyn,
|
|
26
26
|
MemberRef,
|
|
27
|
+
Pilot,
|
|
27
28
|
Post,
|
|
29
|
+
Robot,
|
|
28
30
|
Worker,
|
|
29
31
|
} from '../util/tables/cascadeDeleteTestTables';
|
|
30
32
|
|
|
@@ -191,6 +193,50 @@ export const cascadeDeleteTests = (
|
|
|
191
193
|
const remainingTasks = await db.query(taskTable, {});
|
|
192
194
|
expect(remainingTasks.length).toBe(0);
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
test('DynamicReferenceColumn: edge applies to every target table, scoped per row', async () => {
|
|
198
|
+
const pilotTable = cascadeDeleteTestTables.Pilot;
|
|
199
|
+
const robotTable = cascadeDeleteTestTables.Robot;
|
|
200
|
+
const missionTable = cascadeDeleteTestTables.Mission;
|
|
201
|
+
|
|
202
|
+
const pilot = await db.insert(pilotTable, { name: 'Pia Pilot' });
|
|
203
|
+
const robot = await db.insert(robotTable, { name: 'Rusty Robot' });
|
|
204
|
+
const pilotMission = await db.insert(missionTable, {
|
|
205
|
+
title: 'Fly Cargo',
|
|
206
|
+
assigneeTableName: pilotTable.name,
|
|
207
|
+
assigneeRef: new Reference<Pilot>(pilotTable.name, pilot.id),
|
|
208
|
+
});
|
|
209
|
+
await db.insert(missionTable, {
|
|
210
|
+
title: 'Weld Hull',
|
|
211
|
+
assigneeTableName: robotTable.name,
|
|
212
|
+
assigneeRef: new Reference<Robot>(robotTable.name, robot.id),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Deleting the robot deletes only the robot-assigned mission; the pilot's survives
|
|
216
|
+
const robotDelQb = new QueryBuilder<Robot>(robotTable.name).condition({
|
|
217
|
+
field: 'id',
|
|
218
|
+
operator: '=',
|
|
219
|
+
value: robot.id,
|
|
220
|
+
});
|
|
221
|
+
const robotsDeleted = await db.delete(robotTable, robotDelQb);
|
|
222
|
+
expect(robotsDeleted).toBe(1);
|
|
223
|
+
|
|
224
|
+
const missionsAfterRobot = await db.query(missionTable, {});
|
|
225
|
+
expect(missionsAfterRobot.length).toBe(1);
|
|
226
|
+
expect(missionsAfterRobot[0].id).toBe(pilotMission.id);
|
|
227
|
+
|
|
228
|
+
// The same column reverse-cascades off the other target table too
|
|
229
|
+
const pilotDelQb = new QueryBuilder<Pilot>(pilotTable.name).condition({
|
|
230
|
+
field: 'id',
|
|
231
|
+
operator: '=',
|
|
232
|
+
value: pilot.id,
|
|
233
|
+
});
|
|
234
|
+
const pilotsDeleted = await db.delete(pilotTable, pilotDelQb);
|
|
235
|
+
expect(pilotsDeleted).toBe(1);
|
|
236
|
+
|
|
237
|
+
const missionsAfterPilot = await db.query(missionTable, {});
|
|
238
|
+
expect(missionsAfterPilot.length).toBe(0);
|
|
239
|
+
});
|
|
194
240
|
});
|
|
195
241
|
};
|
|
196
242
|
};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import {
|
|
3
|
+
Db,
|
|
4
|
+
DbDriver,
|
|
5
|
+
DuplicateValuesForUniqueIndexError,
|
|
6
|
+
SourceRecord,
|
|
7
|
+
SourceRecordRepo,
|
|
8
|
+
StringColumn,
|
|
9
|
+
Table,
|
|
10
|
+
TableWatcher,
|
|
11
|
+
getDbAsSystem,
|
|
12
|
+
withSourceRecordColumns,
|
|
13
|
+
} from '@proteinjs/db';
|
|
14
|
+
import type { DefaultTransactionContextFactory } from '@proteinjs/db';
|
|
15
|
+
// Relative on purpose: the class shares its name with the SourceRecordLoader declaration
|
|
16
|
+
// interface exported from the package index, so it is not index-exported.
|
|
17
|
+
import { SourceRecordLoader } from '../../src/source/SourceRecordLoader';
|
|
18
|
+
import { TableWatcherRunner } from '../../src/TableWatcherRunner';
|
|
19
|
+
import { DbTestEnvironment } from '../util/DbTestEnvironment';
|
|
20
|
+
import {
|
|
21
|
+
DupePreflightTable,
|
|
22
|
+
DupePreflightUniqueEmailTable,
|
|
23
|
+
SyncMachineAccount,
|
|
24
|
+
sourceRecordSyncTestTables,
|
|
25
|
+
} from '../util/tables/sourceRecordSyncTestTables';
|
|
26
|
+
|
|
27
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
28
|
+
type DbStatics = { defaultDbDriver?: DbDriver };
|
|
29
|
+
type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
|
|
30
|
+
|
|
31
|
+
/** Records afterUpdate payloads on the machine table — the watcher-visibility contract probe. */
|
|
32
|
+
class RecordingMachineAccountWatcher implements TableWatcher<SyncMachineAccount> {
|
|
33
|
+
static updates: Partial<SyncMachineAccount>[] = [];
|
|
34
|
+
|
|
35
|
+
name(): string {
|
|
36
|
+
return this.constructor.name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
table(): Table<SyncMachineAccount> {
|
|
40
|
+
return sourceRecordSyncTestTables.SyncMachineAccount;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async afterUpdate<T extends SyncMachineAccount>(recordUpdateCount: number, record: Partial<T>): Promise<void> {
|
|
44
|
+
RecordingMachineAccountWatcher.updates.push({ ...record });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Emulator-backed outcome tests for the source-record sync's mixed-table semantics:
|
|
50
|
+
* declare-only ownership (human rows structurally untouchable), natural-key adoption
|
|
51
|
+
* (existing row keeps its id and runtime fields), `onSourceRemoved` policies (flag-not-delete
|
|
52
|
+
* through `Db.update` so watchers fire; default delete unchanged), boot-time natural-key
|
|
53
|
+
* validation, and the unique-index duplicate preflight.
|
|
54
|
+
*/
|
|
55
|
+
export const sourceRecordSyncTests = (
|
|
56
|
+
driver: DbDriver,
|
|
57
|
+
transactionContextFactory: DefaultTransactionContextFactory,
|
|
58
|
+
dropTable: (table: Table<any>) => Promise<void>
|
|
59
|
+
) => {
|
|
60
|
+
return () => {
|
|
61
|
+
const testEnv = new DbTestEnvironment(driver, dropTable);
|
|
62
|
+
const machineTable = sourceRecordSyncTestTables.SyncMachineAccount;
|
|
63
|
+
const defaultPolicyTable = sourceRecordSyncTestTables.SyncDefaultPolicy;
|
|
64
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
65
|
+
let originalWatchers: unknown[] | undefined;
|
|
66
|
+
|
|
67
|
+
/** Seed the boot declarations and run the sync — one boot of `Db.init`'s loader leg. */
|
|
68
|
+
const boot = async (declarations: { table: Table<any>; record: any }[]) => {
|
|
69
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
|
|
70
|
+
await new SourceRecordLoader().load();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const machineDeclaration = (record: Partial<SyncMachineAccount> & { id: string; email: string }) => ({
|
|
74
|
+
table: machineTable,
|
|
75
|
+
record: { status: 'active', ...record },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const machineRows = async (query: Partial<SyncMachineAccount> = {}) =>
|
|
79
|
+
await getDbAsSystem().query(machineTable, query);
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
// The suite drives the loader exactly the way Db.init does — through the DEFAULT driver —
|
|
83
|
+
// so the driver under test must be the resolvable default (and any previously cached
|
|
84
|
+
// default must not leak in from earlier suites in this process).
|
|
85
|
+
objectCache()['@proteinjs/db/DefaultDbDriverFactory'] = [{ getDbDriver: () => driver }];
|
|
86
|
+
objectCache()['@proteinjs/db/DefaultTransactionContextFactory'] = [transactionContextFactory];
|
|
87
|
+
(Db as unknown as DbStatics).defaultDbDriver = undefined;
|
|
88
|
+
// Only the recording watcher observes this run — and the runner's static map must be
|
|
89
|
+
// rebuilt from the seeded cache rather than whatever an earlier Db construction cached.
|
|
90
|
+
originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
|
|
91
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new RecordingMachineAccountWatcher()];
|
|
92
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
93
|
+
await testEnv.beforeAll();
|
|
94
|
+
}, 60000);
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await testEnv.afterAll();
|
|
98
|
+
delete objectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
99
|
+
delete objectCache()['@proteinjs/db/DefaultDbDriverFactory'];
|
|
100
|
+
delete objectCache()['@proteinjs/db/DefaultTransactionContextFactory'];
|
|
101
|
+
if (originalWatchers) {
|
|
102
|
+
objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
|
|
103
|
+
} else {
|
|
104
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
105
|
+
}
|
|
106
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
107
|
+
(Db as unknown as DbStatics).defaultDbDriver = undefined;
|
|
108
|
+
}, 60000);
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
const db = getDbAsSystem();
|
|
112
|
+
await db.delete(machineTable, {});
|
|
113
|
+
await db.delete(defaultPolicyTable, {});
|
|
114
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('declare-only on a mixed table: declared records insert; human rows are untouched', async () => {
|
|
118
|
+
const db = getDbAsSystem();
|
|
119
|
+
const human = await db.insert(machineTable, {
|
|
120
|
+
email: 'human@test.local',
|
|
121
|
+
displayName: 'A human',
|
|
122
|
+
runtimeNote: 'human-owned',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local', displayName: 'Machine' })]);
|
|
126
|
+
|
|
127
|
+
const machine = await db.get(machineTable, { email: 'machine@test.local' });
|
|
128
|
+
expect(machine).toMatchObject({
|
|
129
|
+
id: 'machine-1',
|
|
130
|
+
displayName: 'Machine',
|
|
131
|
+
status: 'active',
|
|
132
|
+
isLoadedFromSource: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The human row survives the boot byte-for-byte: not deleted, not flagged, fields intact.
|
|
136
|
+
const humanAfter = await db.get(machineTable, { id: human.id });
|
|
137
|
+
expect(humanAfter).toBeDefined();
|
|
138
|
+
expect(humanAfter.isLoadedFromSource).toBeFalsy();
|
|
139
|
+
expect(humanAfter).toMatchObject({ displayName: 'A human', runtimeNote: 'human-owned' });
|
|
140
|
+
expect(humanAfter.status).toBeFalsy();
|
|
141
|
+
expect(await machineRows()).toHaveLength(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('declared fields revert on boot; runtime-owned fields survive; converged boots write nothing', async () => {
|
|
145
|
+
const declarations = [
|
|
146
|
+
machineDeclaration({ id: 'machine-1', email: 'machine@test.local', displayName: 'Machine' }),
|
|
147
|
+
];
|
|
148
|
+
await boot(declarations);
|
|
149
|
+
|
|
150
|
+
// Runtime drift on a declared field + a runtime-owned write (the credential stand-in).
|
|
151
|
+
const db = getDbAsSystem();
|
|
152
|
+
await db.update(machineTable, { id: 'machine-1', displayName: 'Drifted', runtimeNote: 'provisioned' });
|
|
153
|
+
|
|
154
|
+
await boot(declarations);
|
|
155
|
+
const reverted = await db.get(machineTable, { id: 'machine-1' });
|
|
156
|
+
expect(reverted).toMatchObject({ displayName: 'Machine', runtimeNote: 'provisioned' });
|
|
157
|
+
|
|
158
|
+
// A converged boot is a no-op: the row's `updated` stamp does not churn.
|
|
159
|
+
const stampBefore = reverted.updated.valueOf();
|
|
160
|
+
await boot(declarations);
|
|
161
|
+
const afterIdleBoot = await db.get(machineTable, { id: 'machine-1' });
|
|
162
|
+
expect(afterIdleBoot.updated.valueOf()).toBe(stampBefore);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('natural-key adoption: an existing row is adopted in place — id and runtime fields preserved', async () => {
|
|
166
|
+
const db = getDbAsSystem();
|
|
167
|
+
// The hand-made bridge row: env-random id, runtime-provisioned fields, never flagged.
|
|
168
|
+
const handMade = await db.insert(machineTable, {
|
|
169
|
+
email: 'bridge@test.local',
|
|
170
|
+
displayName: 'Hand-made bridge',
|
|
171
|
+
runtimeNote: 'the-password-hash',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await boot([machineDeclaration({ id: 'declared-id', email: 'bridge@test.local', displayName: 'Ops bridge' })]);
|
|
175
|
+
|
|
176
|
+
const rows = await machineRows({ email: 'bridge@test.local' });
|
|
177
|
+
expect(rows).toHaveLength(1);
|
|
178
|
+
// Adopted: the existing id survives (scoped rows reference it); declared fields reverted;
|
|
179
|
+
// runtime fields (the credential) preserved; the row is now source-owned.
|
|
180
|
+
expect(rows[0]).toMatchObject({
|
|
181
|
+
id: handMade.id,
|
|
182
|
+
displayName: 'Ops bridge',
|
|
183
|
+
status: 'active',
|
|
184
|
+
runtimeNote: 'the-password-hash',
|
|
185
|
+
isLoadedFromSource: true,
|
|
186
|
+
});
|
|
187
|
+
expect(await db.get(machineTable, { id: 'declared-id' })).toBeUndefined();
|
|
188
|
+
|
|
189
|
+
// The in-process repo registers the record under the ADOPTED id, not the declared one.
|
|
190
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, handMade.id)).toBeDefined();
|
|
191
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, 'declared-id')).toBeUndefined();
|
|
192
|
+
|
|
193
|
+
// Adoption converges: the id difference is not perpetual drift.
|
|
194
|
+
const stampBefore = rows[0].updated.valueOf();
|
|
195
|
+
await boot([machineDeclaration({ id: 'declared-id', email: 'bridge@test.local', displayName: 'Ops bridge' })]);
|
|
196
|
+
const afterIdleBoot = await db.get(machineTable, { id: handMade.id });
|
|
197
|
+
expect(afterIdleBoot.updated.valueOf()).toBe(stampBefore);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('onSourceRemoved update: removed rows are flagged through Db.update (watchers fire), never deleted; re-declaring reverts', async () => {
|
|
201
|
+
const db = getDbAsSystem();
|
|
202
|
+
const human = await db.insert(machineTable, { email: 'human@test.local', displayName: 'A human' });
|
|
203
|
+
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local' })]);
|
|
204
|
+
|
|
205
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
206
|
+
await boot([]);
|
|
207
|
+
|
|
208
|
+
const removed = await db.get(machineTable, { id: 'machine-1' });
|
|
209
|
+
expect(removed).toBeDefined();
|
|
210
|
+
expect(removed.status).toBe('deactivated');
|
|
211
|
+
expect(removed.isLoadedFromSource).toBe(true);
|
|
212
|
+
// The write went through Db.update — table watchers observed the deactivation.
|
|
213
|
+
expect(RecordingMachineAccountWatcher.updates).toHaveLength(1);
|
|
214
|
+
expect(RecordingMachineAccountWatcher.updates[0]).toMatchObject({ id: 'machine-1', status: 'deactivated' });
|
|
215
|
+
// The human row is structurally out of reach of the removed reconcile.
|
|
216
|
+
expect((await db.get(machineTable, { id: human.id })).status).toBeFalsy();
|
|
217
|
+
|
|
218
|
+
// Idempotent: an already-flagged row is not re-written on the next boot.
|
|
219
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
220
|
+
await boot([]);
|
|
221
|
+
expect(RecordingMachineAccountWatcher.updates).toHaveLength(0);
|
|
222
|
+
|
|
223
|
+
// Removal is reversible in source: re-declaring reverts the patch via drift reversion.
|
|
224
|
+
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local' })]);
|
|
225
|
+
expect((await db.get(machineTable, { id: 'machine-1' })).status).toBe('active');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test(`onSourceRemoved default: removed source rows are deleted; human rows survive`, async () => {
|
|
229
|
+
const db = getDbAsSystem();
|
|
230
|
+
const human = await db.insert(defaultPolicyTable, { email: 'human@test.local' });
|
|
231
|
+
await boot([{ table: defaultPolicyTable, record: { id: 'default-1', email: 'temp@test.local' } }]);
|
|
232
|
+
expect(await db.get(defaultPolicyTable, { id: 'default-1' })).toBeDefined();
|
|
233
|
+
|
|
234
|
+
await boot([]);
|
|
235
|
+
expect(await db.get(defaultPolicyTable, { id: 'default-1' })).toBeUndefined();
|
|
236
|
+
expect(await db.get(defaultPolicyTable, { id: human.id })).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a natural key without a unique guarantee fails boot loudly', async () => {
|
|
240
|
+
// Local (unregistered, never created) on purpose: a registered table with an invalid
|
|
241
|
+
// naturalKey would poison every loader run in the process — validation fires before any
|
|
242
|
+
// db access, so the schema is never needed.
|
|
243
|
+
interface Misdeclared extends SourceRecord {
|
|
244
|
+
nickname: string;
|
|
245
|
+
}
|
|
246
|
+
class MisdeclaredNaturalKeyTable extends Table<Misdeclared> {
|
|
247
|
+
name = 'db_test_sync_misdeclared_natural_key';
|
|
248
|
+
columns = withSourceRecordColumns<Misdeclared>({ nickname: new StringColumn('nickname') });
|
|
249
|
+
sourceRecordOptions = { naturalKey: 'nickname' } as Table<Misdeclared>['sourceRecordOptions'];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await expect(
|
|
253
|
+
boot([{ table: new MisdeclaredNaturalKeyTable(), record: { id: 'm-1', nickname: 'dupe-prone' } }])
|
|
254
|
+
).rejects.toThrow(/naturalKey 'nickname' requires the column to be unique/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('two declarations sharing a natural key fail boot loudly', async () => {
|
|
258
|
+
await expect(
|
|
259
|
+
boot([
|
|
260
|
+
machineDeclaration({ id: 'machine-1', email: 'shared@test.local' }),
|
|
261
|
+
machineDeclaration({ id: 'machine-2', email: 'shared@test.local' }),
|
|
262
|
+
])
|
|
263
|
+
).rejects.toThrow(/share the natural key 'email' = 'shared@test.local'/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('unique-index preflight: adding a unique index over duplicate data fails by name; clean data proceeds', async () => {
|
|
267
|
+
const tableManager = driver.getTableManager();
|
|
268
|
+
const generationOne = new DupePreflightTable();
|
|
269
|
+
const generationTwo = new DupePreflightUniqueEmailTable();
|
|
270
|
+
try {
|
|
271
|
+
await tableManager.loadTable(generationOne);
|
|
272
|
+
const db = getDbAsSystem();
|
|
273
|
+
const first = await db.insert(generationOne, { email: 'dupe@test.local' });
|
|
274
|
+
await db.insert(generationOne, { email: 'dupe@test.local' });
|
|
275
|
+
|
|
276
|
+
// The pre-sync duplicate check turns the opaque index-backfill failure into a named error.
|
|
277
|
+
await expect(tableManager.loadTable(generationTwo)).rejects.toThrow(DuplicateValuesForUniqueIndexError);
|
|
278
|
+
await expect(tableManager.loadTable(generationTwo)).rejects.toThrow(/dupe@test\.local/);
|
|
279
|
+
|
|
280
|
+
// Resolve the duplicates and the same boot proceeds.
|
|
281
|
+
await db.delete(generationOne, { id: first.id });
|
|
282
|
+
await tableManager.loadTable(generationTwo);
|
|
283
|
+
} finally {
|
|
284
|
+
await dropTable(generationOne);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
};
|
|
@@ -5,6 +5,7 @@ import { columnTypesTestTables } from './tables/columnTypesTestTables';
|
|
|
5
5
|
import { crudTestTables } from './tables/crudTestTables';
|
|
6
6
|
import { dynamicReferenceTestTables } from './tables/dynamicReferenceColumnTestTables';
|
|
7
7
|
import { recordIteratorTestTables } from './tables/recordIteratorTestTables';
|
|
8
|
+
import { sourceRecordSyncTestTables } from './tables/sourceRecordSyncTestTables';
|
|
8
9
|
import { tableManagerTestTables } from './tables/tableManagerTestTables';
|
|
9
10
|
import { transactionTestTables } from './tables/transactionTestTables';
|
|
10
11
|
|
|
@@ -14,6 +15,7 @@ const TABLES: Table<any>[] = [
|
|
|
14
15
|
...Object.values(crudTestTables),
|
|
15
16
|
...Object.values(dynamicReferenceTestTables),
|
|
16
17
|
...Object.values(recordIteratorTestTables),
|
|
18
|
+
...Object.values(sourceRecordSyncTestTables),
|
|
17
19
|
...Object.values(tableManagerTestTables),
|
|
18
20
|
...Object.values(transactionTestTables),
|
|
19
21
|
];
|