@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,19 +1,25 @@
1
1
  "use strict";
2
+ /**
3
+ * Unit tests for TrackedDataSource
4
+ */
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
- const lodash_1 = require("lodash");
4
6
  const peers_sdk_1 = require("@peers-app/peers-sdk");
5
7
  const zod_1 = require("zod");
6
8
  const tracked_data_source_1 = require("./tracked-data-source");
7
9
  const local_data_source_1 = require("./local.data-source");
8
- describe('tracked-table', () => {
9
- const db = new local_data_source_1.DBLocal(':memory:');
10
+ describe('TrackedDataSource', () => {
11
+ let db;
12
+ let sqlDataSource;
13
+ // let Notes: Table<INote>;
14
+ let changeTrackingTable;
15
+ let trackedTable;
10
16
  const notesSchema = zod_1.z.object({
11
17
  noteId: zod_1.z.string(),
12
18
  title: zod_1.z.string(),
13
19
  completed: zod_1.z.boolean().optional(),
14
- number: zod_1.z.number().optional(),
15
- string: zod_1.z.string().optional(),
16
- date: zod_1.z.date().optional(),
20
+ count: zod_1.z.number().optional(),
21
+ tags: zod_1.z.array(zod_1.z.string()).optional(),
22
+ links: peers_sdk_1.zodAnyObject.optional(),
17
23
  });
18
24
  const NotesMetaData = {
19
25
  name: 'notes',
@@ -21,805 +27,717 @@ describe('tracked-table', () => {
21
27
  primaryKeyName: 'noteId',
22
28
  fields: (0, peers_sdk_1.schemaToFields)(notesSchema),
23
29
  };
24
- const sqlDataSource = new peers_sdk_1.SQLDataSource(db, NotesMetaData, notesSchema);
25
- const mockDataContext = {
26
- dataSourceFactory: () => sqlDataSource,
27
- eventRegistry: {
28
- getEmitter: () => ({
29
- event: { subscribe: () => { } },
30
- emit: () => { }
31
- })
32
- }
33
- };
34
- const deps = {
35
- dataSource: sqlDataSource,
36
- eventRegistry: mockDataContext.eventRegistry
37
- };
38
- const Notes = new peers_sdk_1.Table(NotesMetaData, deps);
39
- const changeTrackingTable = new peers_sdk_1.ChangeTrackingTable({ db });
40
- const trackedTable = new tracked_data_source_1.TrackedDataSource(Notes, changeTrackingTable);
41
- beforeEach(async () => {
42
- // await Notes.dropTableIfExists();
43
- await changeTrackingTable.dropTableIfExists();
44
- await (0, peers_sdk_1.sleep)(200);
30
+ beforeAll(async () => {
31
+ db = new local_data_source_1.DBLocal(':memory:');
45
32
  });
46
- afterAll(async () => {
47
- await (0, peers_sdk_1.sleep)(200);
33
+ afterEach(() => {
34
+ trackedTable.preserveHistory = false;
48
35
  });
49
- it('should track changes when inserting data', async () => {
50
- // Insert a new record
51
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
52
- expect(note.noteId).toBeDefined();
53
- // Check that the change was recorded
54
- const changes = await changeTrackingTable.list();
55
- expect(changes.length).toBe(1);
56
- expect(changes[0].tableName).toBe('notes');
57
- const change = changes[0];
58
- expect(change).toEqual({
59
- changeId: expect.any(String),
60
- changeType: 'insert',
61
- timestamp: expect.any(Number),
62
- timestampApplied: expect.any(Number),
63
- tableName: 'notes',
64
- recordId: note.noteId,
65
- newRecord: note,
66
- });
36
+ afterAll(async () => {
37
+ if (db) {
38
+ await db.close();
39
+ }
67
40
  });
68
- it('should track changes when updating data', async () => {
69
- // Insert a new record
70
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
71
- // Update the record
72
- note.title = 'updated note';
73
- note.completed = true;
74
- await trackedTable.update(note);
75
- // Check that both changes were recorded
76
- const changes = await changeTrackingTable.list({ tableName: 'notes' }, { sortBy: ['timestamp'] });
77
- expect(changes.length).toBe(2);
78
- // Verify insert change
79
- expect(changes[0]).toEqual({
80
- changeId: expect.any(String),
81
- changeType: 'insert',
82
- timestamp: expect.any(Number),
83
- timestampApplied: expect.any(Number),
84
- tableName: 'notes',
85
- recordId: note.noteId,
86
- newRecord: expect.objectContaining({
87
- title: 'test note',
88
- noteId: note.noteId
89
- }),
90
- });
91
- // Verify update change
92
- const updateChange = changes[1];
93
- expect(updateChange).toEqual({
94
- changeId: expect.any(String),
95
- changeType: 'update',
96
- timestamp: expect.any(Number),
97
- timestampApplied: expect.any(Number),
98
- tableName: 'notes',
99
- recordId: note.noteId,
100
- newRecord: expect.objectContaining({
101
- title: 'updated note',
102
- noteId: note.noteId,
103
- completed: true
104
- }),
105
- jsonDiff: expect.any(Array),
106
- });
107
- // TypeScript needs this cast for type narrowing
108
- expect(updateChange.jsonDiff.length).toBeGreaterThan(0);
41
+ async function dataSourceFactory(db) {
42
+ let createDb = !db;
43
+ if (!db) {
44
+ db = new local_data_source_1.DBLocal(':memory:');
45
+ }
46
+ // Create fresh instances for each test
47
+ sqlDataSource = new peers_sdk_1.SQLDataSource(db, NotesMetaData, notesSchema);
48
+ const mockDataContext = {
49
+ dataSourceFactory: () => sqlDataSource,
50
+ eventRegistry: {
51
+ getEmitter: () => ({
52
+ event: { subscribe: () => { } },
53
+ emit: () => { },
54
+ }),
55
+ },
56
+ };
57
+ const deps = {
58
+ dataSource: sqlDataSource,
59
+ eventRegistry: mockDataContext.eventRegistry,
60
+ };
61
+ const Notes = new peers_sdk_1.Table(NotesMetaData, deps);
62
+ const changeTrackingTable = new peers_sdk_1.ChangeTrackingTable({ db });
63
+ const trackedTable = new tracked_data_source_1.TrackedDataSource(Notes, changeTrackingTable);
64
+ if (!createDb) {
65
+ // Clean up tables
66
+ await changeTrackingTable.dropTableIfExists();
67
+ await sqlDataSource.dropTableIfExists();
68
+ await (0, peers_sdk_1.sleep)(50);
69
+ }
70
+ return [changeTrackingTable, trackedTable];
71
+ }
72
+ beforeEach(async () => {
73
+ [changeTrackingTable, trackedTable] = await dataSourceFactory(db);
109
74
  });
110
- it('should track changes when deleting data', async () => {
111
- // Insert a new record
112
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
113
- // Delete the record
114
- await trackedTable.delete(note);
115
- // Check that both changes were recorded
116
- const changes = await changeTrackingTable.list({ tableName: 'notes' }, { sortBy: ['timestamp'] });
117
- expect(changes.length).toBe(2);
118
- // Get the changes in the right order
119
- const insertChange = changes.find(c => c.changeType === 'insert');
120
- const deleteChange = changes.find(c => c.changeType === 'delete');
121
- expect(insertChange).toEqual({
122
- changeId: expect.any(String),
123
- changeType: 'insert',
124
- timestamp: expect.any(Number),
125
- timestampApplied: expect.any(Number),
126
- tableName: 'notes',
127
- recordId: note.noteId,
128
- newRecord: expect.objectContaining({
129
- title: 'test note',
130
- noteId: note.noteId
131
- }),
132
- });
133
- // Verify delete change
134
- expect(deleteChange).toEqual({
135
- changeId: expect.any(String),
136
- changeType: 'delete',
137
- timestamp: expect.any(Number),
138
- timestampApplied: expect.any(Number),
139
- tableName: 'notes',
140
- recordId: note.noteId,
141
- oldRecord: expect.objectContaining({
142
- title: 'test note',
143
- noteId: note.noteId
144
- }),
75
+ describe('Insert Operations', () => {
76
+ it('should insert a record and create a single full-object change', async () => {
77
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note', completed: false });
78
+ expect(note.noteId).toBeDefined();
79
+ expect(note.title).toBe('test note');
80
+ // Check that changes were recorded
81
+ const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
82
+ // Should have exactly one change at path "/"
83
+ expect(changes.length).toBe(1);
84
+ expect(changes[0].op).toBe('set');
85
+ expect(changes[0].path).toBe('/');
86
+ expect(changes[0].value).toEqual(note);
87
+ expect(changes[0].tableName).toBe('notes');
88
+ expect(changes[0].recordId).toBe(note.noteId);
89
+ expect(changes[0].createdAt).toBeDefined();
90
+ expect(changes[0].appliedAt).toBeDefined();
91
+ // Verify the record was actually inserted
92
+ const retrieved = await trackedTable.get(note.noteId);
93
+ expect(retrieved).toEqual(note);
94
+ });
95
+ it('should auto-generate noteId if not provided', async () => {
96
+ const note = await trackedTable.insert({ title: 'test note' });
97
+ expect(note.noteId).toBeDefined();
98
+ expect(note.noteId.length).toBeGreaterThan(0);
99
+ });
100
+ it('should store operations in sequential timestamp order', async () => {
101
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note', completed: false });
102
+ const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId }, { sortBy: ['createdAt'] });
103
+ // Verify timestamps are sequential
104
+ for (let i = 1; i < changes.length; i++) {
105
+ expect(changes[i].createdAt).toBeGreaterThan(changes[i - 1].createdAt);
106
+ }
145
107
  });
146
108
  });
147
- it('should track changes when using save method for new records', async () => {
148
- // Save a new record
149
- const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
150
- // Check that the change was recorded
151
- const changes = await changeTrackingTable.list();
152
- expect(changes.length).toBe(1);
153
- expect(changes[0]).toEqual({
154
- changeId: expect.any(String),
155
- changeType: 'insert',
156
- timestamp: expect.any(Number),
157
- timestampApplied: expect.any(Number),
158
- tableName: 'notes',
159
- recordId: note.noteId,
160
- newRecord: expect.objectContaining({
161
- title: 'test note',
162
- noteId: note.noteId
163
- }),
109
+ describe('Update Operations', () => {
110
+ it('should update a record and store granular change records', async () => {
111
+ // Insert
112
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original' });
113
+ // Count initial changes
114
+ const initialChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
115
+ const initialCount = initialChanges.length;
116
+ // Update
117
+ note.title = 'updated';
118
+ note.completed = true;
119
+ await trackedTable.update(note);
120
+ // Check that new changes were added
121
+ const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
122
+ expect(allChanges.length).toBeGreaterThan(initialCount);
123
+ // The new changes should be 'replace' or 'add' operations
124
+ const updateChanges = allChanges.slice(initialCount);
125
+ updateChanges.forEach((change) => {
126
+ expect(['set', 'delete', 'patch-text']).toContain(change.op);
127
+ });
128
+ // Verify the record was actually updated
129
+ const retrieved = await trackedTable.get(note.noteId);
130
+ expect(retrieved?.title).toBe('updated');
131
+ expect(retrieved?.completed).toBe(true);
132
+ });
133
+ it('should handle no-op updates gracefully', async () => {
134
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
135
+ const changesBeforeUpdate = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
136
+ // Update with same values
137
+ await trackedTable.update(note);
138
+ const changesAfterUpdate = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
139
+ expect(changesAfterUpdate.length).toBe(changesBeforeUpdate.length); // No new changes
140
+ });
141
+ it('should mark old changes as superseded when saveAsSnapshot is true', async () => {
142
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original' });
143
+ // Get initial changes
144
+ const initialChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
145
+ expect(initialChanges.every((c) => !c.supersededAt)).toBe(true);
146
+ // Update with saveAsSnapshot
147
+ note.title = 'updated';
148
+ await trackedTable.save(note, { saveAsSnapshot: true });
149
+ // Check that old changes are marked as superseded
150
+ const oldChanges = await changeTrackingTable.list({
151
+ recordId: note.noteId,
152
+ supersededAt: { $exists: true },
153
+ });
154
+ expect(oldChanges.length).toBeGreaterThan(0);
155
+ oldChanges.forEach((change) => {
156
+ expect(change.supersededAt).toBeDefined();
157
+ });
158
+ // New changes should not be superseded
159
+ const activeChanges = await changeTrackingTable.list({
160
+ recordId: note.noteId,
161
+ supersededAt: { $exists: false },
162
+ });
163
+ expect(activeChanges.length).toBeGreaterThan(0);
164
+ activeChanges.forEach((change) => {
165
+ expect(change.supersededAt).toBeUndefined();
166
+ });
164
167
  });
165
168
  });
166
- it('should track changes when using save method for existing records', async () => {
167
- // Save a new record
168
- const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
169
- // Update using save
170
- note.title = 'updated note';
171
- await trackedTable.save(note);
172
- // Check that both changes were recorded
173
- const changes = await changeTrackingTable.list({ tableName: 'notes' }, { sortBy: ['timestamp'] });
174
- expect(changes.length).toBe(2);
175
- // Verify first change (insert)
176
- expect(changes[0]).toEqual({
177
- changeId: expect.any(String),
178
- changeType: 'insert',
179
- timestamp: expect.any(Number),
180
- timestampApplied: expect.any(Number),
181
- tableName: 'notes',
182
- recordId: note.noteId,
183
- newRecord: expect.objectContaining({
184
- title: 'test note',
185
- noteId: note.noteId
186
- }),
187
- });
188
- // Verify second change (update)
189
- const updateChange = changes[1];
190
- expect(updateChange).toEqual({
191
- changeId: expect.any(String),
192
- changeType: 'update',
193
- timestamp: expect.any(Number),
194
- timestampApplied: expect.any(Number),
195
- tableName: 'notes',
196
- recordId: note.noteId,
197
- newRecord: expect.objectContaining({
198
- title: 'updated note',
199
- noteId: note.noteId
200
- }),
201
- jsonDiff: expect.any(Array),
202
- });
203
- // TypeScript needs this cast for type narrowing
204
- expect(updateChange.jsonDiff.length).toBeGreaterThan(0);
169
+ describe('Delete Operations', () => {
170
+ it('should delete a record and create a remove operation', async () => {
171
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
172
+ await trackedTable.delete(note);
173
+ // Check for remove operation
174
+ const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
175
+ const removeOp = changes.find((c) => c.op === 'delete');
176
+ expect(removeOp).toBeDefined();
177
+ expect(removeOp?.op).toBe('delete');
178
+ // Verify record was deleted
179
+ const retrieved = await trackedTable.get(note.noteId);
180
+ expect(retrieved).toBeUndefined();
181
+ });
182
+ it('should delete by string ID', async () => {
183
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
184
+ await trackedTable.delete(note.noteId);
185
+ const retrieved = await trackedTable.get(note.noteId);
186
+ expect(retrieved).toBeUndefined();
187
+ });
188
+ it('should mark old changes as superseded after delete', async () => {
189
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
190
+ // Initial changes should not be superseded
191
+ const beforeDelete = await changeTrackingTable.list({
192
+ tableName: 'notes',
193
+ recordId: note.noteId,
194
+ supersededAt: { $exists: false },
195
+ });
196
+ expect(beforeDelete.length).toBeGreaterThan(0);
197
+ await trackedTable.delete(note);
198
+ // Old changes should now be superseded (except the remove operation)
199
+ const superseded = await changeTrackingTable.list({
200
+ tableName: 'notes',
201
+ recordId: note.noteId,
202
+ supersededAt: { $exists: true },
203
+ });
204
+ expect(superseded.length).toBeGreaterThan(0);
205
+ });
205
206
  });
206
- it('should not track read operations', async () => {
207
- // Insert a record
208
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
209
- // Perform read operations
210
- await trackedTable.get(note.noteId);
211
- await trackedTable.list();
212
- await trackedTable.cursor();
213
- // Only the insert should be recorded
214
- const changes = await changeTrackingTable.list();
215
- expect(changes.length).toBe(1);
216
- expect(changes[0].changeType).toBe('insert');
207
+ describe('Restore Operations', () => {
208
+ it('should restore a deleted record', async () => {
209
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
210
+ await trackedTable.delete(note);
211
+ // Restore
212
+ const restored = await trackedTable.restore(note);
213
+ expect(restored.noteId).toBe(note.noteId);
214
+ expect(restored.title).toBe(note.title);
215
+ // Verify it's accessible
216
+ const retrieved = await trackedTable.get(note.noteId);
217
+ expect(retrieved).toEqual(note);
218
+ });
219
+ it('should create a single add operation for restored record', async () => {
220
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
221
+ const noteId = note.noteId;
222
+ await trackedTable.delete(note);
223
+ // Count changes before restore
224
+ const beforeRestore = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId });
225
+ await trackedTable.restore(note);
226
+ const afterRestore = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId });
227
+ expect(afterRestore.length).toBe(beforeRestore.length + 1);
228
+ // Find the new 'add' operation from restore (should be exactly one at path "/")
229
+ const restoreOps = afterRestore.filter((c) => !c.supersededAt);
230
+ expect(restoreOps.length).toBe(1);
231
+ expect(restoreOps[0].op).toBe('set');
232
+ expect(restoreOps[0].path).toBe('/');
233
+ expect(restoreOps[0].value).toEqual(note);
234
+ });
235
+ it('should mark old changes as superseded after restore', async () => {
236
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
237
+ await trackedTable.delete(note);
238
+ await trackedTable.restore(note);
239
+ const superseded = await changeTrackingTable.list({
240
+ tableName: 'notes',
241
+ recordId: note.noteId,
242
+ supersededAt: { $exists: true },
243
+ });
244
+ expect(superseded.length).toBeGreaterThan(0);
245
+ });
217
246
  });
218
- it('should handle snapshot changes', async () => {
219
- const noteId = (0, peers_sdk_1.newid)();
220
- // Create a snapshot change directly
221
- const snapshotChange = {
222
- changeId: (0, peers_sdk_1.newid)(),
223
- changeType: 'snapshot',
224
- timestamp: Date.now(),
225
- timestampApplied: Date.now(),
226
- tableName: 'notes',
227
- recordId: noteId,
228
- newRecord: { noteId, title: 'snapshot note', completed: false }
229
- };
230
- // Apply the snapshot change
231
- await trackedTable.applyChanges([snapshotChange]);
232
- // Verify the record was created
233
- const note = await trackedTable.get(noteId);
234
- expect(note).toEqual({ noteId, title: 'snapshot note', completed: false });
235
- // Verify the snapshot change was recorded
236
- const changes = await changeTrackingTable.list({ recordId: noteId });
237
- expect(changes.length).toBe(1);
238
- expect(changes[0]).toEqual({
239
- changeId: snapshotChange.changeId,
240
- changeType: 'snapshot',
241
- timestamp: snapshotChange.timestamp,
242
- timestampApplied: expect.any(Number),
243
- tableName: 'notes',
244
- recordId: noteId,
245
- newRecord: expect.objectContaining({
246
- noteId,
247
- title: 'snapshot note',
248
- completed: false
249
- })
247
+ describe('Save Operations', () => {
248
+ it('should route to insert for new records', async () => {
249
+ const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
250
+ expect(note.noteId).toBeDefined();
251
+ const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
252
+ expect(changes.length).toBeGreaterThan(0);
253
+ expect(changes.every((c) => c.op === 'set')).toBe(true);
254
+ });
255
+ it('should route to update for existing records', async () => {
256
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original' });
257
+ const initialCount = (await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId })).length;
258
+ note.title = 'updated';
259
+ await trackedTable.save(note);
260
+ const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
261
+ expect(allChanges.length).toBeGreaterThan(initialCount);
262
+ });
263
+ it('should restore deleted record when restoreIfDeleted is true', async () => {
264
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
265
+ await trackedTable.delete(note);
266
+ // Try to save with restoreIfDeleted option
267
+ const restored = await trackedTable.save(note, { restoreIfDeleted: true });
268
+ expect(restored.noteId).toBe(note.noteId);
269
+ const retrieved = await trackedTable.get(note.noteId);
270
+ expect(retrieved).toBeDefined();
271
+ });
272
+ it('should throw error when saving deleted record without restoreIfDeleted', async () => {
273
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
274
+ await trackedTable.delete(note);
275
+ await expect(trackedTable.save(note)).rejects.toThrow('has been deleted');
276
+ });
277
+ it('should save snapshots as a single add at root path', async () => {
278
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original', count: 1 });
279
+ const updatedNote = await trackedTable.save({ ...note, title: 'updated', count: 2 }, { saveAsSnapshot: true });
280
+ const changes = await changeTrackingTable.list({ recordId: note.noteId }, { sortBy: ['createdAt'] });
281
+ expect(changes.length).toBe(2); // One for insert, one for snapshot save
282
+ expect(changes[0].value).toEqual(note);
283
+ expect(changes[1].value).toEqual(updatedNote);
284
+ });
285
+ it('should save non-snapshot updates as individual changes', async () => {
286
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original', count: 1 });
287
+ await trackedTable.save({ ...note, title: 'updated', count: 2 });
288
+ const changes = await changeTrackingTable.list({ recordId: note.noteId }, { sortBy: ['createdAt'] });
289
+ expect(changes.length).toBe(3); // One for insert, two for field updates
290
+ expect(changes[0].value).toEqual(note);
291
+ const titleChange = changes.find(c => c.path === '/title');
292
+ const countChange = changes.find(c => c.path === '/count');
293
+ expect(titleChange?.value).toBe('updated');
294
+ expect(countChange?.value).toBe(2);
250
295
  });
251
296
  });
252
- it('should list changes for a specific table', async () => {
253
- // Insert a record
254
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
255
- // Update the record
256
- note.title = 'updated note';
257
- await trackedTable.update(note);
258
- // Delete the record
259
- await trackedTable.delete(note);
260
- // Use listChanges to get changes
261
- const changes = await trackedTable.listChanges({}, { sortBy: ['timestamp'] });
262
- // Verify we have 3 changes (insert, update, delete)
263
- expect(changes.length).toBe(3);
264
- expect(changes[0].changeType).toBe('insert');
265
- expect(changes[1].changeType).toBe('update');
266
- expect(changes[2].changeType).toBe('delete');
267
- // Test filtering by changeType
268
- const insertChanges = await trackedTable.listChanges({ changeType: 'insert' });
269
- expect(insertChanges.length).toBe(1);
270
- expect(insertChanges[0].changeType).toBe('insert');
271
- // Test filtering by recordId
272
- const recordChanges = await trackedTable.listChanges({ recordId: note.noteId }, { sortBy: ['timestamp'] });
273
- expect(recordChanges.length).toBe(3);
274
- expect(recordChanges.map(c => c.changeType)).toEqual(['insert', 'update', 'delete']);
297
+ describe('Read Operations', () => {
298
+ it('should get a record by id', async () => {
299
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
300
+ const retrieved = await trackedTable.get(note.noteId);
301
+ expect(retrieved).toEqual(note);
302
+ });
303
+ it('should list records with filters', async () => {
304
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1', completed: true });
305
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2', completed: false });
306
+ const completed = await trackedTable.list({ completed: true });
307
+ expect(completed.length).toBe(1);
308
+ expect(completed[0].title).toBe('note1');
309
+ });
310
+ it('should count records', async () => {
311
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
312
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
313
+ const count = await trackedTable.count();
314
+ expect(count).toBe(2);
315
+ });
316
+ it('should iterate records with cursor', async () => {
317
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
318
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
319
+ const cursor = trackedTable.cursor();
320
+ const notes = [];
321
+ for await (const note of cursor) {
322
+ notes.push(note);
323
+ }
324
+ expect(notes.length).toBe(2);
325
+ });
275
326
  });
276
- it('should apply new changes to a table and remove old, irrelevant changes', async () => {
277
- // Create a change to insert a record
278
- const noteId = (0, peers_sdk_1.newid)();
279
- const insertChange = {
280
- changeId: (0, peers_sdk_1.newid)(),
281
- changeType: 'insert',
282
- timestamp: Date.now(),
283
- timestampApplied: Date.now(),
284
- tableName: 'notes',
285
- recordId: noteId,
286
- newRecord: { noteId, title: 'test note' }
287
- };
288
- // Apply the insert change
289
- await trackedTable.applyChanges([insertChange]);
290
- // Verify the record was inserted
291
- const note = await trackedTable.get(noteId);
292
- expect(note).toEqual({ noteId, title: 'test note' });
293
- // Create an update change
294
- const updateChange = {
295
- changeId: (0, peers_sdk_1.newid)(),
296
- changeType: 'update',
297
- timestamp: insertChange.timestamp + 1,
298
- timestampApplied: insertChange.timestamp + 1,
299
- tableName: 'notes',
300
- recordId: noteId,
301
- oldRecord: { noteId, title: 'test note' },
302
- newRecord: { noteId, title: 'updated note', completed: true },
303
- jsonDiff: [{ op: 'replace', path: '/title', value: 'updated note' },
304
- { op: 'add', path: '/completed', value: true }]
305
- };
306
- // Apply the update change
307
- await trackedTable.applyChanges([updateChange]);
308
- // Verify the record was updated
309
- const updatedNote = await trackedTable.get(noteId);
310
- expect(updatedNote).toEqual({ noteId, title: 'updated note', completed: true });
311
- // Verify the changes were recorded in the change tracking table
312
- let changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
313
- expect(changes.length).toBe(2);
314
- expect(changes.map(c => c.changeType)).toEqual(['insert', 'update']);
315
- // Create a delete change
316
- const deleteChange = {
317
- changeId: (0, peers_sdk_1.newid)(),
318
- changeType: 'delete',
319
- timestamp: insertChange.timestamp + 2,
320
- timestampApplied: insertChange.timestamp + 2,
321
- tableName: 'notes',
322
- recordId: noteId,
323
- oldRecord: { noteId, title: 'updated note', completed: true },
324
- };
325
- // Apply the delete change
326
- await trackedTable.applyChanges([deleteChange]);
327
- // Verify the record was deleted
328
- const deletedNote = await trackedTable.get(noteId);
329
- expect(deletedNote).toBeUndefined();
330
- // Verify the delete change was recorded and removed older (and no longer applicable changes)
331
- changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
332
- expect(changes.length).toBe(1);
333
- expect(changes.map(c => c.changeType)).toEqual(['delete']);
334
- // reapply the insert change
335
- // Create a change to insert a record
336
- const insertChange2 = {
337
- changeId: (0, peers_sdk_1.newid)(),
338
- changeType: 'insert',
339
- timestamp: insertChange.timestamp + 3,
340
- timestampApplied: insertChange.timestamp + 3,
341
- tableName: 'notes',
342
- recordId: noteId,
343
- newRecord: { noteId, title: 'reinsert note' }
344
- };
345
- // Apply the insert change
346
- await trackedTable.applyChanges([insertChange2]);
347
- // Verify the insert change was recorded and removed older (and no longer applicable delete change)
348
- changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
349
- expect(changes.length).toBe(1);
350
- expect(changes.map(c => c.changeType)).toEqual(['insert']);
327
+ describe('Change Tracking Methods', () => {
328
+ it('should list changes for this table', async () => {
329
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
330
+ const changes = await trackedTable.listChanges();
331
+ expect(changes.length).toBeGreaterThan(0);
332
+ changes.forEach((c) => {
333
+ expect(c.tableName).toBe('notes');
334
+ });
335
+ });
336
+ it('should filter changes by recordId', async () => {
337
+ const note1 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
338
+ const note2 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
339
+ const changes1 = await trackedTable.listChanges({ recordId: note1.noteId });
340
+ expect(changes1.every((c) => c.recordId === note1.noteId)).toBe(true);
341
+ const changes2 = await trackedTable.listChanges({ recordId: note2.noteId });
342
+ expect(changes2.every((c) => c.recordId === note2.noteId)).toBe(true);
343
+ });
344
+ it('should use cursor for changes', async () => {
345
+ await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
346
+ const cursor = await trackedTable.cursorChanges();
347
+ const changes = [];
348
+ for await (const change of cursor) {
349
+ changes.push(change);
350
+ }
351
+ expect(changes.length).toBeGreaterThan(0);
352
+ });
351
353
  });
352
- it('should not reapply changes that have already been applied', async () => {
353
- // Create a test change
354
- const noteId = (0, peers_sdk_1.newid)();
355
- const changeId = (0, peers_sdk_1.newid)();
356
- const insertChange = {
357
- changeId,
358
- changeType: 'insert',
359
- timestamp: Date.now(),
360
- timestampApplied: Date.now(),
361
- tableName: 'notes',
362
- recordId: noteId,
363
- newRecord: { noteId, title: 'duplicate test' }
364
- };
365
- // Apply the change
366
- await trackedTable.applyChanges([insertChange]);
367
- // Verify the record was inserted
368
- const note = await trackedTable.get(noteId);
369
- expect(note).toEqual({ noteId, title: 'duplicate test' });
370
- // Try to apply the same change again
371
- await trackedTable.applyChanges([insertChange]);
372
- // Verify the change was only recorded once
373
- const changes = await changeTrackingTable.list({ changeId });
374
- expect(changes.length).toBe(1);
354
+ describe('Helper Methods', () => {
355
+ it('should identify deleted records', async () => {
356
+ const note1 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
357
+ const note2 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
358
+ await trackedTable.delete(note1);
359
+ const deletedIds = await changeTrackingTable.filterToDeletedIds([note1.noteId, note2.noteId]);
360
+ expect(deletedIds).toContain(note1.noteId);
361
+ expect(deletedIds).not.toContain(note2.noteId);
362
+ });
363
+ it('should handle empty array in filterToDeletedIds', async () => {
364
+ const deletedIds = await changeTrackingTable.filterToDeletedIds([]);
365
+ expect(deletedIds).toEqual([]);
366
+ });
375
367
  });
376
- it('should apply changes in timestamp order', async () => {
377
- const noteId = (0, peers_sdk_1.newid)();
378
- const now = Date.now();
379
- // Create changes with out-of-order timestamps
380
- const changes = [
381
- {
368
+ describe('Edge Cases', () => {
369
+ it('should handle insert with all optional fields', async () => {
370
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'minimal' });
371
+ expect(note.completed).toBeUndefined();
372
+ expect(note.count).toBeUndefined();
373
+ });
374
+ it('should handle update removing optional fields', async () => {
375
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test', completed: true, count: 5 });
376
+ // Update to remove optional fields
377
+ const updated = { noteId: note.noteId, title: 'test' };
378
+ await trackedTable.update(updated);
379
+ const retrieved = await trackedTable.get(note.noteId);
380
+ expect(retrieved?.title).toBe('test');
381
+ // The optional fields behavior depends on the underlying implementation
382
+ });
383
+ it('should handle concurrent operations on different records', async () => {
384
+ const promises = [];
385
+ for (let i = 0; i < 5; i++) {
386
+ promises.push(trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: `note${i}` }));
387
+ }
388
+ const notes = await Promise.all(promises);
389
+ expect(notes.length).toBe(5);
390
+ const count = await trackedTable.count();
391
+ expect(count).toBe(5);
392
+ });
393
+ it('should handle distributed deletion scenario', async () => {
394
+ // Scenario: One peer deletes a record, another peer (disconnected) makes changes
395
+ const noteId = (0, peers_sdk_1.newid)();
396
+ trackedTable.preserveHistory = true;
397
+ // Peer 1: Create record
398
+ await trackedTable.insert({ noteId, title: 'original', completed: false });
399
+ // Peer 1: Delete record at timestamp 2000
400
+ await trackedTable.delete(noteId);
401
+ // Peer 2: Makes changes AFTER deletion (while disconnected)
402
+ // Simulates this by manually inserting change records with later timestamps
403
+ const laterTimestamp = Date.now() + 10000; // 10 seconds later
404
+ // Peer 2 writes to specific paths (doesn't know about deletion)
405
+ const laterChange1 = {
382
406
  changeId: (0, peers_sdk_1.newid)(),
383
- changeType: 'update',
384
- timestamp: now + 200,
385
- timestampApplied: now + 200,
386
407
  tableName: 'notes',
387
408
  recordId: noteId,
388
- oldRecord: { noteId, title: 'second title' },
389
- newRecord: { noteId, title: 'final title' },
390
- jsonDiff: [{ op: 'replace', path: '/title', value: 'final title' }]
391
- },
392
- {
409
+ op: 'set',
410
+ path: '/title',
411
+ value: 'updated by peer 2',
412
+ createdAt: laterTimestamp,
413
+ appliedAt: laterTimestamp,
414
+ };
415
+ const laterChange2 = {
393
416
  changeId: (0, peers_sdk_1.newid)(),
394
- changeType: 'insert',
395
- timestamp: now,
396
- timestampApplied: now,
397
417
  tableName: 'notes',
398
418
  recordId: noteId,
399
- newRecord: { noteId, title: 'initial title' }
400
- },
401
- {
419
+ op: 'set',
420
+ path: '/completed',
421
+ value: true,
422
+ createdAt: laterTimestamp + 1,
423
+ appliedAt: laterTimestamp + 1,
424
+ };
425
+ // Apply changes from "Peer 2" - this will insert them and trigger superseding detection
426
+ await trackedTable.applyChanges([laterChange1, laterChange2]);
427
+ // Verify the record is still considered deleted (based on path "/" remove)
428
+ const deletedIds = await changeTrackingTable.filterToDeletedIds([noteId]);
429
+ expect(deletedIds).toContain(noteId);
430
+ // Verify the later changes are marked as superseded
431
+ const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId }, { sortBy: ['createdAt'] });
432
+ // Find the changes with the later timestamps (the ones we manually added)
433
+ const change1InDb = allChanges.find((c) => c.changeId === laterChange1.changeId);
434
+ const change2InDb = allChanges.find((c) => c.changeId === laterChange2.changeId);
435
+ // All changes after the deletion should be superseded
436
+ expect(change1InDb).toBeDefined();
437
+ expect(change1InDb?.supersededAt).toBeDefined();
438
+ expect(change2InDb).toBeDefined();
439
+ expect(change2InDb?.supersededAt).toBeDefined();
440
+ // Verify the record does not exist in the data source (still deleted)
441
+ const record = await trackedTable.get(noteId);
442
+ expect(record).toBeUndefined();
443
+ });
444
+ it('should handle insert with existing deleted record', async () => {
445
+ const noteId = (0, peers_sdk_1.newid)();
446
+ // Insert and then delete a record
447
+ await trackedTable.insert({ noteId, title: 'original note' });
448
+ await trackedTable.delete(noteId);
449
+ // Try to insert with same ID - should fail
450
+ await expect(trackedTable.insert({ noteId, title: 'new note' })).rejects.toThrow('because it has been deleted');
451
+ });
452
+ it('should handle update with missing record', async () => {
453
+ const noteId = (0, peers_sdk_1.newid)();
454
+ // Try to update non-existent record
455
+ await expect(trackedTable.update({ noteId, title: 'new note' })).rejects.toThrow('No record found to update');
456
+ });
457
+ it('should handle delete with string ID', async () => {
458
+ const noteId = (0, peers_sdk_1.newid)();
459
+ // Insert a record first
460
+ await trackedTable.insert({ noteId, title: 'test note' });
461
+ // Delete by string ID should work
462
+ await trackedTable.delete(noteId);
463
+ // Verify it was deleted
464
+ const note = await trackedTable.get(noteId);
465
+ expect(note).toBeUndefined();
466
+ });
467
+ it('should find and track untracked records', async () => {
468
+ // Insert records directly into the underlying data source (bypassing change tracking)
469
+ const note1 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 1' };
470
+ const note2 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 2' };
471
+ const note3 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 3' };
472
+ await sqlDataSource.insert(note1);
473
+ await sqlDataSource.insert(note2);
474
+ await sqlDataSource.insert(note3);
475
+ // Verify no change records exist for these
476
+ let changes = await changeTrackingTable.list({ tableName: 'notes' });
477
+ expect(changes.length).toBe(0);
478
+ // Run findAndTrackRecords
479
+ await trackedTable.findAndTrackRecords();
480
+ // Verify change records were created
481
+ changes = await changeTrackingTable.list({ tableName: 'notes' });
482
+ expect(changes.length).toBe(3);
483
+ // All should be 'set' operations at path "/"
484
+ changes.forEach(change => {
485
+ expect(change.op).toBe('set');
486
+ expect(change.path).toBe('/');
487
+ expect(change.createdAt).toBeLessThan(10); // Low timestamp
488
+ expect(change.appliedAt).toBeGreaterThan(Date.now() - 1000); // Recent appliedAt
489
+ });
490
+ // Verify the records themselves are accessible
491
+ const allNotes = await trackedTable.list();
492
+ expect(allNotes.length).toBe(3);
493
+ });
494
+ it('should compact superseded changes older than given timestamp', async () => {
495
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
496
+ const noteId = note.noteId;
497
+ // Make several updates to create multiple changes
498
+ await trackedTable.update({ noteId, title: 'update 1' });
499
+ await (0, peers_sdk_1.sleep)(10);
500
+ await trackedTable.update({ noteId, title: 'update 2' });
501
+ await (0, peers_sdk_1.sleep)(10);
502
+ await trackedTable.update({ noteId, title: 'update 3' });
503
+ await (0, peers_sdk_1.sleep)(10);
504
+ // Get all changes before compacting
505
+ const changesBefore = await changeTrackingTable.list({ recordId: noteId });
506
+ expect(changesBefore.length).toBeGreaterThan(1);
507
+ // Count superseded changes
508
+ const supersededBefore = changesBefore.filter(c => c.supersededAt).length;
509
+ expect(supersededBefore).toBeGreaterThan(0);
510
+ // Compact (remove all superseded changes by using future timestamp)
511
+ await trackedTable.compact(Date.now() + 1000);
512
+ // Get changes after compacting
513
+ const changesAfter = await changeTrackingTable.list({ recordId: noteId });
514
+ // Should have fewer changes now
515
+ expect(changesAfter.length).toBeLessThan(changesBefore.length);
516
+ // Should have no superseded changes left
517
+ const supersededAfter = changesAfter.filter(c => c.supersededAt).length;
518
+ expect(supersededAfter).toBe(0);
519
+ // Verify the record is still accessible and correct
520
+ const retrieved = await trackedTable.get(noteId);
521
+ expect(retrieved?.title).toBe('update 3');
522
+ });
523
+ it('should compact only changes superseded before given timestamp', async () => {
524
+ const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
525
+ const noteId = note.noteId;
526
+ // Make updates
527
+ await trackedTable.update({ noteId, title: 'update 1' });
528
+ await (0, peers_sdk_1.sleep)(10);
529
+ const midTimestamp = Date.now();
530
+ await (0, peers_sdk_1.sleep)(10);
531
+ await trackedTable.update({ noteId, title: 'update 2' });
532
+ await (0, peers_sdk_1.sleep)(10);
533
+ await trackedTable.update({ noteId, title: 'update 3' });
534
+ const changesBefore = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
535
+ const supersededBefore = changesBefore.filter(c => c.supersededAt).length;
536
+ // Compact only changes superseded before midTimestamp
537
+ await trackedTable.compact(midTimestamp);
538
+ const changesAfter = await changeTrackingTable.list({ recordId: noteId });
539
+ // Since all updates happen to /title, and superseding happens when the next update occurs,
540
+ // only changes superseded BEFORE midTimestamp will be removed.
541
+ // In this test, update 1 happens before midTimestamp, but is superseded by update 2 which happens AFTER midTimestamp.
542
+ // Therefore, no changes should be removed.
543
+ expect(changesAfter.length).toBe(changesBefore.length); // No changes should be removed
544
+ // There should still be superseded changes (the ones after midTimestamp)
545
+ const supersededAfter = changesAfter.filter(c => c.supersededAt).length;
546
+ expect(supersededAfter).toBe(supersededBefore); // Same number of superseded changes
547
+ });
548
+ });
549
+ describe('applyChanges', () => {
550
+ it('should save but supersede remote changes that are older', async () => {
551
+ const noteId = `000000000000000000note001`;
552
+ trackedTable.preserveHistory = true;
553
+ const note = await trackedTable.save({ noteId, title: 'original' });
554
+ const timeUpdatedRemote = (0, peers_sdk_1.getTimestamp)();
555
+ note.title = 'updated1';
556
+ await trackedTable.save(note);
557
+ const changes = await changeTrackingTable.list({ recordId: note.noteId });
558
+ expect(changes.length).toBe(2);
559
+ // Create changes to apply
560
+ const remoteChange = {
402
561
  changeId: (0, peers_sdk_1.newid)(),
403
- changeType: 'update',
404
- timestamp: now + 100,
405
- timestampApplied: now + 100,
406
562
  tableName: 'notes',
407
- recordId: noteId,
408
- oldRecord: { noteId, title: 'initial title' },
409
- newRecord: { noteId, title: 'second title' },
410
- jsonDiff: [{ op: 'replace', path: '/title', value: 'second title' }]
411
- }
412
- ];
413
- // Store the changeIds for verification later
414
- const changeIds = {
415
- insert: changes[1].changeId,
416
- update1: changes[2].changeId,
417
- update2: changes[0].changeId
418
- };
419
- // Apply the out-of-order changes
420
- await trackedTable.applyChanges(changes);
421
- // Verify the final state reflects the changes applied in timestamp order
422
- const note = await trackedTable.get(noteId);
423
- expect(note).toEqual({ noteId, title: 'final title' });
424
- // Verify the changes were recorded in order
425
- const recordedChanges = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
426
- expect(recordedChanges.length).toBe(3);
427
- expect(recordedChanges.map(c => c.changeId)).toEqual([
428
- changeIds.insert,
429
- changeIds.update1,
430
- changeIds.update2
431
- ]);
432
- });
433
- it('should require a restore operation to bring a record back after being deleted', async () => {
434
- // Create a change to insert a record
435
- const noteId = (0, peers_sdk_1.newid)();
436
- const insertChange = {
437
- changeId: (0, peers_sdk_1.newid)(),
438
- changeType: 'insert',
439
- timestamp: Date.now(),
440
- timestampApplied: Date.now(),
441
- tableName: 'notes',
442
- recordId: noteId,
443
- newRecord: { noteId, title: 'test note' }
444
- };
445
- // Apply the insert change
446
- await trackedTable.applyChanges([insertChange]);
447
- // Verify the record was inserted
448
- const note = await trackedTable.get(noteId);
449
- expect(note).toEqual({ noteId, title: 'test note' });
450
- // Create a delete change
451
- const deleteChange = {
452
- changeId: (0, peers_sdk_1.newid)(),
453
- changeType: 'delete',
454
- timestamp: insertChange.timestamp + 1,
455
- timestampApplied: insertChange.timestamp + 1,
456
- tableName: 'notes',
457
- recordId: noteId,
458
- oldRecord: { noteId, title: 'test note' }
459
- };
460
- // Apply the delete change
461
- await trackedTable.applyChanges([deleteChange]);
462
- // Verify the record was deleted
463
- const deletedNote = await trackedTable.get(noteId);
464
- expect(deletedNote).toBeUndefined();
465
- // Create an update change
466
- const updateChange = {
467
- changeId: (0, peers_sdk_1.newid)(),
468
- changeType: 'update',
469
- timestamp: insertChange.timestamp + 1,
470
- timestampApplied: insertChange.timestamp + 1,
471
- tableName: 'notes',
472
- recordId: noteId,
473
- oldRecord: { noteId, title: 'test note' },
474
- newRecord: { noteId, title: 'updated note', completed: true },
475
- jsonDiff: [{ op: 'replace', path: '/title', value: 'updated note' },
476
- { op: 'add', path: '/completed', value: true }]
477
- };
478
- // Apply the update change
479
- await trackedTable.applyChanges([updateChange]);
480
- // Verify the record was not updated
481
- const updatedNote = await trackedTable.get(noteId);
482
- expect(updatedNote).toBeUndefined();
483
- // Create a restore change
484
- const restoreChange = {
485
- changeId: (0, peers_sdk_1.newid)(),
486
- changeType: 'restore',
487
- timestamp: insertChange.timestamp + 2,
488
- timestampApplied: insertChange.timestamp + 2,
489
- tableName: 'notes',
490
- recordId: noteId,
491
- newRecord: { noteId, title: 'restored note' }
492
- };
493
- // Apply the restore change
494
- await trackedTable.applyChanges([restoreChange]);
495
- // Verify the record was restored
496
- const restoredNote = await trackedTable.get(noteId);
497
- expect(restoredNote).toEqual({ noteId, title: 'restored note' });
498
- });
499
- it('should correctly and efficiently apply new changes to records with a very large amount of existing writes', async () => {
500
- const db2 = new local_data_source_1.DBLocal(':memory:');
501
- const mockDataContext2 = {
502
- dataSourceFactory: () => new peers_sdk_1.SQLDataSource(db2, NotesMetaData, notesSchema),
503
- eventRegistry: {
504
- getEmitter: () => ({
505
- event: { subscribe: () => { } },
506
- emit: () => { }
507
- })
508
- }
509
- };
510
- const deps2 = {
511
- dataSource: new (require("@peers-app/peers-sdk")).SQLDataSource(db2, NotesMetaData, notesSchema),
512
- eventRegistry: mockDataContext2.eventRegistry,
513
- };
514
- const Notes2 = new peers_sdk_1.Table(NotesMetaData, deps2);
515
- const changeTrackingTable2 = new peers_sdk_1.ChangeTrackingTable({ db: db2 });
516
- const trackedTable2 = new tracked_data_source_1.TrackedDataSource(Notes2, changeTrackingTable2);
517
- // Create a record with a large number of changes
518
- const noteId = (0, peers_sdk_1.newid)();
519
- const note = { noteId, title: 'test note' };
520
- const checkpointCount = 20;
521
- const writesBetweenCheckpointsCount = 20;
522
- let lastCheckpointTimestamp = 0;
523
- let note1Deleted = false;
524
- let note2Deleted = false;
525
- async function save1() {
526
- if (note1Deleted) {
527
- await trackedTable.save(note, { restoreIfDeleted: true });
528
- note1Deleted = false;
529
- }
530
- else {
531
- await trackedTable.save(note);
532
- }
533
- }
534
- async function save2() {
535
- if (note2Deleted) {
536
- await trackedTable2.save(note, { restoreIfDeleted: true });
537
- note2Deleted = false;
538
- }
539
- else {
540
- await trackedTable2.save(note);
541
- }
542
- }
543
- for (let i = 0; i < checkpointCount; i++) {
544
- for (let j = 0; j < writesBetweenCheckpointsCount; j++) {
545
- note.title = `update ${i} ${j}`;
546
- note.completed = (0, lodash_1.random)(0, 10) === 0;
547
- if ((0, lodash_1.random)(0, 10) === 0) {
548
- note.number = (0, lodash_1.random)(0, 1000);
549
- }
550
- if ((0, lodash_1.random)(0, 2) === 0) {
551
- note.string = (note.string || '') + note.title;
552
- note.string = (note.string || '') + note.title;
553
- const replaceStartI = (0, lodash_1.random)(0, note.string.length - 1);
554
- const replaceEndI = (0, lodash_1.random)(replaceStartI, note.string.length - 1);
555
- note.string = note.string.replace(note.string?.substring(replaceStartI, replaceEndI), note.title);
556
- }
557
- if ((0, lodash_1.random)(0, 4) > 0) {
558
- note.date = new Date();
559
- }
560
- if ((0, lodash_1.random)(0, 10) > 0 || j === writesBetweenCheckpointsCount - 1) {
561
- await save1();
562
- }
563
- if ((0, lodash_1.random)(0, 10) > 0) {
564
- await save2();
565
- }
566
- // random delete
567
- if ((0, lodash_1.random)(0, 10) === 0) {
568
- await trackedTable.delete(note);
569
- note1Deleted = true;
570
- }
571
- if ((0, lodash_1.random)(0, 10) === 0) {
572
- await trackedTable2.delete(note);
573
- note2Deleted = true;
574
- }
575
- }
576
- if (note1Deleted !== note2Deleted) {
577
- note.title = `checkpoint ${i}`;
578
- await save1();
579
- await save2();
580
- note1Deleted = false;
581
- note2Deleted = false;
582
- }
583
- let changes = await trackedTable.listChanges({ timestamp: { $gte: lastCheckpointTimestamp } }, { sortBy: ['timestamp'] });
584
- let changes2 = await trackedTable2.listChanges({ timestamp: { $gte: lastCheckpointTimestamp } }, { sortBy: ['timestamp'] });
585
- await trackedTable.applyChanges(changes2);
586
- await trackedTable2.applyChanges(changes);
587
- changes = await trackedTable.listChanges({ timestamp: { $gte: lastCheckpointTimestamp } }, { sortBy: ['timestamp'] });
588
- changes2 = await trackedTable2.listChanges({ timestamp: { $gte: lastCheckpointTimestamp } }, { sortBy: ['timestamp'] });
589
- expect(changes.map(c => c.changeId)).toEqual(changes2.map(c => c.changeId));
590
- lastCheckpointTimestamp = changes[changes.length - 1].timestamp;
591
- }
592
- const changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
593
- const changes2 = await changeTrackingTable2.list({ recordId: noteId }, { sortBy: ['timestamp'] });
594
- expect(changes.map(c => c.changeId)).toEqual(changes2.map(c => c.changeId));
595
- const dbNote = await Notes.get(noteId);
596
- expect(dbNote).toEqual(note);
597
- const dbNote2 = await Notes2.get(noteId);
598
- expect(dbNote2).toEqual(note);
599
- });
600
- it("should be able to compact changes to remain performant", async () => {
601
- const db = new local_data_source_1.DBLocal(':memory:');
602
- const mockDataContext3 = {
603
- dataSourceFactory: () => new peers_sdk_1.SQLDataSource(db, NotesMetaData, notesSchema),
604
- eventRegistry: {
605
- getEmitter: () => ({
606
- event: { subscribe: () => { } },
607
- emit: () => { }
608
- })
609
- }
610
- };
611
- const deps3 = {
612
- dataSource: new (require("@peers-app/peers-sdk")).SQLDataSource(db, NotesMetaData, notesSchema),
613
- eventRegistry: mockDataContext3.eventRegistry,
614
- };
615
- const NotesTable = new peers_sdk_1.Table(NotesMetaData, deps3);
616
- const changeTrackingTable = new peers_sdk_1.ChangeTrackingTable({ db: db });
617
- const trackedTable = new tracked_data_source_1.TrackedDataSource(NotesTable, changeTrackingTable);
618
- // Create a record with a large number of updates
619
- const noteId = (0, peers_sdk_1.newid)();
620
- const note = { noteId, title: 'test note' };
621
- const writesCount = 200;
622
- for (let i = 0; i < writesCount; i++) {
623
- note.title = `update ${i}`;
624
- note.completed = (0, lodash_1.random)(0, 10) === 0;
625
- if ((0, lodash_1.random)(0, 10) === 0) {
626
- note.number = (0, lodash_1.random)(0, 1000);
627
- }
628
- if ((0, lodash_1.random)(0, 2) === 0) {
629
- note.string = (note.string || '') + note.title;
630
- note.string = (note.string || '') + note.title;
631
- const replaceStartI = (0, lodash_1.random)(0, note.string.length - 1);
632
- const replaceEndI = (0, lodash_1.random)(replaceStartI, note.string.length - 1);
633
- note.string = note.string.replace(note.string?.substring(replaceStartI, replaceEndI), note.title);
634
- }
635
- if ((0, lodash_1.random)(0, 4) > 0) {
636
- note.date = new Date();
637
- }
563
+ recordId: note.noteId,
564
+ op: 'set',
565
+ path: '/title',
566
+ value: 'updated2',
567
+ createdAt: timeUpdatedRemote,
568
+ appliedAt: timeUpdatedRemote,
569
+ };
570
+ await trackedTable.applyChanges([remoteChange]);
571
+ const finalRecord = await trackedTable.get(note.noteId);
572
+ expect(finalRecord).toBeDefined();
573
+ expect(finalRecord?.title).toBe('updated1'); // because remote change is superseded
574
+ const finalChanges = await changeTrackingTable.list({ recordId: note.noteId }, { sortBy: ['createdAt'] });
575
+ expect(finalChanges.length).toBe(3);
576
+ const remoteChangeInDb = finalChanges.find((c) => c.changeId === remoteChange.changeId);
577
+ expect(remoteChangeInDb).toBeDefined();
578
+ expect(remoteChangeInDb?.supersededAt).toBeDefined();
579
+ });
580
+ it('should replace the entire array when an item in the array is added or removed', async () => {
581
+ const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'original', tags: ['tag1', 'tag2'] });
582
+ note.tags = ['tag1', 'tag2', 'tag3'];
638
583
  await trackedTable.save(note);
639
- }
640
- let dbNote = await NotesTable.get(noteId);
641
- expect(dbNote).toEqual(note);
642
- const changesBeforeCompact = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
643
- expect(changesBeforeCompact.length).toBeGreaterThan(100);
644
- const tenthLastChange = changesBeforeCompact[changesBeforeCompact.length - 10];
645
- await trackedTable.compact(tenthLastChange.timestamp);
646
- const changesAfterCompact = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['timestamp'] });
647
- expect(changesAfterCompact.length).toEqual(11);
648
- dbNote = await NotesTable.get(noteId);
649
- expect(dbNote).toEqual(note);
650
- });
651
- it('should handle restore operation correctly', async () => {
652
- const noteId = (0, peers_sdk_1.newid)();
653
- // Insert and then delete a record
654
- await trackedTable.insert({ noteId, title: 'original note' });
655
- await trackedTable.delete(noteId);
656
- // Verify it's deleted
657
- const deletedNote = await trackedTable.get(noteId);
658
- expect(deletedNote).toBeUndefined();
659
- // Restore the record
660
- const restoredNote = await trackedTable.restore({ noteId, title: 'restored note' });
661
- expect(restoredNote).toEqual({ noteId, title: 'restored note' });
662
- // Verify it's restored
663
- const retrievedNote = await trackedTable.get(noteId);
664
- expect(retrievedNote).toEqual({ noteId, title: 'restored note' });
665
- // Verify restore change was tracked
666
- const changes = await trackedTable.listChanges({ recordId: noteId, changeType: 'restore' });
667
- expect(changes.length).toBe(1);
668
- expect(changes[0].changeType).toBe('restore');
669
- });
670
- it('should handle restore without recordId provided', async () => {
671
- await expect(trackedTable.restore({ title: 'no id' })).rejects.toThrow('No recordId provided to restore');
672
- });
673
- it('should handle insert with existing deleted record', async () => {
674
- const noteId = (0, peers_sdk_1.newid)();
675
- // Insert and then delete a record
676
- await trackedTable.insert({ noteId, title: 'original note' });
677
- await trackedTable.delete(noteId);
678
- // Try to insert with same ID - should fail
679
- await expect(trackedTable.insert({ noteId, title: 'new note' })).rejects.toThrow('because it has been deleted. Use restore instead');
680
- });
681
- it('should handle save with deleted record without restore flag', async () => {
682
- const noteId = (0, peers_sdk_1.newid)();
683
- // Insert and then delete a record
684
- await trackedTable.insert({ noteId, title: 'original note' });
685
- await trackedTable.delete(noteId);
686
- // Try to save without restore flag - should fail
687
- await expect(trackedTable.save({ noteId, title: 'new note' })).rejects.toThrow('because it has been deleted. Use restore instead');
688
- });
689
- it('should handle update with missing record', async () => {
690
- const noteId = (0, peers_sdk_1.newid)();
691
- // Try to update non-existent record
692
- await expect(trackedTable.update({ noteId, title: 'new note' })).rejects.toThrow('No record found to update');
693
- });
694
- it('should handle delete with string ID for non-existent record', async () => {
695
- const noteId = (0, peers_sdk_1.newid)();
696
- // Mock console.warn to capture the warning
697
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
698
- // Try to delete non-existent record
699
- await trackedTable.delete(noteId);
700
- // Verify warning was logged
701
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Record not found'));
702
- warnSpy.mockRestore();
703
- });
704
- it('should handle update with no changes', async () => {
705
- const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test note' });
706
- // Update with same data - should return unchanged
707
- const result = await trackedTable.update(note);
708
- expect(result).toEqual(note);
709
- // Should still only have one change (the insert)
710
- const changes = await trackedTable.listChanges({ recordId: note.noteId });
711
- expect(changes.length).toBe(1);
712
- expect(changes[0].changeType).toBe('insert');
713
- });
714
- it('should handle compact with default timestamp', async () => {
715
- const noteId = (0, peers_sdk_1.newid)();
716
- const note = { noteId, title: 'test note' };
717
- // Create some changes
718
- await trackedTable.save(note);
719
- note.title = 'updated';
720
- await trackedTable.save(note);
721
- // Test compact with default timestamp (should use 2 weeks ago)
722
- await trackedTable.compact();
723
- // Should still have changes since they're recent
724
- const changes = await trackedTable.listChanges({ recordId: noteId });
725
- expect(changes.length).toBeGreaterThan(0);
726
- });
727
- it('should handle findAndTrackRecords method', async () => {
728
- // Use separate instance for isolation
729
- const db = new local_data_source_1.DBLocal(':memory:');
730
- const mockDataContext4 = {
731
- dataSourceFactory: () => new peers_sdk_1.SQLDataSource(db, NotesMetaData, notesSchema),
732
- eventRegistry: {
733
- getEmitter: () => ({
734
- event: { subscribe: () => { } },
735
- emit: () => { }
736
- })
737
- }
738
- };
739
- const deps4 = {
740
- dataSource: new (require("@peers-app/peers-sdk")).SQLDataSource(db, NotesMetaData, notesSchema),
741
- eventRegistry: mockDataContext4.eventRegistry,
742
- };
743
- const NotesTable = new peers_sdk_1.Table(NotesMetaData, deps4);
744
- const changeTrackingTable = new peers_sdk_1.ChangeTrackingTable({ db });
745
- const isolatedTrackedTable = new tracked_data_source_1.TrackedDataSource(NotesTable, changeTrackingTable);
746
- // Insert some records directly to data source (bypassing tracking)
747
- const note1 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 1' };
748
- const note2 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 2' };
749
- await NotesTable.insert(note1);
750
- await NotesTable.insert(note2);
751
- // Mock console.log to capture output
752
- const logSpy = jest.spyOn(console, 'log').mockImplementation();
753
- // Find and track records
754
- await isolatedTrackedTable.findAndTrackRecords(1);
755
- // Verify records were tracked
756
- const changes = await isolatedTrackedTable.listChanges({ changeType: 'snapshot' });
757
- expect(changes.length).toBe(2);
758
- // Verify console output
759
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 new records to track in table: notes'));
760
- logSpy.mockRestore();
761
- });
762
- it('should handle apply changes with restore after delete', async () => {
763
- const noteId = (0, peers_sdk_1.newid)();
764
- // Apply delete change
765
- const deleteChange = {
766
- changeId: (0, peers_sdk_1.newid)(),
767
- changeType: 'delete',
768
- timestamp: Date.now(),
769
- timestampApplied: Date.now(),
770
- tableName: 'notes',
771
- recordId: noteId,
772
- oldRecord: { noteId, title: 'deleted note' }
773
- };
774
- await trackedTable.applyChanges([deleteChange]);
775
- // Apply restore change
776
- const restoreChange = {
777
- changeId: (0, peers_sdk_1.newid)(),
778
- changeType: 'restore',
779
- timestamp: Date.now() + 1,
780
- timestampApplied: Date.now() + 1,
781
- tableName: 'notes',
782
- recordId: noteId,
783
- newRecord: { noteId, title: 'restored note' }
784
- };
785
- await trackedTable.applyChanges([restoreChange]);
786
- // Verify record was restored
787
- const note = await trackedTable.get(noteId);
788
- expect(note).toEqual({ noteId, title: 'restored note' });
789
- });
790
- it('should handle error cases in applyChanges', async () => {
791
- const noteId = (0, peers_sdk_1.newid)();
792
- // Create a change with unsupported type
793
- const unsupportedChange = {
794
- changeId: (0, peers_sdk_1.newid)(),
795
- changeType: 'unsupported',
796
- timestamp: Date.now(),
797
- timestampApplied: Date.now(),
798
- tableName: 'notes',
799
- recordId: noteId,
800
- };
801
- await expect(trackedTable.applyChanges([unsupportedChange])).rejects.toThrow('Unsupported change type');
802
- });
803
- it('should handle insert without primaryKey', async () => {
804
- // Test insert without providing primary key
805
- const result = await trackedTable.insert({ title: 'no id' });
806
- expect(result.noteId).toBeDefined();
807
- expect(result.title).toBe('no id');
808
- });
809
- it('should handle save with record that needs insert', async () => {
810
- const noteId = (0, peers_sdk_1.newid)();
811
- // Test save with new record (should insert)
812
- const result = await trackedTable.save({ noteId, title: 'new record' });
813
- expect(result).toEqual({ noteId, title: 'new record' });
814
- // Verify it was inserted
815
- const retrieved = await trackedTable.get(noteId);
816
- expect(retrieved).toEqual({ noteId, title: 'new record' });
817
- });
818
- it('should handle save without primaryKey', async () => {
819
- // Test save without providing primary key (should insert)
820
- const result = await trackedTable.save({ title: 'no id' });
821
- expect(result.noteId).toBeDefined();
822
- expect(result.title).toBe('no id');
584
+ const changes = await changeTrackingTable.list({ recordId: note.noteId });
585
+ expect(changes.length).toBe(2);
586
+ const addTagChange = changes.find((c) => c.op === 'set' && c.path === '/tags');
587
+ expect(addTagChange).toBeDefined();
588
+ expect(addTagChange?.value).toEqual(['tag1', 'tag2', 'tag3']);
589
+ });
590
+ it('should updating two similarly named fields', async () => {
591
+ const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'original', links: { a: 1, aa: 2 } });
592
+ const ts = (0, peers_sdk_1.getTimestamp)();
593
+ await trackedTable.applyChanges([
594
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'set', path: '/links/aa', value: 3, createdAt: ts, appliedAt: ts },
595
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'set', path: '/links/a', value: 4, createdAt: ts + 1, appliedAt: ts + 1 },
596
+ ]);
597
+ const dbNote = await trackedTable.get(note.noteId);
598
+ expect(dbNote).toBeDefined();
599
+ expect(dbNote?.links).toBeDefined();
600
+ expect(dbNote?.links?.aa).toBe(3);
601
+ expect(dbNote?.links?.a).toBe(4);
602
+ });
603
+ it('should apply deep changes after higher changes', async () => {
604
+ const noteId = `000000000000000000note001`;
605
+ let note = {
606
+ noteId,
607
+ title: 'original',
608
+ };
609
+ const ts = (0, peers_sdk_1.getTimestamp)();
610
+ await trackedTable.applyChanges([
611
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
612
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
613
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
614
+ ]);
615
+ note = await trackedTable.get(noteId);
616
+ expect(note).toBeDefined();
617
+ expect(note?.links).toBeDefined();
618
+ expect(note?.links?.a).toBe('2');
619
+ });
620
+ it('should correctly apply and supersede changes from a single batch', async () => {
621
+ const noteId = `000000000000000000note001`;
622
+ let note = {
623
+ noteId,
624
+ title: 'original',
625
+ };
626
+ const ts = (0, peers_sdk_1.getTimestamp)();
627
+ await trackedTable.applyChanges([
628
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
629
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
630
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
631
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/links', createdAt: ts + 3, appliedAt: ts + 3 },
632
+ ]);
633
+ note = await trackedTable.get(noteId);
634
+ expect(note).toBeDefined();
635
+ expect(note?.links).toBeUndefined();
636
+ });
637
+ it('should delete records on a delete change on path /', async () => {
638
+ let note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'to be deleted' });
639
+ const ts = (0, peers_sdk_1.getTimestamp)();
640
+ await trackedTable.applyChanges([
641
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
642
+ ]);
643
+ note = await trackedTable.get(note.noteId);
644
+ expect(note).toBeUndefined();
645
+ });
646
+ it('should correctly apply and supersede changes from a single batch that includes both creating and deleting the object', async () => {
647
+ const noteId = `000000000000000000note001`;
648
+ let note = {
649
+ noteId,
650
+ title: 'original',
651
+ };
652
+ const ts = (0, peers_sdk_1.getTimestamp)();
653
+ await trackedTable.applyChanges([
654
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
655
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
656
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
657
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/links', createdAt: ts + 3, appliedAt: ts + 3 },
658
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
659
+ ]);
660
+ note = await trackedTable.get(noteId);
661
+ expect(note).toBeUndefined();
662
+ });
663
+ it('should correctly apply and supersede changes from a single batch that includes both creating, deleting the object, and restoring the object', async () => {
664
+ const noteId = `000000000000000000note001`;
665
+ const note = {
666
+ noteId,
667
+ title: 'original',
668
+ };
669
+ const ts = (0, peers_sdk_1.getTimestamp)();
670
+ await trackedTable.applyChanges([
671
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
672
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 2, appliedAt: ts + 2 },
673
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
674
+ { changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts + 5, appliedAt: ts + 5 },
675
+ ]);
676
+ const dbNote = await trackedTable.get(noteId);
677
+ expect(dbNote).toEqual(note);
678
+ });
679
+ // // not sure we want to support this behavior
680
+ // it.skip('should create nested objects if they do not exist along a path', async () => {
681
+ // const noteId = `000000000000000000note001`;
682
+ // const note: INote = {
683
+ // noteId,
684
+ // title: 'original',
685
+ // }
686
+ // const ts = getTimestamp();
687
+ // await trackedTable.applyChanges([
688
+ // { changeId: newid(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
689
+ // { changeId: newid(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: 1, createdAt: ts + 2, appliedAt: ts + 2 },
690
+ // ]);
691
+ // const dbNote = await trackedTable.get(noteId) as INote;
692
+ // expect(dbNote?.links?.a).toEqual(1);
693
+ // });
694
+ it('should handle two peers adding items to an array separately (last write wins)', async () => {
695
+ const [changeTrackingTable, trackedTable] = await dataSourceFactory();
696
+ const [changeTrackingTableRemote, trackedTableRemote] = await dataSourceFactory();
697
+ const noteId = `000000000000000000note001`;
698
+ const note = await trackedTable.save({ noteId, title: 'original', tags: ['tag1', 'tag2'] });
699
+ await trackedTableRemote.applyChanges(await changeTrackingTable.list({ recordId: note.noteId }));
700
+ let noteLocal = await trackedTable.get(note.noteId);
701
+ let noteRemote = await trackedTableRemote.get(note.noteId);
702
+ expect(noteLocal).toEqual(noteRemote);
703
+ noteLocal.tags.push('tag3');
704
+ await trackedTable.save(noteLocal);
705
+ noteRemote.tags.push('tag4');
706
+ await trackedTableRemote.save(noteRemote);
707
+ await trackedTableRemote.applyChanges(await changeTrackingTable.list({ recordId: note.noteId }));
708
+ await trackedTable.applyChanges(await changeTrackingTableRemote.list({ recordId: note.noteId }));
709
+ noteRemote = await trackedTableRemote.get(note.noteId);
710
+ noteLocal = await trackedTable.get(note.noteId);
711
+ // changes to arrays are treated as full replacements, so last write wins
712
+ expect(noteRemote?.tags).toEqual(['tag1', 'tag2', 'tag4']);
713
+ expect(noteLocal?.tags).toEqual(['tag1', 'tag2', 'tag4']);
714
+ });
715
+ it('should treat changes to objects nested in arrays as a full replacement of the array', async () => {
716
+ const noteId = `000000000000000000note001`;
717
+ const note = { noteId, title: 'original', links: { a: [{ b: 1 }, { c: 2 }] }, };
718
+ const noteInserted = await trackedTable.save(note);
719
+ noteInserted.links.a[0].b = 2;
720
+ const noteUpdated = await trackedTable.save(noteInserted);
721
+ expect(noteUpdated).toEqual(noteInserted);
722
+ const changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
723
+ expect(changes.length).toBe(2);
724
+ const arrayChange = changes[1];
725
+ expect(arrayChange.path).toBe('/links/a');
726
+ expect(arrayChange.value).toEqual([{ b: 2 }, { c: 2 }]);
727
+ });
728
+ it('should treat changes to arrays nested in another array as a full replacement of the top array', async () => {
729
+ const noteId = `000000000000000000note001`;
730
+ const note = { noteId, title: 'original', links: { a: [[{ b: 1 }, { c: 2 }, 2], 3] }, };
731
+ const noteInserted = await trackedTable.save(note);
732
+ noteInserted.links.a[0][0].b = 2;
733
+ const noteUpdated = await trackedTable.save(noteInserted);
734
+ expect(noteUpdated).toEqual(noteInserted);
735
+ const changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
736
+ expect(changes.length).toBe(2);
737
+ const arrayChange = changes[1];
738
+ expect(arrayChange.path).toBe('/links/a');
739
+ expect(arrayChange.value).toEqual([[{ b: 2 }, { c: 2 }, 2], 3]);
740
+ });
823
741
  });
824
742
  });
825
743
  //# sourceMappingURL=tracked-data-source.test.js.map