@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.
@@ -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
- applyChangesPromise = Promise.resolve();
27
- async applyChanges(changes) {
28
- this.applyChangesPromise = this.applyChangesPromise.finally(() => this._applyChanges(changes));
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. Use restore instead.`);
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. Use restore instead.`);
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 change = await this.changeTrackingTable.insert({
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
- newRecord: data,
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 this.dataSource.insert(data);
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 change = await this.changeTrackingTable.insert({
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
- // cleans up any old changes since they are all overshadowed by the snapshot
220
- await this.changeTrackingTable.db.exec(`
221
- DELETE FROM "${this.changeTrackingTable.tableName}"
222
- WHERE
223
- tableName = ? AND
224
- recordId = ? AND
225
- timestamp < ?
226
- `, [this.tableName, recordId, timestamp]);
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
- this.changeTrackingTable.dataChangedEmitter.emit(change);
229
- return this.dataSource.update(newData);
230
- // TODO handle errors if update fails
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
- const change = await this.changeTrackingTable.insert({
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
- oldRecord: oldData
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
- await this.dataSource.delete(data);
258
- // TODO handle errors if the delete fails
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
- const change = await this.changeTrackingTable.insert({
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
- newRecord: data,
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
- return this.dataSource.insert(data);
277
- // TODO handle errors if restore fails
226
+ // Mark all old changes as superseded
227
+ await this.changeTrackingTable.markAllPriorChangesSuperseded(this.tableName, recordId, timestamp);
228
+ return insertedData;
278
229
  }
279
- /**
280
- * WARNING: This isn't well tested.
281
- */
282
- async compact(beforeTimestamp) {
283
- if (!beforeTimestamp) {
284
- const twoWeeksAgo = new Date();
285
- twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
286
- beforeTimestamp = twoWeeksAgo.getTime();
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
- for (let i = 0; i < 100; i++) {
289
- const recordIdsWithOldUpdates = await this.changeTrackingTable.list({
290
- timestamp: { $lt: beforeTimestamp },
291
- tableName: this.tableName,
292
- changeType: 'update',
293
- }, { pageSize: 100 });
294
- if (!recordIdsWithOldUpdates.length) {
295
- return;
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
- const recordIds = (0, lodash_1.uniq)(recordIdsWithOldUpdates.map(c => c.recordId));
298
- for (const recordId of recordIds) {
299
- const newestOldChanges = await this.changeTrackingTable.list({
300
- timestamp: { $lt: beforeTimestamp },
301
- changeType: { $in: ['update', 'insert'] },
302
- tableName: this.tableName,
303
- recordId,
304
- }, { pageSize: 1, sortBy: ['-timestamp'] });
305
- // }, { pageSize: 3, sortBy: ['-timestamp'] });
306
- // if (newestOldChanges.length < 3) {
307
- // // if there are less than 3 changes, don't bother, it's not worth the network noise
308
- // continue;
309
- // }
310
- const newestOldChange = newestOldChanges[0];
311
- if (!newestOldChange) {
312
- console.warn(`No changes found for recordId: ${recordId}`);
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
- const snapshot = {
316
- changeId: (0, peers_sdk_1.newid)(),
317
- changeType: 'snapshot',
318
- timestamp: newestOldChange.timestamp + 0.001,
319
- timestampApplied: (0, peers_sdk_1.getTimestamp)(),
320
- tableName: this.tableName,
321
- recordId,
322
- newRecord: newestOldChange.newRecord,
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
- await this.applyChanges([snapshot]);
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
- * WARNING: This iterates through all records in the data source to check for missing records in the change tracking table.
331
- * This can be slow for large data sources, so use with caution.
332
- * This could be improved by using a SQL query somewhere with direct access to the database.
333
- * Also, under normal operation this should not be necessary, as the change tracking table should always be in sync with the data source.
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
- newRecord: batch.find(b => b[this.primaryKeyName] === recordId),
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(`Found ${newlyTrackedRecords} new records to track in table: ${this.tableName}`);
493
+ if (newlyTrackedRecords > 0) {
494
+ console.log(`Tracked ${newlyTrackedRecords} previously untracked records in table ${this.tableName}`);
368
495
  }
369
496
  }
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++;
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
- return (0, peers_sdk_1.dataSourceCursor)(this.dataSource, filter, opts);
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;