@spooky-sync/core 0.0.1-canary.6 → 0.0.1-canary.61

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/dist/index.d.ts +103 -367
  2. package/dist/index.js +595 -289
  3. package/dist/otel/index.d.ts +21 -0
  4. package/dist/otel/index.js +86 -0
  5. package/dist/types.d.ts +361 -0
  6. package/package.json +35 -5
  7. package/skills/sp00ky-core/SKILL.md +258 -0
  8. package/skills/sp00ky-core/references/auth.md +98 -0
  9. package/skills/sp00ky-core/references/config.md +76 -0
  10. package/src/events/events.test.ts +2 -1
  11. package/src/index.ts +3 -2
  12. package/src/modules/auth/events/index.ts +2 -1
  13. package/src/modules/auth/index.ts +17 -20
  14. package/src/modules/cache/index.ts +23 -23
  15. package/src/modules/cache/types.ts +2 -2
  16. package/src/modules/crdt/crdt-field.ts +189 -0
  17. package/src/modules/crdt/index.ts +178 -0
  18. package/src/modules/data/index.ts +64 -43
  19. package/src/modules/devtools/index.ts +23 -20
  20. package/src/modules/sync/engine.ts +31 -21
  21. package/src/modules/sync/events/index.ts +3 -2
  22. package/src/modules/sync/queue/queue-down.ts +5 -4
  23. package/src/modules/sync/queue/queue-up.ts +14 -13
  24. package/src/modules/sync/scheduler.ts +2 -2
  25. package/src/modules/sync/sync.ts +49 -36
  26. package/src/modules/sync/utils.test.ts +2 -2
  27. package/src/modules/sync/utils.ts +15 -17
  28. package/src/otel/index.ts +127 -0
  29. package/src/services/database/database.ts +11 -11
  30. package/src/services/database/events/index.ts +2 -1
  31. package/src/services/database/local-migrator.ts +21 -21
  32. package/src/services/database/local.ts +16 -15
  33. package/src/services/database/remote.ts +13 -13
  34. package/src/services/logger/index.ts +6 -101
  35. package/src/services/persistence/localstorage.ts +2 -2
  36. package/src/services/persistence/resilient.ts +34 -0
  37. package/src/services/persistence/surrealdb.ts +9 -9
  38. package/src/services/stream-processor/index.ts +32 -31
  39. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  40. package/src/services/stream-processor/wasm-types.ts +2 -2
  41. package/src/{spooky.ts → sp00ky.ts} +67 -38
  42. package/src/types.ts +18 -11
  43. package/src/utils/index.ts +22 -10
  44. package/src/utils/parser.ts +3 -2
  45. package/src/utils/surql.ts +15 -15
  46. package/src/utils/withRetry.test.ts +1 -1
  47. package/tsdown.config.ts +1 -1
@@ -1,13 +1,13 @@
1
- import { LocalDatabaseService } from '../../services/database/index';
2
- import {
1
+ import type { LocalDatabaseService } from '../../services/database/index';
2
+ import type {
3
3
  StreamProcessorService,
4
4
  StreamUpdate,
5
5
  StreamUpdateReceiver,
6
6
  } from '../../services/stream-processor/index';
7
- import { Logger } from '../../services/logger/index';
7
+ import type { Logger } from '../../services/logger/index';
8
8
  import { parseRecordIdString, encodeRecordId, surql } from '../../utils/index';
9
- import { CacheRecord, QueryConfig } from './types';
10
- import { RecordVersionArray } from '../../types';
9
+ import type { CacheRecord, QueryConfig } from './types';
10
+ import type { RecordVersionArray } from '../../types';
11
11
 
12
12
  export * from './types';
13
13
 
@@ -43,7 +43,7 @@ export class CacheModule implements StreamUpdateReceiver {
43
43
  {
44
44
  queryHash: update.queryHash,
45
45
  arrayLength: update.localArray?.length,
46
- Category: 'spooky-client::CacheModule::onStreamUpdate',
46
+ Category: 'sp00ky-client::CacheModule::onStreamUpdate',
47
47
  },
48
48
  'Stream update received'
49
49
  );
@@ -73,7 +73,7 @@ export class CacheModule implements StreamUpdateReceiver {
73
73
  this.logger.debug(
74
74
  {
75
75
  count: records.length,
76
- Category: 'spooky-client::CacheModule::saveBatch',
76
+ Category: 'sp00ky-client::CacheModule::saveBatch',
77
77
  },
78
78
  'Saving record batch'
79
79
  );
@@ -85,7 +85,7 @@ export class CacheModule implements StreamUpdateReceiver {
85
85
  ...record,
86
86
  record: {
87
87
  ...record.record,
88
- spooky_rv: record.version,
88
+ _00_rv: record.version,
89
89
  },
90
90
  };
91
91
  });
@@ -114,7 +114,7 @@ export class CacheModule implements StreamUpdateReceiver {
114
114
  await this.local.execute(query, params);
115
115
  }
116
116
 
117
- // 2. Batch ingest into DBSP (use populatedRecords which has spooky_rv set)
117
+ // 2. Batch ingest into DBSP (use populatedRecords which has _00_rv set)
118
118
  for (const record of populatedRecords) {
119
119
  const recordId = encodeRecordId(record.record.id);
120
120
  this.versionLookups[recordId] = record.version;
@@ -122,12 +122,12 @@ export class CacheModule implements StreamUpdateReceiver {
122
122
  }
123
123
 
124
124
  this.logger.debug(
125
- { count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
125
+ { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
126
126
  'Batch saved successfully'
127
127
  );
128
128
  } catch (err) {
129
129
  this.logger.error(
130
- { err, count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
130
+ { err, count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
131
131
  'Failed to save batch'
132
132
  );
133
133
  throw err;
@@ -137,9 +137,9 @@ export class CacheModule implements StreamUpdateReceiver {
137
137
  /**
138
138
  * Delete a record from local DB and ingest deletion into DBSP
139
139
  */
140
- async delete(table: string, id: string, skipDbDelete: boolean = false): Promise<void> {
140
+ async delete(table: string, id: string, skipDbDelete: boolean = false, recordData: Record<string, any> = {}): Promise<void> {
141
141
  this.logger.debug(
142
- { table, id, Category: 'spooky-client::CacheModule::delete' },
142
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
143
143
  'Deleting record'
144
144
  );
145
145
 
@@ -149,17 +149,17 @@ export class CacheModule implements StreamUpdateReceiver {
149
149
  await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
150
150
  }
151
151
 
152
- // 2. Ingest deletion into DBSP
152
+ // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
153
153
  delete this.versionLookups[id];
154
- await this.streamProcessor.ingest(table, 'DELETE', id, {});
154
+ this.streamProcessor.ingest(table, 'DELETE', id, recordData);
155
155
 
156
156
  this.logger.debug(
157
- { table, id, Category: 'spooky-client::CacheModule::delete' },
157
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
158
158
  'Record deleted successfully'
159
159
  );
160
160
  } catch (err) {
161
161
  this.logger.error(
162
- { err, table, id, Category: 'spooky-client::CacheModule::delete' },
162
+ { err, table, id, Category: 'sp00ky-client::CacheModule::delete' },
163
163
  'Failed to delete record'
164
164
  );
165
165
  throw err;
@@ -175,7 +175,7 @@ export class CacheModule implements StreamUpdateReceiver {
175
175
  {
176
176
  queryHash: config.queryHash,
177
177
  surql: config.surql,
178
- Category: 'spooky-client::CacheModule::registerQuery',
178
+ Category: 'sp00ky-client::CacheModule::registerQuery',
179
179
  },
180
180
  'Registering query'
181
181
  );
@@ -202,7 +202,7 @@ export class CacheModule implements StreamUpdateReceiver {
202
202
  {
203
203
  queryHash: config.queryHash,
204
204
  arrayLength: update.localArray?.length,
205
- Category: 'spooky-client::CacheModule::registerQuery',
205
+ Category: 'sp00ky-client::CacheModule::registerQuery',
206
206
  },
207
207
  'Query registered successfully'
208
208
  );
@@ -210,7 +210,7 @@ export class CacheModule implements StreamUpdateReceiver {
210
210
  return { localArray: update.localArray };
211
211
  } catch (err) {
212
212
  this.logger.error(
213
- { err, queryHash: config.queryHash, Category: 'spooky-client::CacheModule::registerQuery' },
213
+ { err, queryHash: config.queryHash, Category: 'sp00ky-client::CacheModule::registerQuery' },
214
214
  'Failed to register query'
215
215
  );
216
216
  throw err;
@@ -222,18 +222,18 @@ export class CacheModule implements StreamUpdateReceiver {
222
222
  */
223
223
  unregisterQuery(queryHash: string): void {
224
224
  this.logger.debug(
225
- { queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
225
+ { queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
226
226
  'Unregistering query'
227
227
  );
228
228
  try {
229
229
  this.streamProcessor.unregisterQueryPlan(queryHash);
230
230
  this.logger.debug(
231
- { queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
231
+ { queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
232
232
  'Query unregistered successfully'
233
233
  );
234
234
  } catch (err) {
235
235
  this.logger.error(
236
- { err, queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
236
+ { err, queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
237
237
  'Failed to unregister query'
238
238
  );
239
239
  }
@@ -1,5 +1,5 @@
1
- import { RecordId, Duration } from 'surrealdb';
2
- import { QueryTimeToLive, RecordVersionArray } from '../../types';
1
+ import type { RecordId, Duration } from 'surrealdb';
2
+ import type { QueryTimeToLive } from '../../types';
3
3
 
4
4
  export type RecordWithId = Record<string, any> & { id: RecordId<string> };
5
5
 
@@ -0,0 +1,189 @@
1
+ import { LoroDoc } from 'loro-crdt';
2
+ import type { RemoteDatabaseService } from '../../services/database/index';
3
+ import type { Logger } from '../../services/logger/index';
4
+ import { parseRecordIdString } from '../../utils/index';
5
+
6
+ // ==================== CURSOR UTILITIES ====================
7
+
8
+ export const CURSOR_COLORS = [
9
+ '#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
10
+ '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
11
+ ];
12
+
13
+ export function cursorColorFromName(name: string): string {
14
+ let hash = 0;
15
+ for (let i = 0; i < name.length; i++) {
16
+ hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
17
+ }
18
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
19
+ }
20
+
21
+ // ==================== CRDT FIELD ====================
22
+
23
+ export class CrdtField {
24
+ private doc: LoroDoc;
25
+ private pushTimer: ReturnType<typeof setTimeout> | null = null;
26
+ private remote: RemoteDatabaseService | null = null;
27
+ private recordId: string | null = null;
28
+ private unsubscribe: (() => void) | null = null;
29
+ private lastPushTime = 0;
30
+ private lastCursorPushTime = 0;
31
+ private loadedFromCrdt = false;
32
+ private pushRetryCount = 0;
33
+ private logger: Logger | null;
34
+
35
+ private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
36
+ private pendingCursorUpdate: Uint8Array | null = null;
37
+
38
+ /** Callback set by the editor to receive remote cursor updates.
39
+ * Any cursor data that arrived before this callback was set will be replayed. */
40
+ set onCursorUpdate(cb: ((data: Uint8Array) => void) | null) {
41
+ this._onCursorUpdate = cb;
42
+ if (cb && this.pendingCursorUpdate) {
43
+ try { cb(this.pendingCursorUpdate); } catch (e) {
44
+ this.logger?.warn(
45
+ { error: e, Category: 'sp00ky-client::CrdtField::onCursorUpdate' },
46
+ 'Failed to replay pending cursor update'
47
+ );
48
+ }
49
+ this.pendingCursorUpdate = null;
50
+ }
51
+ }
52
+
53
+ get onCursorUpdate() { return this._onCursorUpdate; }
54
+
55
+ constructor(
56
+ private fieldName: string,
57
+ initialState?: string,
58
+ logger?: Logger | null,
59
+ ) {
60
+ this.logger = logger ?? null;
61
+ this.doc = new LoroDoc();
62
+ if (initialState) {
63
+ this.doc.import(decodeBase64(initialState));
64
+ this.loadedFromCrdt = true;
65
+ }
66
+ }
67
+
68
+ getDoc(): LoroDoc { return this.doc; }
69
+
70
+ /** Whether the LoroDoc was loaded from saved CRDT state */
71
+ hasContent(): boolean {
72
+ return this.loadedFromCrdt;
73
+ }
74
+
75
+ startSync(remote: RemoteDatabaseService, recordId: string): void {
76
+ this.remote = remote;
77
+ this.recordId = recordId;
78
+ this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
79
+ this.schedulePush();
80
+ });
81
+ }
82
+
83
+ stopSync(): void {
84
+ if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
85
+ if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
86
+ if (this.remote && this.recordId) { void this.pushToRemote(); }
87
+ }
88
+
89
+ importRemote(base64State: string): void {
90
+ // Suppress echo: skip imports within 500ms of our own push
91
+ if (Date.now() - this.lastPushTime < 500) return;
92
+ try {
93
+ this.doc.import(decodeBase64(base64State));
94
+ } catch (e) {
95
+ this.logger?.warn(
96
+ { error: e, Category: 'sp00ky-client::CrdtField::importRemote' },
97
+ 'Failed to import remote CRDT state'
98
+ );
99
+ }
100
+ }
101
+
102
+ exportSnapshot(): string {
103
+ return encodeBase64(this.doc.export({ mode: 'snapshot' }));
104
+ }
105
+
106
+ /** Push cursor ephemeral state to _00_crdt as a "_cursor_<fieldName>" entry */
107
+ async pushCursorState(encoded: Uint8Array): Promise<void> {
108
+ if (!this.remote || !this.recordId) return;
109
+ this.lastCursorPushTime = Date.now();
110
+ try {
111
+ const state = encodeBase64(encoded);
112
+ await this.remote.query(
113
+ `INSERT INTO _00_crdt (record_id, field, state) VALUES ($rid, $field, $state)
114
+ ON DUPLICATE KEY UPDATE state = $state`,
115
+ { rid: parseRecordIdString(this.recordId), field: `_cursor_${this.fieldName}`, state }
116
+ );
117
+ } catch (e) {
118
+ this.logger?.warn(
119
+ { error: e, Category: 'sp00ky-client::CrdtField::pushCursorState' },
120
+ 'Failed to push cursor state'
121
+ );
122
+ }
123
+ }
124
+
125
+ /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
126
+ importRemoteCursor(base64State: string): void {
127
+ if (Date.now() - this.lastCursorPushTime < 300) return; // echo suppression
128
+ try {
129
+ const data = decodeBase64(base64State);
130
+ if (this._onCursorUpdate) {
131
+ this._onCursorUpdate(data);
132
+ } else {
133
+ // Only keep the latest cursor state — older positions are useless
134
+ this.pendingCursorUpdate = data;
135
+ }
136
+ } catch (e) {
137
+ this.logger?.warn(
138
+ { error: e, Category: 'sp00ky-client::CrdtField::importRemoteCursor' },
139
+ 'Failed to apply remote cursor data'
140
+ );
141
+ }
142
+ }
143
+
144
+ private schedulePush(): void {
145
+ if (this.pushTimer) clearTimeout(this.pushTimer);
146
+ this.pushTimer = setTimeout(() => void this.pushToRemote(), 300);
147
+ }
148
+
149
+ private async pushToRemote(): Promise<void> {
150
+ if (!this.remote || !this.recordId) return;
151
+ this.lastPushTime = Date.now();
152
+ try {
153
+ await this.remote.query(
154
+ `INSERT INTO _00_crdt (record_id, field, state) VALUES ($rid, $field, $state)
155
+ ON DUPLICATE KEY UPDATE state = $state`,
156
+ { rid: parseRecordIdString(this.recordId), field: this.fieldName, state: this.exportSnapshot() }
157
+ );
158
+ this.pushRetryCount = 0;
159
+ } catch (e) {
160
+ this.logger?.warn(
161
+ { error: e, Category: 'sp00ky-client::CrdtField::pushToRemote' },
162
+ 'Failed to push CRDT state to remote'
163
+ );
164
+ if (this.pushRetryCount < 2) {
165
+ this.pushRetryCount++;
166
+ this.schedulePush();
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ export function decodeBase64(b64: string): Uint8Array {
173
+ if (typeof atob === 'function') {
174
+ const binary = atob(b64);
175
+ const bytes = new Uint8Array(binary.length);
176
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
177
+ return bytes;
178
+ }
179
+ return new Uint8Array(Buffer.from(b64, 'base64'));
180
+ }
181
+
182
+ export function encodeBase64(bytes: Uint8Array): string {
183
+ if (typeof btoa === 'function') {
184
+ let binary = '';
185
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
186
+ return btoa(binary);
187
+ }
188
+ return Buffer.from(bytes).toString('base64');
189
+ }
@@ -0,0 +1,178 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { RemoteDatabaseService } from '../../services/database/index';
3
+ import type { Logger } from '../../services/logger/index';
4
+ import type { Uuid } from 'surrealdb';
5
+ import { CrdtField } from './crdt-field';
6
+ import { parseRecordIdString } from '../../utils/index';
7
+
8
+ export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
9
+
10
+ /**
11
+ * CrdtManager manages active CrdtField instances and their sync channels.
12
+ *
13
+ * Each open record gets a LIVE SELECT on _00_crdt that delivers remote
14
+ * changes in real time.
15
+ */
16
+ export class CrdtManager {
17
+ private fields = new Map<string, CrdtField>();
18
+ private liveQueries = new Map<string, { uuid: Uuid; table: string }>();
19
+ private logger: Logger;
20
+
21
+ constructor(
22
+ private schema: SchemaStructure,
23
+ private remote: RemoteDatabaseService,
24
+ logger: Logger
25
+ ) {
26
+ this.logger = logger.child({ service: 'CrdtManager' });
27
+ }
28
+
29
+ /**
30
+ * Open a CRDT field for collaborative editing.
31
+ *
32
+ * @param table - Table name
33
+ * @param recordId - Full record ID (e.g., "thread:abc")
34
+ * @param field - Field name (e.g., "title", "content")
35
+ * @param fallbackText - Current plain text from the record, used to seed the
36
+ * LoroDoc if no CRDT state exists yet (migration path)
37
+ */
38
+ async open(
39
+ table: string,
40
+ recordId: string,
41
+ field: string,
42
+ fallbackText?: string,
43
+ ): Promise<CrdtField> {
44
+ const key = this.makeKey(table, recordId, field);
45
+ let crdtField = this.fields.get(key);
46
+
47
+ if (crdtField) {
48
+ return crdtField;
49
+ }
50
+
51
+ // Load saved CRDT state from remote _00_crdt table
52
+ let initialCrdtState: string | undefined;
53
+ try {
54
+ const [result] = await this.remote.query<[string[]]>(
55
+ 'SELECT VALUE state FROM _00_crdt WHERE record_id = $rid AND field = $field LIMIT 1',
56
+ { rid: parseRecordIdString(recordId), field }
57
+ );
58
+ if (result && result.length > 0 && result[0]) {
59
+ initialCrdtState = result[0];
60
+ }
61
+ } catch (e) {
62
+ this.logger.debug(
63
+ { error: e, Category: 'sp00ky-client::CrdtManager::open' },
64
+ 'No existing CRDT state found'
65
+ );
66
+ }
67
+
68
+ crdtField = new CrdtField(field, initialCrdtState, this.logger);
69
+ crdtField.startSync(this.remote, recordId);
70
+ this.fields.set(key, crdtField);
71
+
72
+ this.logger.info(
73
+ { key, hasInitialState: !!initialCrdtState, hasFallback: !!fallbackText, Category: 'sp00ky-client::CrdtManager::open' },
74
+ 'CrdtField opened'
75
+ );
76
+
77
+ await this.ensureLiveSelect(table, recordId);
78
+
79
+ return crdtField;
80
+ }
81
+
82
+ close(table: string, recordId: string, field: string): void {
83
+ const key = this.makeKey(table, recordId, field);
84
+ const crdtField = this.fields.get(key);
85
+ if (crdtField) {
86
+ crdtField.stopSync();
87
+ this.fields.delete(key);
88
+ }
89
+
90
+ const prefix = `${table}:${recordId}:`;
91
+ const hasOtherFields = Array.from(this.fields.keys()).some(
92
+ (k) => k !== key && k.startsWith(prefix)
93
+ );
94
+ if (!hasOtherFields) {
95
+ this.killLiveSelect(recordId);
96
+ }
97
+
98
+ this.logger.debug(
99
+ { key, Category: 'sp00ky-client::CrdtManager::close' },
100
+ 'CrdtField closed'
101
+ );
102
+ }
103
+
104
+ closeAll(): void {
105
+ for (const [_, field] of this.fields) {
106
+ field.stopSync();
107
+ }
108
+ this.fields.clear();
109
+ for (const recordId of this.liveQueries.keys()) {
110
+ this.killLiveSelect(recordId);
111
+ }
112
+ }
113
+
114
+ private async ensureLiveSelect(table: string, recordId: string): Promise<void> {
115
+ if (this.liveQueries.has(recordId)) return;
116
+
117
+ try {
118
+ const [uuid] = await this.remote.query<[Uuid]>(
119
+ `LIVE SELECT * FROM _00_crdt WHERE record_id = $rid`,
120
+ { rid: parseRecordIdString(recordId) },
121
+ );
122
+
123
+ this.liveQueries.set(recordId, { uuid, table });
124
+
125
+ const subscription = await this.remote.getClient().liveOf(uuid);
126
+ subscription.subscribe((message) => {
127
+ if (message.action === 'KILLED') return;
128
+
129
+ if (message.action === 'CREATE' || message.action === 'UPDATE') {
130
+ const fieldName = message.value.field as string;
131
+ const state = message.value.state as string;
132
+
133
+ if (!fieldName || !state) return;
134
+
135
+ // Route cursor updates to the corresponding CrdtField
136
+ if (fieldName.startsWith('_cursor_')) {
137
+ const actualField = fieldName.slice('_cursor_'.length);
138
+ const key = this.makeKey(table, recordId, actualField);
139
+ const crdtField = this.fields.get(key);
140
+ if (crdtField) {
141
+ crdtField.importRemoteCursor(state);
142
+ }
143
+ return;
144
+ }
145
+
146
+ // Route document updates
147
+ const key = this.makeKey(table, recordId, fieldName);
148
+ const crdtField = this.fields.get(key);
149
+ if (crdtField) {
150
+ crdtField.importRemote(state);
151
+ }
152
+ }
153
+ });
154
+
155
+ this.logger.info(
156
+ { recordId, Category: 'sp00ky-client::CrdtManager::ensureLiveSelect' },
157
+ 'LIVE SELECT on _00_crdt started'
158
+ );
159
+ } catch (e) {
160
+ this.logger.warn(
161
+ { error: e, recordId, Category: 'sp00ky-client::CrdtManager::ensureLiveSelect' },
162
+ 'Failed to start LIVE SELECT on _00_crdt'
163
+ );
164
+ }
165
+ }
166
+
167
+ private killLiveSelect(recordId: string): void {
168
+ const entry = this.liveQueries.get(recordId);
169
+ if (entry) {
170
+ this.remote.query('KILL $uuid', { uuid: entry.uuid }).catch(() => {});
171
+ this.liveQueries.delete(recordId);
172
+ }
173
+ }
174
+
175
+ private makeKey(table: string, recordId: string, field: string): string {
176
+ return `${table}:${recordId}:${field}`;
177
+ }
178
+ }