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