@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,777 @@
|
|
|
1
|
+
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
+
import {
|
|
3
|
+
SchemaStructure,
|
|
4
|
+
TableNames,
|
|
5
|
+
BackendNames,
|
|
6
|
+
BackendRoutes,
|
|
7
|
+
RoutePayload,
|
|
8
|
+
} from '@spooky-sync/query-builder';
|
|
9
|
+
import { LocalDatabaseService } from '../../services/database/index';
|
|
10
|
+
import { CacheModule, RecordWithId } from '../cache/index';
|
|
11
|
+
import { Logger } from '../../services/logger/index';
|
|
12
|
+
import { StreamUpdate } from '../../services/stream-processor/index';
|
|
13
|
+
import {
|
|
14
|
+
MutationEvent,
|
|
15
|
+
QueryConfig,
|
|
16
|
+
QueryHash,
|
|
17
|
+
QueryState,
|
|
18
|
+
QueryTimeToLive,
|
|
19
|
+
QueryUpdateCallback,
|
|
20
|
+
MutationCallback,
|
|
21
|
+
RecordVersionArray,
|
|
22
|
+
QueryConfigRecord,
|
|
23
|
+
UpdateOptions,
|
|
24
|
+
RunOptions,
|
|
25
|
+
} from '../../types';
|
|
26
|
+
import {
|
|
27
|
+
parseRecordIdString,
|
|
28
|
+
extractIdPart,
|
|
29
|
+
encodeRecordId,
|
|
30
|
+
parseDuration,
|
|
31
|
+
withRetry,
|
|
32
|
+
surql,
|
|
33
|
+
parseParams,
|
|
34
|
+
extractTablePart,
|
|
35
|
+
generateId,
|
|
36
|
+
} from '../../utils/index';
|
|
37
|
+
import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
38
|
+
import { PushEventOptions } from '../../events/index';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* DataModule - Unified query and mutation management
|
|
42
|
+
*
|
|
43
|
+
* Merges the functionality of QueryManager and MutationManager.
|
|
44
|
+
* Uses CacheModule for all storage operations.
|
|
45
|
+
*/
|
|
46
|
+
export class DataModule<S extends SchemaStructure> {
|
|
47
|
+
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
48
|
+
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
49
|
+
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
50
|
+
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
51
|
+
private logger: Logger;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private cache: CacheModule,
|
|
55
|
+
private local: LocalDatabaseService,
|
|
56
|
+
private schema: S,
|
|
57
|
+
logger: Logger,
|
|
58
|
+
private streamDebounceTime: number = 100
|
|
59
|
+
) {
|
|
60
|
+
this.logger = logger.child({ service: 'DataModule' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async init(): Promise<void> {
|
|
64
|
+
this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ==================== QUERY MANAGEMENT ====================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register a query and return its hash for subscriptions
|
|
71
|
+
*/
|
|
72
|
+
async query<T extends TableNames<S>>(
|
|
73
|
+
tableName: T,
|
|
74
|
+
surqlString: string,
|
|
75
|
+
params: Record<string, any>,
|
|
76
|
+
ttl: QueryTimeToLive
|
|
77
|
+
): Promise<QueryHash> {
|
|
78
|
+
const hash = await this.calculateHash({ surql: surqlString, params });
|
|
79
|
+
this.logger.debug(
|
|
80
|
+
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
81
|
+
'Query Initialization: started'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const recordId = new RecordId('_spooky_query', hash);
|
|
85
|
+
|
|
86
|
+
if (this.activeQueries.has(hash)) {
|
|
87
|
+
this.logger.debug(
|
|
88
|
+
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
89
|
+
'Query Initialization: exists, returning'
|
|
90
|
+
);
|
|
91
|
+
return hash;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.logger.debug(
|
|
95
|
+
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
96
|
+
'Query Initialization: not found, creating new query'
|
|
97
|
+
);
|
|
98
|
+
const queryState = await this.createNewQuery<T>({
|
|
99
|
+
recordId,
|
|
100
|
+
surql: surqlString,
|
|
101
|
+
params,
|
|
102
|
+
ttl,
|
|
103
|
+
tableName,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { localArray } = this.cache.registerQuery({
|
|
107
|
+
queryHash: hash,
|
|
108
|
+
surql: surqlString,
|
|
109
|
+
params,
|
|
110
|
+
ttl: new Duration(ttl),
|
|
111
|
+
lastActiveAt: new Date(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await withRetry(this.logger, () =>
|
|
115
|
+
this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
116
|
+
id: recordId,
|
|
117
|
+
localArray,
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
this.activeQueries.set(hash, queryState);
|
|
122
|
+
this.startTTLHeartbeat(queryState);
|
|
123
|
+
this.logger.debug(
|
|
124
|
+
{
|
|
125
|
+
hash,
|
|
126
|
+
tableName,
|
|
127
|
+
recordCount: queryState.records.length,
|
|
128
|
+
Category: 'spooky-client::DataModule::query',
|
|
129
|
+
},
|
|
130
|
+
'Query registered'
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return hash;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Subscribe to query updates
|
|
138
|
+
*/
|
|
139
|
+
subscribe(
|
|
140
|
+
queryHash: string,
|
|
141
|
+
callback: QueryUpdateCallback,
|
|
142
|
+
options: { immediate?: boolean } = {}
|
|
143
|
+
): () => void {
|
|
144
|
+
if (!this.subscriptions.has(queryHash)) {
|
|
145
|
+
this.subscriptions.set(queryHash, new Set());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.subscriptions.get(queryHash)?.add(callback);
|
|
149
|
+
|
|
150
|
+
if (options.immediate) {
|
|
151
|
+
const query = this.activeQueries.get(queryHash);
|
|
152
|
+
if (query) {
|
|
153
|
+
callback(query.records);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Return unsubscribe function
|
|
158
|
+
return () => {
|
|
159
|
+
const subs = this.subscriptions.get(queryHash);
|
|
160
|
+
if (subs) {
|
|
161
|
+
subs.delete(callback);
|
|
162
|
+
if (subs.size === 0) {
|
|
163
|
+
this.subscriptions.delete(queryHash);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Subscribe to mutations (for sync)
|
|
171
|
+
*/
|
|
172
|
+
onMutation(callback: MutationCallback): () => void {
|
|
173
|
+
this.mutationCallbacks.add(callback);
|
|
174
|
+
return () => {
|
|
175
|
+
this.mutationCallbacks.delete(callback);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Handle stream updates from DBSP (via CacheModule)
|
|
181
|
+
*/
|
|
182
|
+
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
183
|
+
const { queryHash, op } = update;
|
|
184
|
+
|
|
185
|
+
// Only debounce UPDATE operations
|
|
186
|
+
// CREATE and DELETE should propagate immediately
|
|
187
|
+
if (op === 'UPDATE') {
|
|
188
|
+
// Clear existing timer if any
|
|
189
|
+
if (this.debounceTimers.has(queryHash)) {
|
|
190
|
+
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Set new timer
|
|
194
|
+
const timer = setTimeout(async () => {
|
|
195
|
+
this.debounceTimers.delete(queryHash);
|
|
196
|
+
await this.processStreamUpdate(update);
|
|
197
|
+
}, this.streamDebounceTime);
|
|
198
|
+
|
|
199
|
+
this.debounceTimers.set(queryHash, timer);
|
|
200
|
+
} else {
|
|
201
|
+
// CREATE and DELETE - process immediately
|
|
202
|
+
await this.processStreamUpdate(update);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
207
|
+
const { queryHash, localArray } = update;
|
|
208
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
209
|
+
if (!queryState) {
|
|
210
|
+
this.logger.warn(
|
|
211
|
+
{ queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
|
|
212
|
+
'Received update for unknown query. Skipping...'
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
// Fetch updated records
|
|
219
|
+
const [records] = await this.local.query<[Record<string, any>[]]>(
|
|
220
|
+
queryState.config.surql,
|
|
221
|
+
queryState.config.params
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Update state
|
|
225
|
+
queryState.records = records || [];
|
|
226
|
+
queryState.config.localArray = localArray;
|
|
227
|
+
queryState.updateCount++;
|
|
228
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
229
|
+
id: queryState.config.id,
|
|
230
|
+
localArray,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Notify subscribers
|
|
234
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
235
|
+
if (subscribers) {
|
|
236
|
+
for (const callback of subscribers) {
|
|
237
|
+
callback(queryState.records);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.logger.debug(
|
|
242
|
+
{
|
|
243
|
+
queryHash,
|
|
244
|
+
recordCount: records?.length,
|
|
245
|
+
Category: 'spooky-client::DataModule::onStreamUpdate',
|
|
246
|
+
},
|
|
247
|
+
'Query updated from stream'
|
|
248
|
+
);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
this.logger.error(
|
|
251
|
+
{ err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
|
|
252
|
+
'Failed to fetch records for stream update'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get query state (for sync and devtools)
|
|
259
|
+
*/
|
|
260
|
+
getQueryByHash(hash: string): QueryState | undefined {
|
|
261
|
+
return this.activeQueries.get(hash);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get query state by id (for sync and devtools)
|
|
266
|
+
*/
|
|
267
|
+
getQueryById(id: RecordId<string>): QueryState | undefined {
|
|
268
|
+
return this.activeQueries.get(extractIdPart(id));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get all active queries (for devtools)
|
|
273
|
+
*/
|
|
274
|
+
getActiveQueries(): QueryState[] {
|
|
275
|
+
return Array.from(this.activeQueries.values());
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
|
|
279
|
+
const queryState = this.activeQueries.get(id);
|
|
280
|
+
if (!queryState) {
|
|
281
|
+
this.logger.warn(
|
|
282
|
+
{ id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
|
|
283
|
+
'Query to update local array not found'
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
queryState.config.localArray = localArray;
|
|
288
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
289
|
+
id: queryState.config.id,
|
|
290
|
+
localArray,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
|
|
295
|
+
const queryState = this.getQueryByHash(hash);
|
|
296
|
+
if (!queryState) {
|
|
297
|
+
this.logger.warn(
|
|
298
|
+
{ hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
|
|
299
|
+
'Query to update remote array not found'
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
queryState.config.remoteArray = remoteArray;
|
|
304
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
|
|
305
|
+
id: queryState.config.id,
|
|
306
|
+
remoteArray,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ==================== RUN JOBS ====================
|
|
311
|
+
|
|
312
|
+
async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
313
|
+
backend: B,
|
|
314
|
+
path: R,
|
|
315
|
+
data: RoutePayload<S, B, R>,
|
|
316
|
+
options?: RunOptions
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
319
|
+
if (!route) {
|
|
320
|
+
throw new Error(`Route ${backend}.${path} not found`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
324
|
+
if (!tableName) {
|
|
325
|
+
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const payload: Record<string, unknown> = {};
|
|
329
|
+
for (const argName of Object.keys(route.args)) {
|
|
330
|
+
const arg = route.args[argName];
|
|
331
|
+
if ((data as Record<string, unknown>)[argName] === undefined && arg.optional === false) {
|
|
332
|
+
throw new Error(`Missing required argument ${argName}`);
|
|
333
|
+
}
|
|
334
|
+
payload[argName] = (data as Record<string, unknown>)[argName];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const record: Record<string, unknown> = {
|
|
338
|
+
path,
|
|
339
|
+
payload: JSON.stringify(payload),
|
|
340
|
+
max_retries: options?.max_retries ?? 3,
|
|
341
|
+
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (options?.assignedTo) {
|
|
345
|
+
record.assigned_to = options.assignedTo;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
349
|
+
await this.create(recordId, record);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ==================== MUTATION MANAGEMENT ====================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Create a new record
|
|
356
|
+
*/
|
|
357
|
+
async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
|
|
358
|
+
const tableName = extractTablePart(id);
|
|
359
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
360
|
+
if (!tableSchema) {
|
|
361
|
+
throw new Error(`Table ${tableName} not found`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const rid = parseRecordIdString(id);
|
|
365
|
+
const params = parseParams(tableSchema.columns, data);
|
|
366
|
+
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
367
|
+
|
|
368
|
+
const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
|
|
369
|
+
const prefixedParams = Object.fromEntries(
|
|
370
|
+
dataKeys.map(({ key, variable }) => [variable, params[key]])
|
|
371
|
+
);
|
|
372
|
+
const query = surql.seal<T>(
|
|
373
|
+
surql.tx([
|
|
374
|
+
surql.createSet('id', dataKeys),
|
|
375
|
+
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
376
|
+
]),
|
|
377
|
+
{ resultIndex: 0 }
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const target = await withRetry(this.logger, () =>
|
|
381
|
+
this.local.execute(query, {
|
|
382
|
+
id: rid,
|
|
383
|
+
mid: mutationId,
|
|
384
|
+
...prefixedParams,
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
389
|
+
|
|
390
|
+
// Save to cache (which handles DBSP ingestion)
|
|
391
|
+
await this.cache.save(
|
|
392
|
+
{
|
|
393
|
+
table: tableName,
|
|
394
|
+
op: 'CREATE',
|
|
395
|
+
record: parsedRecord,
|
|
396
|
+
version: 1,
|
|
397
|
+
},
|
|
398
|
+
true
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Emit mutation event for sync
|
|
402
|
+
const mutationEvent: CreateEvent = {
|
|
403
|
+
type: 'create',
|
|
404
|
+
mutation_id: mutationId,
|
|
405
|
+
record_id: rid,
|
|
406
|
+
data: params,
|
|
407
|
+
record: target,
|
|
408
|
+
tableName,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
for (const callback of this.mutationCallbacks) {
|
|
412
|
+
callback([mutationEvent]);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
|
|
416
|
+
|
|
417
|
+
return target;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Update an existing record
|
|
422
|
+
*/
|
|
423
|
+
async update<T extends Record<string, unknown>>(
|
|
424
|
+
table: string,
|
|
425
|
+
id: string,
|
|
426
|
+
data: Partial<T>,
|
|
427
|
+
options?: UpdateOptions
|
|
428
|
+
): Promise<T> {
|
|
429
|
+
const tableName = extractTablePart(id);
|
|
430
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
431
|
+
if (!tableSchema) {
|
|
432
|
+
throw new Error(`Table ${tableName} not found`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const rid = parseRecordIdString(id);
|
|
436
|
+
const params = parseParams(tableSchema.columns, data);
|
|
437
|
+
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
438
|
+
|
|
439
|
+
// Capture current record state before mutation for rollback support
|
|
440
|
+
const [beforeRecord] = await withRetry(this.logger, () =>
|
|
441
|
+
this.local.query<[Record<string, any>]>('SELECT * FROM ONLY $id', { id: rid })
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const query = surql.seal<{ target: T }>(
|
|
445
|
+
surql.tx([
|
|
446
|
+
surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
|
|
447
|
+
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
448
|
+
surql.createMutation('update', 'mid', 'id', 'data'),
|
|
449
|
+
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
450
|
+
])
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const { target } = await withRetry(this.logger, () =>
|
|
454
|
+
this.local.execute(query, {
|
|
455
|
+
id: rid,
|
|
456
|
+
mid: mutationId,
|
|
457
|
+
data: params,
|
|
458
|
+
})
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// Replace record in all queries directly
|
|
462
|
+
// Does not respect sorting or other advanced query features
|
|
463
|
+
// But is fast for quick typing for example
|
|
464
|
+
this.replaceRecordInQueries(target);
|
|
465
|
+
|
|
466
|
+
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
467
|
+
|
|
468
|
+
// Save to cache
|
|
469
|
+
await this.cache.save(
|
|
470
|
+
{
|
|
471
|
+
table: table,
|
|
472
|
+
op: 'UPDATE',
|
|
473
|
+
record: parsedRecord,
|
|
474
|
+
version: target.spooky_rv as number,
|
|
475
|
+
},
|
|
476
|
+
true
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
480
|
+
|
|
481
|
+
// Emit mutation event
|
|
482
|
+
const mutationEvent: UpdateEvent = {
|
|
483
|
+
type: 'update',
|
|
484
|
+
mutation_id: mutationId,
|
|
485
|
+
record_id: rid,
|
|
486
|
+
data: params,
|
|
487
|
+
record: target,
|
|
488
|
+
beforeRecord: beforeRecord || undefined,
|
|
489
|
+
options: pushEventOptions,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
for (const callback of this.mutationCallbacks) {
|
|
493
|
+
callback([mutationEvent]);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
|
|
497
|
+
|
|
498
|
+
return target;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Delete a record
|
|
503
|
+
*/
|
|
504
|
+
async delete(table: string, id: string): Promise<void> {
|
|
505
|
+
const tableName = extractTablePart(id);
|
|
506
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
507
|
+
if (!tableSchema) {
|
|
508
|
+
throw new Error(`Table ${tableName} not found`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const rid = parseRecordIdString(id);
|
|
512
|
+
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
513
|
+
|
|
514
|
+
const query = surql.seal<void>(
|
|
515
|
+
surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
519
|
+
await this.cache.delete(table, id, true);
|
|
520
|
+
|
|
521
|
+
// Emit mutation event
|
|
522
|
+
const mutationEvent: DeleteEvent = {
|
|
523
|
+
type: 'delete',
|
|
524
|
+
mutation_id: mutationId,
|
|
525
|
+
record_id: rid,
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
for (const callback of this.mutationCallbacks) {
|
|
529
|
+
callback([mutationEvent]);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ==================== ROLLBACK METHODS ====================
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Rollback a failed optimistic create by deleting the record locally
|
|
539
|
+
*/
|
|
540
|
+
async rollbackCreate(recordId: RecordId, tableName: string): Promise<void> {
|
|
541
|
+
const id = encodeRecordId(recordId);
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
await withRetry(this.logger, () =>
|
|
545
|
+
this.local.query('DELETE $id', { id: recordId })
|
|
546
|
+
);
|
|
547
|
+
await this.cache.delete(tableName, id, true);
|
|
548
|
+
this.removeRecordFromQueries(recordId);
|
|
549
|
+
|
|
550
|
+
this.logger.info(
|
|
551
|
+
{ id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
|
|
552
|
+
'Rolled back optimistic create'
|
|
553
|
+
);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
this.logger.error(
|
|
556
|
+
{ err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
|
|
557
|
+
'Failed to rollback create'
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Rollback a failed optimistic update by restoring the previous record state
|
|
564
|
+
*/
|
|
565
|
+
async rollbackUpdate(
|
|
566
|
+
recordId: RecordId,
|
|
567
|
+
tableName: string,
|
|
568
|
+
beforeRecord: Record<string, unknown>
|
|
569
|
+
): Promise<void> {
|
|
570
|
+
const id = encodeRecordId(recordId);
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const { id: _recordId, ...content } = beforeRecord;
|
|
574
|
+
await withRetry(this.logger, () =>
|
|
575
|
+
this.local.query(surql.seal(surql.upsert('id', 'content')), {
|
|
576
|
+
id: recordId,
|
|
577
|
+
content,
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
582
|
+
const parsedRecord = tableSchema
|
|
583
|
+
? (parseParams(tableSchema.columns, beforeRecord) as RecordWithId)
|
|
584
|
+
: (beforeRecord as RecordWithId);
|
|
585
|
+
|
|
586
|
+
await this.cache.save(
|
|
587
|
+
{
|
|
588
|
+
table: tableName,
|
|
589
|
+
op: 'UPDATE',
|
|
590
|
+
record: parsedRecord,
|
|
591
|
+
version: (beforeRecord.spooky_rv as number) || 1,
|
|
592
|
+
},
|
|
593
|
+
true
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Replace in active queries for immediate UI update
|
|
597
|
+
await this.replaceRecordInQueries(beforeRecord);
|
|
598
|
+
|
|
599
|
+
this.logger.info(
|
|
600
|
+
{ id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
|
|
601
|
+
'Rolled back optimistic update'
|
|
602
|
+
);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
this.logger.error(
|
|
605
|
+
{ err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
|
|
606
|
+
'Failed to rollback update'
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Remove a record from all active query states and notify subscribers
|
|
613
|
+
*/
|
|
614
|
+
private removeRecordFromQueries(recordId: RecordId): void {
|
|
615
|
+
const encodedId = encodeRecordId(recordId);
|
|
616
|
+
|
|
617
|
+
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
618
|
+
const index = queryState.records.findIndex((r) => {
|
|
619
|
+
const rId = r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id);
|
|
620
|
+
return rId === encodedId;
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (index !== -1) {
|
|
624
|
+
queryState.records.splice(index, 1);
|
|
625
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
626
|
+
if (subscribers) {
|
|
627
|
+
for (const callback of subscribers) {
|
|
628
|
+
callback(queryState.records);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ==================== PRIVATE HELPERS ====================
|
|
636
|
+
|
|
637
|
+
private async createNewQuery<T extends TableNames<S>>({
|
|
638
|
+
recordId,
|
|
639
|
+
surql: surqlString,
|
|
640
|
+
params,
|
|
641
|
+
ttl,
|
|
642
|
+
tableName,
|
|
643
|
+
}: {
|
|
644
|
+
recordId: RecordId;
|
|
645
|
+
surql: string;
|
|
646
|
+
params: Record<string, any>;
|
|
647
|
+
ttl: QueryTimeToLive;
|
|
648
|
+
tableName: T;
|
|
649
|
+
}): Promise<QueryState> {
|
|
650
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
651
|
+
if (!tableSchema) {
|
|
652
|
+
throw new Error(`Table ${tableName} not found`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
let [configRecord] = await withRetry(this.logger, () =>
|
|
656
|
+
this.local.query<[QueryConfigRecord]>('SELECT * FROM ONLY $id', {
|
|
657
|
+
id: recordId,
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
if (!configRecord) {
|
|
662
|
+
const [createdRecord] = await withRetry(this.logger, () =>
|
|
663
|
+
this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
|
|
664
|
+
id: recordId,
|
|
665
|
+
data: {
|
|
666
|
+
surql: surqlString,
|
|
667
|
+
params: params,
|
|
668
|
+
localArray: [],
|
|
669
|
+
remoteArray: [],
|
|
670
|
+
lastActiveAt: new Date(),
|
|
671
|
+
ttl,
|
|
672
|
+
tableName,
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
);
|
|
676
|
+
configRecord = createdRecord;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const config: QueryConfig = {
|
|
680
|
+
...configRecord,
|
|
681
|
+
id: recordId,
|
|
682
|
+
params: parseParams(tableSchema.columns, configRecord.params),
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
let records: Record<string, any>[] = [];
|
|
686
|
+
try {
|
|
687
|
+
const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
|
|
688
|
+
records = result || [];
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.logger.warn(
|
|
691
|
+
{ err, Category: 'spooky-client::DataModule::createNewQuery' },
|
|
692
|
+
'Failed to load initial cached records'
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
config,
|
|
698
|
+
records,
|
|
699
|
+
ttlTimer: null,
|
|
700
|
+
ttlDurationMs: parseDuration(ttl),
|
|
701
|
+
updateCount: 0,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async calculateHash(data: any): Promise<string> {
|
|
706
|
+
const content = JSON.stringify(data);
|
|
707
|
+
const msgBuffer = new TextEncoder().encode(content);
|
|
708
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
709
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
710
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private startTTLHeartbeat(queryState: QueryState): void {
|
|
714
|
+
if (queryState.ttlTimer) return;
|
|
715
|
+
|
|
716
|
+
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
717
|
+
|
|
718
|
+
queryState.ttlTimer = setTimeout(() => {
|
|
719
|
+
// TODO: Emit heartbeat event for sync
|
|
720
|
+
this.logger.debug(
|
|
721
|
+
{
|
|
722
|
+
id: encodeRecordId(queryState.config.id),
|
|
723
|
+
Category: 'spooky-client::DataModule::startTTLHeartbeat',
|
|
724
|
+
},
|
|
725
|
+
'TTL heartbeat'
|
|
726
|
+
);
|
|
727
|
+
this.startTTLHeartbeat(queryState);
|
|
728
|
+
}, heartbeatTime);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
732
|
+
if (queryState.ttlTimer) {
|
|
733
|
+
clearTimeout(queryState.ttlTimer);
|
|
734
|
+
queryState.ttlTimer = null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
739
|
+
for (const queryState of this.activeQueries.values()) {
|
|
740
|
+
this.replaceRecordInQuery(queryState, record);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private replaceRecordInQuery(queryState: QueryState, record: Record<string, any>): void {
|
|
745
|
+
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
746
|
+
if (index !== -1) {
|
|
747
|
+
queryState.records[index] = record;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ==================== HELPER FUNCTIONS ====================
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Parse update options to generate push event options
|
|
756
|
+
*/
|
|
757
|
+
export function parseUpdateOptions(
|
|
758
|
+
id: string,
|
|
759
|
+
data: any,
|
|
760
|
+
options?: UpdateOptions
|
|
761
|
+
): PushEventOptions {
|
|
762
|
+
let pushEventOptions: PushEventOptions = {};
|
|
763
|
+
if (options?.debounced) {
|
|
764
|
+
const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
|
|
765
|
+
const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
|
|
766
|
+
const key =
|
|
767
|
+
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
|
|
768
|
+
|
|
769
|
+
pushEventOptions = {
|
|
770
|
+
debounced: {
|
|
771
|
+
delay,
|
|
772
|
+
key,
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return pushEventOptions;
|
|
777
|
+
}
|