@peers-app/peers-device 0.7.25 → 0.7.26
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/dist/connection-manager/connection-manager.js +13 -0
- package/dist/connection-manager/connection-manager.js.map +1 -1
- package/dist/json-diff.d.ts +13 -1
- package/dist/json-diff.js +157 -25
- package/dist/json-diff.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/packages.tracked-data-source.d.ts +2 -2
- package/dist/packages.tracked-data-source.js.map +1 -1
- package/dist/pvars.tracked-data-source.d.ts +2 -2
- package/dist/pvars.tracked-data-source.js +16 -9
- package/dist/pvars.tracked-data-source.js.map +1 -1
- package/dist/sync-group.d.ts +8 -4
- package/dist/sync-group.js +37 -11
- package/dist/sync-group.js.map +1 -1
- package/dist/sync-group.test.js +150 -51
- package/dist/sync-group.test.js.map +1 -1
- package/dist/tracked-data-source.d.ts +41 -21
- package/dist/tracked-data-source.js +370 -252
- package/dist/tracked-data-source.js.map +1 -1
- package/dist/tracked-data-source.test.d.ts +3 -0
- package/dist/tracked-data-source.test.js +699 -781
- package/dist/tracked-data-source.test.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TrackedDataSource = void 0;
|
|
4
|
-
const lodash_1 = require("lodash");
|
|
5
4
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
5
|
+
const lodash_1 = require("lodash");
|
|
6
6
|
const json_diff_1 = require("./json-diff");
|
|
7
|
+
/**
|
|
8
|
+
* TrackedDataSource wraps a data source and tracks all changes using granular change records.
|
|
9
|
+
*
|
|
10
|
+
* Key improvements over V1:
|
|
11
|
+
* - Each JSON patch operation gets its own change record
|
|
12
|
+
* - Superseding logic prevents temporal inconsistencies
|
|
13
|
+
* - More efficient cleanup via supersededAt timestamps
|
|
14
|
+
*/
|
|
7
15
|
class TrackedDataSource {
|
|
8
16
|
dataSource;
|
|
9
17
|
changeTrackingTable;
|
|
10
18
|
tableName;
|
|
11
19
|
primaryKeyName;
|
|
20
|
+
preserveHistory = false;
|
|
12
21
|
constructor(dataSource, changeTrackingTable) {
|
|
13
22
|
this.dataSource = dataSource;
|
|
14
23
|
this.changeTrackingTable = changeTrackingTable;
|
|
15
24
|
this.tableName = dataSource.tableName;
|
|
16
25
|
this.primaryKeyName = dataSource.primaryKeyName;
|
|
17
26
|
}
|
|
27
|
+
//================================================================================================
|
|
28
|
+
// Change Tracking Methods
|
|
29
|
+
//================================================================================================
|
|
18
30
|
async listChanges(filter = {}, opts) {
|
|
19
31
|
filter = { $and: [{ tableName: this.tableName }, filter] };
|
|
20
32
|
return this.changeTrackingTable.list(filter, opts);
|
|
@@ -23,122 +35,9 @@ class TrackedDataSource {
|
|
|
23
35
|
filter = { $and: [{ tableName: this.tableName }, filter] };
|
|
24
36
|
return this.changeTrackingTable.cursor(filter, opts);
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return this.applyChangesPromise;
|
|
30
|
-
}
|
|
31
|
-
async _applyChanges(changes) {
|
|
32
|
-
changes = (0, lodash_1.uniqBy)(changes, 'changeId');
|
|
33
|
-
const oldestTimestamp = Math.min(...changes.map(c => c.timestamp));
|
|
34
|
-
const recordIds = (0, lodash_1.uniq)(changes.map(c => c.recordId)).sort();
|
|
35
|
-
const existingChanges = await this.listChanges({ timestamp: { $gte: oldestTimestamp }, recordId: { $in: recordIds } });
|
|
36
|
-
if (existingChanges.length > 10000) {
|
|
37
|
-
console.warn(`Merging a very large number of changes (${existingChanges.length}) for table ${this.tableName}. This can cause severe performance degradation or system down. This indicates there may be a small number of records with a very large number of changes. Use full record writes (snapshot or delete) to clear out excessive partial changes.`, { tableName: this.tableName, recordIds, });
|
|
38
|
-
}
|
|
39
|
-
const newChanges = changes.filter(c => !existingChanges.find(ec => ec.changeId === c.changeId));
|
|
40
|
-
if (newChanges.length === 0)
|
|
41
|
-
return;
|
|
42
|
-
const allChanges = (0, lodash_1.sortBy)([...newChanges, ...existingChanges], 'timestamp');
|
|
43
|
-
const recordIdsNotBeingDeletedOrRestored = (0, lodash_1.uniq)(allChanges.filter(c => c.changeType !== 'delete' && c.changeType !== 'restore').map(c => c.recordId));
|
|
44
|
-
const deletedRecordIds = await this.filterToDeletedIds(recordIdsNotBeingDeletedOrRestored);
|
|
45
|
-
const existingRecordIds = recordIds.filter(r => !deletedRecordIds.includes(r));
|
|
46
|
-
const existingRecords = await this.dataSource.list({ [this.primaryKeyName]: { $in: existingRecordIds } });
|
|
47
|
-
const groupedChanges = (0, lodash_1.groupBy)(allChanges, c => c.recordId);
|
|
48
|
-
let changesApplied = false;
|
|
49
|
-
for (const recordId of Object.keys(groupedChanges)) {
|
|
50
|
-
const changes = groupedChanges[recordId];
|
|
51
|
-
let record = existingRecords.find(r => r[this.primaryKeyName] === recordId);
|
|
52
|
-
let deletedRecord = deletedRecordIds.includes(recordId) ? { [this.primaryKeyName]: recordId } : undefined;
|
|
53
|
-
const lastFullWriteTimestamp = (0, lodash_1.findLast)(changes, c => c.changeType !== 'update')?.timestamp || 0;
|
|
54
|
-
for (const change of changes) {
|
|
55
|
-
// if the change is older than the last full write, skip it because even if we don't have it, it will immediately be deleted
|
|
56
|
-
if (change.timestamp < lastFullWriteTimestamp) {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
switch (change.changeType) {
|
|
60
|
-
case 'insert':
|
|
61
|
-
case 'snapshot':
|
|
62
|
-
if (!deletedRecord) {
|
|
63
|
-
changesApplied = true;
|
|
64
|
-
record = change.newRecord;
|
|
65
|
-
}
|
|
66
|
-
break;
|
|
67
|
-
case 'update':
|
|
68
|
-
if (!deletedRecord) {
|
|
69
|
-
changesApplied = true;
|
|
70
|
-
if (record) {
|
|
71
|
-
record = (0, json_diff_1.applyJsonDiff)(record, change.jsonDiff);
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
/* istanbul ignore next */
|
|
75
|
-
record = change.newRecord;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
break;
|
|
79
|
-
case 'delete':
|
|
80
|
-
changesApplied = true;
|
|
81
|
-
deletedRecord = (change.oldRecord || record || { [this.primaryKeyName]: recordId });
|
|
82
|
-
record = undefined;
|
|
83
|
-
break;
|
|
84
|
-
case 'restore':
|
|
85
|
-
changesApplied = true;
|
|
86
|
-
deletedRecord = undefined;
|
|
87
|
-
record = change.newRecord;
|
|
88
|
-
break;
|
|
89
|
-
default:
|
|
90
|
-
throw new Error(`Unsupported change type: ${JSON.stringify(change, null, 2)}`);
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
if (newChanges.find(ec => ec.changeId === change.changeId)) {
|
|
94
|
-
change.timestampApplied = (0, peers_sdk_1.getTimestamp)();
|
|
95
|
-
await this.changeTrackingTable.insert(change);
|
|
96
|
-
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
if (String(err).includes('UNIQUE constraint failed')) {
|
|
101
|
-
console.warn(`Change already exists in table (are you using the same changeTracking table for syncing tables with different names?): ${change.changeId}: ` + JSON.stringify(change));
|
|
102
|
-
}
|
|
103
|
-
else if (String(err).includes('Validation on insert failed ')) {
|
|
104
|
-
console.warn(`Validation on insert failed. Continuing with the change applied but not saved - ${change.changeId}: ` + JSON.stringify(change));
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
throw err;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (lastFullWriteTimestamp > 0) {
|
|
112
|
-
await this.changeTrackingTable.db.exec(`
|
|
113
|
-
DELETE FROM "${this.changeTrackingTable.tableName}"
|
|
114
|
-
WHERE
|
|
115
|
-
tableName = ? AND
|
|
116
|
-
recordId = ? AND
|
|
117
|
-
timestamp < ?
|
|
118
|
-
`, [this.tableName, recordId, lastFullWriteTimestamp]);
|
|
119
|
-
}
|
|
120
|
-
if (changesApplied) {
|
|
121
|
-
try {
|
|
122
|
-
// TODO if this fails the data will be out of sync and won't be back in sync until the orphaned changes are fully overwritten
|
|
123
|
-
if (record) {
|
|
124
|
-
await this.dataSource.save(record);
|
|
125
|
-
}
|
|
126
|
-
else if (deletedRecord) {
|
|
127
|
-
await this.dataSource.delete(deletedRecord || recordId);
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
throw new Error(`record and deletedRecord are both undefined. This should never happen.`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
console.error(`Error committing changes for recordId: ${recordId}`, err);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
async filterToDeletedIds(recordIds) {
|
|
140
|
-
return this.changeTrackingTable.filterToDeletedIds(recordIds);
|
|
141
|
-
}
|
|
38
|
+
//================================================================================================
|
|
39
|
+
// Write Operations
|
|
40
|
+
//================================================================================================
|
|
142
41
|
async save(data, opts) {
|
|
143
42
|
const primaryKeyName = this.primaryKeyName;
|
|
144
43
|
const recordId = data[primaryKeyName];
|
|
@@ -149,12 +48,12 @@ class TrackedDataSource {
|
|
|
149
48
|
if (oldData) {
|
|
150
49
|
return this._update(data, oldData, opts?.saveAsSnapshot);
|
|
151
50
|
}
|
|
152
|
-
const deletedIds = await this.filterToDeletedIds([recordId]);
|
|
51
|
+
const deletedIds = await this.changeTrackingTable.filterToDeletedIds([recordId]);
|
|
153
52
|
if (deletedIds.length) {
|
|
154
53
|
if (opts?.restoreIfDeleted) {
|
|
155
54
|
return this.restore(data);
|
|
156
55
|
}
|
|
157
|
-
throw new Error(`Cannot save record with id ${recordId} because it has been deleted.
|
|
56
|
+
throw new Error(`Cannot save record with id ${recordId} because it has been deleted. Use restore instead.`);
|
|
158
57
|
}
|
|
159
58
|
return this._insert(data, true);
|
|
160
59
|
}
|
|
@@ -169,24 +68,28 @@ class TrackedDataSource {
|
|
|
169
68
|
data[this.primaryKeyName] = recordId;
|
|
170
69
|
}
|
|
171
70
|
else if (!skipDeletedCheck) {
|
|
172
|
-
const deletedIds = await this.filterToDeletedIds([recordId]);
|
|
71
|
+
const deletedIds = await this.changeTrackingTable.filterToDeletedIds([recordId]);
|
|
173
72
|
if (deletedIds.length) {
|
|
174
|
-
throw new Error(`Cannot insert record with id ${recordId} because it has been deleted.
|
|
73
|
+
throw new Error(`Cannot insert record with id ${recordId} because it has been deleted. Use restore instead.`);
|
|
175
74
|
}
|
|
176
75
|
}
|
|
177
76
|
const timestamp = (0, peers_sdk_1.getTimestamp)();
|
|
178
|
-
const
|
|
77
|
+
const insertedData = await this.dataSource.insert(data);
|
|
78
|
+
// Store a single 'add' operation at path "/" with the full object
|
|
79
|
+
// This is more efficient than storing individual field operations
|
|
80
|
+
const change = {
|
|
179
81
|
changeId: (0, peers_sdk_1.newid)(),
|
|
180
|
-
changeType: 'insert',
|
|
181
|
-
timestamp,
|
|
182
|
-
timestampApplied: timestamp,
|
|
183
82
|
tableName: this.tableName,
|
|
184
83
|
recordId,
|
|
185
|
-
|
|
186
|
-
|
|
84
|
+
op: 'set',
|
|
85
|
+
path: '/', // Root path for full object
|
|
86
|
+
value: data,
|
|
87
|
+
createdAt: timestamp,
|
|
88
|
+
appliedAt: timestamp,
|
|
89
|
+
};
|
|
90
|
+
await this.changeTrackingTable.insert(change);
|
|
187
91
|
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
188
|
-
return
|
|
189
|
-
// TODO handle errors if insert fails
|
|
92
|
+
return insertedData;
|
|
190
93
|
}
|
|
191
94
|
async update(newData) {
|
|
192
95
|
return this._update(newData);
|
|
@@ -200,62 +103,103 @@ class TrackedDataSource {
|
|
|
200
103
|
if (!oldData) {
|
|
201
104
|
throw new Error(`No record found to update: ${recordId}: ` + JSON.stringify(newData));
|
|
202
105
|
}
|
|
203
|
-
const jsonDiff = (0, json_diff_1.createJsonDiff)(oldData, newData);
|
|
204
|
-
if (!jsonDiff) {
|
|
205
|
-
return newData;
|
|
206
|
-
}
|
|
207
106
|
const timestamp = (0, peers_sdk_1.getTimestamp)();
|
|
208
|
-
const
|
|
209
|
-
changeId: (0, peers_sdk_1.newid)(),
|
|
210
|
-
changeType: saveAsSnapshot ? 'snapshot' : 'update',
|
|
211
|
-
timestamp,
|
|
212
|
-
timestampApplied: timestamp,
|
|
213
|
-
tableName: this.tableName,
|
|
214
|
-
recordId,
|
|
215
|
-
newRecord: newData,
|
|
216
|
-
jsonDiff,
|
|
217
|
-
});
|
|
107
|
+
const updatedObject = await this.dataSource.update(newData);
|
|
218
108
|
if (saveAsSnapshot) {
|
|
219
|
-
//
|
|
220
|
-
await this.changeTrackingTable.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
109
|
+
// If saving as snapshot, we store a single 'set' operation at path "/" with the full object
|
|
110
|
+
const change = await this.changeTrackingTable.insert({
|
|
111
|
+
changeId: (0, peers_sdk_1.newid)(),
|
|
112
|
+
tableName: this.tableName,
|
|
113
|
+
recordId,
|
|
114
|
+
op: 'set',
|
|
115
|
+
path: '/', // Root path for full object
|
|
116
|
+
value: updatedObject,
|
|
117
|
+
createdAt: timestamp,
|
|
118
|
+
appliedAt: timestamp,
|
|
119
|
+
});
|
|
120
|
+
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
121
|
+
await this.changeTrackingTable.markAllPriorChangesSuperseded(this.tableName, recordId, timestamp);
|
|
227
122
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
123
|
+
else {
|
|
124
|
+
const jsonDiff = (0, json_diff_1.createJsonDiff)(oldData, updatedObject);
|
|
125
|
+
if (jsonDiff) {
|
|
126
|
+
// Store each operation as a separate change record
|
|
127
|
+
const changes = [];
|
|
128
|
+
for (let i = 0; i < jsonDiff.length; i++) {
|
|
129
|
+
const op = jsonDiff[i];
|
|
130
|
+
const changeOp = op.op === 'patch-text' ? 'patch-text'
|
|
131
|
+
: op.op === 'remove' ? 'delete'
|
|
132
|
+
: 'set';
|
|
133
|
+
const changePath = op.path || '/';
|
|
134
|
+
const value = op.op === 'add' || op.op === 'replace' || op.op === 'patch-text' ? op.value : undefined;
|
|
135
|
+
const change = {
|
|
136
|
+
changeId: (0, peers_sdk_1.newid)(),
|
|
137
|
+
tableName: this.tableName,
|
|
138
|
+
recordId,
|
|
139
|
+
op: changeOp,
|
|
140
|
+
path: changePath,
|
|
141
|
+
value,
|
|
142
|
+
createdAt: timestamp, // timestamp should be the same for all changes
|
|
143
|
+
appliedAt: timestamp,
|
|
144
|
+
};
|
|
145
|
+
changes.push(change);
|
|
146
|
+
}
|
|
147
|
+
// get all active changes for these records
|
|
148
|
+
const activeExistingChanges = await this.changeTrackingTable.list({
|
|
149
|
+
tableName: this.tableName,
|
|
150
|
+
recordId,
|
|
151
|
+
supersededAt: { $exists: false },
|
|
152
|
+
}, { sortBy: ['createdAt'] });
|
|
153
|
+
const supersededChangeIds = [];
|
|
154
|
+
for (const existingChange of activeExistingChanges) {
|
|
155
|
+
const superseded = changes.some(newChange => (0, json_diff_1.isChildPathOrSame)(newChange.path, existingChange.path));
|
|
156
|
+
if (superseded) {
|
|
157
|
+
supersededChangeIds.push(existingChange.changeId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// TODO make this a bulk insert
|
|
161
|
+
for (const change of changes) {
|
|
162
|
+
await this.changeTrackingTable.insert(change);
|
|
163
|
+
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
164
|
+
}
|
|
165
|
+
if (supersededChangeIds.length > 0) {
|
|
166
|
+
await this.changeTrackingTable.batchMarkSuperseded(supersededChangeIds.map(changeId => ({
|
|
167
|
+
changeId,
|
|
168
|
+
supersededAt: timestamp,
|
|
169
|
+
})));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return updatedObject;
|
|
231
174
|
}
|
|
232
175
|
async delete(data) {
|
|
233
176
|
const primaryKeyName = this.primaryKeyName;
|
|
234
|
-
let oldData = data;
|
|
235
177
|
let recordId = '';
|
|
236
178
|
if (typeof data === 'string') {
|
|
237
179
|
recordId = data;
|
|
238
|
-
oldData = (await this.dataSource.get(data));
|
|
239
|
-
if (!oldData) {
|
|
240
|
-
console.warn(`Record not found: ${data}. Possibly already deleted or was never created.`);
|
|
241
|
-
}
|
|
242
180
|
}
|
|
243
181
|
else {
|
|
244
182
|
recordId = data[primaryKeyName];
|
|
245
183
|
}
|
|
246
184
|
const timestamp = (0, peers_sdk_1.getTimestamp)();
|
|
247
|
-
|
|
185
|
+
// we write this to the db first in case there are errors
|
|
186
|
+
await this.dataSource.delete(data);
|
|
187
|
+
// A delete operation supersedes all prior changes for this record
|
|
188
|
+
// Store a 'remove' operation
|
|
189
|
+
const change = {
|
|
248
190
|
changeId: (0, peers_sdk_1.newid)(),
|
|
249
|
-
changeType: 'delete',
|
|
250
|
-
timestamp,
|
|
251
|
-
timestampApplied: timestamp,
|
|
252
191
|
tableName: this.tableName,
|
|
253
192
|
recordId,
|
|
254
|
-
|
|
255
|
-
|
|
193
|
+
op: 'delete',
|
|
194
|
+
path: '/', // Root path for delete
|
|
195
|
+
value: undefined,
|
|
196
|
+
createdAt: timestamp,
|
|
197
|
+
appliedAt: timestamp,
|
|
198
|
+
};
|
|
199
|
+
await this.changeTrackingTable.insert(change);
|
|
256
200
|
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
257
|
-
|
|
258
|
-
|
|
201
|
+
// Mark all old changes as superseded
|
|
202
|
+
await this.changeTrackingTable.markAllPriorChangesSuperseded(this.tableName, recordId, timestamp);
|
|
259
203
|
}
|
|
260
204
|
async restore(data) {
|
|
261
205
|
let recordId = data[this.primaryKeyName];
|
|
@@ -263,76 +207,251 @@ class TrackedDataSource {
|
|
|
263
207
|
throw new Error(`No recordId provided to restore: ` + JSON.stringify(data));
|
|
264
208
|
}
|
|
265
209
|
const timestamp = (0, peers_sdk_1.getTimestamp)();
|
|
266
|
-
|
|
210
|
+
// we write this to the db first in case there are errors
|
|
211
|
+
const insertedData = await this.dataSource.insert(data);
|
|
212
|
+
// Store a single 'add' operation at path "/" with the full object
|
|
213
|
+
// Using 'add' since we're restoring (adding back) the record
|
|
214
|
+
const change = {
|
|
267
215
|
changeId: (0, peers_sdk_1.newid)(),
|
|
268
|
-
changeType: 'restore',
|
|
269
|
-
timestamp,
|
|
270
|
-
timestampApplied: timestamp,
|
|
271
216
|
tableName: this.tableName,
|
|
272
217
|
recordId,
|
|
273
|
-
|
|
274
|
-
|
|
218
|
+
op: 'set',
|
|
219
|
+
path: '/', // Root path for full object
|
|
220
|
+
value: insertedData,
|
|
221
|
+
createdAt: timestamp,
|
|
222
|
+
appliedAt: timestamp,
|
|
223
|
+
};
|
|
224
|
+
await this.changeTrackingTable.insert(change);
|
|
275
225
|
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
276
|
-
|
|
277
|
-
|
|
226
|
+
// Mark all old changes as superseded
|
|
227
|
+
await this.changeTrackingTable.markAllPriorChangesSuperseded(this.tableName, recordId, timestamp);
|
|
228
|
+
return insertedData;
|
|
278
229
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
230
|
+
//================================================================================================
|
|
231
|
+
// Apply Changes (Sync)
|
|
232
|
+
//================================================================================================
|
|
233
|
+
applyChangesPromise = Promise.resolve();
|
|
234
|
+
async applyChanges(changes) {
|
|
235
|
+
this.applyChangesPromise = this.applyChangesPromise.finally(() => this._applyChanges(changes));
|
|
236
|
+
return this.applyChangesPromise;
|
|
237
|
+
}
|
|
238
|
+
async _applyChanges(changes) {
|
|
239
|
+
if (changes.length === 0) {
|
|
240
|
+
return;
|
|
287
241
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
242
|
+
changes = (0, lodash_1.uniqBy)(changes, c => c.changeId);
|
|
243
|
+
const missingChangeIds = await this.changeTrackingTable.filterToMissingChangeIds(changes.map(c => c.changeId));
|
|
244
|
+
if (missingChangeIds.length === 0) {
|
|
245
|
+
// All changes are already applied
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const newChanges = changes.filter(c => missingChangeIds.some(id => id === c.changeId));
|
|
249
|
+
// Get affected recordIds (sorted by order in which they were changed)
|
|
250
|
+
const recordIds = (0, lodash_1.chain)(newChanges)
|
|
251
|
+
// .sortBy(c => c.changeId) // this is relatively expensive and not needed
|
|
252
|
+
.map(c => c.recordId)
|
|
253
|
+
.uniq()
|
|
254
|
+
.value();
|
|
255
|
+
// get all active changes for these records
|
|
256
|
+
const activeExistingChanges = await this.changeTrackingTable.list({
|
|
257
|
+
tableName: this.tableName,
|
|
258
|
+
recordId: { $in: recordIds },
|
|
259
|
+
supersededAt: { $exists: false },
|
|
260
|
+
}, { sortBy: ['createdAt'] });
|
|
261
|
+
// Process changes by record
|
|
262
|
+
const allChanges = (0, lodash_1.chain)([...activeExistingChanges, ...newChanges])
|
|
263
|
+
.uniqBy("changeId")
|
|
264
|
+
.value();
|
|
265
|
+
// write superseded changes to db (if preserving history)
|
|
266
|
+
const newSupersededChanges = newChanges.filter(c => c.supersededAt);
|
|
267
|
+
if (this.preserveHistory && newSupersededChanges.length) {
|
|
268
|
+
for (const newSupersededChange of newSupersededChanges) {
|
|
269
|
+
try {
|
|
270
|
+
await this.changeTrackingTable.insert(newSupersededChange);
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
console.warn(`Skipping invalid superseded change for recordId: ${newSupersededChange.recordId}, changeId: ${newSupersededChange.changeId}`, e);
|
|
274
|
+
}
|
|
296
275
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
276
|
+
}
|
|
277
|
+
const newActiveChanges = newChanges.filter(c => !c.supersededAt);
|
|
278
|
+
if (newActiveChanges.length === 0) {
|
|
279
|
+
// No new active changes to apply
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const allActiveChanges = allChanges.filter(c => !c.supersededAt);
|
|
283
|
+
const batchTimestamp = (0, peers_sdk_1.getTimestamp)();
|
|
284
|
+
const groupedActiveChanges = (0, lodash_1.groupBy)(allActiveChanges, "recordId");
|
|
285
|
+
const supersededChanges = [];
|
|
286
|
+
for (const recordId of Object.keys(groupedActiveChanges)) {
|
|
287
|
+
// changes for this record sorted in reverse order (newest first) for superseding logic
|
|
288
|
+
const recordChangesActiveReverse = (0, lodash_1.sortBy)(groupedActiveChanges[recordId], ['createdAt', 'changeId']).reverse();
|
|
289
|
+
// get the last full write for this record (which is the first found since we're in reverse order)
|
|
290
|
+
const lastFullWrite = recordChangesActiveReverse.find(c => c.path === '/');
|
|
291
|
+
if (!lastFullWrite) {
|
|
292
|
+
console.error(`No full write found for recordId: ${recordId}, skipping changes`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
let finalRecord = undefined;
|
|
296
|
+
let isDeleted = lastFullWrite.op === 'delete';
|
|
297
|
+
if (isDeleted) {
|
|
298
|
+
// mark new and existing changes as superseded except the last full write
|
|
299
|
+
for (const c of recordChangesActiveReverse) {
|
|
300
|
+
if (c !== lastFullWrite) {
|
|
301
|
+
supersededChanges.push(c);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (newActiveChanges.includes(lastFullWrite)) {
|
|
305
|
+
// if this delete is a new change, we need to apply it to the data source
|
|
306
|
+
await this.dataSource.delete(recordId);
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
finalRecord = lastFullWrite.value;
|
|
312
|
+
}
|
|
313
|
+
if (!finalRecord) {
|
|
314
|
+
console.error(`Last full write has no value for recordId: ${recordId}, skipping changes`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// Apply changes in reverse order to detect superseding
|
|
318
|
+
const writtenPaths = [];
|
|
319
|
+
const writtenChanges = [];
|
|
320
|
+
for (const change of recordChangesActiveReverse) {
|
|
321
|
+
// validate the change before applying
|
|
322
|
+
if (newActiveChanges.includes(change)) {
|
|
323
|
+
try {
|
|
324
|
+
this.changeTrackingTable.schema.parse(change);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
console.warn(`Skipping invalid change for recordId: ${recordId}, changeId: ${change.changeId}`, e);
|
|
328
|
+
newActiveChanges.splice(newActiveChanges.indexOf(change), 1);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const superseded = writtenPaths.some(parentPath => (0, json_diff_1.isChildPathOrSame)(parentPath, change.path));
|
|
333
|
+
if (superseded) {
|
|
334
|
+
supersededChanges.push(change);
|
|
313
335
|
continue;
|
|
314
336
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
337
|
+
writtenPaths.push(change.path);
|
|
338
|
+
if (change !== lastFullWrite) {
|
|
339
|
+
writtenChanges.push(change);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// convert changes to diff ops
|
|
343
|
+
const changesToApply = (0, lodash_1.chain)(writtenChanges)
|
|
344
|
+
.sortBy(['createdAt', 'changeId'])
|
|
345
|
+
.map(c => {
|
|
346
|
+
const diffOp = c.op === 'delete' ? 'remove'
|
|
347
|
+
: c.op === 'set' ? 'add'
|
|
348
|
+
: c.op;
|
|
349
|
+
const diff = {
|
|
350
|
+
op: diffOp,
|
|
351
|
+
path: c.path,
|
|
352
|
+
value: c.value,
|
|
323
353
|
};
|
|
324
|
-
|
|
354
|
+
return diff;
|
|
355
|
+
})
|
|
356
|
+
.value();
|
|
357
|
+
if (changesToApply.length) {
|
|
358
|
+
finalRecord = (0, json_diff_1.applyJsonDiff)(finalRecord, changesToApply);
|
|
359
|
+
}
|
|
360
|
+
if (!finalRecord) {
|
|
361
|
+
console.error(`Failed to apply changes for recordId: ${recordId}, skipping`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Save the final record state first to ensure the underlying dataSource accepts it
|
|
365
|
+
// TODO if an error is thrown we shouldn't save the changes or update superseded status
|
|
366
|
+
try {
|
|
367
|
+
await this.dataSource.save(finalRecord);
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
// TODO we could improve this by trying to find which change caused the failure and just skip that one
|
|
371
|
+
console.warn(`Failed to save final record for recordId: ${recordId}, skipping changes`, e);
|
|
372
|
+
for (const change of writtenChanges) {
|
|
373
|
+
if (newActiveChanges.includes(change)) {
|
|
374
|
+
newActiveChanges.splice(newActiveChanges.indexOf(change), 1);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Now save all new changes and mark superseded changes
|
|
380
|
+
// TODO: make this a bulk insert
|
|
381
|
+
for (const change of newActiveChanges) {
|
|
382
|
+
change.appliedAt = batchTimestamp;
|
|
383
|
+
await this.changeTrackingTable.insert(change).catch(e => {
|
|
384
|
+
if (e.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
|
|
385
|
+
console.warn(`Attempted to insert a change that already exists for recordId: ${change.recordId}, changeId: ${change.changeId}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Failed to save change for recordId: ${change.recordId}, changeId: ${change.changeId}, state corrupted: ${e.message}`, { cause: e });
|
|
389
|
+
});
|
|
390
|
+
if (!supersededChanges.includes(change)) {
|
|
391
|
+
this.changeTrackingTable.dataChangedEmitter.emit(change);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// NOTE: if something interrupts this, it will will just do it the next time applyChanges is called
|
|
395
|
+
// because the superseding flag is just an optimization
|
|
396
|
+
if (supersededChanges.length > 0) {
|
|
397
|
+
if (this.preserveHistory) {
|
|
398
|
+
// update superseded changes in place
|
|
399
|
+
await this.changeTrackingTable.batchMarkSuperseded(supersededChanges.map(change => ({
|
|
400
|
+
changeId: change.changeId,
|
|
401
|
+
supersededAt: change.supersededAt || batchTimestamp,
|
|
402
|
+
})));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
// delete superseded changes
|
|
406
|
+
await this.changeTrackingTable.deleteChanges(supersededChanges.map(change => change.changeId));
|
|
407
|
+
await this.changeTrackingTable.deleteSupersededChangesOlderThan(this.tableName, batchTimestamp);
|
|
325
408
|
}
|
|
326
409
|
}
|
|
327
|
-
console.warn(`Compaction failed to complete in 100 iterations. Try running it again to see if it eventually finishes.`);
|
|
328
410
|
}
|
|
411
|
+
//================================================================================================
|
|
412
|
+
// Read Operations (pass-through to underlying data source)
|
|
413
|
+
//================================================================================================
|
|
414
|
+
async get(id) {
|
|
415
|
+
return this.dataSource.get(id);
|
|
416
|
+
}
|
|
417
|
+
async list(filter = {}, opts = {}) {
|
|
418
|
+
return this.dataSource.list(filter, opts);
|
|
419
|
+
}
|
|
420
|
+
async count(filter = {}) {
|
|
421
|
+
if (this.dataSource.count) {
|
|
422
|
+
return this.dataSource.count(filter);
|
|
423
|
+
}
|
|
424
|
+
console.warn(`count not implemented for data source: ${this.dataSource.constructor.name}, falling back to cursor`);
|
|
425
|
+
let cnt = 0;
|
|
426
|
+
const cursor = this.dataSource.cursor?.(filter) ?? (0, peers_sdk_1.dataSourceCursor)(this.dataSource, filter);
|
|
427
|
+
for await (const _ of cursor) {
|
|
428
|
+
cnt++;
|
|
429
|
+
}
|
|
430
|
+
return cnt;
|
|
431
|
+
}
|
|
432
|
+
cursor(filter = {}, opts = {}) {
|
|
433
|
+
if (this.dataSource.cursor) {
|
|
434
|
+
return this.dataSource.cursor(filter, opts);
|
|
435
|
+
}
|
|
436
|
+
return (0, peers_sdk_1.dataSourceCursor)(this.dataSource, filter, opts);
|
|
437
|
+
}
|
|
438
|
+
//================================================================================================
|
|
439
|
+
// Utility Methods
|
|
440
|
+
//================================================================================================
|
|
329
441
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
442
|
+
* Find all records in the data source that don't have change tracking records,
|
|
443
|
+
* and create initial change records for them.
|
|
444
|
+
*
|
|
445
|
+
* This is useful for:
|
|
446
|
+
* - Initializing change tracking for existing data
|
|
447
|
+
* - Recovery after change tracking table corruption
|
|
448
|
+
* - Migration scenarios
|
|
449
|
+
*
|
|
450
|
+
* @param batchSize Number of records to process at once (default: 100)
|
|
334
451
|
*/
|
|
335
452
|
async findAndTrackRecords(batchSize = 100) {
|
|
453
|
+
// TEMPORARY: Take this out
|
|
454
|
+
await this.changeTrackingTable.db.exec('DROP TABLE IF EXISTS ChangeTracking');
|
|
336
455
|
const cursor = this.cursor();
|
|
337
456
|
const nextBatch = async () => {
|
|
338
457
|
const batch = [];
|
|
@@ -350,49 +469,48 @@ class TrackedDataSource {
|
|
|
350
469
|
const recordIds = batch.map(b => b[this.primaryKeyName]);
|
|
351
470
|
const missingRecordIds = await this.changeTrackingTable.filterToMissingIds(recordIds);
|
|
352
471
|
for (const recordId of missingRecordIds) {
|
|
472
|
+
const record = batch.find(b => b[this.primaryKeyName] === recordId);
|
|
473
|
+
if (!record)
|
|
474
|
+
continue;
|
|
475
|
+
// Create a change record with very low timestamp so it gets superseded by any real changes
|
|
476
|
+
// Use random decimal as tie-breaker between multiple snapshots
|
|
477
|
+
const lowTimestamp = 1 + Math.random();
|
|
478
|
+
const now = (0, peers_sdk_1.getTimestamp)();
|
|
353
479
|
await this.changeTrackingTable.insert({
|
|
354
480
|
changeId: (0, peers_sdk_1.newid)(),
|
|
355
|
-
changeType: 'snapshot',
|
|
356
|
-
timestamp: 1, // keep this low so it gets overwritten by any real changes
|
|
357
|
-
timestampApplied: (0, peers_sdk_1.getTimestamp)(),
|
|
358
481
|
tableName: this.tableName,
|
|
359
482
|
recordId,
|
|
360
|
-
|
|
483
|
+
op: 'set',
|
|
484
|
+
path: '/',
|
|
485
|
+
value: record,
|
|
486
|
+
createdAt: lowTimestamp,
|
|
487
|
+
appliedAt: now,
|
|
361
488
|
});
|
|
362
489
|
newlyTrackedRecords++;
|
|
363
490
|
}
|
|
364
491
|
batch = await nextBatch();
|
|
365
492
|
}
|
|
366
|
-
if (newlyTrackedRecords) {
|
|
367
|
-
console.log(`
|
|
493
|
+
if (newlyTrackedRecords > 0) {
|
|
494
|
+
console.log(`Tracked ${newlyTrackedRecords} previously untracked records in table ${this.tableName}`);
|
|
368
495
|
}
|
|
369
496
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
let cnt = 0;
|
|
385
|
-
const cursor = this.dataSource.cursor?.(filter) ?? (0, peers_sdk_1.dataSourceCursor)(this.dataSource, filter);
|
|
386
|
-
for await (const _ of cursor) {
|
|
387
|
-
cnt++;
|
|
388
|
-
}
|
|
389
|
-
return cnt;
|
|
390
|
-
}
|
|
391
|
-
cursor(filter = {}, opts = {}) {
|
|
392
|
-
if (this.dataSource.cursor) {
|
|
393
|
-
return this.dataSource.cursor(filter, opts);
|
|
497
|
+
/**
|
|
498
|
+
* Remove superseded change records older than the given timestamp.
|
|
499
|
+
*
|
|
500
|
+
* This helps keep the change tracking table from growing indefinitely by cleaning up
|
|
501
|
+
* changes that have been superseded and are no longer needed for synchronization.
|
|
502
|
+
*
|
|
503
|
+
* Only removes changes where supersededAt is set AND supersededAt < beforeTimestamp.
|
|
504
|
+
*
|
|
505
|
+
* @param beforeTimestamp Remove changes superseded before this timestamp (default: two weeks ago)
|
|
506
|
+
*/
|
|
507
|
+
async compact(beforeTimestamp) {
|
|
508
|
+
if (!beforeTimestamp) {
|
|
509
|
+
// Default to two weeks ago
|
|
510
|
+
beforeTimestamp = Date.now() - (14 * 24 * 60 * 60 * 1000);
|
|
394
511
|
}
|
|
395
|
-
|
|
512
|
+
// Delete all superseded changes for this table in a single SQL query
|
|
513
|
+
await this.changeTrackingTable.deleteSupersededChangesOlderThan(this.tableName, beforeTimestamp);
|
|
396
514
|
}
|
|
397
515
|
}
|
|
398
516
|
exports.TrackedDataSource = TrackedDataSource;
|