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