@spooky-sync/core 0.0.1-canary.66 → 0.0.1-canary.68
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/dist/index.d.ts +105 -17
- package/dist/index.js +754 -133
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +29 -3
- package/package.json +2 -2
- package/src/modules/cache/index.ts +6 -1
- package/src/modules/crdt/crdt-field.ts +115 -23
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +207 -68
- package/src/modules/data/index.ts +170 -17
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +47 -11
- package/src/modules/sync/sync.ts +304 -13
- package/src/modules/sync/utils.test.ts +157 -0
- package/src/modules/sync/utils.ts +79 -0
- package/src/services/stream-processor/index.ts +25 -0
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +112 -31
- package/src/types.ts +29 -2
- package/src/utils/surql.ts +9 -0
|
@@ -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 {
|
|
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
|
|
160
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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::
|
|
405
|
-
'Failed to
|
|
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;
|
package/src/utils/surql.ts
CHANGED
|
@@ -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
|
},
|