@spooky-sync/core 0.0.1-canary.322 → 0.0.1-canary.324
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/AGENTS.md +4 -3
- package/dist/index.d.ts +156 -161
- package/dist/index.js +910 -482
- package/dist/types.d.ts +17 -6
- package/package.json +4 -4
- package/src/boot/preload.saga.test.ts +29 -0
- package/src/boot/preload.saga.ts +11 -2
- package/src/client/services.ts +3 -1
- package/src/client/sp00ky-client.test.ts +4 -1
- package/src/client/sp00ky-client.ts +37 -4
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +48 -255
- package/src/modules/crdt/crdt.test.ts +365 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +610 -363
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/mutation/push.saga.test.ts +16 -0
- package/src/mutation/push.saga.ts +9 -1
- package/src/query/anon-query-isolation.integration.test.ts +31 -0
- package/src/query/env.ts +9 -0
- package/src/services/database/cache-engine.ts +10 -0
- package/src/services/database/local-migrator.ts +5 -3
- package/src/services/database/sqlite-cache-engine.ts +28 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +7 -6
- package/src/utils/surql.ts +3 -4
- package/src/modules/crdt/crdt-hydration.test.ts +0 -210
- package/src/modules/crdt/crdt-reconnect.test.ts +0 -195
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { LoroDoc } from 'loro-crdt';
|
|
2
|
-
import type {
|
|
3
|
-
import type { Logger } from '../../services/logger/index';
|
|
4
|
-
import { parseRecordIdString } from '../../utils/index';
|
|
2
|
+
import type { CrdtKind } from './mirror';
|
|
5
3
|
|
|
6
4
|
// ==================== CURSOR UTILITIES ====================
|
|
7
5
|
|
|
@@ -18,277 +16,72 @@ export function cursorColorFromName(name: string): string {
|
|
|
18
16
|
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
19
17
|
}
|
|
20
18
|
|
|
19
|
+
export { decodeBase64, encodeBase64 } from './bytes';
|
|
20
|
+
|
|
21
21
|
// ==================== CRDT FIELD ====================
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
private remote: RemoteDatabaseService | null = null;
|
|
28
|
-
private recordId: string | null = null;
|
|
29
|
-
private sessionId: string = '';
|
|
30
|
-
private unsubscribe: (() => void) | null = null;
|
|
31
|
-
private lastPushTime = 0;
|
|
32
|
-
private lastCursorPushTime = 0;
|
|
33
|
-
private loadedFromCrdt = false;
|
|
34
|
-
private pushRetryCount = 0;
|
|
35
|
-
private logger: Logger | null;
|
|
36
|
-
private cursorsEnabled: boolean;
|
|
37
|
-
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
38
|
-
* update; the remote UPSERT is coalesced over this window. Configured
|
|
39
|
-
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
40
|
-
private remoteDebounceMs: number = 500;
|
|
23
|
+
/** What a field handle needs from the manager that owns its session. */
|
|
24
|
+
export interface CrdtFieldHost {
|
|
25
|
+
pushPresence(state: Uint8Array): void;
|
|
26
|
+
}
|
|
41
27
|
|
|
28
|
+
/**
|
|
29
|
+
* An open collaborative field: the Loro document an editor binds to. Edits
|
|
30
|
+
* made on it are sent as the client's own edit-log rows; other clients' rows
|
|
31
|
+
* are imported into it as they arrive. Every open of the same field in one tab
|
|
32
|
+
* shares this document.
|
|
33
|
+
*/
|
|
34
|
+
export class CrdtField {
|
|
42
35
|
private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
|
|
43
36
|
private pendingCursorUpdate: Uint8Array | null = null;
|
|
44
37
|
|
|
45
|
-
/** Callback set by the editor to receive remote cursor updates.
|
|
46
|
-
* Any cursor data that arrived before this callback was set will be replayed. */
|
|
47
|
-
set onCursorUpdate(cb: ((data: Uint8Array) => void) | null) {
|
|
48
|
-
this._onCursorUpdate = cb;
|
|
49
|
-
if (cb && this.pendingCursorUpdate) {
|
|
50
|
-
try { cb(this.pendingCursorUpdate); } catch (e) {
|
|
51
|
-
this.logger?.warn(
|
|
52
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::onCursorUpdate' },
|
|
53
|
-
'Failed to replay pending cursor update'
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
this.pendingCursorUpdate = null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get onCursorUpdate() { return this._onCursorUpdate; }
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* @param LoroDocClass the `LoroDoc` constructor, injected by the caller after
|
|
64
|
-
* awaiting {@link loadLoro} — keeps `loro-crdt` out of this module's static
|
|
65
|
-
* import graph so it only ships to apps that use CRDT fields.
|
|
66
|
-
*/
|
|
67
38
|
constructor(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
this.logger = logger ?? null;
|
|
80
|
-
this.cursorsEnabled = cursorsEnabled;
|
|
81
|
-
this.doc = new LoroDocClass();
|
|
82
|
-
if (initialState && initialState.length > 0) {
|
|
83
|
-
// Tolerance: catch bad-snapshot data (corrupt blob, stale legacy
|
|
84
|
-
// value left over from a pre-`bytes` migration) so the editor still
|
|
85
|
-
// mounts. Without this guard the rejection bubbles through
|
|
86
|
-
// `useCrdtField` → permanent fallback `<p>`, with no cursor.
|
|
87
|
-
try {
|
|
88
|
-
this.doc.import(initialState);
|
|
89
|
-
this.loadedFromCrdt = true;
|
|
90
|
-
} catch (e) {
|
|
91
|
-
this.logger?.warn(
|
|
92
|
-
{
|
|
93
|
-
error: e,
|
|
94
|
-
fieldName,
|
|
95
|
-
Category: 'sp00ky-client::CrdtField::constructor',
|
|
96
|
-
},
|
|
97
|
-
'Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text'
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
getDoc(): LoroDoc { return this.doc; }
|
|
104
|
-
|
|
105
|
-
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
106
|
-
hasContent(): boolean {
|
|
107
|
-
return this.loadedFromCrdt;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
startSync(
|
|
111
|
-
local: LocalStore,
|
|
112
|
-
remote: RemoteDatabaseService,
|
|
113
|
-
recordId: string,
|
|
114
|
-
sessionId: string,
|
|
115
|
-
debounceMs: number,
|
|
116
|
-
): void {
|
|
117
|
-
this.local = local;
|
|
118
|
-
this.remote = remote;
|
|
119
|
-
this.recordId = recordId;
|
|
120
|
-
this.sessionId = sessionId;
|
|
121
|
-
this.remoteDebounceMs = debounceMs;
|
|
122
|
-
// Every local Loro update writes the snapshot to the local cache
|
|
123
|
-
// *immediately* (so reload/offline see the latest text), then
|
|
124
|
-
// schedules a debounced push to remote. The local UPSERT is cheap —
|
|
125
|
-
// it's an in-memory SurrealKV write — but errors are swallowed so a
|
|
126
|
-
// bad write never blocks user input.
|
|
127
|
-
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
128
|
-
void this.persistLocal();
|
|
129
|
-
this.scheduleRemotePush();
|
|
130
|
-
});
|
|
131
|
-
}
|
|
39
|
+
readonly table: string,
|
|
40
|
+
readonly recordId: string,
|
|
41
|
+
readonly fieldName: string,
|
|
42
|
+
readonly kind: CrdtKind,
|
|
43
|
+
readonly cursorsEnabled: boolean,
|
|
44
|
+
private readonly doc: LoroDoc,
|
|
45
|
+
private readonly host: CrdtFieldHost
|
|
46
|
+
) {}
|
|
132
47
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
136
|
-
* the remote session already belongs to the NEXT user, and pushing this
|
|
137
|
-
* (previous user's) snapshot under it would clobber the record remotely.
|
|
138
|
-
*/
|
|
139
|
-
stopSync(options: { flush?: boolean } = {}): void {
|
|
140
|
-
const flush = options.flush ?? true;
|
|
141
|
-
if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
|
|
142
|
-
if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
|
|
143
|
-
if (flush && this.remote && this.recordId) { void this.pushToRemote(); }
|
|
48
|
+
getDoc(): LoroDoc {
|
|
49
|
+
return this.doc;
|
|
144
50
|
}
|
|
145
51
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// own write echoes back from the LIVE feed before the debounce
|
|
150
|
-
// window closes.
|
|
151
|
-
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
152
|
-
try {
|
|
153
|
-
this.doc.import(state);
|
|
154
|
-
// Persist the merged snapshot locally so the next reload/offline
|
|
155
|
-
// open sees the freshest converged state without waiting for the
|
|
156
|
-
// LIVE feed.
|
|
157
|
-
void this.persistLocal();
|
|
158
|
-
} catch (e) {
|
|
159
|
-
this.logger?.warn(
|
|
160
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::importRemote' },
|
|
161
|
-
'Failed to import remote CRDT state'
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
exportSnapshot(): Uint8Array {
|
|
167
|
-
return this.doc.export({ mode: 'snapshot' });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Push this session's cursor blob into the parent row at
|
|
171
|
-
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
172
|
-
* field — the editor still calls this method optimistically, but
|
|
173
|
-
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
174
|
-
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
175
|
-
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
176
|
-
async pushCursorState(encoded: Uint8Array): Promise<void> {
|
|
177
|
-
if (!this.remote || !this.recordId) return;
|
|
178
|
-
if (!this.cursorsEnabled) return;
|
|
179
|
-
this.lastCursorPushTime = Date.now();
|
|
180
|
-
try {
|
|
181
|
-
const state = encodeBase64(encoded);
|
|
182
|
-
await this.remote.query(
|
|
183
|
-
`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`,
|
|
184
|
-
{
|
|
185
|
-
id: parseRecordIdString(this.recordId),
|
|
186
|
-
sid: this.sessionId,
|
|
187
|
-
state,
|
|
188
|
-
}
|
|
189
|
-
);
|
|
190
|
-
} catch (e) {
|
|
191
|
-
this.logger?.warn(
|
|
192
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::pushCursorState' },
|
|
193
|
-
'Failed to push cursor state'
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
|
|
199
|
-
importRemoteCursor(base64State: string): void {
|
|
200
|
-
if (Date.now() - this.lastCursorPushTime < 300) return; // echo suppression
|
|
201
|
-
try {
|
|
202
|
-
const data = decodeBase64(base64State);
|
|
203
|
-
if (this._onCursorUpdate) {
|
|
204
|
-
this._onCursorUpdate(data);
|
|
205
|
-
} else {
|
|
206
|
-
// Only keep the latest cursor state — older positions are useless
|
|
207
|
-
this.pendingCursorUpdate = data;
|
|
208
|
-
}
|
|
209
|
-
} catch (e) {
|
|
210
|
-
this.logger?.warn(
|
|
211
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::importRemoteCursor' },
|
|
212
|
-
'Failed to apply remote cursor data'
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
private scheduleRemotePush(): void {
|
|
218
|
-
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
219
|
-
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
223
|
-
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
224
|
-
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
225
|
-
* `<field>.state` next to per-session cursor blobs. */
|
|
226
|
-
private statePath(): string {
|
|
227
|
-
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
52
|
+
/** Whether the document holds anything yet (local state or edits). */
|
|
53
|
+
hasContent(): boolean {
|
|
54
|
+
return this.doc.oplogVersion().length() > 0;
|
|
228
55
|
}
|
|
229
56
|
|
|
230
|
-
/**
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
private async persistLocal(): Promise<void> {
|
|
235
|
-
if (!this.local || !this.recordId) return;
|
|
236
|
-
try {
|
|
237
|
-
await this.local.query(
|
|
238
|
-
`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
|
|
239
|
-
{ id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
|
|
240
|
-
);
|
|
241
|
-
} catch (e) {
|
|
242
|
-
this.logger?.debug(
|
|
243
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::persistLocal' },
|
|
244
|
-
'Local CRDT persist failed (best-effort)'
|
|
245
|
-
);
|
|
246
|
-
}
|
|
57
|
+
/** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
|
|
58
|
+
* editor's cursor). No-op unless the field is `@cursor`. */
|
|
59
|
+
pushCursorState(encoded: Uint8Array): void {
|
|
60
|
+
if (this.cursorsEnabled) this.host.pushPresence(encoded);
|
|
247
61
|
}
|
|
248
62
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.pushRetryCount = 0;
|
|
261
|
-
} catch (e) {
|
|
262
|
-
this.logger?.warn(
|
|
263
|
-
{ error: e, Category: 'sp00ky-client::CrdtField::pushToRemote' },
|
|
264
|
-
'Failed to push CRDT state to remote'
|
|
265
|
-
);
|
|
266
|
-
// Bounded retry. Offline first-loads will exhaust this and stop
|
|
267
|
-
// hammering; the next user keystroke (or the next time a remote
|
|
268
|
-
// event lands once we're back online) will kick off another push.
|
|
269
|
-
if (this.pushRetryCount < 2) {
|
|
270
|
-
this.pushRetryCount++;
|
|
271
|
-
this.scheduleRemotePush();
|
|
63
|
+
/** Called with every other session's presence blob. A blob that arrives
|
|
64
|
+
* before the editor sets this is replayed when it does. */
|
|
65
|
+
set onCursorUpdate(cb: ((data: Uint8Array) => void) | null) {
|
|
66
|
+
this._onCursorUpdate = cb;
|
|
67
|
+
if (cb && this.pendingCursorUpdate) {
|
|
68
|
+
const data = this.pendingCursorUpdate;
|
|
69
|
+
this.pendingCursorUpdate = null;
|
|
70
|
+
try {
|
|
71
|
+
cb(data);
|
|
72
|
+
} catch {
|
|
73
|
+
/* the editor logs its own apply failures */
|
|
272
74
|
}
|
|
273
75
|
}
|
|
274
76
|
}
|
|
275
|
-
}
|
|
276
77
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const binary = atob(b64);
|
|
280
|
-
const bytes = new Uint8Array(binary.length);
|
|
281
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
282
|
-
return bytes;
|
|
78
|
+
get onCursorUpdate() {
|
|
79
|
+
return this._onCursorUpdate;
|
|
283
80
|
}
|
|
284
|
-
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
285
|
-
}
|
|
286
81
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
return btoa(binary);
|
|
82
|
+
/** @internal Deliver one remote presence blob. */
|
|
83
|
+
deliverPresence(data: Uint8Array): void {
|
|
84
|
+
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
85
|
+
else this.pendingCursorUpdate = data;
|
|
292
86
|
}
|
|
293
|
-
return Buffer.from(bytes).toString('base64');
|
|
294
87
|
}
|