@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131
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 +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5726 -1277
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- 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/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1234 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -392
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { 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';
|
|
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 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;
|
|
41
|
+
|
|
42
|
+
private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
|
|
43
|
+
private pendingCursorUpdate: Uint8Array | null = null;
|
|
44
|
+
|
|
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
|
+
constructor(
|
|
63
|
+
private fieldName: string,
|
|
64
|
+
cursorsEnabled: boolean,
|
|
65
|
+
initialState?: Uint8Array,
|
|
66
|
+
logger?: Logger | null,
|
|
67
|
+
) {
|
|
68
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
this.logger = logger ?? null;
|
|
74
|
+
this.cursorsEnabled = cursorsEnabled;
|
|
75
|
+
this.doc = new LoroDoc();
|
|
76
|
+
if (initialState && initialState.length > 0) {
|
|
77
|
+
// Tolerance: catch bad-snapshot data (corrupt blob, stale legacy
|
|
78
|
+
// value left over from a pre-`bytes` migration) so the editor still
|
|
79
|
+
// mounts. Without this guard the rejection bubbles through
|
|
80
|
+
// `useCrdtField` → permanent fallback `<p>`, with no cursor.
|
|
81
|
+
try {
|
|
82
|
+
this.doc.import(initialState);
|
|
83
|
+
this.loadedFromCrdt = true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
this.logger?.warn(
|
|
86
|
+
{
|
|
87
|
+
error: e,
|
|
88
|
+
fieldName,
|
|
89
|
+
Category: 'sp00ky-client::CrdtField::constructor',
|
|
90
|
+
},
|
|
91
|
+
'Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getDoc(): LoroDoc { return this.doc; }
|
|
98
|
+
|
|
99
|
+
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
100
|
+
hasContent(): boolean {
|
|
101
|
+
return this.loadedFromCrdt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
startSync(
|
|
105
|
+
local: LocalStore,
|
|
106
|
+
remote: RemoteDatabaseService,
|
|
107
|
+
recordId: string,
|
|
108
|
+
sessionId: string,
|
|
109
|
+
debounceMs: number,
|
|
110
|
+
): void {
|
|
111
|
+
this.local = local;
|
|
112
|
+
this.remote = remote;
|
|
113
|
+
this.recordId = recordId;
|
|
114
|
+
this.sessionId = sessionId;
|
|
115
|
+
this.remoteDebounceMs = debounceMs;
|
|
116
|
+
// Every local Loro update writes the snapshot to the local cache
|
|
117
|
+
// *immediately* (so reload/offline see the latest text), then
|
|
118
|
+
// schedules a debounced push to remote. The local UPSERT is cheap —
|
|
119
|
+
// it's an in-memory SurrealKV write — but errors are swallowed so a
|
|
120
|
+
// bad write never blocks user input.
|
|
121
|
+
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
122
|
+
void this.persistLocal();
|
|
123
|
+
this.scheduleRemotePush();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stop syncing this field. Flushes one final remote push by default so the
|
|
129
|
+
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
130
|
+
* the remote session already belongs to the NEXT user, and pushing this
|
|
131
|
+
* (previous user's) snapshot under it would clobber the record remotely.
|
|
132
|
+
*/
|
|
133
|
+
stopSync(options: { flush?: boolean } = {}): void {
|
|
134
|
+
const flush = options.flush ?? true;
|
|
135
|
+
if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
|
|
136
|
+
if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
|
|
137
|
+
if (flush && this.remote && this.recordId) { void this.pushToRemote(); }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
importRemote(state: Uint8Array): void {
|
|
141
|
+
// Echo suppression: skip imports within `remoteDebounceMs + 200` of
|
|
142
|
+
// our own push. The +200 guards against round-trip jitter where our
|
|
143
|
+
// own write echoes back from the LIVE feed before the debounce
|
|
144
|
+
// window closes.
|
|
145
|
+
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
146
|
+
try {
|
|
147
|
+
this.doc.import(state);
|
|
148
|
+
// Persist the merged snapshot locally so the next reload/offline
|
|
149
|
+
// open sees the freshest converged state without waiting for the
|
|
150
|
+
// LIVE feed.
|
|
151
|
+
void this.persistLocal();
|
|
152
|
+
} catch (e) {
|
|
153
|
+
this.logger?.warn(
|
|
154
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::importRemote' },
|
|
155
|
+
'Failed to import remote CRDT state'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
exportSnapshot(): Uint8Array {
|
|
161
|
+
return this.doc.export({ mode: 'snapshot' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Push this session's cursor blob into the parent row at
|
|
165
|
+
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
166
|
+
* field — the editor still calls this method optimistically, but
|
|
167
|
+
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
168
|
+
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
169
|
+
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
170
|
+
async pushCursorState(encoded: Uint8Array): Promise<void> {
|
|
171
|
+
if (!this.remote || !this.recordId) return;
|
|
172
|
+
if (!this.cursorsEnabled) return;
|
|
173
|
+
this.lastCursorPushTime = Date.now();
|
|
174
|
+
try {
|
|
175
|
+
const state = encodeBase64(encoded);
|
|
176
|
+
await this.remote.query(
|
|
177
|
+
`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`,
|
|
178
|
+
{
|
|
179
|
+
id: parseRecordIdString(this.recordId),
|
|
180
|
+
sid: this.sessionId,
|
|
181
|
+
state,
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
this.logger?.warn(
|
|
186
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::pushCursorState' },
|
|
187
|
+
'Failed to push cursor state'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
|
|
193
|
+
importRemoteCursor(base64State: string): void {
|
|
194
|
+
if (Date.now() - this.lastCursorPushTime < 300) return; // echo suppression
|
|
195
|
+
try {
|
|
196
|
+
const data = decodeBase64(base64State);
|
|
197
|
+
if (this._onCursorUpdate) {
|
|
198
|
+
this._onCursorUpdate(data);
|
|
199
|
+
} else {
|
|
200
|
+
// Only keep the latest cursor state — older positions are useless
|
|
201
|
+
this.pendingCursorUpdate = data;
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
this.logger?.warn(
|
|
205
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::importRemoteCursor' },
|
|
206
|
+
'Failed to apply remote cursor data'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private scheduleRemotePush(): void {
|
|
212
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
213
|
+
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
217
|
+
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
218
|
+
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
219
|
+
* `<field>.state` next to per-session cursor blobs. */
|
|
220
|
+
private statePath(): string {
|
|
221
|
+
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
225
|
+
* every local update and every remote import so reloads (online or
|
|
226
|
+
* offline) see the freshest content immediately. Failures are
|
|
227
|
+
* swallowed — a stale local write must never block user input. */
|
|
228
|
+
private async persistLocal(): Promise<void> {
|
|
229
|
+
if (!this.local || !this.recordId) return;
|
|
230
|
+
try {
|
|
231
|
+
await this.local.query(
|
|
232
|
+
`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
|
|
233
|
+
{ id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
|
|
234
|
+
);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
this.logger?.debug(
|
|
237
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::persistLocal' },
|
|
238
|
+
'Local CRDT persist failed (best-effort)'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async pushToRemote(): Promise<void> {
|
|
244
|
+
if (!this.remote || !this.recordId) return;
|
|
245
|
+
this.lastPushTime = Date.now();
|
|
246
|
+
try {
|
|
247
|
+
// The UPDATE on the parent fires the parent table's LIVE feed,
|
|
248
|
+
// so cross-browser receivers see this change directly in the LIVE
|
|
249
|
+
// payload — no separate `_00_rv` bump or sidecar UPSERT.
|
|
250
|
+
await this.remote.query(
|
|
251
|
+
`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
|
|
252
|
+
{ id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
|
|
253
|
+
);
|
|
254
|
+
this.pushRetryCount = 0;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
this.logger?.warn(
|
|
257
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::pushToRemote' },
|
|
258
|
+
'Failed to push CRDT state to remote'
|
|
259
|
+
);
|
|
260
|
+
// Bounded retry. Offline first-loads will exhaust this and stop
|
|
261
|
+
// hammering; the next user keystroke (or the next time a remote
|
|
262
|
+
// event lands once we're back online) will kick off another push.
|
|
263
|
+
if (this.pushRetryCount < 2) {
|
|
264
|
+
this.pushRetryCount++;
|
|
265
|
+
this.scheduleRemotePush();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function decodeBase64(b64: string): Uint8Array {
|
|
272
|
+
if (typeof atob === 'function') {
|
|
273
|
+
const binary = atob(b64);
|
|
274
|
+
const bytes = new Uint8Array(binary.length);
|
|
275
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
276
|
+
return bytes;
|
|
277
|
+
}
|
|
278
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function encodeBase64(bytes: Uint8Array): string {
|
|
282
|
+
if (typeof btoa === 'function') {
|
|
283
|
+
let binary = '';
|
|
284
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
285
|
+
return btoa(binary);
|
|
286
|
+
}
|
|
287
|
+
return Buffer.from(bytes).toString('base64');
|
|
288
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { LoroDoc } from 'loro-crdt';
|
|
3
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
4
|
+
import { CrdtManager } from './index';
|
|
5
|
+
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
6
|
+
import type { Logger } from '../../services/logger/index';
|
|
7
|
+
|
|
8
|
+
// Regression test for the offline-reload formatting-loss class of bug.
|
|
9
|
+
//
|
|
10
|
+
// Repro pattern: app uses `store: 'memory'` for the local SurrealDB (or
|
|
11
|
+
// any scenario where the local row exists without a CRDT snapshot —
|
|
12
|
+
// fresh device, post-sync-down with stale meta, …). On reload, the
|
|
13
|
+
// editor mounts but stays empty until some unrelated edit fires the
|
|
14
|
+
// parent LIVE feed. From the user's POV, formatting is gone.
|
|
15
|
+
//
|
|
16
|
+
// Fix: when `CrdtManager.open` finds no local snapshot, it must fetch
|
|
17
|
+
// the parent row from remote (the snapshot lives inline on the row in
|
|
18
|
+
// the consolidated design) and dispatch its CRDT field. This test pins
|
|
19
|
+
// that behavior across both shapes — `@crdt`-only fields where the
|
|
20
|
+
// snapshot IS the field value, and `@crdt @cursor` fields where the
|
|
21
|
+
// snapshot lives at `<field>.state`.
|
|
22
|
+
|
|
23
|
+
function silentLogger(): Logger {
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
const fake: any = {};
|
|
26
|
+
fake.info = noop;
|
|
27
|
+
fake.warn = noop;
|
|
28
|
+
fake.debug = noop;
|
|
29
|
+
fake.error = noop;
|
|
30
|
+
fake.trace = noop;
|
|
31
|
+
fake.child = () => fake;
|
|
32
|
+
return fake as Logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function singleTableSchema(opts: { cursor?: boolean } = {}): SchemaStructure {
|
|
36
|
+
return {
|
|
37
|
+
tables: [
|
|
38
|
+
{
|
|
39
|
+
name: 'thread',
|
|
40
|
+
columns: {
|
|
41
|
+
body: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
optional: false,
|
|
44
|
+
crdt: 'text',
|
|
45
|
+
...(opts.cursor ? { cursor: true } : {}),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
primaryKey: ['id'],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
relationships: [],
|
|
52
|
+
backends: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSnapshot(text: string): Uint8Array {
|
|
57
|
+
const doc = new LoroDoc();
|
|
58
|
+
doc.getText('text').insert(0, text);
|
|
59
|
+
return doc.export({ mode: 'snapshot' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeRemote(handlers: {
|
|
63
|
+
selectRow?: () => unknown;
|
|
64
|
+
}): RemoteDatabaseService {
|
|
65
|
+
const remote = {
|
|
66
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
67
|
+
if (sql.includes('LIVE SELECT')) return ['live-uuid'];
|
|
68
|
+
if (sql.includes('SELECT * FROM ONLY')) {
|
|
69
|
+
return [handlers.selectRow ? handlers.selectRow() : null];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}),
|
|
73
|
+
getClient: () =>
|
|
74
|
+
({
|
|
75
|
+
liveOf: async () => ({
|
|
76
|
+
subscribe: () => () => {},
|
|
77
|
+
}),
|
|
78
|
+
}) as any,
|
|
79
|
+
} satisfies Partial<RemoteDatabaseService> as unknown as RemoteDatabaseService;
|
|
80
|
+
return remote;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('CrdtManager.open hydration', () => {
|
|
84
|
+
it('@crdt-only: fetches the parent row from remote when local is empty', async () => {
|
|
85
|
+
const remoteSnapshot = buildSnapshot('hello world');
|
|
86
|
+
|
|
87
|
+
const local = {
|
|
88
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
89
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) return [null];
|
|
90
|
+
return [];
|
|
91
|
+
}),
|
|
92
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
93
|
+
|
|
94
|
+
const remote = makeRemote({
|
|
95
|
+
selectRow: () => ({ id: 'thread:abc', body: remoteSnapshot }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const manager = new CrdtManager(singleTableSchema(), local, remote, silentLogger());
|
|
99
|
+
manager.setSessionId('session-under-test');
|
|
100
|
+
|
|
101
|
+
const field = await manager.open('thread', 'thread:abc', 'body');
|
|
102
|
+
|
|
103
|
+
await vi.waitFor(() => {
|
|
104
|
+
expect(field.getDoc().getText('text').toString()).toBe('hello world');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const sqls = (remote.query as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
108
|
+
(c) => c[0] as string
|
|
109
|
+
);
|
|
110
|
+
expect(sqls.some((s) => s.includes('SELECT * FROM ONLY'))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('@crdt @cursor: extracts the snapshot from the object shape', async () => {
|
|
114
|
+
const remoteSnapshot = buildSnapshot('object shape');
|
|
115
|
+
|
|
116
|
+
const local = {
|
|
117
|
+
query: vi.fn().mockImplementation(async () => [null]),
|
|
118
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
119
|
+
|
|
120
|
+
// Cursor-enabled field stores `{ state, cursors }`. The remote row
|
|
121
|
+
// returns this shape; the manager must drill into `.state` for the
|
|
122
|
+
// snapshot bytes.
|
|
123
|
+
const remote = makeRemote({
|
|
124
|
+
selectRow: () => ({
|
|
125
|
+
id: 'thread:abc',
|
|
126
|
+
body: { state: remoteSnapshot, cursors: {} },
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const manager = new CrdtManager(
|
|
131
|
+
singleTableSchema({ cursor: true }),
|
|
132
|
+
local,
|
|
133
|
+
remote,
|
|
134
|
+
silentLogger()
|
|
135
|
+
);
|
|
136
|
+
manager.setSessionId('session-under-test');
|
|
137
|
+
|
|
138
|
+
const field = await manager.open('thread', 'thread:abc', 'body');
|
|
139
|
+
|
|
140
|
+
await vi.waitFor(() => {
|
|
141
|
+
expect(field.getDoc().getText('text').toString()).toBe('object shape');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('skips the remote fetch when a local snapshot already exists', async () => {
|
|
146
|
+
const localSnapshot = buildSnapshot('cached');
|
|
147
|
+
|
|
148
|
+
const local = {
|
|
149
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
150
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) return [localSnapshot];
|
|
151
|
+
return [];
|
|
152
|
+
}),
|
|
153
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
154
|
+
|
|
155
|
+
const remote = makeRemote({});
|
|
156
|
+
|
|
157
|
+
const manager = new CrdtManager(singleTableSchema(), local, remote, silentLogger());
|
|
158
|
+
manager.setSessionId('session-under-test');
|
|
159
|
+
|
|
160
|
+
const field = await manager.open('thread', 'thread:def', 'body');
|
|
161
|
+
|
|
162
|
+
expect(field.getDoc().getText('text').toString()).toBe('cached');
|
|
163
|
+
|
|
164
|
+
// Give any stray async work a tick to land before asserting absence.
|
|
165
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
166
|
+
|
|
167
|
+
const sqls = (remote.query as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
168
|
+
(c) => c[0] as string
|
|
169
|
+
);
|
|
170
|
+
expect(sqls.some((s) => s.includes('SELECT * FROM ONLY'))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Regression: SurrealDB returns bytes nested inside a FLEXIBLE object as a
|
|
174
|
+
// plain `number[]` from the local WASM engine (e.g. `state: [108, 111, 114,
|
|
175
|
+
// 111, ...]` — the literal LoroDoc magic header bytes). The cursor-enabled
|
|
176
|
+
// extractSnapshot path used to only accept Uint8Array and would silently
|
|
177
|
+
// return undefined for the array form, leaving every editor empty and
|
|
178
|
+
// killing realtime updates for `@crdt @cursor` fields.
|
|
179
|
+
it('@crdt @cursor: accepts number[] as the state shape (local WASM transport)', async () => {
|
|
180
|
+
const remoteSnapshot = buildSnapshot('from local wasm');
|
|
181
|
+
const remoteAsArray = Array.from(remoteSnapshot) as unknown as number[];
|
|
182
|
+
|
|
183
|
+
const local = {
|
|
184
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
185
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) {
|
|
186
|
+
return [{ state: remoteAsArray, cursors: {} }];
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}),
|
|
190
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
191
|
+
|
|
192
|
+
const remote = makeRemote({});
|
|
193
|
+
|
|
194
|
+
const manager = new CrdtManager(
|
|
195
|
+
singleTableSchema({ cursor: true }),
|
|
196
|
+
local,
|
|
197
|
+
remote,
|
|
198
|
+
silentLogger()
|
|
199
|
+
);
|
|
200
|
+
manager.setSessionId('session-under-test');
|
|
201
|
+
|
|
202
|
+
const field = await manager.open('thread', 'thread:wasm', 'body');
|
|
203
|
+
|
|
204
|
+
expect(field.getDoc().getText('text').toString()).toBe('from local wasm');
|
|
205
|
+
});
|
|
206
|
+
});
|