@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,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
|
+
}
|