@peers-app/peers-device 0.7.25 → 0.7.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connection-manager/connection-manager.js +14 -1
- package/dist/connection-manager/connection-manager.js.map +1 -1
- package/dist/json-diff.d.ts +13 -1
- package/dist/json-diff.js +157 -25
- package/dist/json-diff.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/packages.tracked-data-source.d.ts +2 -2
- package/dist/packages.tracked-data-source.js.map +1 -1
- package/dist/pvars.tracked-data-source.d.ts +2 -2
- package/dist/pvars.tracked-data-source.js +16 -9
- package/dist/pvars.tracked-data-source.js.map +1 -1
- package/dist/sync-group.d.ts +8 -4
- package/dist/sync-group.js +37 -11
- package/dist/sync-group.js.map +1 -1
- package/dist/sync-group.test.js +150 -51
- package/dist/sync-group.test.js.map +1 -1
- package/dist/tracked-data-source.d.ts +42 -22
- package/dist/tracked-data-source.js +382 -258
- package/dist/tracked-data-source.js.map +1 -1
- package/dist/tracked-data-source.test.d.ts +3 -0
- package/dist/tracked-data-source.test.js +823 -780
- package/dist/tracked-data-source.test.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,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('
|
|
9
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,842 @@ describe('tracked-table', () => {
|
|
|
21
27
|
primaryKeyName: 'noteId',
|
|
22
28
|
fields: (0, peers_sdk_1.schemaToFields)(notesSchema),
|
|
23
29
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:');
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
trackedTable.preserveHistory = false;
|
|
45
35
|
});
|
|
46
36
|
afterAll(async () => {
|
|
47
|
-
|
|
37
|
+
if (db) {
|
|
38
|
+
await db.close();
|
|
39
|
+
}
|
|
48
40
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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);
|
|
67
74
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
|
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
|
+
}
|
|
107
|
+
});
|
|
109
108
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
tableName: 'notes',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
});
|
|
145
167
|
});
|
|
146
168
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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);
|
|
164
205
|
});
|
|
165
206
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
});
|
|
205
246
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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);
|
|
295
|
+
});
|
|
217
296
|
});
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
297
|
+
describe('weakInsert Option', () => {
|
|
298
|
+
it('should use weak timestamp (1 + Math.random()) for insert with weakInsert: true', async () => {
|
|
299
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' }, { weakInsert: true });
|
|
300
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
|
|
301
|
+
expect(changes.length).toBe(1);
|
|
302
|
+
const change = changes[0];
|
|
303
|
+
expect(change.createdAt).toBeGreaterThanOrEqual(1);
|
|
304
|
+
expect(change.createdAt).toBeLessThan(2); // 1 + Math.random() is between 1 and 2
|
|
305
|
+
expect(change.appliedAt).toBe(change.createdAt); // appliedAt should match createdAt for weak writes
|
|
306
|
+
});
|
|
307
|
+
it('should use normal timestamp for insert without weakInsert', async () => {
|
|
308
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
309
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
|
|
310
|
+
expect(changes.length).toBe(1);
|
|
311
|
+
const change = changes[0];
|
|
312
|
+
// Normal timestamp should be much larger (performance.timeOrigin + performance.now() or Date.now())
|
|
313
|
+
expect(change.createdAt).toBeGreaterThan(1000);
|
|
314
|
+
});
|
|
315
|
+
it('should use normal timestamp for insert with weakInsert: false', async () => {
|
|
316
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' }, { weakInsert: false });
|
|
317
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
|
|
318
|
+
expect(changes.length).toBe(1);
|
|
319
|
+
const change = changes[0];
|
|
320
|
+
// weakInsert: false should use normal timestamp
|
|
321
|
+
expect(change.createdAt).toBeGreaterThan(1000);
|
|
322
|
+
});
|
|
323
|
+
it('should use weak timestamp for save (new record) with weakInsert: true', async () => {
|
|
324
|
+
const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'test' }, { weakInsert: true });
|
|
325
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId });
|
|
326
|
+
expect(changes.length).toBe(1);
|
|
327
|
+
const change = changes[0];
|
|
328
|
+
expect(change.createdAt).toBeGreaterThanOrEqual(1);
|
|
329
|
+
expect(change.createdAt).toBeLessThan(2);
|
|
330
|
+
});
|
|
331
|
+
it('should not affect update operations (weakInsert ignored for updates)', async () => {
|
|
332
|
+
// Insert with normal timestamp
|
|
333
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original' });
|
|
334
|
+
const initialChange = (await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId }))[0];
|
|
335
|
+
const initialTimestamp = initialChange.createdAt;
|
|
336
|
+
// Update with weakInsert - should not affect the update timestamp
|
|
337
|
+
note.title = 'updated';
|
|
338
|
+
await trackedTable.save(note, { weakInsert: true });
|
|
339
|
+
const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: note.noteId }, { sortBy: ['createdAt'] });
|
|
340
|
+
// Should have more changes (the update)
|
|
341
|
+
expect(allChanges.length).toBeGreaterThan(1);
|
|
342
|
+
// The update changes should have normal timestamps (not weak)
|
|
343
|
+
const updateChanges = allChanges.slice(1);
|
|
344
|
+
updateChanges.forEach(change => {
|
|
345
|
+
expect(change.createdAt).toBeGreaterThan(initialTimestamp);
|
|
346
|
+
expect(change.createdAt).toBeGreaterThan(1000); // Normal timestamp
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
it('should use weak timestamp when save routes to insert (existing ID but no record)', async () => {
|
|
350
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
351
|
+
// Save with an ID but no existing record - this routes to _insert
|
|
352
|
+
const note = await trackedTable.save({ noteId, title: 'test' }, { weakInsert: true });
|
|
353
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId });
|
|
354
|
+
expect(changes.length).toBe(1);
|
|
355
|
+
const change = changes[0];
|
|
356
|
+
expect(change.createdAt).toBeGreaterThanOrEqual(1);
|
|
357
|
+
expect(change.createdAt).toBeLessThan(2);
|
|
358
|
+
});
|
|
359
|
+
it('should work with restoreIfDeleted option', async () => {
|
|
360
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
361
|
+
await trackedTable.delete(note);
|
|
362
|
+
// Restore with weakInsert - note: restore doesn't use weakInsert, but we can test the combination
|
|
363
|
+
const restored = await trackedTable.save(note, { restoreIfDeleted: true, weakInsert: true });
|
|
364
|
+
expect(restored.noteId).toBe(note.noteId);
|
|
365
|
+
// Note: restore() doesn't use _insert, so weakInsert won't affect it
|
|
366
|
+
// But the save that routes to restore should still work
|
|
367
|
+
const retrieved = await trackedTable.get(note.noteId);
|
|
368
|
+
expect(retrieved).toBeDefined();
|
|
369
|
+
});
|
|
370
|
+
it('should work with saveAsSnapshot option', async () => {
|
|
371
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'original' });
|
|
372
|
+
// Save as snapshot with weakInsert
|
|
373
|
+
const updated = await trackedTable.save({ ...note, title: 'updated' }, { saveAsSnapshot: true, weakInsert: true });
|
|
374
|
+
// The snapshot save is an update, so weakInsert won't affect it
|
|
375
|
+
// But we can verify the operation completed successfully
|
|
376
|
+
const retrieved = await trackedTable.get(note.noteId);
|
|
377
|
+
expect(retrieved?.title).toBe('updated');
|
|
378
|
+
});
|
|
379
|
+
it('should allow weak writes to be superseded by normal writes', async () => {
|
|
380
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
381
|
+
// First, insert with weakInsert (simulating a new device creating the record)
|
|
382
|
+
const note1 = await trackedTable.insert({ noteId, title: 'weak write 1' }, { weakInsert: true });
|
|
383
|
+
const change1 = (await changeTrackingTable.list({ tableName: 'notes', recordId: noteId }))[0];
|
|
384
|
+
expect(change1.createdAt).toBeLessThan(2);
|
|
385
|
+
expect(change1.createdAt).toBeGreaterThanOrEqual(1);
|
|
386
|
+
// Wait a bit to ensure normal timestamp is later
|
|
387
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
388
|
+
// Then, update with normal write (simulating another device with authoritative data)
|
|
389
|
+
// This simulates the scenario where an existing device updates the record
|
|
390
|
+
note1.title = 'normal write';
|
|
391
|
+
await trackedTable.save(note1); // Normal save without weakInsert
|
|
392
|
+
const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId }, { sortBy: ['createdAt'] });
|
|
393
|
+
// Should have more than 1 change (initial insert + update changes)
|
|
394
|
+
expect(allChanges.length).toBeGreaterThan(1);
|
|
395
|
+
// The update changes should have normal timestamps
|
|
396
|
+
const updateChanges = allChanges.slice(1);
|
|
397
|
+
updateChanges.forEach(change => {
|
|
398
|
+
expect(change.createdAt).toBeGreaterThan(1000); // Normal timestamp
|
|
399
|
+
});
|
|
400
|
+
// Verify that the initial weak write has a weak timestamp
|
|
401
|
+
expect(change1.createdAt).toBeLessThan(2);
|
|
402
|
+
expect(change1.createdAt).toBeGreaterThanOrEqual(1);
|
|
403
|
+
// The final record should reflect the normal write (update takes precedence)
|
|
404
|
+
const final = await trackedTable.get(noteId);
|
|
405
|
+
expect(final?.title).toBe('normal write');
|
|
406
|
+
// Note: The initial change at path "/" may or may not be superseded depending on the update logic.
|
|
407
|
+
// The key point is that weak writes have very small timestamps (1 + Math.random()) and normal writes
|
|
408
|
+
// have large timestamps, so when syncing between devices, the normal writes will take precedence
|
|
409
|
+
// due to their later timestamps.
|
|
410
|
+
});
|
|
411
|
+
it('should use weak timestamp for insert called via save when recordId is provided but record does not exist', async () => {
|
|
412
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
413
|
+
// Save with an ID but no existing record - routes to _insert with skipDeletedCheck=true
|
|
414
|
+
const note = await trackedTable.save({ noteId, title: 'test' }, { weakInsert: true });
|
|
415
|
+
const changes = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId });
|
|
416
|
+
expect(changes.length).toBe(1);
|
|
417
|
+
const change = changes[0];
|
|
418
|
+
expect(change.createdAt).toBeGreaterThanOrEqual(1);
|
|
419
|
+
expect(change.createdAt).toBeLessThan(2);
|
|
250
420
|
});
|
|
251
421
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
422
|
+
describe('Read Operations', () => {
|
|
423
|
+
it('should get a record by id', async () => {
|
|
424
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
425
|
+
const retrieved = await trackedTable.get(note.noteId);
|
|
426
|
+
expect(retrieved).toEqual(note);
|
|
427
|
+
});
|
|
428
|
+
it('should list records with filters', async () => {
|
|
429
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1', completed: true });
|
|
430
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2', completed: false });
|
|
431
|
+
const completed = await trackedTable.list({ completed: true });
|
|
432
|
+
expect(completed.length).toBe(1);
|
|
433
|
+
expect(completed[0].title).toBe('note1');
|
|
434
|
+
});
|
|
435
|
+
it('should count records', async () => {
|
|
436
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
|
|
437
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
|
|
438
|
+
const count = await trackedTable.count();
|
|
439
|
+
expect(count).toBe(2);
|
|
440
|
+
});
|
|
441
|
+
it('should iterate records with cursor', async () => {
|
|
442
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
|
|
443
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
|
|
444
|
+
const cursor = trackedTable.cursor();
|
|
445
|
+
const notes = [];
|
|
446
|
+
for await (const note of cursor) {
|
|
447
|
+
notes.push(note);
|
|
448
|
+
}
|
|
449
|
+
expect(notes.length).toBe(2);
|
|
450
|
+
});
|
|
275
451
|
});
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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']);
|
|
452
|
+
describe('Change Tracking Methods', () => {
|
|
453
|
+
it('should list changes for this table', async () => {
|
|
454
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
455
|
+
const changes = await trackedTable.listChanges();
|
|
456
|
+
expect(changes.length).toBeGreaterThan(0);
|
|
457
|
+
changes.forEach((c) => {
|
|
458
|
+
expect(c.tableName).toBe('notes');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
it('should filter changes by recordId', async () => {
|
|
462
|
+
const note1 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
|
|
463
|
+
const note2 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
|
|
464
|
+
const changes1 = await trackedTable.listChanges({ recordId: note1.noteId });
|
|
465
|
+
expect(changes1.every((c) => c.recordId === note1.noteId)).toBe(true);
|
|
466
|
+
const changes2 = await trackedTable.listChanges({ recordId: note2.noteId });
|
|
467
|
+
expect(changes2.every((c) => c.recordId === note2.noteId)).toBe(true);
|
|
468
|
+
});
|
|
469
|
+
it('should use cursor for changes', async () => {
|
|
470
|
+
await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
471
|
+
const cursor = await trackedTable.cursorChanges();
|
|
472
|
+
const changes = [];
|
|
473
|
+
for await (const change of cursor) {
|
|
474
|
+
changes.push(change);
|
|
475
|
+
}
|
|
476
|
+
expect(changes.length).toBeGreaterThan(0);
|
|
477
|
+
});
|
|
351
478
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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);
|
|
479
|
+
describe('Helper Methods', () => {
|
|
480
|
+
it('should identify deleted records', async () => {
|
|
481
|
+
const note1 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note1' });
|
|
482
|
+
const note2 = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'note2' });
|
|
483
|
+
await trackedTable.delete(note1);
|
|
484
|
+
const deletedIds = await changeTrackingTable.filterToDeletedIds([note1.noteId, note2.noteId]);
|
|
485
|
+
expect(deletedIds).toContain(note1.noteId);
|
|
486
|
+
expect(deletedIds).not.toContain(note2.noteId);
|
|
487
|
+
});
|
|
488
|
+
it('should handle empty array in filterToDeletedIds', async () => {
|
|
489
|
+
const deletedIds = await changeTrackingTable.filterToDeletedIds([]);
|
|
490
|
+
expect(deletedIds).toEqual([]);
|
|
491
|
+
});
|
|
375
492
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
493
|
+
describe('Edge Cases', () => {
|
|
494
|
+
it('should handle insert with all optional fields', async () => {
|
|
495
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'minimal' });
|
|
496
|
+
expect(note.completed).toBeUndefined();
|
|
497
|
+
expect(note.count).toBeUndefined();
|
|
498
|
+
});
|
|
499
|
+
it('should handle update removing optional fields', async () => {
|
|
500
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test', completed: true, count: 5 });
|
|
501
|
+
// Update to remove optional fields
|
|
502
|
+
const updated = { noteId: note.noteId, title: 'test' };
|
|
503
|
+
await trackedTable.update(updated);
|
|
504
|
+
const retrieved = await trackedTable.get(note.noteId);
|
|
505
|
+
expect(retrieved?.title).toBe('test');
|
|
506
|
+
// The optional fields behavior depends on the underlying implementation
|
|
507
|
+
});
|
|
508
|
+
it('should handle concurrent operations on different records', async () => {
|
|
509
|
+
const promises = [];
|
|
510
|
+
for (let i = 0; i < 5; i++) {
|
|
511
|
+
promises.push(trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: `note${i}` }));
|
|
512
|
+
}
|
|
513
|
+
const notes = await Promise.all(promises);
|
|
514
|
+
expect(notes.length).toBe(5);
|
|
515
|
+
const count = await trackedTable.count();
|
|
516
|
+
expect(count).toBe(5);
|
|
517
|
+
});
|
|
518
|
+
it('should handle distributed deletion scenario', async () => {
|
|
519
|
+
// Scenario: One peer deletes a record, another peer (disconnected) makes changes
|
|
520
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
521
|
+
trackedTable.preserveHistory = true;
|
|
522
|
+
// Peer 1: Create record
|
|
523
|
+
await trackedTable.insert({ noteId, title: 'original', completed: false });
|
|
524
|
+
// Peer 1: Delete record at timestamp 2000
|
|
525
|
+
await trackedTable.delete(noteId);
|
|
526
|
+
// Peer 2: Makes changes AFTER deletion (while disconnected)
|
|
527
|
+
// Simulates this by manually inserting change records with later timestamps
|
|
528
|
+
const laterTimestamp = Date.now() + 10000; // 10 seconds later
|
|
529
|
+
// Peer 2 writes to specific paths (doesn't know about deletion)
|
|
530
|
+
const laterChange1 = {
|
|
382
531
|
changeId: (0, peers_sdk_1.newid)(),
|
|
383
|
-
changeType: 'update',
|
|
384
|
-
timestamp: now + 200,
|
|
385
|
-
timestampApplied: now + 200,
|
|
386
532
|
tableName: 'notes',
|
|
387
533
|
recordId: noteId,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
534
|
+
op: 'set',
|
|
535
|
+
path: '/title',
|
|
536
|
+
value: 'updated by peer 2',
|
|
537
|
+
createdAt: laterTimestamp,
|
|
538
|
+
appliedAt: laterTimestamp,
|
|
539
|
+
};
|
|
540
|
+
const laterChange2 = {
|
|
393
541
|
changeId: (0, peers_sdk_1.newid)(),
|
|
394
|
-
changeType: 'insert',
|
|
395
|
-
timestamp: now,
|
|
396
|
-
timestampApplied: now,
|
|
397
542
|
tableName: 'notes',
|
|
398
543
|
recordId: noteId,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
544
|
+
op: 'set',
|
|
545
|
+
path: '/completed',
|
|
546
|
+
value: true,
|
|
547
|
+
createdAt: laterTimestamp + 1,
|
|
548
|
+
appliedAt: laterTimestamp + 1,
|
|
549
|
+
};
|
|
550
|
+
// Apply changes from "Peer 2" - this will insert them and trigger superseding detection
|
|
551
|
+
await trackedTable.applyChanges([laterChange1, laterChange2]);
|
|
552
|
+
// Verify the record is still considered deleted (based on path "/" remove)
|
|
553
|
+
const deletedIds = await changeTrackingTable.filterToDeletedIds([noteId]);
|
|
554
|
+
expect(deletedIds).toContain(noteId);
|
|
555
|
+
// Verify the later changes are marked as superseded
|
|
556
|
+
const allChanges = await changeTrackingTable.list({ tableName: 'notes', recordId: noteId }, { sortBy: ['createdAt'] });
|
|
557
|
+
// Find the changes with the later timestamps (the ones we manually added)
|
|
558
|
+
const change1InDb = allChanges.find((c) => c.changeId === laterChange1.changeId);
|
|
559
|
+
const change2InDb = allChanges.find((c) => c.changeId === laterChange2.changeId);
|
|
560
|
+
// All changes after the deletion should be superseded
|
|
561
|
+
expect(change1InDb).toBeDefined();
|
|
562
|
+
expect(change1InDb?.supersededAt).toBeDefined();
|
|
563
|
+
expect(change2InDb).toBeDefined();
|
|
564
|
+
expect(change2InDb?.supersededAt).toBeDefined();
|
|
565
|
+
// Verify the record does not exist in the data source (still deleted)
|
|
566
|
+
const record = await trackedTable.get(noteId);
|
|
567
|
+
expect(record).toBeUndefined();
|
|
568
|
+
});
|
|
569
|
+
it('should handle insert with existing deleted record', async () => {
|
|
570
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
571
|
+
// Insert and then delete a record
|
|
572
|
+
await trackedTable.insert({ noteId, title: 'original note' });
|
|
573
|
+
await trackedTable.delete(noteId);
|
|
574
|
+
// Try to insert with same ID - should fail
|
|
575
|
+
await expect(trackedTable.insert({ noteId, title: 'new note' })).rejects.toThrow('because it has been deleted');
|
|
576
|
+
});
|
|
577
|
+
it('should handle update with missing record', async () => {
|
|
578
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
579
|
+
// Try to update non-existent record
|
|
580
|
+
await expect(trackedTable.update({ noteId, title: 'new note' })).rejects.toThrow('No record found to update');
|
|
581
|
+
});
|
|
582
|
+
it('should handle delete with string ID', async () => {
|
|
583
|
+
const noteId = (0, peers_sdk_1.newid)();
|
|
584
|
+
// Insert a record first
|
|
585
|
+
await trackedTable.insert({ noteId, title: 'test note' });
|
|
586
|
+
// Delete by string ID should work
|
|
587
|
+
await trackedTable.delete(noteId);
|
|
588
|
+
// Verify it was deleted
|
|
589
|
+
const note = await trackedTable.get(noteId);
|
|
590
|
+
expect(note).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
it('should find and track untracked records', async () => {
|
|
593
|
+
// Insert records directly into the underlying data source (bypassing change tracking)
|
|
594
|
+
const note1 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 1' };
|
|
595
|
+
const note2 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 2' };
|
|
596
|
+
const note3 = { noteId: (0, peers_sdk_1.newid)(), title: 'untracked 3' };
|
|
597
|
+
await sqlDataSource.insert(note1);
|
|
598
|
+
await sqlDataSource.insert(note2);
|
|
599
|
+
await sqlDataSource.insert(note3);
|
|
600
|
+
// Verify no change records exist for these
|
|
601
|
+
let changes = await changeTrackingTable.list({ tableName: 'notes' });
|
|
602
|
+
expect(changes.length).toBe(0);
|
|
603
|
+
// Run findAndTrackRecords
|
|
604
|
+
await trackedTable.findAndTrackRecords();
|
|
605
|
+
// Verify change records were created
|
|
606
|
+
changes = await changeTrackingTable.list({ tableName: 'notes' });
|
|
607
|
+
expect(changes.length).toBe(3);
|
|
608
|
+
// All should be 'set' operations at path "/"
|
|
609
|
+
changes.forEach(change => {
|
|
610
|
+
expect(change.op).toBe('set');
|
|
611
|
+
expect(change.path).toBe('/');
|
|
612
|
+
expect(change.createdAt).toBeLessThan(10); // Low timestamp
|
|
613
|
+
expect(change.appliedAt).toBeGreaterThan(Date.now() - 1000); // Recent appliedAt
|
|
614
|
+
});
|
|
615
|
+
// Verify the records themselves are accessible
|
|
616
|
+
const allNotes = await trackedTable.list();
|
|
617
|
+
expect(allNotes.length).toBe(3);
|
|
618
|
+
});
|
|
619
|
+
it('should compact superseded changes older than given timestamp', async () => {
|
|
620
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
621
|
+
const noteId = note.noteId;
|
|
622
|
+
// Make several updates to create multiple changes
|
|
623
|
+
await trackedTable.update({ noteId, title: 'update 1' });
|
|
624
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
625
|
+
await trackedTable.update({ noteId, title: 'update 2' });
|
|
626
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
627
|
+
await trackedTable.update({ noteId, title: 'update 3' });
|
|
628
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
629
|
+
// Get all changes before compacting
|
|
630
|
+
const changesBefore = await changeTrackingTable.list({ recordId: noteId });
|
|
631
|
+
expect(changesBefore.length).toBeGreaterThan(1);
|
|
632
|
+
// Count superseded changes
|
|
633
|
+
const supersededBefore = changesBefore.filter(c => c.supersededAt).length;
|
|
634
|
+
expect(supersededBefore).toBeGreaterThan(0);
|
|
635
|
+
// Compact (remove all superseded changes by using future timestamp)
|
|
636
|
+
await trackedTable.compact(Date.now() + 1000);
|
|
637
|
+
// Get changes after compacting
|
|
638
|
+
const changesAfter = await changeTrackingTable.list({ recordId: noteId });
|
|
639
|
+
// Should have fewer changes now
|
|
640
|
+
expect(changesAfter.length).toBeLessThan(changesBefore.length);
|
|
641
|
+
// Should have no superseded changes left
|
|
642
|
+
const supersededAfter = changesAfter.filter(c => c.supersededAt).length;
|
|
643
|
+
expect(supersededAfter).toBe(0);
|
|
644
|
+
// Verify the record is still accessible and correct
|
|
645
|
+
const retrieved = await trackedTable.get(noteId);
|
|
646
|
+
expect(retrieved?.title).toBe('update 3');
|
|
647
|
+
});
|
|
648
|
+
it('should compact only changes superseded before given timestamp', async () => {
|
|
649
|
+
const note = await trackedTable.insert({ noteId: (0, peers_sdk_1.newid)(), title: 'test' });
|
|
650
|
+
const noteId = note.noteId;
|
|
651
|
+
// Make updates
|
|
652
|
+
await trackedTable.update({ noteId, title: 'update 1' });
|
|
653
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
654
|
+
const midTimestamp = Date.now();
|
|
655
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
656
|
+
await trackedTable.update({ noteId, title: 'update 2' });
|
|
657
|
+
await (0, peers_sdk_1.sleep)(10);
|
|
658
|
+
await trackedTable.update({ noteId, title: 'update 3' });
|
|
659
|
+
const changesBefore = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
|
|
660
|
+
const supersededBefore = changesBefore.filter(c => c.supersededAt).length;
|
|
661
|
+
// Compact only changes superseded before midTimestamp
|
|
662
|
+
await trackedTable.compact(midTimestamp);
|
|
663
|
+
const changesAfter = await changeTrackingTable.list({ recordId: noteId });
|
|
664
|
+
// Since all updates happen to /title, and superseding happens when the next update occurs,
|
|
665
|
+
// only changes superseded BEFORE midTimestamp will be removed.
|
|
666
|
+
// In this test, update 1 happens before midTimestamp, but is superseded by update 2 which happens AFTER midTimestamp.
|
|
667
|
+
// Therefore, no changes should be removed.
|
|
668
|
+
expect(changesAfter.length).toBe(changesBefore.length); // No changes should be removed
|
|
669
|
+
// There should still be superseded changes (the ones after midTimestamp)
|
|
670
|
+
const supersededAfter = changesAfter.filter(c => c.supersededAt).length;
|
|
671
|
+
expect(supersededAfter).toBe(supersededBefore); // Same number of superseded changes
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
describe('applyChanges', () => {
|
|
675
|
+
it('should save but supersede remote changes that are older', async () => {
|
|
676
|
+
const noteId = `000000000000000000note001`;
|
|
677
|
+
trackedTable.preserveHistory = true;
|
|
678
|
+
const note = await trackedTable.save({ noteId, title: 'original' });
|
|
679
|
+
const timeUpdatedRemote = (0, peers_sdk_1.getTimestamp)();
|
|
680
|
+
note.title = 'updated1';
|
|
681
|
+
await trackedTable.save(note);
|
|
682
|
+
const changes = await changeTrackingTable.list({ recordId: note.noteId });
|
|
683
|
+
expect(changes.length).toBe(2);
|
|
684
|
+
// Create changes to apply
|
|
685
|
+
const remoteChange = {
|
|
402
686
|
changeId: (0, peers_sdk_1.newid)(),
|
|
403
|
-
changeType: 'update',
|
|
404
|
-
timestamp: now + 100,
|
|
405
|
-
timestampApplied: now + 100,
|
|
406
687
|
tableName: 'notes',
|
|
407
|
-
recordId: noteId,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
}
|
|
688
|
+
recordId: note.noteId,
|
|
689
|
+
op: 'set',
|
|
690
|
+
path: '/title',
|
|
691
|
+
value: 'updated2',
|
|
692
|
+
createdAt: timeUpdatedRemote,
|
|
693
|
+
appliedAt: timeUpdatedRemote,
|
|
694
|
+
};
|
|
695
|
+
await trackedTable.applyChanges([remoteChange]);
|
|
696
|
+
const finalRecord = await trackedTable.get(note.noteId);
|
|
697
|
+
expect(finalRecord).toBeDefined();
|
|
698
|
+
expect(finalRecord?.title).toBe('updated1'); // because remote change is superseded
|
|
699
|
+
const finalChanges = await changeTrackingTable.list({ recordId: note.noteId }, { sortBy: ['createdAt'] });
|
|
700
|
+
expect(finalChanges.length).toBe(3);
|
|
701
|
+
const remoteChangeInDb = finalChanges.find((c) => c.changeId === remoteChange.changeId);
|
|
702
|
+
expect(remoteChangeInDb).toBeDefined();
|
|
703
|
+
expect(remoteChangeInDb?.supersededAt).toBeDefined();
|
|
704
|
+
});
|
|
705
|
+
it('should replace the entire array when an item in the array is added or removed', async () => {
|
|
706
|
+
const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'original', tags: ['tag1', 'tag2'] });
|
|
707
|
+
note.tags = ['tag1', 'tag2', 'tag3'];
|
|
638
708
|
await trackedTable.save(note);
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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');
|
|
709
|
+
const changes = await changeTrackingTable.list({ recordId: note.noteId });
|
|
710
|
+
expect(changes.length).toBe(2);
|
|
711
|
+
const addTagChange = changes.find((c) => c.op === 'set' && c.path === '/tags');
|
|
712
|
+
expect(addTagChange).toBeDefined();
|
|
713
|
+
expect(addTagChange?.value).toEqual(['tag1', 'tag2', 'tag3']);
|
|
714
|
+
});
|
|
715
|
+
it('should updating two similarly named fields', async () => {
|
|
716
|
+
const note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'original', links: { a: 1, aa: 2 } });
|
|
717
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
718
|
+
await trackedTable.applyChanges([
|
|
719
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'set', path: '/links/aa', value: 3, createdAt: ts, appliedAt: ts },
|
|
720
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'set', path: '/links/a', value: 4, createdAt: ts + 1, appliedAt: ts + 1 },
|
|
721
|
+
]);
|
|
722
|
+
const dbNote = await trackedTable.get(note.noteId);
|
|
723
|
+
expect(dbNote).toBeDefined();
|
|
724
|
+
expect(dbNote?.links).toBeDefined();
|
|
725
|
+
expect(dbNote?.links?.aa).toBe(3);
|
|
726
|
+
expect(dbNote?.links?.a).toBe(4);
|
|
727
|
+
});
|
|
728
|
+
it('should apply deep changes after higher changes', async () => {
|
|
729
|
+
const noteId = `000000000000000000note001`;
|
|
730
|
+
let note = {
|
|
731
|
+
noteId,
|
|
732
|
+
title: 'original',
|
|
733
|
+
};
|
|
734
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
735
|
+
await trackedTable.applyChanges([
|
|
736
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
|
|
737
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
|
|
738
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
|
|
739
|
+
]);
|
|
740
|
+
note = await trackedTable.get(noteId);
|
|
741
|
+
expect(note).toBeDefined();
|
|
742
|
+
expect(note?.links).toBeDefined();
|
|
743
|
+
expect(note?.links?.a).toBe('2');
|
|
744
|
+
});
|
|
745
|
+
it('should correctly apply and supersede changes from a single batch', async () => {
|
|
746
|
+
const noteId = `000000000000000000note001`;
|
|
747
|
+
let note = {
|
|
748
|
+
noteId,
|
|
749
|
+
title: 'original',
|
|
750
|
+
};
|
|
751
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
752
|
+
await trackedTable.applyChanges([
|
|
753
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
|
|
754
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
|
|
755
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
|
|
756
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/links', createdAt: ts + 3, appliedAt: ts + 3 },
|
|
757
|
+
]);
|
|
758
|
+
note = await trackedTable.get(noteId);
|
|
759
|
+
expect(note).toBeDefined();
|
|
760
|
+
expect(note?.links).toBeUndefined();
|
|
761
|
+
});
|
|
762
|
+
it('should delete records on a delete change on path /', async () => {
|
|
763
|
+
let note = await trackedTable.save({ noteId: (0, peers_sdk_1.newid)(), title: 'to be deleted' });
|
|
764
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
765
|
+
await trackedTable.applyChanges([
|
|
766
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: note.noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
|
|
767
|
+
]);
|
|
768
|
+
note = await trackedTable.get(note.noteId);
|
|
769
|
+
expect(note).toBeUndefined();
|
|
770
|
+
});
|
|
771
|
+
it('should correctly apply and supersede changes from a single batch that includes both creating and deleting the object', async () => {
|
|
772
|
+
const noteId = `000000000000000000note001`;
|
|
773
|
+
let note = {
|
|
774
|
+
noteId,
|
|
775
|
+
title: 'original',
|
|
776
|
+
};
|
|
777
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
778
|
+
await trackedTable.applyChanges([
|
|
779
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
|
|
780
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 1, appliedAt: ts + 1 },
|
|
781
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: '2', createdAt: ts + 2, appliedAt: ts + 2 },
|
|
782
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/links', createdAt: ts + 3, appliedAt: ts + 3 },
|
|
783
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
|
|
784
|
+
]);
|
|
785
|
+
note = await trackedTable.get(noteId);
|
|
786
|
+
expect(note).toBeUndefined();
|
|
787
|
+
});
|
|
788
|
+
it('should correctly apply and supersede changes from a single batch that includes both creating, deleting the object, and restoring the object', async () => {
|
|
789
|
+
const noteId = `000000000000000000note001`;
|
|
790
|
+
const note = {
|
|
791
|
+
noteId,
|
|
792
|
+
title: 'original',
|
|
793
|
+
};
|
|
794
|
+
const ts = (0, peers_sdk_1.getTimestamp)();
|
|
795
|
+
await trackedTable.applyChanges([
|
|
796
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
|
|
797
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links', value: { a: 1 }, createdAt: ts + 2, appliedAt: ts + 2 },
|
|
798
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'delete', path: '/', createdAt: ts + 4, appliedAt: ts + 4 },
|
|
799
|
+
{ changeId: (0, peers_sdk_1.newid)(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts + 5, appliedAt: ts + 5 },
|
|
800
|
+
]);
|
|
801
|
+
const dbNote = await trackedTable.get(noteId);
|
|
802
|
+
expect(dbNote).toEqual(note);
|
|
803
|
+
});
|
|
804
|
+
// // not sure we want to support this behavior
|
|
805
|
+
// it.skip('should create nested objects if they do not exist along a path', async () => {
|
|
806
|
+
// const noteId = `000000000000000000note001`;
|
|
807
|
+
// const note: INote = {
|
|
808
|
+
// noteId,
|
|
809
|
+
// title: 'original',
|
|
810
|
+
// }
|
|
811
|
+
// const ts = getTimestamp();
|
|
812
|
+
// await trackedTable.applyChanges([
|
|
813
|
+
// { changeId: newid(), tableName: 'notes', recordId: noteId, op: 'set', path: '/', value: note, createdAt: ts, appliedAt: ts },
|
|
814
|
+
// { changeId: newid(), tableName: 'notes', recordId: noteId, op: 'set', path: '/links/a', value: 1, createdAt: ts + 2, appliedAt: ts + 2 },
|
|
815
|
+
// ]);
|
|
816
|
+
// const dbNote = await trackedTable.get(noteId) as INote;
|
|
817
|
+
// expect(dbNote?.links?.a).toEqual(1);
|
|
818
|
+
// });
|
|
819
|
+
it('should handle two peers adding items to an array separately (last write wins)', async () => {
|
|
820
|
+
const [changeTrackingTable, trackedTable] = await dataSourceFactory();
|
|
821
|
+
const [changeTrackingTableRemote, trackedTableRemote] = await dataSourceFactory();
|
|
822
|
+
const noteId = `000000000000000000note001`;
|
|
823
|
+
const note = await trackedTable.save({ noteId, title: 'original', tags: ['tag1', 'tag2'] });
|
|
824
|
+
await trackedTableRemote.applyChanges(await changeTrackingTable.list({ recordId: note.noteId }));
|
|
825
|
+
let noteLocal = await trackedTable.get(note.noteId);
|
|
826
|
+
let noteRemote = await trackedTableRemote.get(note.noteId);
|
|
827
|
+
expect(noteLocal).toEqual(noteRemote);
|
|
828
|
+
noteLocal.tags.push('tag3');
|
|
829
|
+
await trackedTable.save(noteLocal);
|
|
830
|
+
noteRemote.tags.push('tag4');
|
|
831
|
+
await trackedTableRemote.save(noteRemote);
|
|
832
|
+
await trackedTableRemote.applyChanges(await changeTrackingTable.list({ recordId: note.noteId }));
|
|
833
|
+
await trackedTable.applyChanges(await changeTrackingTableRemote.list({ recordId: note.noteId }));
|
|
834
|
+
noteRemote = await trackedTableRemote.get(note.noteId);
|
|
835
|
+
noteLocal = await trackedTable.get(note.noteId);
|
|
836
|
+
// changes to arrays are treated as full replacements, so last write wins
|
|
837
|
+
expect(noteRemote?.tags).toEqual(['tag1', 'tag2', 'tag4']);
|
|
838
|
+
expect(noteLocal?.tags).toEqual(['tag1', 'tag2', 'tag4']);
|
|
839
|
+
});
|
|
840
|
+
it('should treat changes to objects nested in arrays as a full replacement of the array', async () => {
|
|
841
|
+
const noteId = `000000000000000000note001`;
|
|
842
|
+
const note = { noteId, title: 'original', links: { a: [{ b: 1 }, { c: 2 }] }, };
|
|
843
|
+
const noteInserted = await trackedTable.save(note);
|
|
844
|
+
noteInserted.links.a[0].b = 2;
|
|
845
|
+
const noteUpdated = await trackedTable.save(noteInserted);
|
|
846
|
+
expect(noteUpdated).toEqual(noteInserted);
|
|
847
|
+
const changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
|
|
848
|
+
expect(changes.length).toBe(2);
|
|
849
|
+
const arrayChange = changes[1];
|
|
850
|
+
expect(arrayChange.path).toBe('/links/a');
|
|
851
|
+
expect(arrayChange.value).toEqual([{ b: 2 }, { c: 2 }]);
|
|
852
|
+
});
|
|
853
|
+
it('should treat changes to arrays nested in another array as a full replacement of the top array', async () => {
|
|
854
|
+
const noteId = `000000000000000000note001`;
|
|
855
|
+
const note = { noteId, title: 'original', links: { a: [[{ b: 1 }, { c: 2 }, 2], 3] }, };
|
|
856
|
+
const noteInserted = await trackedTable.save(note);
|
|
857
|
+
noteInserted.links.a[0][0].b = 2;
|
|
858
|
+
const noteUpdated = await trackedTable.save(noteInserted);
|
|
859
|
+
expect(noteUpdated).toEqual(noteInserted);
|
|
860
|
+
const changes = await changeTrackingTable.list({ recordId: noteId }, { sortBy: ['createdAt'] });
|
|
861
|
+
expect(changes.length).toBe(2);
|
|
862
|
+
const arrayChange = changes[1];
|
|
863
|
+
expect(arrayChange.path).toBe('/links/a');
|
|
864
|
+
expect(arrayChange.value).toEqual([[{ b: 2 }, { c: 2 }, 2], 3]);
|
|
865
|
+
});
|
|
823
866
|
});
|
|
824
867
|
});
|
|
825
868
|
//# sourceMappingURL=tracked-data-source.test.js.map
|