@spooky-sync/core 0.0.1-canary.6 → 0.0.1-canary.60
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/dist/index.d.ts +103 -367
- package/dist/index.js +595 -289
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +361 -0
- package/package.json +35 -5
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/events/events.test.ts +2 -1
- package/src/index.ts +3 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +17 -20
- package/src/modules/cache/index.ts +23 -23
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +189 -0
- package/src/modules/crdt/index.ts +178 -0
- package/src/modules/data/index.ts +64 -43
- package/src/modules/devtools/index.ts +23 -20
- package/src/modules/sync/engine.ts +31 -21
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +49 -36
- package/src/modules/sync/utils.test.ts +2 -2
- package/src/modules/sync/utils.ts +15 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.ts +16 -15
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +34 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +32 -31
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +2 -2
- package/src/{spooky.ts → sp00ky.ts} +67 -38
- package/src/types.ts +18 -11
- package/src/utils/index.ts +22 -10
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +15 -15
- package/src/utils/withRetry.test.ts +1 -1
- 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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
154
|
+
this.streamProcessor.ingest(table, 'DELETE', id, recordData);
|
|
155
155
|
|
|
156
156
|
this.logger.debug(
|
|
157
|
-
{ table, id, Category: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
+
}
|