@spooky-sync/core 0.0.1-canary.65 → 0.0.1-canary.67

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.
@@ -1,4 +1,4 @@
1
- import { c as PinoTransmit } from "../types.js";
1
+ import { l as PinoTransmit } from "../types.js";
2
2
  import { Level } from "pino";
3
3
 
4
4
  //#region src/otel/index.d.ts
package/dist/types.d.ts CHANGED
@@ -221,8 +221,6 @@ interface Sp00kyConfig<S extends SchemaStructure> {
221
221
  /** Authentication token. */
222
222
  token?: string;
223
223
  };
224
- /** Unique client identifier. If not provided, one will be generated. */
225
- clientId?: string;
226
224
  /** The schema definition. */
227
225
  schema: S;
228
226
  /** The compiled SURQL schema string. */
@@ -241,6 +239,22 @@ interface Sp00kyConfig<S extends SchemaStructure> {
241
239
  * Defaults to 100ms.
242
240
  */
243
241
  streamDebounceTime?: number;
242
+ /**
243
+ * Debounce time in milliseconds for syncing collaborative (CRDT) field
244
+ * changes to the remote database. Local writes happen immediately on
245
+ * every keystroke (so reload/offline works), but the remote UPSERT is
246
+ * coalesced over this window. Lower = snappier remote propagation +
247
+ * more network traffic; higher = less traffic + more lag for other
248
+ * collaborators. Defaults to 500ms.
249
+ */
250
+ crdtDebounceMs?: number;
251
+ /**
252
+ * Cadence (ms) for the `_00_list_ref` poll that catches cross-session
253
+ * UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
254
+ * convergence + more query load; higher = the inverse. Non-positive
255
+ * values fall back to the default (500ms).
256
+ */
257
+ refSyncIntervalMs?: number;
244
258
  }
245
259
  type QueryHash = string;
246
260
  type RecordVersionArray = Array<[string, number]>;
@@ -301,7 +315,19 @@ interface QueryState {
301
315
  ttlDurationMs: number;
302
316
  /** Number of times the query has been updated. */
303
317
  updateCount: number;
318
+ /**
319
+ * Rolling window of the most recent materialization-step latencies (ms).
320
+ * Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
321
+ * before each persist to `_00_query`. Samples themselves are not persisted.
322
+ */
323
+ materializationSamples: number[];
324
+ /** Most recent end-to-end ingest latency in ms, or null until the first ingest. */
325
+ lastIngestLatencyMs: number | null;
326
+ /** Cumulative count of ingest/materialization errors observed for this query. */
327
+ errorCount: number;
304
328
  }
329
+ /** Cap on the rolling materialization-sample window kept per query in memory. */
330
+ declare const MATERIALIZATION_SAMPLE_WINDOW = 100;
305
331
  type QueryUpdateCallback = (records: Record<string, any>[]) => void;
306
332
  type MutationCallback = (mutations: UpEvent[]) => void;
307
333
  type MutationEventType = 'create' | 'update' | 'delete';
@@ -358,4 +384,4 @@ interface DebounceOptions {
358
384
  delay?: number;
359
385
  }
360
386
  //#endregion
361
- export { Logger$1 as C, UpdateOptions as S, EventSystem as T, RunOptions as _, MutationEvent as a, Sp00kyQueryResultPromise as b, PinoTransmit as c, QueryHash as d, QueryState as f, RecordVersionDiff as g, RecordVersionArray as h, MutationCallback as i, QueryConfig as l, QueryUpdateCallback as m, EventSubscriptionOptions as n, MutationEventType as o, QueryTimeToLive as p, Level$1 as r, PersistenceClient as s, DebounceOptions as t, QueryConfigRecord as u, Sp00kyConfig as v, EventDefinition as w, StoreType as x, Sp00kyQueryResult as y };
387
+ export { UpdateOptions as C, EventSystem as E, StoreType as S, EventDefinition as T, RecordVersionDiff as _, MutationCallback as a, Sp00kyQueryResult as b, PersistenceClient as c, QueryConfigRecord as d, QueryHash as f, RecordVersionArray as g, QueryUpdateCallback as h, MATERIALIZATION_SAMPLE_WINDOW as i, PinoTransmit as l, QueryTimeToLive as m, EventSubscriptionOptions as n, MutationEvent as o, QueryState as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, QueryConfig as u, RunOptions as v, Logger$1 as w, Sp00kyQueryResultPromise as x, Sp00kyConfig as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.65",
3
+ "version": "0.0.1-canary.67",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -65,7 +65,7 @@
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
67
67
  "pino": "^10.1.0",
68
- "surrealdb": "2.0.0"
68
+ "surrealdb": "2.0.3"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/node": "^22.5.2",
@@ -94,7 +94,12 @@ export class CacheModule implements StreamUpdateReceiver {
94
94
  const query = surql.seal<void>(
95
95
  surql.tx(
96
96
  populatedRecords.map((_, i) => {
97
- return surql.upsert(`id${i}`, `content${i}`);
97
+ // MERGE, not REPLACE: the remote payload omits local-only
98
+ // fields (`_00_crdt`, `_00_cursor`) injected by the CLI's
99
+ // local schema, so REPLACE would wipe the persisted CRDT
100
+ // snapshot on every sync-down round-trip and break offline
101
+ // reload of formatted text.
102
+ return surql.upsertMerge(`id${i}`, `content${i}`);
98
103
  })
99
104
  )
100
105
  );
@@ -1,5 +1,5 @@
1
1
  import { LoroDoc } from 'loro-crdt';
2
- import type { RemoteDatabaseService } from '../../services/database/index';
2
+ import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
3
3
  import type { Logger } from '../../services/logger/index';
4
4
  import { parseRecordIdString } from '../../utils/index';
5
5
 
@@ -23,14 +23,21 @@ export function cursorColorFromName(name: string): string {
23
23
  export class CrdtField {
24
24
  private doc: LoroDoc;
25
25
  private pushTimer: ReturnType<typeof setTimeout> | null = null;
26
+ private local: LocalDatabaseService | null = null;
26
27
  private remote: RemoteDatabaseService | null = null;
27
28
  private recordId: string | null = null;
29
+ private sessionId: string = '';
28
30
  private unsubscribe: (() => void) | null = null;
29
31
  private lastPushTime = 0;
30
32
  private lastCursorPushTime = 0;
31
33
  private loadedFromCrdt = false;
32
34
  private pushRetryCount = 0;
33
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;
34
41
 
35
42
  private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
36
43
  private pendingCursorUpdate: Uint8Array | null = null;
@@ -54,14 +61,36 @@ export class CrdtField {
54
61
 
55
62
  constructor(
56
63
  private fieldName: string,
57
- initialState?: string,
64
+ cursorsEnabled: boolean,
65
+ initialState?: Uint8Array,
58
66
  logger?: Logger | null,
59
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
+ }
60
73
  this.logger = logger ?? null;
74
+ this.cursorsEnabled = cursorsEnabled;
61
75
  this.doc = new LoroDoc();
62
- if (initialState) {
63
- this.doc.import(decodeBase64(initialState));
64
- this.loadedFromCrdt = true;
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
+ }
65
94
  }
66
95
  }
67
96
 
@@ -72,11 +101,26 @@ export class CrdtField {
72
101
  return this.loadedFromCrdt;
73
102
  }
74
103
 
75
- startSync(remote: RemoteDatabaseService, recordId: string): void {
104
+ startSync(
105
+ local: LocalDatabaseService,
106
+ remote: RemoteDatabaseService,
107
+ recordId: string,
108
+ sessionId: string,
109
+ debounceMs: number,
110
+ ): void {
111
+ this.local = local;
76
112
  this.remote = remote;
77
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.
78
121
  this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
79
- this.schedulePush();
122
+ void this.persistLocal();
123
+ this.scheduleRemotePush();
80
124
  });
81
125
  }
82
126
 
@@ -86,11 +130,18 @@ export class CrdtField {
86
130
  if (this.remote && this.recordId) { void this.pushToRemote(); }
87
131
  }
88
132
 
89
- importRemote(base64State: string): void {
90
- // Suppress echo: skip imports within 500ms of our own push
91
- if (Date.now() - this.lastPushTime < 500) return;
133
+ importRemote(state: Uint8Array): void {
134
+ // Echo suppression: skip imports within `remoteDebounceMs + 200` of
135
+ // our own push. The +200 guards against round-trip jitter where our
136
+ // own write echoes back from the LIVE feed before the debounce
137
+ // window closes.
138
+ if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
92
139
  try {
93
- this.doc.import(decodeBase64(base64State));
140
+ this.doc.import(state);
141
+ // Persist the merged snapshot locally so the next reload/offline
142
+ // open sees the freshest converged state without waiting for the
143
+ // LIVE feed.
144
+ void this.persistLocal();
94
145
  } catch (e) {
95
146
  this.logger?.warn(
96
147
  { error: e, Category: 'sp00ky-client::CrdtField::importRemote' },
@@ -99,20 +150,29 @@ export class CrdtField {
99
150
  }
100
151
  }
101
152
 
102
- exportSnapshot(): string {
103
- return encodeBase64(this.doc.export({ mode: 'snapshot' }));
153
+ exportSnapshot(): Uint8Array {
154
+ return this.doc.export({ mode: 'snapshot' });
104
155
  }
105
156
 
106
- /** Push cursor ephemeral state to _00_crdt as a "_cursor_<fieldName>" entry */
157
+ /** Push this session's cursor blob into the parent row at
158
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
159
+ * field — the editor still calls this method optimistically, but
160
+ * without `@cursor` on the schema there's nowhere to store the blob.
161
+ * The UPDATE itself fires the parent table's LIVE feed, so other
162
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
107
163
  async pushCursorState(encoded: Uint8Array): Promise<void> {
108
164
  if (!this.remote || !this.recordId) return;
165
+ if (!this.cursorsEnabled) return;
109
166
  this.lastCursorPushTime = Date.now();
110
167
  try {
111
168
  const state = encodeBase64(encoded);
112
169
  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 }
170
+ `UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`,
171
+ {
172
+ id: parseRecordIdString(this.recordId),
173
+ sid: this.sessionId,
174
+ state,
175
+ }
116
176
  );
117
177
  } catch (e) {
118
178
  this.logger?.warn(
@@ -141,19 +201,48 @@ export class CrdtField {
141
201
  }
142
202
  }
143
203
 
144
- private schedulePush(): void {
204
+ private scheduleRemotePush(): void {
145
205
  if (this.pushTimer) clearTimeout(this.pushTimer);
146
- this.pushTimer = setTimeout(() => void this.pushToRemote(), 300);
206
+ this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
207
+ }
208
+
209
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
210
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
211
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
212
+ * `<field>.state` next to per-session cursor blobs. */
213
+ private statePath(): string {
214
+ return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
215
+ }
216
+
217
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
218
+ * every local update and every remote import so reloads (online or
219
+ * offline) see the freshest content immediately. Failures are
220
+ * swallowed — a stale local write must never block user input. */
221
+ private async persistLocal(): Promise<void> {
222
+ if (!this.local || !this.recordId) return;
223
+ try {
224
+ await this.local.query(
225
+ `UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
226
+ { id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
227
+ );
228
+ } catch (e) {
229
+ this.logger?.debug(
230
+ { error: e, Category: 'sp00ky-client::CrdtField::persistLocal' },
231
+ 'Local CRDT persist failed (best-effort)'
232
+ );
233
+ }
147
234
  }
148
235
 
149
236
  private async pushToRemote(): Promise<void> {
150
237
  if (!this.remote || !this.recordId) return;
151
238
  this.lastPushTime = Date.now();
152
239
  try {
240
+ // The UPDATE on the parent fires the parent table's LIVE feed,
241
+ // so cross-browser receivers see this change directly in the LIVE
242
+ // payload — no separate `_00_rv` bump or sidecar UPSERT.
153
243
  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() }
244
+ `UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
245
+ { id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
157
246
  );
158
247
  this.pushRetryCount = 0;
159
248
  } catch (e) {
@@ -161,9 +250,12 @@ export class CrdtField {
161
250
  { error: e, Category: 'sp00ky-client::CrdtField::pushToRemote' },
162
251
  'Failed to push CRDT state to remote'
163
252
  );
253
+ // Bounded retry. Offline first-loads will exhaust this and stop
254
+ // hammering; the next user keystroke (or the next time a remote
255
+ // event lands once we're back online) will kick off another push.
164
256
  if (this.pushRetryCount < 2) {
165
257
  this.pushRetryCount++;
166
- this.schedulePush();
258
+ this.scheduleRemotePush();
167
259
  }
168
260
  }
169
261
  }
@@ -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 { LocalDatabaseService, 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<LocalDatabaseService> as unknown as LocalDatabaseService;
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<LocalDatabaseService> as unknown as LocalDatabaseService;
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<LocalDatabaseService> as unknown as LocalDatabaseService;
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<LocalDatabaseService> as unknown as LocalDatabaseService;
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
+ });