@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.
Files changed (33) hide show
  1. package/AGENTS.md +4 -3
  2. package/dist/index.d.ts +156 -161
  3. package/dist/index.js +910 -482
  4. package/dist/types.d.ts +17 -6
  5. package/package.json +4 -4
  6. package/src/boot/preload.saga.test.ts +29 -0
  7. package/src/boot/preload.saga.ts +11 -2
  8. package/src/client/services.ts +3 -1
  9. package/src/client/sp00ky-client.test.ts +4 -1
  10. package/src/client/sp00ky-client.ts +37 -4
  11. package/src/modules/crdt/acceptance.ts +75 -0
  12. package/src/modules/crdt/bytes.ts +50 -0
  13. package/src/modules/crdt/crdt-field.ts +48 -255
  14. package/src/modules/crdt/crdt.test.ts +365 -0
  15. package/src/modules/crdt/hash.ts +103 -0
  16. package/src/modules/crdt/index.ts +610 -363
  17. package/src/modules/crdt/mirror.ts +37 -0
  18. package/src/mutation/push.saga.test.ts +16 -0
  19. package/src/mutation/push.saga.ts +9 -1
  20. package/src/query/anon-query-isolation.integration.test.ts +31 -0
  21. package/src/query/env.ts +9 -0
  22. package/src/services/database/cache-engine.ts +10 -0
  23. package/src/services/database/local-migrator.ts +5 -3
  24. package/src/services/database/sqlite-cache-engine.ts +28 -0
  25. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  26. package/src/services/database/sqlite-column-reset.ts +91 -0
  27. package/src/services/stream-processor/index.ts +24 -0
  28. package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
  29. package/src/testing/fake-services.ts +1 -1
  30. package/src/types.ts +7 -6
  31. package/src/utils/surql.ts +3 -4
  32. package/src/modules/crdt/crdt-hydration.test.ts +0 -210
  33. 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 { LocalStore, RemoteDatabaseService } from '../../services/database/index';
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
- export class CrdtField {
24
- private doc: LoroDoc;
25
- private pushTimer: ReturnType<typeof setTimeout> | null = null;
26
- private local: LocalStore | null = null;
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
- private fieldName: string,
69
- cursorsEnabled: boolean,
70
- LoroDocClass: typeof LoroDoc,
71
- initialState?: Uint8Array,
72
- logger?: Logger | null,
73
- ) {
74
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) {
75
- throw new Error(
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
- * Stop syncing this field. Flushes one final remote push by default so the
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
- importRemote(state: Uint8Array): void {
147
- // Echo suppression: skip imports within `remoteDebounceMs + 200` of
148
- // our own push. The +200 guards against round-trip jitter where our
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
- /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
231
- * every local update and every remote import so reloads (online or
232
- * offline) see the freshest content immediately. Failures are
233
- * swallowed — a stale local write must never block user input. */
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
- private async pushToRemote(): Promise<void> {
250
- if (!this.remote || !this.recordId) return;
251
- this.lastPushTime = Date.now();
252
- try {
253
- // The UPDATE on the parent fires the parent table's LIVE feed,
254
- // so cross-browser receivers see this change directly in the LIVE
255
- // payload — no separate `_00_rv` bump or sidecar UPSERT.
256
- await this.remote.query(
257
- `UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
258
- { id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
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
- export function decodeBase64(b64: string): Uint8Array {
278
- if (typeof atob === 'function') {
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
- export function encodeBase64(bytes: Uint8Array): string {
288
- if (typeof btoa === 'function') {
289
- let binary = '';
290
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
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
  }