@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.
@@ -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
- 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];
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. Use restore instead.`);
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. 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
- const timestamp = (0, peers_sdk_1.getTimestamp)();
178
- const change = await this.changeTrackingTable.insert({
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
- newRecord: data,
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 this.dataSource.insert(data);
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 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
- });
108
+ const updatedObject = await this.dataSource.update(newData);
218
109
  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]);
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
- this.changeTrackingTable.dataChangedEmitter.emit(change);
229
- return this.dataSource.update(newData);
230
- // TODO handle errors if update fails
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
- const change = await this.changeTrackingTable.insert({
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
- oldRecord: oldData
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
- await this.dataSource.delete(data);
258
- // TODO handle errors if the delete fails
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
- const change = await this.changeTrackingTable.insert({
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
- newRecord: data,
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
- return this.dataSource.insert(data);
277
- // TODO handle errors if restore fails
227
+ // Mark all old changes as superseded
228
+ await this.changeTrackingTable.markAllPriorChangesSuperseded(this.tableName, recordId, timestamp);
229
+ return insertedData;
278
230
  }
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();
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
- 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;
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
- 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}`);
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
- 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,
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
- await this.applyChanges([snapshot]);
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
- console.warn(`Compaction failed to complete in 100 iterations. Try running it again to see if it eventually finishes.`);
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
- * 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.
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
- newRecord: batch.find(b => b[this.primaryKeyName] === recordId),
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(`Found ${newlyTrackedRecords} new records to track in table: ${this.tableName}`);
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
- cursor(filter = {}, opts = {}) {
392
- if (this.dataSource.cursor) {
393
- return this.dataSource.cursor(filter, opts);
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
- return (0, peers_sdk_1.dataSourceCursor)(this.dataSource, filter, opts);
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;