@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.
Files changed (47) hide show
  1. package/README.md +21 -0
  2. package/dist/index.d.ts +590 -0
  3. package/dist/index.js +3082 -0
  4. package/package.json +46 -0
  5. package/src/events/events.test.ts +242 -0
  6. package/src/events/index.ts +261 -0
  7. package/src/index.ts +3 -0
  8. package/src/modules/auth/events/index.ts +18 -0
  9. package/src/modules/auth/index.ts +267 -0
  10. package/src/modules/cache/index.ts +241 -0
  11. package/src/modules/cache/types.ts +19 -0
  12. package/src/modules/data/data.test.ts +58 -0
  13. package/src/modules/data/index.ts +777 -0
  14. package/src/modules/devtools/index.ts +364 -0
  15. package/src/modules/sync/engine.ts +163 -0
  16. package/src/modules/sync/events/index.ts +77 -0
  17. package/src/modules/sync/index.ts +3 -0
  18. package/src/modules/sync/queue/index.ts +2 -0
  19. package/src/modules/sync/queue/queue-down.ts +89 -0
  20. package/src/modules/sync/queue/queue-up.ts +223 -0
  21. package/src/modules/sync/scheduler.ts +84 -0
  22. package/src/modules/sync/sync.ts +407 -0
  23. package/src/modules/sync/utils.test.ts +311 -0
  24. package/src/modules/sync/utils.ts +171 -0
  25. package/src/services/database/database.ts +108 -0
  26. package/src/services/database/events/index.ts +32 -0
  27. package/src/services/database/index.ts +5 -0
  28. package/src/services/database/local-migrator.ts +203 -0
  29. package/src/services/database/local.ts +99 -0
  30. package/src/services/database/remote.ts +110 -0
  31. package/src/services/logger/index.ts +118 -0
  32. package/src/services/persistence/localstorage.ts +26 -0
  33. package/src/services/persistence/surrealdb.ts +62 -0
  34. package/src/services/stream-processor/index.ts +364 -0
  35. package/src/services/stream-processor/stream-processor.test.ts +140 -0
  36. package/src/services/stream-processor/wasm-types.ts +31 -0
  37. package/src/spooky.ts +346 -0
  38. package/src/types.ts +237 -0
  39. package/src/utils/error-classification.ts +28 -0
  40. package/src/utils/index.ts +172 -0
  41. package/src/utils/parser.test.ts +125 -0
  42. package/src/utils/parser.ts +46 -0
  43. package/src/utils/surql.ts +182 -0
  44. package/src/utils/utils.test.ts +152 -0
  45. package/src/utils/withRetry.test.ts +153 -0
  46. package/tsconfig.json +14 -0
  47. package/tsdown.config.ts +9 -0
@@ -0,0 +1,223 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import { LocalDatabaseService } from '../../../services/database/index';
3
+ import {
4
+ createSyncQueueEventSystem,
5
+ SyncQueueEventSystem,
6
+ SyncQueueEventTypes,
7
+ } from '../events/index';
8
+ import { parseRecordIdString, extractTablePart, classifySyncError } from '../../../utils/index';
9
+ import { Logger } from '../../../services/logger/index';
10
+ import { PushEventOptions } from '../../../events/index';
11
+
12
+ export type CreateEvent = {
13
+ type: 'create';
14
+ mutation_id: RecordId;
15
+ record_id: RecordId;
16
+ data: Record<string, unknown>;
17
+ record?: Record<string, unknown>;
18
+ tableName?: string;
19
+ options?: PushEventOptions;
20
+ };
21
+
22
+ export type UpdateEvent = {
23
+ type: 'update';
24
+ mutation_id: RecordId;
25
+ record_id: RecordId;
26
+ data: Record<string, unknown>;
27
+ record?: Record<string, unknown>;
28
+ beforeRecord?: Record<string, unknown>;
29
+ options?: PushEventOptions;
30
+ };
31
+
32
+ export type DeleteEvent = {
33
+ type: 'delete';
34
+ mutation_id: RecordId;
35
+ record_id: RecordId;
36
+ options?: PushEventOptions;
37
+ };
38
+
39
+ export type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
40
+
41
+ export type RollbackCallback = (event: UpEvent, error: Error) => Promise<void>;
42
+
43
+ export class UpQueue {
44
+ private queue: UpEvent[] = [];
45
+ private _events: SyncQueueEventSystem;
46
+ private logger: Logger;
47
+ private debouncedMutations: Map<string, { timer: any; firstBeforeRecord?: Record<string, unknown> }>;
48
+
49
+ get events(): SyncQueueEventSystem {
50
+ return this._events;
51
+ }
52
+
53
+ constructor(
54
+ private local: LocalDatabaseService,
55
+ logger: Logger
56
+ ) {
57
+ this._events = createSyncQueueEventSystem();
58
+ this.logger = logger.child({ service: 'UpQueue' });
59
+ this.debouncedMutations = new Map();
60
+ }
61
+
62
+ get size(): number {
63
+ return this.queue.length;
64
+ }
65
+
66
+ push(event: UpEvent) {
67
+ if (event.options?.debounced) {
68
+ const { key, delay } = event.options.debounced;
69
+ this.handleDebouncedMutation(event, key, delay);
70
+ return;
71
+ }
72
+ this.addToQueue(event);
73
+ }
74
+
75
+ private addToQueue(event: UpEvent) {
76
+ this.queue.push(event);
77
+ this._events.addEvent({
78
+ type: SyncQueueEventTypes.MutationEnqueued,
79
+ payload: { queueSize: this.queue.length },
80
+ });
81
+ }
82
+
83
+ private handleDebouncedMutation(event: UpEvent, key: string, delay: number) {
84
+ const existing = this.debouncedMutations.get(key);
85
+ let firstBeforeRecord: Record<string, unknown> | undefined;
86
+
87
+ if (existing) {
88
+ clearTimeout(existing.timer);
89
+ // Preserve the beforeRecord from the first event in the debounce sequence
90
+ firstBeforeRecord = existing.firstBeforeRecord;
91
+ } else if (event.type === 'update') {
92
+ firstBeforeRecord = event.beforeRecord;
93
+ }
94
+
95
+ const timer = setTimeout(() => {
96
+ this.debouncedMutations.delete(key);
97
+ // Attach the first beforeRecord to the final debounced event
98
+ if (firstBeforeRecord && event.type === 'update') {
99
+ event.beforeRecord = firstBeforeRecord;
100
+ }
101
+ this.addToQueue(event);
102
+ }, delay);
103
+
104
+ this.debouncedMutations.set(key, { timer, firstBeforeRecord });
105
+ }
106
+
107
+ async next(fn: (event: UpEvent) => Promise<void>, onRollback?: RollbackCallback): Promise<void> {
108
+ const event = this.queue.shift();
109
+ if (event) {
110
+ try {
111
+ await fn(event);
112
+ } catch (error) {
113
+ const errorType = classifySyncError(error);
114
+
115
+ if (errorType === 'network') {
116
+ this.logger.error(
117
+ { error, event, Category: 'spooky-client::UpQueue::next' },
118
+ 'Network error processing mutation, re-queuing'
119
+ );
120
+ this.queue.unshift(event);
121
+ throw error;
122
+ }
123
+
124
+ // Application error — rollback instead of re-queuing
125
+ this.logger.error(
126
+ { error, event, Category: 'spooky-client::UpQueue::next' },
127
+ 'Application error processing mutation, rolling back'
128
+ );
129
+ try {
130
+ await this.removeEventFromDatabase(event.mutation_id);
131
+ } catch (removeError) {
132
+ this.logger.error(
133
+ { error: removeError, event, Category: 'spooky-client::UpQueue::next' },
134
+ 'Failed to remove rolled-back mutation from database'
135
+ );
136
+ }
137
+ if (onRollback) {
138
+ try {
139
+ await onRollback(event, error instanceof Error ? error : new Error(String(error)));
140
+ } catch (rollbackError) {
141
+ this.logger.error(
142
+ { error: rollbackError, event, Category: 'spooky-client::UpQueue::next' },
143
+ 'Rollback handler failed'
144
+ );
145
+ }
146
+ }
147
+ this._events.addEvent({
148
+ type: SyncQueueEventTypes.MutationDequeued,
149
+ payload: { queueSize: this.queue.length },
150
+ });
151
+ return;
152
+ }
153
+ try {
154
+ await this.removeEventFromDatabase(event.mutation_id);
155
+ } catch (error) {
156
+ this.logger.error(
157
+ { error, event, Category: 'spooky-client::UpQueue::next' },
158
+ 'Failed to remove mutation from database after successful processing'
159
+ );
160
+ }
161
+ this._events.addEvent({
162
+ type: SyncQueueEventTypes.MutationDequeued,
163
+ payload: { queueSize: this.queue.length },
164
+ });
165
+ }
166
+ }
167
+
168
+ async removeEventFromDatabase(mutation_id: RecordId) {
169
+ return this.local.query(`DELETE $mutation_id`, { mutation_id });
170
+ }
171
+
172
+ async loadFromDatabase() {
173
+ try {
174
+ const [records] = await this.local.query<any>(
175
+ `SELECT * FROM _spooky_pending_mutations ORDER BY created_at ASC`
176
+ );
177
+
178
+ this.queue = records
179
+ .map((r: any): UpEvent | null => {
180
+ switch (r.mutationType) {
181
+ case 'create':
182
+ return {
183
+ type: 'create',
184
+ mutation_id: parseRecordIdString(r.id),
185
+ record_id: parseRecordIdString(r.recordId),
186
+ data: r.data,
187
+ tableName: extractTablePart(r.recordId),
188
+ };
189
+ case 'update':
190
+ return {
191
+ type: 'update',
192
+ mutation_id: parseRecordIdString(r.id),
193
+ record_id: parseRecordIdString(r.recordId),
194
+ data: r.data,
195
+ beforeRecord: r.beforeRecord,
196
+ };
197
+ case 'delete':
198
+ return {
199
+ type: 'delete',
200
+ mutation_id: parseRecordIdString(r.id),
201
+ record_id: parseRecordIdString(r.recordId),
202
+ };
203
+ default:
204
+ this.logger.warn(
205
+ {
206
+ mutationType: r.mutationType,
207
+ record: r,
208
+ Category: 'spooky-client::UpQueue::loadFromDatabase',
209
+ },
210
+ 'Unknown mutation type'
211
+ );
212
+ return null;
213
+ }
214
+ })
215
+ .filter((e: UpEvent | null): e is UpEvent => e !== null);
216
+ } catch (error) {
217
+ this.logger.error(
218
+ { error, Category: 'spooky-client::UpQueue::loadFromDatabase' },
219
+ 'Failed to load pending mutations from database'
220
+ );
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,84 @@
1
+ import { Logger } from '../../services/logger/index';
2
+ import { UpQueue, DownQueue, DownEvent, UpEvent, RollbackCallback } from './queue/index';
3
+ import { SyncQueueEventTypes } from './events/index';
4
+
5
+ /**
6
+ * SyncScheduler manages when to sync: queue management and orchestration.
7
+ * Decides the order and timing of sync operations.
8
+ */
9
+ export class SyncScheduler {
10
+ private isSyncingUp: boolean = false;
11
+ private isSyncingDown: boolean = false;
12
+
13
+ constructor(
14
+ private upQueue: UpQueue,
15
+ private downQueue: DownQueue,
16
+ private onProcessUp: (event: UpEvent) => Promise<void>,
17
+ private onProcessDown: (event: DownEvent) => Promise<void>,
18
+ private logger: Logger,
19
+ private onRollback?: RollbackCallback
20
+ ) {}
21
+
22
+ async init() {
23
+ await this.upQueue.loadFromDatabase();
24
+ this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, this.syncUp.bind(this));
25
+ this.downQueue.events.subscribe(
26
+ SyncQueueEventTypes.QueryItemEnqueued,
27
+ this.syncDown.bind(this)
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Add mutations to the upload queue
33
+ */
34
+ enqueueMutation(mutations: UpEvent[]) {
35
+ for (const mutation of mutations) {
36
+ this.upQueue.push(mutation);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Add query events to the download queue
42
+ */
43
+ enqueueDownEvent(event: DownEvent) {
44
+ this.downQueue.push(event);
45
+ }
46
+
47
+ /**
48
+ * Process upload queue
49
+ */
50
+ async syncUp() {
51
+ if (this.isSyncingUp) return;
52
+ this.isSyncingUp = true;
53
+ try {
54
+ while (this.upQueue.size > 0) {
55
+ await this.upQueue.next(this.onProcessUp, this.onRollback);
56
+ }
57
+ } finally {
58
+ this.isSyncingUp = false;
59
+ void this.syncDown();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Process download queue
65
+ */
66
+ async syncDown() {
67
+ if (this.isSyncingDown) return;
68
+ if (this.upQueue.size > 0) return;
69
+
70
+ this.isSyncingDown = true;
71
+ try {
72
+ while (this.downQueue.size > 0) {
73
+ if (this.upQueue.size > 0) break;
74
+ await this.downQueue.next(this.onProcessDown);
75
+ }
76
+ } finally {
77
+ this.isSyncingDown = false;
78
+ }
79
+ }
80
+
81
+ get isSyncing() {
82
+ return this.isSyncingUp || this.isSyncingDown;
83
+ }
84
+ }
@@ -0,0 +1,407 @@
1
+ import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
+ import { MutationEvent, RecordVersionArray } from '../../types';
3
+ import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
4
+ import { Logger } from '../../services/logger/index';
5
+ import { DownEvent, DownQueue, UpEvent, UpQueue } from './queue/index';
6
+ import { RecordId, Uuid } from 'surrealdb';
7
+ import { ArraySyncer, createDiffFromDbOp } from './utils';
8
+ import { SyncEngine } from './engine';
9
+ import { SyncScheduler } from './scheduler';
10
+ import { SchemaStructure } from '@spooky-sync/query-builder';
11
+ import { CacheModule } from '../cache/index';
12
+ import { DataModule } from '../data/index';
13
+ import { encodeRecordId, extractTablePart, parseDuration, surql } from '../../utils/index';
14
+
15
+ /**
16
+ * The main synchronization engine for Spooky.
17
+ * Handles the bidirectional synchronization between the local database and the remote backend.
18
+ * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
+ * @template S The schema structure type.
20
+ */
21
+ export class SpookySync<S extends SchemaStructure> {
22
+ private clientId: string = '';
23
+ private upQueue: UpQueue;
24
+ private downQueue: DownQueue;
25
+ private isInit: boolean = false;
26
+ private logger: Logger;
27
+ private syncEngine: SyncEngine;
28
+ private scheduler: SyncScheduler;
29
+ public events = createSyncEventSystem();
30
+
31
+ get isSyncing() {
32
+ return this.scheduler.isSyncing;
33
+ }
34
+
35
+ get pendingMutationCount(): number {
36
+ return this.upQueue.size;
37
+ }
38
+
39
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
40
+ const id1 = this.upQueue.events.subscribe(
41
+ SyncQueueEventTypes.MutationEnqueued,
42
+ (event) => cb(event.payload.queueSize)
43
+ );
44
+ const id2 = this.upQueue.events.subscribe(
45
+ SyncQueueEventTypes.MutationDequeued,
46
+ (event) => cb(event.payload.queueSize)
47
+ );
48
+ return () => {
49
+ this.upQueue.events.unsubscribe(id1);
50
+ this.upQueue.events.unsubscribe(id2);
51
+ };
52
+ }
53
+
54
+ constructor(
55
+ private local: LocalDatabaseService,
56
+ private remote: RemoteDatabaseService,
57
+ private cache: CacheModule,
58
+ private dataModule: DataModule<S>,
59
+ private schema: S,
60
+ logger: Logger
61
+ ) {
62
+ this.logger = logger.child({ service: 'SpookySync' });
63
+ this.upQueue = new UpQueue(this.local, this.logger);
64
+ this.downQueue = new DownQueue(this.local, this.logger);
65
+ this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
66
+ this.scheduler = new SyncScheduler(
67
+ this.upQueue,
68
+ this.downQueue,
69
+ this.processUpEvent.bind(this),
70
+ this.processDownEvent.bind(this),
71
+ this.logger,
72
+ this.handleRollback.bind(this)
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Initializes the synchronization system.
78
+ * Starts the scheduler and initiates the initial sync cycles.
79
+ * @param clientId The unique identifier for this client instance.
80
+ * @throws Error if already initialized.
81
+ */
82
+ public async init(clientId: string) {
83
+ if (this.isInit) throw new Error('SpookySync is already initialized');
84
+ this.clientId = clientId;
85
+ this.isInit = true;
86
+ await this.scheduler.init();
87
+ void this.scheduler.syncUp();
88
+ void this.scheduler.syncUp();
89
+ void this.scheduler.syncDown();
90
+ void this.startRefLiveQueries();
91
+ }
92
+
93
+ private async startRefLiveQueries() {
94
+ this.logger.debug(
95
+ { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
96
+ 'Starting ref live queries'
97
+ );
98
+
99
+ const [queryUuid] = await this.remote.query<[Uuid]>(
100
+ 'LIVE SELECT * FROM _spooky_list_ref'
101
+ );
102
+
103
+ (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
104
+ this.logger.debug(
105
+ { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
106
+ 'Live update received'
107
+ );
108
+ if (message.action === 'KILLED') return;
109
+ this.handleRemoteListRefChange(
110
+ message.action,
111
+ message.value.in as RecordId<string>,
112
+ message.value.out as RecordId<string>,
113
+ message.value.version as number
114
+ ).catch((err) => {
115
+ this.logger.error(
116
+ { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
117
+ 'Error handling remote list ref change'
118
+ );
119
+ });
120
+ });
121
+ }
122
+
123
+ private async handleRemoteListRefChange(
124
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
125
+ queryId: RecordId,
126
+ recordId: RecordId,
127
+ version: number
128
+ ) {
129
+ const existing = this.dataModule.getQueryById(queryId);
130
+
131
+ if (!existing) {
132
+ this.logger.warn(
133
+ {
134
+ queryId: queryId.toString(),
135
+ Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
136
+ },
137
+ 'Received remote update for unknown local query'
138
+ );
139
+ return;
140
+ }
141
+
142
+ const { localArray } = existing.config;
143
+
144
+ this.logger.debug(
145
+ {
146
+ action,
147
+ queryId,
148
+ recordId,
149
+ version,
150
+ localArray,
151
+ Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
152
+ },
153
+ 'Live update is being processed'
154
+ );
155
+ const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
+ await this.syncEngine.syncRecords(diff);
157
+ }
158
+
159
+ /**
160
+ * Enqueues a 'down' event (from remote to local) for processing.
161
+ * @param event The DownEvent to enqueue.
162
+ */
163
+ public enqueueDownEvent(event: DownEvent) {
164
+ this.scheduler.enqueueDownEvent(event);
165
+ }
166
+
167
+ private async processUpEvent(event: UpEvent) {
168
+ this.logger.debug(
169
+ { event, Category: 'spooky-client::SpookySync::processUpEvent' },
170
+ 'Processing up event'
171
+ );
172
+ console.log('xx1', event);
173
+ switch (event.type) {
174
+ case 'create':
175
+ const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
+ const prefixedParams = Object.fromEntries(
177
+ dataKeys.map(({ key, variable }) => [variable, event.data[key]])
178
+ );
179
+ const query = surql.seal(surql.createSet('id', dataKeys));
180
+ await this.remote.query(query, {
181
+ id: event.record_id,
182
+ ...prefixedParams,
183
+ });
184
+ break;
185
+ case 'update':
186
+ await this.remote.query(`UPDATE $id MERGE $data`, {
187
+ id: event.record_id,
188
+ data: event.data,
189
+ });
190
+ break;
191
+ case 'delete':
192
+ await this.remote.query(`DELETE $id`, {
193
+ id: event.record_id,
194
+ });
195
+ break;
196
+ default:
197
+ this.logger.error(
198
+ { event, Category: 'spooky-client::SpookySync::processUpEvent' },
199
+ 'processUpEvent unknown event type'
200
+ );
201
+ return;
202
+ }
203
+ }
204
+
205
+ private async handleRollback(event: UpEvent, error: Error): Promise<void> {
206
+ const recordId = encodeRecordId(event.record_id);
207
+ const tableName =
208
+ event.type === 'create' && event.tableName
209
+ ? event.tableName
210
+ : extractTablePart(recordId);
211
+
212
+ this.logger.warn(
213
+ {
214
+ type: event.type,
215
+ recordId,
216
+ tableName,
217
+ error: error.message,
218
+ Category: 'spooky-client::SpookySync::handleRollback',
219
+ },
220
+ 'Rolling back failed mutation'
221
+ );
222
+
223
+ switch (event.type) {
224
+ case 'create':
225
+ await this.dataModule.rollbackCreate(event.record_id, tableName);
226
+ break;
227
+ case 'update':
228
+ if (event.beforeRecord) {
229
+ await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
230
+ } else {
231
+ this.logger.warn(
232
+ {
233
+ recordId,
234
+ Category: 'spooky-client::SpookySync::handleRollback',
235
+ },
236
+ 'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
+ );
238
+ }
239
+ break;
240
+ case 'delete':
241
+ this.logger.warn(
242
+ {
243
+ recordId,
244
+ Category: 'spooky-client::SpookySync::handleRollback',
245
+ },
246
+ 'Delete rollback not implemented. Down-sync will reconcile.'
247
+ );
248
+ break;
249
+ }
250
+
251
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
252
+ eventType: event.type,
253
+ recordId,
254
+ error: error.message,
255
+ });
256
+ }
257
+
258
+ private async processDownEvent(event: DownEvent) {
259
+ this.logger.debug(
260
+ { event, Category: 'spooky-client::SpookySync::processDownEvent' },
261
+ 'Processing down event'
262
+ );
263
+ switch (event.type) {
264
+ case 'register':
265
+ return this.registerQuery(event.payload.hash);
266
+ case 'sync':
267
+ return this.syncQuery(event.payload.hash);
268
+ case 'heartbeat':
269
+ return this.heartbeatQuery(event.payload.hash);
270
+ case 'cleanup':
271
+ return this.cleanupQuery(event.payload.hash);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Synchronizes a specific query by hash.
277
+ * Compares local and remote version arrays and fetches differences.
278
+ * @param hash The hash of the query to sync.
279
+ */
280
+ public async syncQuery(hash: string) {
281
+ const queryState = this.dataModule.getQueryByHash(hash);
282
+ if (!queryState) {
283
+ this.logger.warn(
284
+ { hash, Category: 'spooky-client::SpookySync::syncQuery' },
285
+ 'Query not found'
286
+ );
287
+ return;
288
+ }
289
+
290
+ const diff = new ArraySyncer(
291
+ queryState.config.localArray,
292
+ queryState.config.remoteArray
293
+ ).nextSet();
294
+
295
+ if (!diff) {
296
+ return;
297
+ }
298
+ return this.syncEngine.syncRecords(diff);
299
+ }
300
+
301
+ /**
302
+ * Enqueues a list of mutations (up events) to be sent to the remote.
303
+ * @param mutations Array of UpEvents (create/update/delete) to enqueue.
304
+ */
305
+ public async enqueueMutation(mutations: UpEvent[]) {
306
+ this.scheduler.enqueueMutation(mutations);
307
+ }
308
+
309
+ private async registerQuery(queryHash: string) {
310
+ try {
311
+ this.logger.debug(
312
+ { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
313
+ 'Register Query state'
314
+ );
315
+ await this.createRemoteQuery(queryHash);
316
+ await this.syncQuery(queryHash);
317
+ } catch (e) {
318
+ this.logger.error(
319
+ { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
320
+ 'registerQuery error'
321
+ );
322
+ throw e;
323
+ }
324
+ }
325
+
326
+ private async createRemoteQuery(queryHash: string) {
327
+ const queryState = this.dataModule.getQueryByHash(queryHash);
328
+
329
+ if (!queryState) {
330
+ this.logger.warn(
331
+ { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
332
+ 'Query to register not found'
333
+ );
334
+ throw new Error('Query to register not found');
335
+ }
336
+ // Delegate to remote function which handles DBSP registration & persistence
337
+ await this.remote.query('fn::query::register($config)', {
338
+ config: {
339
+ clientId: this.clientId,
340
+ id: queryState.config.id,
341
+ surql: queryState.config.surql,
342
+ params: queryState.config.params,
343
+ ttl: queryState.config.ttl,
344
+ },
345
+ });
346
+
347
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
348
+ surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
349
+ {
350
+ in: queryState.config.id,
351
+ }
352
+ );
353
+
354
+ this.logger.trace(
355
+ {
356
+ queryId: encodeRecordId(queryState.config.id),
357
+ items,
358
+ Category: 'spooky-client::SpookySync::createRemoteQuery',
359
+ },
360
+ 'Got query record version array from remote'
361
+ );
362
+
363
+ const array: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
364
+
365
+ this.logger.debug(
366
+ {
367
+ queryId: encodeRecordId(queryState.config.id),
368
+ array,
369
+ Category: 'spooky-client::SpookySync::createRemoteQuery',
370
+ },
371
+ 'createdRemoteQuery'
372
+ );
373
+
374
+ if (array) {
375
+ /// Incantation existed already
376
+ await this.dataModule.updateQueryRemoteArray(queryHash, array);
377
+ }
378
+ }
379
+
380
+ private async heartbeatQuery(queryHash: string) {
381
+ const queryState = this.dataModule.getQueryByHash(queryHash);
382
+ if (!queryState) {
383
+ this.logger.warn(
384
+ { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
385
+ 'Query to register not found'
386
+ );
387
+ throw new Error('Query to register not found');
388
+ }
389
+ await this.remote.query('fn::query::heartbeat($id)', {
390
+ id: queryState.config.id,
391
+ });
392
+ }
393
+
394
+ private async cleanupQuery(queryHash: string) {
395
+ const queryState = this.dataModule.getQueryByHash(queryHash);
396
+ if (!queryState) {
397
+ this.logger.warn(
398
+ { queryHash, Category: 'spooky-client::SpookySync::cleanupQuery' },
399
+ 'Query to register not found'
400
+ );
401
+ throw new Error('Query to register not found');
402
+ }
403
+ await this.remote.query(`DELETE $id`, {
404
+ id: queryState.config.id,
405
+ });
406
+ }
407
+ }