@spooky-sync/core 0.0.0-canary.1
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/README.md +21 -0
- package/dist/index.d.ts +590 -0
- package/dist/index.js +3082 -0
- package/package.json +46 -0
- package/src/events/events.test.ts +242 -0
- package/src/events/index.ts +261 -0
- package/src/index.ts +3 -0
- package/src/modules/auth/events/index.ts +18 -0
- package/src/modules/auth/index.ts +267 -0
- package/src/modules/cache/index.ts +241 -0
- package/src/modules/cache/types.ts +19 -0
- package/src/modules/data/data.test.ts +58 -0
- package/src/modules/data/index.ts +777 -0
- package/src/modules/devtools/index.ts +364 -0
- package/src/modules/sync/engine.ts +163 -0
- package/src/modules/sync/events/index.ts +77 -0
- package/src/modules/sync/index.ts +3 -0
- package/src/modules/sync/queue/index.ts +2 -0
- package/src/modules/sync/queue/queue-down.ts +89 -0
- package/src/modules/sync/queue/queue-up.ts +223 -0
- package/src/modules/sync/scheduler.ts +84 -0
- package/src/modules/sync/sync.ts +407 -0
- package/src/modules/sync/utils.test.ts +311 -0
- package/src/modules/sync/utils.ts +171 -0
- package/src/services/database/database.ts +108 -0
- package/src/services/database/events/index.ts +32 -0
- package/src/services/database/index.ts +5 -0
- package/src/services/database/local-migrator.ts +203 -0
- package/src/services/database/local.ts +99 -0
- package/src/services/database/remote.ts +110 -0
- package/src/services/logger/index.ts +118 -0
- package/src/services/persistence/localstorage.ts +26 -0
- package/src/services/persistence/surrealdb.ts +62 -0
- package/src/services/stream-processor/index.ts +364 -0
- package/src/services/stream-processor/stream-processor.test.ts +140 -0
- package/src/services/stream-processor/wasm-types.ts +31 -0
- package/src/spooky.ts +346 -0
- package/src/types.ts +237 -0
- package/src/utils/error-classification.ts +28 -0
- package/src/utils/index.ts +172 -0
- package/src/utils/parser.test.ts +125 -0
- package/src/utils/parser.ts +46 -0
- package/src/utils/surql.ts +182 -0
- package/src/utils/utils.test.ts +152 -0
- package/src/utils/withRetry.test.ts +153 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import {
|
|
4
|
+
diffRecordVersionArray,
|
|
5
|
+
applyRecordVersionDiff,
|
|
6
|
+
createDiffFromDbOp,
|
|
7
|
+
ArraySyncer,
|
|
8
|
+
} from './utils';
|
|
9
|
+
import { RecordVersionArray, RecordVersionDiff } from '../../types';
|
|
10
|
+
import { encodeRecordId } from '../../utils/index';
|
|
11
|
+
|
|
12
|
+
function rid(table: string, id: string): RecordId<string> {
|
|
13
|
+
return new RecordId(table, id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('diffRecordVersionArray', () => {
|
|
17
|
+
it('detects added records (in remote, not local)', () => {
|
|
18
|
+
const local: RecordVersionArray = [['user:1', 1]];
|
|
19
|
+
const remote: RecordVersionArray = [
|
|
20
|
+
['user:1', 1],
|
|
21
|
+
['user:2', 1],
|
|
22
|
+
];
|
|
23
|
+
const diff = diffRecordVersionArray(local, remote);
|
|
24
|
+
|
|
25
|
+
expect(diff.added).toHaveLength(1);
|
|
26
|
+
expect(encodeRecordId(diff.added[0].id)).toBe('user:2');
|
|
27
|
+
expect(diff.added[0].version).toBe(1);
|
|
28
|
+
expect(diff.updated).toHaveLength(0);
|
|
29
|
+
expect(diff.removed).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('detects updated records (remote version > local version)', () => {
|
|
33
|
+
const local: RecordVersionArray = [['user:1', 1]];
|
|
34
|
+
const remote: RecordVersionArray = [['user:1', 3]];
|
|
35
|
+
const diff = diffRecordVersionArray(local, remote);
|
|
36
|
+
|
|
37
|
+
expect(diff.updated).toHaveLength(1);
|
|
38
|
+
expect(encodeRecordId(diff.updated[0].id)).toBe('user:1');
|
|
39
|
+
expect(diff.updated[0].version).toBe(3);
|
|
40
|
+
expect(diff.added).toHaveLength(0);
|
|
41
|
+
expect(diff.removed).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('detects removed records (in local, not remote)', () => {
|
|
45
|
+
const local: RecordVersionArray = [
|
|
46
|
+
['user:1', 1],
|
|
47
|
+
['user:2', 1],
|
|
48
|
+
];
|
|
49
|
+
const remote: RecordVersionArray = [['user:1', 1]];
|
|
50
|
+
const diff = diffRecordVersionArray(local, remote);
|
|
51
|
+
|
|
52
|
+
expect(diff.removed).toHaveLength(1);
|
|
53
|
+
expect(encodeRecordId(diff.removed[0])).toBe('user:2');
|
|
54
|
+
expect(diff.added).toHaveLength(0);
|
|
55
|
+
expect(diff.updated).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles null arrays', () => {
|
|
59
|
+
const diff = diffRecordVersionArray(null, null);
|
|
60
|
+
expect(diff.added).toHaveLength(0);
|
|
61
|
+
expect(diff.updated).toHaveLength(0);
|
|
62
|
+
expect(diff.removed).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles empty arrays', () => {
|
|
66
|
+
const diff = diffRecordVersionArray([], []);
|
|
67
|
+
expect(diff.added).toHaveLength(0);
|
|
68
|
+
expect(diff.updated).toHaveLength(0);
|
|
69
|
+
expect(diff.removed).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('no diff when arrays match', () => {
|
|
73
|
+
const arr: RecordVersionArray = [
|
|
74
|
+
['user:1', 1],
|
|
75
|
+
['user:2', 2],
|
|
76
|
+
];
|
|
77
|
+
const diff = diffRecordVersionArray(arr, arr);
|
|
78
|
+
expect(diff.added).toHaveLength(0);
|
|
79
|
+
expect(diff.updated).toHaveLength(0);
|
|
80
|
+
expect(diff.removed).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('handles mixed adds/updates/removes', () => {
|
|
84
|
+
const local: RecordVersionArray = [
|
|
85
|
+
['user:1', 1],
|
|
86
|
+
['user:2', 1],
|
|
87
|
+
['user:3', 1],
|
|
88
|
+
];
|
|
89
|
+
const remote: RecordVersionArray = [
|
|
90
|
+
['user:1', 1], // same
|
|
91
|
+
['user:2', 3], // updated
|
|
92
|
+
// user:3 removed
|
|
93
|
+
['user:4', 1], // added
|
|
94
|
+
];
|
|
95
|
+
const diff = diffRecordVersionArray(local, remote);
|
|
96
|
+
|
|
97
|
+
expect(diff.added).toHaveLength(1);
|
|
98
|
+
expect(encodeRecordId(diff.added[0].id)).toBe('user:4');
|
|
99
|
+
expect(diff.updated).toHaveLength(1);
|
|
100
|
+
expect(encodeRecordId(diff.updated[0].id)).toBe('user:2');
|
|
101
|
+
expect(diff.removed).toHaveLength(1);
|
|
102
|
+
expect(encodeRecordId(diff.removed[0])).toBe('user:3');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('applyRecordVersionDiff', () => {
|
|
107
|
+
it('applies additions', () => {
|
|
108
|
+
const current: RecordVersionArray = [['user:1', 1]];
|
|
109
|
+
const diff: RecordVersionDiff = {
|
|
110
|
+
added: [{ id: rid('user', '2'), version: 1 }],
|
|
111
|
+
updated: [],
|
|
112
|
+
removed: [],
|
|
113
|
+
};
|
|
114
|
+
const result = applyRecordVersionDiff(current, diff);
|
|
115
|
+
expect(result).toEqual([
|
|
116
|
+
['user:1', 1],
|
|
117
|
+
['user:2', 1],
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('applies updates', () => {
|
|
122
|
+
const current: RecordVersionArray = [['user:1', 1]];
|
|
123
|
+
const diff: RecordVersionDiff = {
|
|
124
|
+
added: [],
|
|
125
|
+
updated: [{ id: rid('user', '1'), version: 5 }],
|
|
126
|
+
removed: [],
|
|
127
|
+
};
|
|
128
|
+
const result = applyRecordVersionDiff(current, diff);
|
|
129
|
+
expect(result).toEqual([['user:1', 5]]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('applies removals', () => {
|
|
133
|
+
const current: RecordVersionArray = [
|
|
134
|
+
['user:1', 1],
|
|
135
|
+
['user:2', 2],
|
|
136
|
+
];
|
|
137
|
+
const diff: RecordVersionDiff = {
|
|
138
|
+
added: [],
|
|
139
|
+
updated: [],
|
|
140
|
+
removed: [rid('user', '1')],
|
|
141
|
+
};
|
|
142
|
+
const result = applyRecordVersionDiff(current, diff);
|
|
143
|
+
expect(result).toEqual([['user:2', 2]]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('result is sorted by record ID', () => {
|
|
147
|
+
const current: RecordVersionArray = [['user:c', 1]];
|
|
148
|
+
const diff: RecordVersionDiff = {
|
|
149
|
+
added: [
|
|
150
|
+
{ id: rid('user', 'a'), version: 1 },
|
|
151
|
+
{ id: rid('user', 'z'), version: 1 },
|
|
152
|
+
],
|
|
153
|
+
updated: [],
|
|
154
|
+
removed: [],
|
|
155
|
+
};
|
|
156
|
+
const result = applyRecordVersionDiff(current, diff);
|
|
157
|
+
expect(result).toEqual([
|
|
158
|
+
['user:a', 1],
|
|
159
|
+
['user:c', 1],
|
|
160
|
+
['user:z', 1],
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('empty diff returns original (sorted)', () => {
|
|
165
|
+
const current: RecordVersionArray = [
|
|
166
|
+
['user:b', 2],
|
|
167
|
+
['user:a', 1],
|
|
168
|
+
];
|
|
169
|
+
const diff: RecordVersionDiff = { added: [], updated: [], removed: [] };
|
|
170
|
+
const result = applyRecordVersionDiff(current, diff);
|
|
171
|
+
expect(result).toEqual([
|
|
172
|
+
['user:a', 1],
|
|
173
|
+
['user:b', 2],
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('createDiffFromDbOp', () => {
|
|
179
|
+
it('CREATE populates added array', () => {
|
|
180
|
+
const recordId = rid('user', '1');
|
|
181
|
+
const diff = createDiffFromDbOp('CREATE', recordId, 1);
|
|
182
|
+
expect(diff.added).toHaveLength(1);
|
|
183
|
+
expect(diff.added[0].id).toBe(recordId);
|
|
184
|
+
expect(diff.added[0].version).toBe(1);
|
|
185
|
+
expect(diff.updated).toHaveLength(0);
|
|
186
|
+
expect(diff.removed).toHaveLength(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('UPDATE populates updated array', () => {
|
|
190
|
+
const recordId = rid('user', '1');
|
|
191
|
+
const diff = createDiffFromDbOp('UPDATE', recordId, 2);
|
|
192
|
+
expect(diff.updated).toHaveLength(1);
|
|
193
|
+
expect(diff.updated[0].id).toBe(recordId);
|
|
194
|
+
expect(diff.updated[0].version).toBe(2);
|
|
195
|
+
expect(diff.added).toHaveLength(0);
|
|
196
|
+
expect(diff.removed).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('DELETE populates removed array', () => {
|
|
200
|
+
const recordId = rid('user', '1');
|
|
201
|
+
const diff = createDiffFromDbOp('DELETE', recordId, 1);
|
|
202
|
+
expect(diff.removed).toHaveLength(1);
|
|
203
|
+
expect(diff.removed[0]).toBe(recordId);
|
|
204
|
+
expect(diff.added).toHaveLength(0);
|
|
205
|
+
expect(diff.updated).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('skips if existing version >= new version', () => {
|
|
209
|
+
const recordId = rid('user', '1');
|
|
210
|
+
const versions: RecordVersionArray = [['user:1', 5]];
|
|
211
|
+
const diff = createDiffFromDbOp('UPDATE', recordId, 3, versions);
|
|
212
|
+
expect(diff.added).toHaveLength(0);
|
|
213
|
+
expect(diff.updated).toHaveLength(0);
|
|
214
|
+
expect(diff.removed).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('applies if existing version < new version', () => {
|
|
218
|
+
const recordId = rid('user', '1');
|
|
219
|
+
const versions: RecordVersionArray = [['user:1', 2]];
|
|
220
|
+
const diff = createDiffFromDbOp('UPDATE', recordId, 5, versions);
|
|
221
|
+
expect(diff.updated).toHaveLength(1);
|
|
222
|
+
expect(diff.updated[0].version).toBe(5);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('ArraySyncer', () => {
|
|
227
|
+
it('insert adds to local array', () => {
|
|
228
|
+
const syncer = new ArraySyncer(
|
|
229
|
+
[['user:1', 1]],
|
|
230
|
+
[['user:1', 1]]
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
syncer.insert('user:2', 1);
|
|
234
|
+
const diff = syncer.nextSet();
|
|
235
|
+
expect(diff).not.toBeNull();
|
|
236
|
+
// local now has user:2 which remote does not → user:2 is in local removed from remote perspective
|
|
237
|
+
// Actually: local=[user:1, user:2], remote=[user:1] → user:2 is "removed" (in local, not remote)
|
|
238
|
+
expect(diff!.removed).toHaveLength(1);
|
|
239
|
+
expect(encodeRecordId(diff!.removed[0])).toBe('user:2');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('update modifies version in local array', () => {
|
|
243
|
+
const syncer = new ArraySyncer(
|
|
244
|
+
[['user:1', 1]],
|
|
245
|
+
[['user:1', 1]]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
syncer.update('user:1', 5);
|
|
249
|
+
const diff = syncer.nextSet();
|
|
250
|
+
// local version (5) > remote version (1), so no "updated" from diff perspective
|
|
251
|
+
// diff finds remote additions/updates relative to local; local version higher means no remote update
|
|
252
|
+
expect(diff).not.toBeNull();
|
|
253
|
+
expect(diff!.added).toHaveLength(0);
|
|
254
|
+
expect(diff!.updated).toHaveLength(0);
|
|
255
|
+
expect(diff!.removed).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('delete removes from local array', () => {
|
|
259
|
+
const syncer = new ArraySyncer(
|
|
260
|
+
[
|
|
261
|
+
['user:1', 1],
|
|
262
|
+
['user:2', 1],
|
|
263
|
+
],
|
|
264
|
+
[
|
|
265
|
+
['user:1', 1],
|
|
266
|
+
['user:2', 1],
|
|
267
|
+
]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
syncer.delete('user:2');
|
|
271
|
+
const diff = syncer.nextSet();
|
|
272
|
+
expect(diff).not.toBeNull();
|
|
273
|
+
// remote has user:2, local does not → added from remote perspective
|
|
274
|
+
expect(diff!.added).toHaveLength(1);
|
|
275
|
+
expect(encodeRecordId(diff!.added[0].id)).toBe('user:2');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('nextSet returns correct diff against remote', () => {
|
|
279
|
+
const syncer = new ArraySyncer(
|
|
280
|
+
[['user:1', 1]],
|
|
281
|
+
[
|
|
282
|
+
['user:1', 1],
|
|
283
|
+
['user:2', 1],
|
|
284
|
+
]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const diff = syncer.nextSet();
|
|
288
|
+
expect(diff).not.toBeNull();
|
|
289
|
+
expect(diff!.added).toHaveLength(1);
|
|
290
|
+
expect(encodeRecordId(diff!.added[0].id)).toBe('user:2');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('maintains sorting after mutations', () => {
|
|
294
|
+
const syncer = new ArraySyncer(
|
|
295
|
+
[['user:c', 1]],
|
|
296
|
+
[]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
syncer.insert('user:a', 1);
|
|
300
|
+
syncer.insert('user:z', 1);
|
|
301
|
+
|
|
302
|
+
// nextSet triggers sort
|
|
303
|
+
const diff = syncer.nextSet();
|
|
304
|
+
// All 3 are in local but not remote → 3 removed items
|
|
305
|
+
expect(diff!.removed).toHaveLength(3);
|
|
306
|
+
// Check they come in sorted order
|
|
307
|
+
const removedIds = diff!.removed.map((r) => encodeRecordId(r));
|
|
308
|
+
const sorted = [...removedIds].sort();
|
|
309
|
+
expect(removedIds).toEqual(sorted);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import { RecordVersionArray, RecordVersionDiff } from '../../types';
|
|
3
|
+
import { parseRecordIdString, encodeRecordId } from '../../utils/index';
|
|
4
|
+
|
|
5
|
+
export class ArraySyncer {
|
|
6
|
+
private localArray: RecordVersionArray;
|
|
7
|
+
private remoteArray: RecordVersionArray;
|
|
8
|
+
private needsSort = false;
|
|
9
|
+
|
|
10
|
+
constructor(localArray: RecordVersionArray, remoteArray: RecordVersionArray) {
|
|
11
|
+
this.remoteArray = remoteArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
12
|
+
this.localArray = localArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inserts an item into the local array
|
|
17
|
+
*/
|
|
18
|
+
insert(recordId: string, version: number) {
|
|
19
|
+
this.localArray.push([recordId, version]);
|
|
20
|
+
this.needsSort = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Updates the current local RecordVersionArray state.
|
|
25
|
+
*/
|
|
26
|
+
update(recordId: string, version: number) {
|
|
27
|
+
this.localArray = this.localArray.map((record) => {
|
|
28
|
+
if (record[0] === recordId) {
|
|
29
|
+
this.needsSort = true;
|
|
30
|
+
return [recordId, version];
|
|
31
|
+
}
|
|
32
|
+
return record;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deletes an item from the local array
|
|
38
|
+
*/
|
|
39
|
+
delete(recordId: string) {
|
|
40
|
+
this.localArray = this.localArray.filter((record) => record[0] !== recordId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the difference between the local and remote arrays.
|
|
45
|
+
* Includes sets of added, updated, and removed records.
|
|
46
|
+
*/
|
|
47
|
+
nextSet(): RecordVersionDiff | null {
|
|
48
|
+
if (this.needsSort) {
|
|
49
|
+
this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
50
|
+
this.needsSort = false;
|
|
51
|
+
}
|
|
52
|
+
console.log('xxxx555', this.localArray, this.remoteArray);
|
|
53
|
+
const diff = diffRecordVersionArray(this.localArray, this.remoteArray);
|
|
54
|
+
return diff;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function diffRecordVersionArray(
|
|
59
|
+
local: RecordVersionArray | null,
|
|
60
|
+
remote: RecordVersionArray | null
|
|
61
|
+
): RecordVersionDiff {
|
|
62
|
+
const localArray = local || [];
|
|
63
|
+
const remoteArray = remote || [];
|
|
64
|
+
|
|
65
|
+
// Convert arrays to Maps for O(1) lookup
|
|
66
|
+
const localMap = new Map<string, number>(localArray);
|
|
67
|
+
const remoteMap = new Map<string, number>(remoteArray);
|
|
68
|
+
|
|
69
|
+
const added: string[] = [];
|
|
70
|
+
const updated: string[] = [];
|
|
71
|
+
const removed: string[] = [];
|
|
72
|
+
|
|
73
|
+
// Find added and updated records
|
|
74
|
+
for (const [recordId, remoteVersion] of remoteMap) {
|
|
75
|
+
const localVersion = localMap.get(recordId);
|
|
76
|
+
|
|
77
|
+
if (localVersion === undefined) {
|
|
78
|
+
// Record exists in remote but not in local
|
|
79
|
+
added.push(recordId);
|
|
80
|
+
} else if (localVersion < remoteVersion) {
|
|
81
|
+
// Record exists in both but remote has newer version
|
|
82
|
+
updated.push(recordId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Find removed records
|
|
87
|
+
for (const [recordId] of localMap) {
|
|
88
|
+
if (!remoteMap.has(recordId)) {
|
|
89
|
+
removed.push(recordId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
added: added.map((id) => ({
|
|
95
|
+
id: parseRecordIdString(id),
|
|
96
|
+
version: remoteMap.get(id)!,
|
|
97
|
+
})),
|
|
98
|
+
updated: updated.map((id) => ({
|
|
99
|
+
id: parseRecordIdString(id),
|
|
100
|
+
version: remoteMap.get(id)!,
|
|
101
|
+
})),
|
|
102
|
+
removed: removed.map(parseRecordIdString),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Applies a RecordVersionDiff to a RecordVersionArray and returns a new sorted array.
|
|
108
|
+
*/
|
|
109
|
+
export function applyRecordVersionDiff(
|
|
110
|
+
current: RecordVersionArray,
|
|
111
|
+
diff: RecordVersionDiff
|
|
112
|
+
): RecordVersionArray {
|
|
113
|
+
const currentMap = new Map(current);
|
|
114
|
+
|
|
115
|
+
// Apply removals
|
|
116
|
+
for (const id of diff.removed) {
|
|
117
|
+
currentMap.delete(encodeRecordId(id));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply additions
|
|
121
|
+
for (const item of diff.added) {
|
|
122
|
+
currentMap.set(encodeRecordId(item.id), item.version);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Apply updates
|
|
126
|
+
for (const item of diff.updated) {
|
|
127
|
+
currentMap.set(encodeRecordId(item.id), item.version);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Array.from(currentMap).sort((a, b) => a[0].localeCompare(b[0]));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createDiffFromDbOp(
|
|
134
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE',
|
|
135
|
+
recordId: RecordId,
|
|
136
|
+
version: number,
|
|
137
|
+
versions?: RecordVersionArray
|
|
138
|
+
): RecordVersionDiff {
|
|
139
|
+
// Version guard: skip stale CREATE/UPDATE, but always process DELETE
|
|
140
|
+
if (op !== 'DELETE') {
|
|
141
|
+
const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
|
|
142
|
+
|
|
143
|
+
if (old && old[1] >= version) {
|
|
144
|
+
return {
|
|
145
|
+
added: [],
|
|
146
|
+
updated: [],
|
|
147
|
+
removed: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (op === 'CREATE') {
|
|
153
|
+
return {
|
|
154
|
+
added: [{ id: recordId, version }],
|
|
155
|
+
updated: [],
|
|
156
|
+
removed: [],
|
|
157
|
+
};
|
|
158
|
+
} else if (op === 'UPDATE') {
|
|
159
|
+
return {
|
|
160
|
+
added: [],
|
|
161
|
+
updated: [{ id: recordId, version }],
|
|
162
|
+
removed: [],
|
|
163
|
+
};
|
|
164
|
+
} else {
|
|
165
|
+
return {
|
|
166
|
+
added: [],
|
|
167
|
+
updated: [],
|
|
168
|
+
removed: [recordId],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Surreal, SurrealTransaction } from 'surrealdb';
|
|
2
|
+
import { createLogger, Logger } from '../logger/index';
|
|
3
|
+
import {
|
|
4
|
+
DatabaseEventSystem,
|
|
5
|
+
DatabaseEventTypes,
|
|
6
|
+
DatabaseQueryEventPayload,
|
|
7
|
+
} from './events/index';
|
|
8
|
+
import { SealedQuery } from '../../utils/surql';
|
|
9
|
+
|
|
10
|
+
export abstract class AbstractDatabaseService {
|
|
11
|
+
protected client: Surreal;
|
|
12
|
+
protected logger: Logger;
|
|
13
|
+
protected events: DatabaseEventSystem;
|
|
14
|
+
protected abstract eventType:
|
|
15
|
+
| typeof DatabaseEventTypes.LocalQuery
|
|
16
|
+
| typeof DatabaseEventTypes.RemoteQuery;
|
|
17
|
+
|
|
18
|
+
constructor(client: Surreal, logger: Logger, events: DatabaseEventSystem) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.logger = logger.child({ service: 'Database' });
|
|
21
|
+
this.events = events;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
abstract connect(): Promise<void>;
|
|
25
|
+
|
|
26
|
+
getClient(): Surreal {
|
|
27
|
+
return this.client;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getEvents(): DatabaseEventSystem {
|
|
31
|
+
return this.events;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tx(): Promise<SurrealTransaction> {
|
|
35
|
+
return this.client.beginTransaction();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private queryQueue: Promise<void> = Promise.resolve();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Execute a query with serialized execution to prevent WASM transaction issues.
|
|
42
|
+
*/
|
|
43
|
+
async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
this.queryQueue = this.queryQueue
|
|
46
|
+
.then(async () => {
|
|
47
|
+
const startTime = performance.now();
|
|
48
|
+
try {
|
|
49
|
+
this.logger.debug(
|
|
50
|
+
{ query, vars, Category: 'spooky-client::Database::query' },
|
|
51
|
+
'Executing query'
|
|
52
|
+
);
|
|
53
|
+
const pending = this.client.query(query, vars);
|
|
54
|
+
// In SurrealDB 2.0, .query() collects results by default.
|
|
55
|
+
// We cast to T directly as proper typing depends on the caller knowing the return structure.
|
|
56
|
+
const result = (await pending) as unknown as T;
|
|
57
|
+
const duration = performance.now() - startTime;
|
|
58
|
+
|
|
59
|
+
// Emit query event
|
|
60
|
+
this.events.emit(this.eventType, {
|
|
61
|
+
query,
|
|
62
|
+
vars,
|
|
63
|
+
duration,
|
|
64
|
+
success: true,
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
resolve(result);
|
|
69
|
+
this.logger.trace(
|
|
70
|
+
{ query, result, Category: 'spooky-client::Database::query' },
|
|
71
|
+
'Query executed successfully'
|
|
72
|
+
);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const duration = performance.now() - startTime;
|
|
75
|
+
|
|
76
|
+
// Emit query event with error
|
|
77
|
+
this.events.emit(this.eventType, {
|
|
78
|
+
query,
|
|
79
|
+
vars,
|
|
80
|
+
duration,
|
|
81
|
+
success: false,
|
|
82
|
+
error: err instanceof Error ? err.message : String(err),
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.logger.error(
|
|
87
|
+
{ query, vars, err, Category: 'spooky-client::Database::query' },
|
|
88
|
+
'Query execution failed'
|
|
89
|
+
);
|
|
90
|
+
reject(err);
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {
|
|
94
|
+
// Ignore queue errors to keep the chain alive; the specific promise was rejected above.
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
|
|
100
|
+
const raw = await this.query<unknown[]>(query.sql, vars);
|
|
101
|
+
return query.extract(raw);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close(): Promise<void> {
|
|
105
|
+
this.logger.info({ Category: 'spooky-client::Database::close' }, 'Closing database connection');
|
|
106
|
+
await this.client.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
|
|
2
|
+
|
|
3
|
+
export const DatabaseEventTypes = {
|
|
4
|
+
LocalQuery: 'DATABASE_LOCAL_QUERY',
|
|
5
|
+
RemoteQuery: 'DATABASE_REMOTE_QUERY',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export interface DatabaseQueryEventPayload {
|
|
9
|
+
query: string;
|
|
10
|
+
vars?: Record<string, unknown>;
|
|
11
|
+
duration: number;
|
|
12
|
+
success: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DatabaseEventTypeMap = {
|
|
18
|
+
[DatabaseEventTypes.LocalQuery]: EventDefinition<
|
|
19
|
+
typeof DatabaseEventTypes.LocalQuery,
|
|
20
|
+
DatabaseQueryEventPayload
|
|
21
|
+
>;
|
|
22
|
+
[DatabaseEventTypes.RemoteQuery]: EventDefinition<
|
|
23
|
+
typeof DatabaseEventTypes.RemoteQuery,
|
|
24
|
+
DatabaseQueryEventPayload
|
|
25
|
+
>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DatabaseEventSystem = EventSystem<DatabaseEventTypeMap>;
|
|
29
|
+
|
|
30
|
+
export function createDatabaseEventSystem(): DatabaseEventSystem {
|
|
31
|
+
return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
|
|
32
|
+
}
|