@spooky-sync/core 0.0.1-canary.66 → 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.
@@ -5,6 +5,10 @@ import {
5
5
  applyRecordVersionDiff,
6
6
  createDiffFromDbOp,
7
7
  ArraySyncer,
8
+ resolveListRefPollInterval,
9
+ DEFAULT_LIST_REF_POLL_INTERVAL_MS,
10
+ buildListRefSelect,
11
+ nextPollDelayMs,
8
12
  } from './utils';
9
13
  import type { RecordVersionArray, RecordVersionDiff } from '../../types';
10
14
  import { encodeRecordId } from '../../utils/index';
@@ -309,3 +313,156 @@ describe('ArraySyncer', () => {
309
313
  expect(removedIds).toEqual(sorted);
310
314
  });
311
315
  });
316
+
317
+ describe('resolveListRefPollInterval', () => {
318
+ it('returns the default when no option is provided', () => {
319
+ expect(resolveListRefPollInterval()).toBe(DEFAULT_LIST_REF_POLL_INTERVAL_MS);
320
+ expect(resolveListRefPollInterval(undefined)).toBe(
321
+ DEFAULT_LIST_REF_POLL_INTERVAL_MS
322
+ );
323
+ });
324
+
325
+ it('respects a positive override', () => {
326
+ expect(resolveListRefPollInterval(1000)).toBe(1000);
327
+ expect(resolveListRefPollInterval(5000)).toBe(5000);
328
+ });
329
+
330
+ it('falls back to default for zero, negative, NaN, or Infinity', () => {
331
+ // Accepting these would either silently disable polling or busy-loop
332
+ // the event loop, so they fall back to the default.
333
+ expect(resolveListRefPollInterval(0)).toBe(DEFAULT_LIST_REF_POLL_INTERVAL_MS);
334
+ expect(resolveListRefPollInterval(-500)).toBe(
335
+ DEFAULT_LIST_REF_POLL_INTERVAL_MS
336
+ );
337
+ expect(resolveListRefPollInterval(NaN)).toBe(
338
+ DEFAULT_LIST_REF_POLL_INTERVAL_MS
339
+ );
340
+ expect(resolveListRefPollInterval(Infinity)).toBe(
341
+ DEFAULT_LIST_REF_POLL_INTERVAL_MS
342
+ );
343
+ });
344
+
345
+ it('locks the default at 500ms so accidental tuning trips this test', () => {
346
+ expect(DEFAULT_LIST_REF_POLL_INTERVAL_MS).toBe(500);
347
+ });
348
+ });
349
+
350
+ describe('buildListRefSelect', () => {
351
+ it('substitutes the table name', () => {
352
+ expect(buildListRefSelect('_00_list_ref')).toContain('FROM _00_list_ref ');
353
+ expect(buildListRefSelect('_00_list_ref_user_abc')).toContain(
354
+ 'FROM _00_list_ref_user_abc '
355
+ );
356
+ });
357
+
358
+ it('filters by the bound query id', () => {
359
+ expect(buildListRefSelect('_00_list_ref')).toContain('WHERE in = $in');
360
+ });
361
+
362
+ it('excludes subquery rows via parent IS NONE', () => {
363
+ // Without this predicate the poll would surface child records (the
364
+ // subquery-projection rows the SSP also writes into list_ref) as
365
+ // spurious "added" diffs against `localArray` every tick.
366
+ expect(buildListRefSelect('_00_list_ref')).toContain('parent IS NONE');
367
+ });
368
+
369
+ it('selects only `out` and `version`', () => {
370
+ // The diff only needs the record id and its version; pulling more
371
+ // fields would bloat the per-tick query traffic.
372
+ const sql = buildListRefSelect('_00_list_ref');
373
+ expect(sql.startsWith('SELECT out, version FROM ')).toBe(true);
374
+ });
375
+ });
376
+
377
+ describe('nextPollDelayMs', () => {
378
+ it('returns the base interval when LIVE has never delivered', () => {
379
+ expect(
380
+ nextPollDelayMs({
381
+ now: 10_000,
382
+ lastLiveEventAt: null,
383
+ baseIntervalMs: 500,
384
+ })
385
+ ).toBe(500);
386
+ });
387
+
388
+ it('returns the healthy interval when a LIVE event fired within the cooldown', () => {
389
+ expect(
390
+ nextPollDelayMs({
391
+ now: 10_000,
392
+ lastLiveEventAt: 8_000, // 2s ago, well within 5s cooldown
393
+ baseIntervalMs: 500,
394
+ })
395
+ ).toBe(5_000);
396
+ });
397
+
398
+ it('snaps back to the base interval after the cooldown elapses', () => {
399
+ expect(
400
+ nextPollDelayMs({
401
+ now: 20_000,
402
+ lastLiveEventAt: 10_000, // 10s ago, well past cooldown
403
+ baseIntervalMs: 500,
404
+ })
405
+ ).toBe(500);
406
+ });
407
+
408
+ it('treats the cooldown boundary as expired (>= cooldownMs returns base)', () => {
409
+ // Exactly at the boundary, the LIVE feed is considered cold so we
410
+ // resume the aggressive cadence. Picks the same direction as
411
+ // `if (sinceLive >= cooldownMs)`.
412
+ expect(
413
+ nextPollDelayMs({
414
+ now: 15_000,
415
+ lastLiveEventAt: 10_000, // exactly 5s ago
416
+ baseIntervalMs: 500,
417
+ cooldownMs: 5_000,
418
+ })
419
+ ).toBe(500);
420
+ });
421
+
422
+ it('clamps a clock-skew negative sinceLive to the base interval', () => {
423
+ // If a stale timestamp puts the LIVE event in the future, we
424
+ // should not interpret that as "LIVE healthy" — treat it as
425
+ // unknown and use the conservative aggressive cadence.
426
+ expect(
427
+ nextPollDelayMs({
428
+ now: 10_000,
429
+ lastLiveEventAt: 20_000, // future timestamp
430
+ baseIntervalMs: 500,
431
+ })
432
+ ).toBe(500);
433
+ });
434
+
435
+ it('never widens below an aggressively-configured base interval', () => {
436
+ // If the caller picked a faster base (e.g. 100ms) on purpose,
437
+ // the healthy path should not silently slow things back to 5s.
438
+ // The healthy interval is clamped to at least base.
439
+ expect(
440
+ nextPollDelayMs({
441
+ now: 10_000,
442
+ lastLiveEventAt: 9_500,
443
+ baseIntervalMs: 8_000,
444
+ })
445
+ ).toBe(8_000);
446
+ });
447
+
448
+ it('respects custom cooldown and healthy-interval values', () => {
449
+ expect(
450
+ nextPollDelayMs({
451
+ now: 10_000,
452
+ lastLiveEventAt: 9_500, // 0.5s ago
453
+ baseIntervalMs: 250,
454
+ cooldownMs: 1_000,
455
+ healthyIntervalMs: 2_000,
456
+ })
457
+ ).toBe(2_000);
458
+ expect(
459
+ nextPollDelayMs({
460
+ now: 10_000,
461
+ lastLiveEventAt: 8_500, // 1.5s ago > cooldown
462
+ baseIntervalMs: 250,
463
+ cooldownMs: 1_000,
464
+ healthyIntervalMs: 2_000,
465
+ })
466
+ ).toBe(250);
467
+ });
468
+ });
@@ -167,3 +167,82 @@ export function createDiffFromDbOp(
167
167
  };
168
168
  }
169
169
  }
170
+
171
+ /**
172
+ * Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
173
+ * poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
174
+ * deliveries; 500ms is aggressive enough to feel real-time on the
175
+ * happy path while keeping the per-session query load bounded.
176
+ */
177
+ export const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
178
+
179
+ /**
180
+ * Build the SurrealQL select that powers both the initial-fetch and
181
+ * the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
182
+ * predicate excludes subquery entries (rows with `parent_rel` set)
183
+ * because the client's `RecordVersionArray` only tracks primary rows;
184
+ * including subquery rows would surface them as spurious "added"
185
+ * diffs every tick.
186
+ */
187
+ export function buildListRefSelect(table: string): string {
188
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
189
+ }
190
+
191
+ /**
192
+ * Resolve the effective list-ref poll interval. Negative or zero
193
+ * values fall back to the default — accepting them would either
194
+ * disable polling silently or busy-loop the event loop.
195
+ */
196
+ export function resolveListRefPollInterval(opt?: number): number {
197
+ if (typeof opt !== 'number' || !Number.isFinite(opt) || opt <= 0) {
198
+ return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
199
+ }
200
+ return opt;
201
+ }
202
+
203
+ /**
204
+ * When the LIVE feed has delivered an event within this window, treat
205
+ * the LIVE subscription as healthy and back the poll off to
206
+ * {@link LIVE_HEALTHY_POLL_INTERVAL_MS}. As soon as LIVE quiets for
207
+ * longer than this, the poll snaps back to the aggressive default.
208
+ */
209
+ export const LIVE_HEALTHY_COOLDOWN_MS = 5_000;
210
+
211
+ /**
212
+ * Poll interval while LIVE is delivering events. The aggressive
213
+ * default 500ms costs ~120 queries / minute / session — wasted work
214
+ * when LIVE is already covering us. 5s keeps the safety net in place
215
+ * at 1/10th the load.
216
+ */
217
+ export const LIVE_HEALTHY_POLL_INTERVAL_MS = 5_000;
218
+
219
+ /**
220
+ * Pick the next poll delay based on whether LIVE has been healthy
221
+ * recently. If a LIVE event fired within `cooldownMs`, use the slow
222
+ * (`healthyIntervalMs`) cadence; otherwise the fast (`baseIntervalMs`)
223
+ * cadence. Pure so it's unit-testable; `Sp00kySync.startListRefPoll`
224
+ * calls it from a self-rescheduling timer.
225
+ *
226
+ * The healthy interval is clamped to at least `baseIntervalMs` so
227
+ * configuring an aggressive base (e.g. 100ms) never gets implicitly
228
+ * widened by this helper.
229
+ */
230
+ export function nextPollDelayMs(args: {
231
+ now: number;
232
+ lastLiveEventAt: number | null;
233
+ baseIntervalMs: number;
234
+ cooldownMs?: number;
235
+ healthyIntervalMs?: number;
236
+ }): number {
237
+ const {
238
+ now,
239
+ lastLiveEventAt,
240
+ baseIntervalMs,
241
+ cooldownMs = LIVE_HEALTHY_COOLDOWN_MS,
242
+ healthyIntervalMs = LIVE_HEALTHY_POLL_INTERVAL_MS,
243
+ } = args;
244
+ if (lastLiveEventAt === null) return baseIntervalMs;
245
+ const sinceLive = now - lastLiveEventAt;
246
+ if (sinceLive < 0 || sinceLive >= cooldownMs) return baseIntervalMs;
247
+ return Math.max(healthyIntervalMs, baseIntervalMs);
248
+ }
@@ -28,6 +28,12 @@ export interface StreamUpdate {
28
28
  queryHash: string;
29
29
  localArray: RecordVersionArray;
30
30
  op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
31
+ /**
32
+ * End-to-end ingest latency for the WASM call that produced this update,
33
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
34
+ * for the initial register_view snapshot.
35
+ */
36
+ materializationTimeMs?: number;
31
37
  }
32
38
 
33
39
  // Define events map (kept for DevTools compatibility)
@@ -205,13 +211,16 @@ export class StreamProcessorService {
205
211
  try {
206
212
  const normalizedRecord = this.normalizeValue(record);
207
213
 
214
+ const t0 = performance.now();
208
215
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
216
+ const materializationTimeMs = performance.now() - t0;
209
217
  this.logger.debug(
210
218
  {
211
219
  table,
212
220
  op,
213
221
  id,
214
222
  rawUpdates: rawUpdates.length,
223
+ materializationTimeMs,
215
224
  Category: 'sp00ky-client::StreamProcessorService::ingest',
216
225
  },
217
226
  'Ingesting into ssp done'
@@ -222,6 +231,7 @@ export class StreamProcessorService {
222
231
  queryHash: u.query_id,
223
232
  localArray: u.result_data,
224
233
  op: op,
234
+ materializationTimeMs,
225
235
  }));
226
236
  // Direct handler call instead of event
227
237
  this.notifyUpdates(updates);
@@ -324,6 +334,21 @@ export class StreamProcessorService {
324
334
  if (value === null || value === undefined) return value;
325
335
 
326
336
  if (typeof value === 'object') {
337
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
338
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
339
+ // those when deserializing into `serde_json::Value` (JSON has no
340
+ // binary variant), and the SSP can't filter on opaque bytes
341
+ // anyway. Replace with `null` so the row still flows through the
342
+ // ingest path with its other columns intact, and downstream
343
+ // predicates referencing the bytes column simply don't match.
344
+ if (
345
+ value instanceof Uint8Array ||
346
+ value instanceof ArrayBuffer ||
347
+ ArrayBuffer.isView(value)
348
+ ) {
349
+ return null;
350
+ }
351
+
327
352
  // RecordId detection using duck typing (constructor.name may be minified)
328
353
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
329
354
  // Check for table getter that has its own toString AND id property
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+
5
+ // Regression guard for the auth-callback ordering bug we fixed in
6
+ // `Sp00kyClient.init()`. The contract:
7
+ //
8
+ // this.auth.subscribe(async (userId) => {
9
+ // this.dataModule.setCurrentUserId(userId); // ← must be first
10
+ // // ... awaits and other work ...
11
+ // });
12
+ //
13
+ // If any future refactor moves `setCurrentUserId` after the first
14
+ // `await`, the AuthProvider's sibling subscriber gets to run with a
15
+ // stale `dataModule.currentUserId` and registers queries against the
16
+ // wrong `_00_query[_user_*]` / `_00_list_ref_user_<id>` tables.
17
+ //
18
+ // A runtime mock test for this is heavy — Sp00kyClient drags in
19
+ // SurrealDB, the WASM SSP, the persistence client, etc. A
20
+ // structural regex test is enough: it catches the only failure mode
21
+ // (someone moves the synchronous setCurrentUserId call) without
22
+ // needing the full graph.
23
+
24
+ describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
25
+ const sourcePath = resolve(__dirname, 'sp00ky.ts');
26
+ const source = readFileSync(sourcePath, 'utf-8');
27
+
28
+ it('source file is readable', () => {
29
+ expect(source.length).toBeGreaterThan(0);
30
+ });
31
+
32
+ it('auth.subscribe sets currentUserId before any await', () => {
33
+ // Find the auth.subscribe arrow body. Match the contents of the
34
+ // outermost `{}` after `this.auth.subscribe(async (userId) => `.
35
+ const match = source.match(
36
+ /this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
37
+ );
38
+ expect(
39
+ match,
40
+ 'expected an auth.subscribe(async (userId) => { ... }) block in sp00ky.ts'
41
+ ).not.toBeNull();
42
+
43
+ const body = match![1];
44
+
45
+ // Strip line comments so a stray `//` doesn't trick the regex.
46
+ const stripped = body
47
+ .split('\n')
48
+ .map((line) => line.replace(/\/\/.*$/, ''))
49
+ .join('\n');
50
+
51
+ const setUserIdIdx = stripped.indexOf('this.dataModule.setCurrentUserId(userId)');
52
+ expect(
53
+ setUserIdIdx,
54
+ 'dataModule.setCurrentUserId(userId) must appear in the auth.subscribe body'
55
+ ).toBeGreaterThanOrEqual(0);
56
+
57
+ const firstAwaitIdx = stripped.search(/\bawait\b/);
58
+ if (firstAwaitIdx >= 0) {
59
+ expect(
60
+ setUserIdIdx,
61
+ `dataModule.setCurrentUserId(userId) (idx ${setUserIdIdx}) must come BEFORE the first \`await\` (idx ${firstAwaitIdx}) so the AuthProvider's sibling subscriber sees a fresh user id when it fires synchronously after our callback. Moving the call past an \`await\` reintroduces the stale-user-id race documented in docs/surrealdb-bugs/ if a related gap is found, and the prior session's retrospective.`
62
+ ).toBeLessThan(firstAwaitIdx);
63
+ }
64
+ });
65
+ });
package/src/sp00ky.ts CHANGED
@@ -36,7 +36,7 @@ import { EventSystem } from './events/index';
36
36
  import { CacheModule } from './modules/cache/index';
37
37
  import { CrdtManager, CrdtField } from './modules/crdt/index';
38
38
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
39
- import { generateId, parseParams } from './utils/index';
39
+ import { parseParams } from './utils/index';
40
40
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
41
41
  import { ResilientPersistenceClient } from './services/persistence/resilient';
42
42
 
@@ -109,6 +109,15 @@ export class Sp00kyClient<S extends SchemaStructure> {
109
109
  return this.sync.pendingMutationCount;
110
110
  }
111
111
 
112
+ /** Number of times the initial list_ref LIVE subscription retried on
113
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
114
+ * pre-emptive user-table creation got there first; >0 when LIVE
115
+ * registration hit a "table not found" race. Exposed so the e2e
116
+ * suite can guard the pre-emptive path against regression. */
117
+ get liveRetryCount(): number {
118
+ return this.sync.liveRetryCount;
119
+ }
120
+
112
121
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
113
122
  return this.sync.subscribeToPendingMutations(cb);
114
123
  }
@@ -156,8 +165,18 @@ export class Sp00kyClient<S extends SchemaStructure> {
156
165
  logger
157
166
  );
158
167
 
159
- // Initialize CRDT Manager (needs remote for _00_crdt LIVE SELECT)
160
- this.crdtManager = new CrdtManager(this.config.schema, this.remote, logger);
168
+ // Initialize CRDT Manager. `local` is used to read the initial
169
+ // `_00_crdt` snapshot when a field opens AND to mirror every local
170
+ // edit (so reload/offline see the freshest state); `remote` is used
171
+ // for the debounced outgoing UPSERTs and the parent-table LIVE feed.
172
+ // The debounce window is configurable via `crdtDebounceMs`.
173
+ this.crdtManager = new CrdtManager(
174
+ this.config.schema,
175
+ this.local,
176
+ this.remote,
177
+ logger,
178
+ config.crdtDebounceMs ?? 500,
179
+ );
161
180
 
162
181
  this.dataModule = new DataModule(
163
182
  this.cache,
@@ -171,7 +190,15 @@ export class Sp00kyClient<S extends SchemaStructure> {
171
190
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
172
191
 
173
192
  // Initialize Sync
174
- this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger);
193
+ this.sync = new Sp00kySync(
194
+ this.local,
195
+ this.remote,
196
+ this.cache,
197
+ this.dataModule,
198
+ this.config.schema,
199
+ this.logger,
200
+ { refSyncIntervalMs: this.config.refSyncIntervalMs }
201
+ );
175
202
 
176
203
  // Initialize DevTools
177
204
  this.devTools = new DevToolsService(
@@ -210,6 +237,32 @@ export class Sp00kyClient<S extends SchemaStructure> {
210
237
  this.devTools.logEvent('SYNC_QUERY_UPDATED', event.payload);
211
238
  });
212
239
 
240
+ // Hand list_ref-driven row ingests to the CrdtManager so CRDT body
241
+ // / cursor updates reach the receiver even when the cross-session
242
+ // LIVE on the parent table is filtered out by the SurrealDB
243
+ // permission-LIVE gap. Same-user clients receive these rows via
244
+ // CrdtManager's own `LIVE SELECT * FROM <table>`; this hook is the
245
+ // redundant path that fires when only the list_ref bumped.
246
+ this.sync.engineEvents.subscribe('SYNC_REMOTE_DATA_INGESTED', (event: any) => {
247
+ try {
248
+ const records: Array<Record<string, any>> = event.payload?.records ?? [];
249
+ for (const row of records) {
250
+ const id = row?.id;
251
+ const table =
252
+ id && typeof id === 'object' && id.table !== undefined
253
+ ? String(id.table)
254
+ : undefined;
255
+ if (!table) continue;
256
+ this.crdtManager.applyRow(table, row);
257
+ }
258
+ } catch (err) {
259
+ this.logger.debug(
260
+ { err, Category: 'sp00ky-client::engineEvents::ingested' },
261
+ 'applyRow forwarding from sync ingest failed'
262
+ );
263
+ }
264
+ });
265
+
213
266
  // Database events for DevTools
214
267
  this.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => {
215
268
  this.devTools.logEvent('LOCAL_QUERY', event.payload);
@@ -226,13 +279,6 @@ export class Sp00kyClient<S extends SchemaStructure> {
226
279
  'Sp00kyClient initialization started'
227
280
  );
228
281
  try {
229
- const clientId = this.config.clientId ?? (await this.loadOrGenerateClientId());
230
- this.persistClientId(clientId);
231
- this.logger.debug(
232
- { clientId, Category: 'sp00ky-client::Sp00kyClient::init' },
233
- 'Client ID loaded'
234
- );
235
-
236
282
  await this.local.connect();
237
283
  this.logger.debug(
238
284
  { Category: 'sp00ky-client::Sp00kyClient::init' },
@@ -257,13 +303,51 @@ export class Sp00kyClient<S extends SchemaStructure> {
257
303
  await this.auth.init();
258
304
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
259
305
 
260
- await this.dataModule.init();
306
+ // Salt query-id hashing with the SurrealDB session id so two browsers
307
+ // for the same user don't collide on shared `_00_query` rows. The same
308
+ // session id is the `session_id` key in `_00_cursor` rows, so the
309
+ // CrdtManager needs it too.
310
+ const sessionId = await this.fetchSessionId();
311
+ await this.dataModule.init(sessionId);
312
+ this.crdtManager.setSessionId(sessionId);
261
313
  this.logger.debug(
262
- { Category: 'sp00ky-client::Sp00kyClient::init' },
314
+ { sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
263
315
  'DataModule initialized'
264
316
  );
265
317
 
266
- await this.sync.init(clientId);
318
+ // Refresh the salt whenever auth state flips (sign-in, sign-out).
319
+ // session::id() changes per WebSocket session, and a sign-in spawns
320
+ // a new authenticated session, so the salt must follow. Also
321
+ // forward the user id into `DataModule` and `Sp00kySync` so they
322
+ // can route to per-user `_00_query_user_<id>` /
323
+ // `_00_list_ref_user_<id>` tables in `RefMode.Dedicated` — the
324
+ // LIVE subscription on `_00_list_ref_user_<id>` is restarted
325
+ // under the new auth context inside `Sp00kySync.setCurrentUserId`
326
+ // since SurrealDB binds the LIVE permission at registration time.
327
+ //
328
+ // Sync prefix BEFORE the first `await`: setting `currentUserId`
329
+ // synchronously here is critical because the AuthProvider's own
330
+ // subscribe callback runs right after ours and immediately enables
331
+ // queries that depend on the user id. Any `await` before
332
+ // `setCurrentUserId` would let those queries register against the
333
+ // stale (null) user id and hit the wrong `_00_query[_user_*]`
334
+ // table.
335
+ this.auth.subscribe(async (userId) => {
336
+ this.dataModule.setCurrentUserId(userId);
337
+ const next = await this.fetchSessionId();
338
+ this.dataModule.setSessionId(next);
339
+ this.crdtManager.setSessionId(next);
340
+ try {
341
+ await this.sync.setCurrentUserId(userId);
342
+ } catch (e) {
343
+ this.logger.error(
344
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::authChange' },
345
+ 'sync.setCurrentUserId failed'
346
+ );
347
+ }
348
+ });
349
+
350
+ await this.sync.init();
267
351
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
268
352
 
269
353
  this.logger.info(
@@ -292,7 +376,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
292
376
  /**
293
377
  * Open a CRDT field for collaborative editing.
294
378
  * Returns a CrdtField with a LoroDoc that can be bound to any editor.
295
- * Also starts a LIVE SELECT on _00_crdt for real-time sync.
379
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
380
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
296
381
  */
297
382
  async openCrdtField(
298
383
  table: string,
@@ -396,26 +481,22 @@ export class Sp00kyClient<S extends SchemaStructure> {
396
481
  return fn(this.remote.getClient());
397
482
  }
398
483
 
399
- private persistClientId(id: string) {
484
+ /**
485
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
486
+ * query-id hashing so two sessions for the same user get distinct
487
+ * `_00_query` rows. Returns empty string if the query fails (we still
488
+ * boot, just without session scoping for IDs).
489
+ */
490
+ private async fetchSessionId(): Promise<string> {
400
491
  try {
401
- this.persistenceClient.set('sp00ky_client_id', id);
492
+ const [sid] = await this.remote.query<[string]>('RETURN <string>session::id()');
493
+ return typeof sid === 'string' ? sid : '';
402
494
  } catch (e) {
403
495
  this.logger.warn(
404
- { error: e, Category: 'sp00ky-client::Sp00kyClient::persistClientId' },
405
- 'Failed to persist client ID'
496
+ { error: e, Category: 'sp00ky-client::Sp00kyClient::fetchSessionId' },
497
+ 'Failed to fetch session::id() — proceeding with empty salt'
406
498
  );
499
+ return '';
407
500
  }
408
501
  }
409
-
410
- private async loadOrGenerateClientId(): Promise<string> {
411
- const clientId = await this.persistenceClient.get<string>('sp00ky_client_id');
412
-
413
- if (clientId) {
414
- return clientId;
415
- }
416
-
417
- const newId = generateId();
418
- await this.persistClientId(newId);
419
- return newId;
420
- }
421
502
  }
package/src/types.ts CHANGED
@@ -99,8 +99,6 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
99
99
  /** Authentication token. */
100
100
  token?: string;
101
101
  };
102
- /** Unique client identifier. If not provided, one will be generated. */
103
- clientId?: string;
104
102
  /** The schema definition. */
105
103
  schema: S;
106
104
  /** The compiled SURQL schema string. */
@@ -119,6 +117,22 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
119
117
  * Defaults to 100ms.
120
118
  */
121
119
  streamDebounceTime?: number;
120
+ /**
121
+ * Debounce time in milliseconds for syncing collaborative (CRDT) field
122
+ * changes to the remote database. Local writes happen immediately on
123
+ * every keystroke (so reload/offline works), but the remote UPSERT is
124
+ * coalesced over this window. Lower = snappier remote propagation +
125
+ * more network traffic; higher = less traffic + more lag for other
126
+ * collaborators. Defaults to 500ms.
127
+ */
128
+ crdtDebounceMs?: number;
129
+ /**
130
+ * Cadence (ms) for the `_00_list_ref` poll that catches cross-session
131
+ * UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
132
+ * convergence + more query load; higher = the inverse. Non-positive
133
+ * values fall back to the default (500ms).
134
+ */
135
+ refSyncIntervalMs?: number;
122
136
  }
123
137
 
124
138
  export type QueryHash = string;
@@ -178,8 +192,21 @@ export interface QueryState {
178
192
  ttlDurationMs: number;
179
193
  /** Number of times the query has been updated. */
180
194
  updateCount: number;
195
+ /**
196
+ * Rolling window of the most recent materialization-step latencies (ms).
197
+ * Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
198
+ * before each persist to `_00_query`. Samples themselves are not persisted.
199
+ */
200
+ materializationSamples: number[];
201
+ /** Most recent end-to-end ingest latency in ms, or null until the first ingest. */
202
+ lastIngestLatencyMs: number | null;
203
+ /** Cumulative count of ingest/materialization errors observed for this query. */
204
+ errorCount: number;
181
205
  }
182
206
 
207
+ /** Cap on the rolling materialization-sample window kept per query in memory. */
208
+ export const MATERIALIZATION_SAMPLE_WINDOW = 100;
209
+
183
210
  // Callback types
184
211
  export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
185
212
  export type MutationCallback = (mutations: UpEvent[]) => void;
@@ -34,6 +34,7 @@ export interface SurqlHelper {
34
34
  keyDataVars: ({ key: string; variable: string } | { statement: string } | string)[]
35
35
  ): string;
36
36
  upsert(idVar: string, dataVar: string): string;
37
+ upsertMerge(idVar: string, dataVar: string): string;
37
38
  updateMerge(idVar: string, dataVar: string): string;
38
39
  updateSet(
39
40
  idVar: string,
@@ -127,6 +128,14 @@ export const surql: SurqlHelper = {
127
128
  return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
128
129
  },
129
130
 
131
+ // Use this for sync-down ingestion: the remote payload omits local-only
132
+ // fields injected by the CLI's local-schema setup (`_00_crdt`,
133
+ // `_00_cursor`), and REPLACE would wipe them on every round-trip and
134
+ // break offline reload of CRDT state.
135
+ upsertMerge(idVar: string, dataVar: string) {
136
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
137
+ },
138
+
130
139
  updateMerge(idVar: string, dataVar: string) {
131
140
  return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
132
141
  },