@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70
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 +102 -2
- package/dist/index.js +1066 -129
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +74 -1
- package/package.json +2 -2
- package/src/build-globals.d.ts +6 -0
- package/src/events/index.ts +3 -0
- package/src/modules/cache/index.ts +13 -6
- package/src/modules/crdt/index.ts +9 -1
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +436 -56
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +54 -1
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/sync/engine.ts +43 -29
- package/src/modules/sync/sync.ts +247 -55
- package/src/modules/sync/utils.test.ts +80 -0
- package/src/modules/sync/utils.ts +72 -0
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +182 -23
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/stream-processor/index.ts +148 -5
- 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/wasm-types.ts +16 -0
- package/src/sp00ky.ts +86 -6
- package/src/types.ts +85 -0
- package/src/utils/index.ts +13 -3
- package/tsdown.config.ts +54 -0
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
|
|
2
|
-
import type { RecordVersionArray } from '../../types';
|
|
2
|
+
import type { RecordVersionArray, RecordVersionDiff } from '../../types';
|
|
3
3
|
import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
|
|
4
4
|
import type { Logger } from '../../services/logger/index';
|
|
5
5
|
import type { DownEvent, UpEvent} from './queue/index';
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
ArraySyncer,
|
|
10
10
|
buildListRefSelect,
|
|
11
11
|
createDiffFromDbOp,
|
|
12
|
-
|
|
12
|
+
listRefPollDelayMs,
|
|
13
|
+
recordVersionArraysEqual,
|
|
13
14
|
resolveListRefPollInterval,
|
|
14
15
|
} from './utils';
|
|
15
16
|
import { SyncEngine } from './engine';
|
|
@@ -17,7 +18,7 @@ import { SyncScheduler } from './scheduler';
|
|
|
17
18
|
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
18
19
|
import type { CacheModule } from '../cache/index';
|
|
19
20
|
import type { DataModule } from '../data/index';
|
|
20
|
-
import { encodeRecordId, extractTablePart, surql } from '../../utils/index';
|
|
21
|
+
import { encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
|
|
21
22
|
import { DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -83,9 +84,26 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
83
84
|
private listRefPollRunning: boolean = false;
|
|
84
85
|
public readonly refSyncIntervalMs: number;
|
|
85
86
|
|
|
87
|
+
// Consecutive poll cycles that observed NO list_ref change. Drives the
|
|
88
|
+
// adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
|
|
89
|
+
// page coasts from the fast base cadence toward the 5s cap, and any activity
|
|
90
|
+
// (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
|
|
91
|
+
// back to responsive. Replaces the old LIVE-liveness backoff, which kept the
|
|
92
|
+
// poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
|
|
93
|
+
// on a quiet page, thanks to the cross-session LIVE-permission gap).
|
|
94
|
+
private listRefIdleStreak: number = 0;
|
|
95
|
+
|
|
96
|
+
// `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
|
|
97
|
+
// remote" (left the query's list_ref but still exists upstream). Used to
|
|
98
|
+
// distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
|
|
99
|
+
// converged once it crosses the threshold) from a record that's merely
|
|
100
|
+
// mid-deletion (still-remote for ~one round, then gone) — which must NOT be
|
|
101
|
+
// converged, or it gets stranded in this window before its delete is observed.
|
|
102
|
+
private stillRemoteStreaks: Map<string, number> = new Map();
|
|
103
|
+
|
|
86
104
|
// Wall-clock timestamp (ms) of the most recent LIVE event delivered
|
|
87
|
-
// through `handleRemoteListRefChange`.
|
|
88
|
-
//
|
|
105
|
+
// through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
|
|
106
|
+
// signal; the poll cadence is now driven by `listRefIdleStreak`.
|
|
89
107
|
private lastLiveEventAt: number | null = null;
|
|
90
108
|
|
|
91
109
|
// Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
|
|
@@ -222,13 +240,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
222
240
|
const schedule = (delayMs: number) => {
|
|
223
241
|
this.listRefPollTimer = setTimeout(async () => {
|
|
224
242
|
if (!this.listRefPollRunning) return;
|
|
243
|
+
let changed = false;
|
|
225
244
|
try {
|
|
226
|
-
await this.pollListRefForActiveQueries();
|
|
245
|
+
changed = await this.pollListRefForActiveQueries();
|
|
227
246
|
} finally {
|
|
228
247
|
if (!this.listRefPollRunning) return;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
248
|
+
// Reset the idle streak on any observed change so the poll snaps
|
|
249
|
+
// back to the fast base cadence; otherwise grow it so a quiet page
|
|
250
|
+
// backs off toward the cap. (`handleRemoteListRefChange` also resets
|
|
251
|
+
// it when a LIVE event lands.)
|
|
252
|
+
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
253
|
+
const next = listRefPollDelayMs({
|
|
254
|
+
idleStreak: this.listRefIdleStreak,
|
|
232
255
|
baseIntervalMs: this.refSyncIntervalMs,
|
|
233
256
|
});
|
|
234
257
|
schedule(next);
|
|
@@ -246,12 +269,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
246
269
|
}
|
|
247
270
|
}
|
|
248
271
|
|
|
249
|
-
|
|
272
|
+
/**
|
|
273
|
+
* One poll cycle: refetch `_00_list_ref` for every active query. Returns
|
|
274
|
+
* whether ANY query's remoteArray actually changed — the scheduler uses this
|
|
275
|
+
* to drive the adaptive idle backoff.
|
|
276
|
+
*/
|
|
277
|
+
private async pollListRefForActiveQueries(): Promise<boolean> {
|
|
250
278
|
const hashes = this.dataModule.getActiveQueryHashes();
|
|
251
|
-
if (hashes.length === 0) return;
|
|
279
|
+
if (hashes.length === 0) return false;
|
|
280
|
+
let anyChanged = false;
|
|
252
281
|
for (const hash of hashes) {
|
|
253
282
|
try {
|
|
254
|
-
await this.refetchListRefForQuery(hash);
|
|
283
|
+
if (await this.refetchListRefForQuery(hash)) anyChanged = true;
|
|
255
284
|
} catch (err) {
|
|
256
285
|
this.logger.debug(
|
|
257
286
|
{ err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
|
|
@@ -259,6 +288,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
259
288
|
);
|
|
260
289
|
}
|
|
261
290
|
}
|
|
291
|
+
return anyChanged;
|
|
262
292
|
}
|
|
263
293
|
|
|
264
294
|
/**
|
|
@@ -269,30 +299,49 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
269
299
|
* what `handleRemoteListRefChange` does per-LIVE-event — we reuse
|
|
270
300
|
* it on a timer as a fallback for missed LIVE notifications.
|
|
271
301
|
*/
|
|
272
|
-
private async refetchListRefForQuery(queryHash: string): Promise<
|
|
302
|
+
private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
|
|
273
303
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
274
|
-
if (!queryState) return;
|
|
304
|
+
if (!queryState) return false;
|
|
275
305
|
const listRefTbl = this.listRefTable();
|
|
276
306
|
const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
|
|
277
307
|
buildListRefSelect(listRefTbl),
|
|
278
308
|
{ in: queryState.config.id }
|
|
279
309
|
);
|
|
280
|
-
if (!Array.isArray(items)) return;
|
|
310
|
+
if (!Array.isArray(items)) return false;
|
|
281
311
|
const fresh: RecordVersionArray = items.map((item) => [
|
|
282
312
|
encodeRecordId(item.out),
|
|
283
313
|
item.version,
|
|
284
314
|
]);
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
315
|
+
// Capture which ids LEFT the query's window (present in the cached
|
|
316
|
+
// remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
|
|
317
|
+
// are cross-window deletes (or rows that scrolled out). They drive the
|
|
318
|
+
// forced re-render below.
|
|
319
|
+
const prevRemote = queryState.config.remoteArray ?? [];
|
|
320
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
321
|
+
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
322
|
+
// Idempotent poll: only persist the remoteArray when it actually changed.
|
|
323
|
+
// The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
|
|
324
|
+
// equals the cached array every tick — re-writing it (an `UPDATE _00_query`
|
|
325
|
+
// each cycle, per active query) was pure churn and the bulk of the idle
|
|
326
|
+
// traffic. `recordVersionArraysEqual` is order-insensitive because the
|
|
327
|
+
// list_ref SELECT has no `ORDER BY`.
|
|
328
|
+
const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
329
|
+
if (changed) {
|
|
330
|
+
// Update the cached remoteArray so the next diff/sync sees the new state.
|
|
331
|
+
// `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
|
|
332
|
+
// the local DB row and ingests it into the in-browser SSP — the SSP's
|
|
333
|
+
// stream updates run `processStreamUpdate`, which re-queries the local DB
|
|
334
|
+
// and notifies subscribers. We skip an explicit `notifyQuerySynced`
|
|
335
|
+
// because that path races the stream-update path (can notify with stale
|
|
336
|
+
// records).
|
|
337
|
+
await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
|
|
338
|
+
}
|
|
339
|
+
// Run `syncQuery` every tick regardless: it's a no-op when localArray has
|
|
340
|
+
// caught up to remoteArray (`if (!diff) return`, issues no query), but it
|
|
341
|
+
// covers the rare case where remoteArray is stable yet localArray is behind
|
|
342
|
+
// (a prior record fetch failed) — so a missed row still gets retried.
|
|
343
|
+
// For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
|
|
344
|
+
// confirmed-gone records from the local DB.
|
|
296
345
|
try {
|
|
297
346
|
await this.syncQuery(queryHash);
|
|
298
347
|
} catch (err) {
|
|
@@ -301,6 +350,22 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
301
350
|
'syncQuery failed during poll'
|
|
302
351
|
);
|
|
303
352
|
}
|
|
353
|
+
// A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
|
|
354
|
+
// get a re-render from the SSP stream on this code path reliably (and the
|
|
355
|
+
// non-windowed window-0 query re-queries the local DB rather than the id-set).
|
|
356
|
+
// Force a re-materialize + notify so the deleted row drops from the list in
|
|
357
|
+
// this (second) window — the reliable, LIVE-independent cross-window path.
|
|
358
|
+
if (removedIds.length > 0) {
|
|
359
|
+
try {
|
|
360
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
this.logger.info(
|
|
363
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
|
|
364
|
+
'notifyQuerySynced failed during poll-removal re-render'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return changed;
|
|
304
369
|
}
|
|
305
370
|
|
|
306
371
|
/**
|
|
@@ -363,6 +428,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
363
428
|
for (const hash of this.dataModule.getActiveQueryHashes()) {
|
|
364
429
|
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
365
430
|
}
|
|
431
|
+
// The WS reconnect leaves the server-side LIVE subscription dead — the
|
|
432
|
+
// re-enqueued `register` events only re-fetch initial state, they don't
|
|
433
|
+
// re-subscribe. Without this, LIVE never recovers after a reconnect and
|
|
434
|
+
// the poll silently becomes the sole sync path (and never backs off).
|
|
435
|
+
// Only when authenticated: a signed-out reconnect has no per-user table.
|
|
436
|
+
if (this.currentUserId) {
|
|
437
|
+
this.restartRefLiveQuery().catch((err) => {
|
|
438
|
+
this.logger.debug(
|
|
439
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
|
|
440
|
+
'LIVE restart after reconnect failed; relying on poll fallback'
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
366
444
|
});
|
|
367
445
|
}
|
|
368
446
|
|
|
@@ -385,6 +463,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
385
463
|
'Live update received'
|
|
386
464
|
);
|
|
387
465
|
if (message.action === 'KILLED') return;
|
|
466
|
+
// Skip subquery entries (rows with `parent` set) — they're synthetic
|
|
467
|
+
// CRDT/cursor metadata rows. The poll filters them with `parent IS NONE`
|
|
468
|
+
// (the client's RecordVersionArray only tracks primary rows); the LIVE
|
|
469
|
+
// feed delivers every row, so without this they'd surface as spurious
|
|
470
|
+
// "added" diffs.
|
|
471
|
+
if ((message.value as { parent?: unknown }).parent != null) return;
|
|
388
472
|
this.handleRemoteListRefChange(
|
|
389
473
|
message.action,
|
|
390
474
|
message.value.in as RecordId<string>,
|
|
@@ -405,24 +489,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
405
489
|
recordId: RecordId,
|
|
406
490
|
version: number
|
|
407
491
|
) {
|
|
408
|
-
// Any LIVE delivery is evidence
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
492
|
+
// Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
|
|
493
|
+
// query's window, or a notification for an unknown local query. Reset the
|
|
494
|
+
// poll's idle streak so it snaps back to the fast base cadence (the page
|
|
495
|
+
// is clearly not idle), and record the timestamp as a liveness diagnostic.
|
|
412
496
|
this.lastLiveEventAt = Date.now();
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
'Ignoring DELETE on list_ref — should not happen'
|
|
422
|
-
);
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
497
|
+
this.listRefIdleStreak = 0;
|
|
498
|
+
|
|
499
|
+
// NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
|
|
500
|
+
// this one) deletes a record, the server's SSP removes it from `_00_list_ref`
|
|
501
|
+
// and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
|
|
502
|
+
// turns it into a `removed: [recordId]` diff so the row drops from the window
|
|
503
|
+
// in realtime. (It was previously ignored, so other windows only caught up on
|
|
504
|
+
// reload / the slow poll.)
|
|
426
505
|
const existing = this.dataModule.getQueryById(queryId);
|
|
427
506
|
|
|
428
507
|
if (!existing) {
|
|
@@ -450,7 +529,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
450
529
|
'Live update is being processed'
|
|
451
530
|
);
|
|
452
531
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
453
|
-
|
|
532
|
+
// `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
|
|
533
|
+
// (a SHA-256 over query content + sessionId) — the key DataModule uses.
|
|
534
|
+
const hash = extractIdPart(existing.config.id);
|
|
535
|
+
await this.runSyncForQuery(hash, diff);
|
|
454
536
|
}
|
|
455
537
|
|
|
456
538
|
/**
|
|
@@ -592,7 +674,104 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
592
674
|
if (!diff) {
|
|
593
675
|
return;
|
|
594
676
|
}
|
|
595
|
-
return this.
|
|
677
|
+
return this.runSyncForQuery(hash, diff);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Run a sync for a single query while reflecting its fetch status. Marks the
|
|
682
|
+
* query `fetching` for the duration when the diff actually pulls records
|
|
683
|
+
* (added/updated), then resets to `idle` in a `finally` so a failed sync
|
|
684
|
+
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
685
|
+
* means the single resulting UI update lands after this completes.
|
|
686
|
+
*/
|
|
687
|
+
private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
|
|
688
|
+
// Don't let sync re-add a record the user just deleted locally. The remote
|
|
689
|
+
// delete is queued in the outbox, so until it's processed the server's
|
|
690
|
+
// `_00_list_ref` still lists the record — the diff then classifies it as
|
|
691
|
+
// `added` (present remotely, absent locally) and `syncRecords` re-fetches +
|
|
692
|
+
// re-inserts it, so a deleted database reappears a few seconds later. Drop
|
|
693
|
+
// any id with a pending local DELETE from the re-add paths. Once the remote
|
|
694
|
+
// delete lands, the pending row clears and the server drops it from
|
|
695
|
+
// `_00_list_ref`, so this guard naturally stops applying.
|
|
696
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
697
|
+
const pendingDeletes = await this.getPendingDeleteIds();
|
|
698
|
+
if (pendingDeletes.size > 0) {
|
|
699
|
+
diff = {
|
|
700
|
+
added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
701
|
+
updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
702
|
+
removed: diff.removed,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const fetching = diff.added.length + diff.updated.length > 0;
|
|
708
|
+
if (fetching) {
|
|
709
|
+
this.dataModule.setQueryStatus(hash, 'fetching');
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
713
|
+
if (fetching) {
|
|
714
|
+
this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
715
|
+
}
|
|
716
|
+
// Converge localArray to the authoritative remoteArray for ids that left
|
|
717
|
+
// the server's list_ref but still exist — a view-membership change, not a
|
|
718
|
+
// delete — so the poll's diff stops re-flagging them every tick (the `job:`
|
|
719
|
+
// churn). CRUCIAL: only converge after the id has been still-remote for
|
|
720
|
+
// several CONSECUTIVE rounds. A record that's merely mid-deletion is
|
|
721
|
+
// still-remote for ~one round (its delete hasn't committed when our
|
|
722
|
+
// existence check races it) and is gone the next round → it never reaches
|
|
723
|
+
// the threshold, so it's deleted normally instead of being stranded here.
|
|
724
|
+
if (stillRemoteIds.length > 0) {
|
|
725
|
+
const CONVERGE_AFTER = 3;
|
|
726
|
+
const toConverge: string[] = [];
|
|
727
|
+
for (const id of stillRemoteIds) {
|
|
728
|
+
const key = `${hash}:${id}`;
|
|
729
|
+
const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
|
|
730
|
+
if (n >= CONVERGE_AFTER) {
|
|
731
|
+
this.stillRemoteStreaks.delete(key);
|
|
732
|
+
toConverge.push(id);
|
|
733
|
+
} else {
|
|
734
|
+
this.stillRemoteStreaks.set(key, n);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (toConverge.length > 0) {
|
|
738
|
+
const qs = this.dataModule.getQueryByHash(hash);
|
|
739
|
+
const local = qs?.config.localArray;
|
|
740
|
+
if (local && local.length > 0) {
|
|
741
|
+
const drop = new Set(toConverge);
|
|
742
|
+
const next = local.filter(([id]) => !drop.has(id));
|
|
743
|
+
if (next.length !== local.length) {
|
|
744
|
+
await this.dataModule.updateQueryLocalArray(hash, next);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
if (fetching) {
|
|
751
|
+
this.dataModule.setQueryStatus(hash, 'idle');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
|
|
758
|
+
* Sync must not re-fetch/re-insert these — the remote delete is async, so the
|
|
759
|
+
* server's `_00_list_ref` still lists them until it's processed, and the diff
|
|
760
|
+
* would otherwise resurrect a just-deleted record.
|
|
761
|
+
*/
|
|
762
|
+
private async getPendingDeleteIds(): Promise<Set<string>> {
|
|
763
|
+
try {
|
|
764
|
+
const [rows] = await this.local.query<[{ recordId: RecordId<string> }[]]>(
|
|
765
|
+
"SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'"
|
|
766
|
+
);
|
|
767
|
+
return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
|
|
768
|
+
} catch (err) {
|
|
769
|
+
this.logger.warn(
|
|
770
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::getPendingDeleteIds' },
|
|
771
|
+
'Failed to read pending deletes; sync may briefly resurrect a just-deleted record'
|
|
772
|
+
);
|
|
773
|
+
return new Set();
|
|
774
|
+
}
|
|
596
775
|
}
|
|
597
776
|
|
|
598
777
|
/**
|
|
@@ -683,7 +862,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
683
862
|
}
|
|
684
863
|
}
|
|
685
864
|
|
|
686
|
-
|
|
865
|
+
public async heartbeatQuery(queryHash: string) {
|
|
687
866
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
688
867
|
if (!queryState) {
|
|
689
868
|
this.logger.warn(
|
|
@@ -697,17 +876,30 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
697
876
|
});
|
|
698
877
|
}
|
|
699
878
|
|
|
879
|
+
// Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
|
|
880
|
+
// e.g. a viewport-windowed list cancelling an off-screen window). Query ids
|
|
881
|
+
// are a deterministic hash of (surql+params), so a DELETE racing a scroll-back
|
|
882
|
+
// re-register (same id) could nuke a freshly-recreated view — hence two
|
|
883
|
+
// guards: abort if a subscriber reappeared BEFORE the delete; re-register if
|
|
884
|
+
// one reappears DURING the delete's network await. Tolerant of a
|
|
885
|
+
// missing/already-gone query (no throw).
|
|
700
886
|
private async cleanupQuery(queryHash: string) {
|
|
701
887
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
702
|
-
if (!queryState)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
888
|
+
if (!queryState) return; // already torn down / never registered
|
|
889
|
+
|
|
890
|
+
// Re-subscribed before the queued cleanup ran → keep everything as-is.
|
|
891
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
892
|
+
|
|
893
|
+
await this.remote.query(`DELETE $id`, { id: queryState.config.id });
|
|
894
|
+
|
|
895
|
+
// Re-subscribed while we awaited the DELETE → the remote view is now gone
|
|
896
|
+
// but a subscriber needs it; recreate it instead of leaving a zombie.
|
|
897
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
898
|
+
this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
|
|
899
|
+
return;
|
|
708
900
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
901
|
+
|
|
902
|
+
// No subscribers throughout → safe to free the local view + state.
|
|
903
|
+
this.dataModule.finalizeDeregister(queryHash);
|
|
712
904
|
}
|
|
713
905
|
}
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
DEFAULT_LIST_REF_POLL_INTERVAL_MS,
|
|
10
10
|
buildListRefSelect,
|
|
11
11
|
nextPollDelayMs,
|
|
12
|
+
listRefPollDelayMs,
|
|
13
|
+
LIST_REF_POLL_MAX_INTERVAL_MS,
|
|
14
|
+
recordVersionArraysEqual,
|
|
12
15
|
} from './utils';
|
|
13
16
|
import type { RecordVersionArray, RecordVersionDiff } from '../../types';
|
|
14
17
|
import { encodeRecordId } from '../../utils/index';
|
|
@@ -466,3 +469,80 @@ describe('nextPollDelayMs', () => {
|
|
|
466
469
|
).toBe(250);
|
|
467
470
|
});
|
|
468
471
|
});
|
|
472
|
+
|
|
473
|
+
describe('listRefPollDelayMs', () => {
|
|
474
|
+
it('returns the base interval at idle streak 0 (something just happened)', () => {
|
|
475
|
+
expect(listRefPollDelayMs({ idleStreak: 0, baseIntervalMs: 500 })).toBe(500);
|
|
476
|
+
// Negative streak is defensively treated as "active" too.
|
|
477
|
+
expect(listRefPollDelayMs({ idleStreak: -3, baseIntervalMs: 500 })).toBe(500);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('doubles per idle streak (exponential backoff)', () => {
|
|
481
|
+
expect(listRefPollDelayMs({ idleStreak: 1, baseIntervalMs: 500 })).toBe(1_000);
|
|
482
|
+
expect(listRefPollDelayMs({ idleStreak: 2, baseIntervalMs: 500 })).toBe(2_000);
|
|
483
|
+
expect(listRefPollDelayMs({ idleStreak: 3, baseIntervalMs: 500 })).toBe(4_000);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('caps at LIST_REF_POLL_MAX_INTERVAL_MS (5s) once the doubling exceeds it', () => {
|
|
487
|
+
// streak 4 would be 8000ms uncapped → clamped to 5000.
|
|
488
|
+
expect(listRefPollDelayMs({ idleStreak: 4, baseIntervalMs: 500 })).toBe(5_000);
|
|
489
|
+
expect(listRefPollDelayMs({ idleStreak: 50, baseIntervalMs: 500 })).toBe(5_000);
|
|
490
|
+
expect(LIST_REF_POLL_MAX_INTERVAL_MS).toBe(5_000);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('never returns below the configured base, even when base exceeds the cap', () => {
|
|
494
|
+
// An aggressively-large base must not be implicitly shrunk by the cap.
|
|
495
|
+
expect(listRefPollDelayMs({ idleStreak: 0, baseIntervalMs: 8_000 })).toBe(8_000);
|
|
496
|
+
expect(listRefPollDelayMs({ idleStreak: 5, baseIntervalMs: 8_000 })).toBe(8_000);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('respects a custom max interval', () => {
|
|
500
|
+
expect(
|
|
501
|
+
listRefPollDelayMs({ idleStreak: 10, baseIntervalMs: 500, maxIntervalMs: 3_000 })
|
|
502
|
+
).toBe(3_000);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('does not overflow for a very long idle streak', () => {
|
|
506
|
+
// 2^1000 would be Infinity; the exponent clamp keeps it finite and capped.
|
|
507
|
+
expect(listRefPollDelayMs({ idleStreak: 1_000, baseIntervalMs: 500 })).toBe(5_000);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('recordVersionArraysEqual', () => {
|
|
512
|
+
it('treats the same reference and identical contents as equal', () => {
|
|
513
|
+
const a: RecordVersionArray = [['game:1', 1], ['game:2', 3]];
|
|
514
|
+
expect(recordVersionArraysEqual(a, a)).toBe(true);
|
|
515
|
+
expect(recordVersionArraysEqual(a, [['game:1', 1], ['game:2', 3]])).toBe(true);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('is order-insensitive (the list_ref SELECT has no ORDER BY)', () => {
|
|
519
|
+
expect(
|
|
520
|
+
recordVersionArraysEqual(
|
|
521
|
+
[['game:1', 1], ['game:2', 3]],
|
|
522
|
+
[['game:2', 3], ['game:1', 1]]
|
|
523
|
+
)
|
|
524
|
+
).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('returns false when a version differs for the same id', () => {
|
|
528
|
+
expect(
|
|
529
|
+
recordVersionArraysEqual([['game:1', 1]], [['game:1', 2]])
|
|
530
|
+
).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('returns false on length mismatch', () => {
|
|
534
|
+
expect(
|
|
535
|
+
recordVersionArraysEqual([['game:1', 1]], [['game:1', 1], ['game:2', 1]])
|
|
536
|
+
).toBe(false);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('returns false when an id is present in one but not the other', () => {
|
|
540
|
+
expect(
|
|
541
|
+
recordVersionArraysEqual([['game:1', 1]], [['game:2', 1]])
|
|
542
|
+
).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('treats two empty arrays as equal', () => {
|
|
546
|
+
expect(recordVersionArraysEqual([], [])).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
@@ -226,6 +226,13 @@ export const LIVE_HEALTHY_POLL_INTERVAL_MS = 5_000;
|
|
|
226
226
|
* The healthy interval is clamped to at least `baseIntervalMs` so
|
|
227
227
|
* configuring an aggressive base (e.g. 100ms) never gets implicitly
|
|
228
228
|
* widened by this helper.
|
|
229
|
+
*
|
|
230
|
+
* @deprecated Superseded by {@link listRefPollDelayMs}, which backs the
|
|
231
|
+
* poll off based on observed change activity (LIVE *or* poll-detected)
|
|
232
|
+
* rather than LIVE liveness alone — the cross-session LIVE-permission gap
|
|
233
|
+
* means LIVE often never fires, so this helper would keep the poll pinned
|
|
234
|
+
* at the aggressive base forever even on a fully idle page. Kept (and
|
|
235
|
+
* tested) for reference.
|
|
229
236
|
*/
|
|
230
237
|
export function nextPollDelayMs(args: {
|
|
231
238
|
now: number;
|
|
@@ -246,3 +253,68 @@ export function nextPollDelayMs(args: {
|
|
|
246
253
|
if (sinceLive < 0 || sinceLive >= cooldownMs) return baseIntervalMs;
|
|
247
254
|
return Math.max(healthyIntervalMs, baseIntervalMs);
|
|
248
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
|
|
259
|
+
* events, no poll-detected list_ref changes) coasts up to this cadence;
|
|
260
|
+
* the existing healthy-LIVE safety net runs at the same 5s, so this keeps
|
|
261
|
+
* the worst-case catch-up latency for a missed cross-session change at the
|
|
262
|
+
* cadence the codebase already treats as acceptable.
|
|
263
|
+
*/
|
|
264
|
+
export const LIST_REF_POLL_MAX_INTERVAL_MS = 5_000;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Adaptive poll delay: stay at the responsive `baseIntervalMs` while
|
|
268
|
+
* changes are arriving, and exponentially back off toward `maxIntervalMs`
|
|
269
|
+
* while the `_00_list_ref` is quiet.
|
|
270
|
+
*
|
|
271
|
+
* `idleStreak` is the count of consecutive poll cycles that observed *no*
|
|
272
|
+
* change. `Sp00kySync` resets it to 0 whenever a poll detects a real
|
|
273
|
+
* remoteArray change OR a LIVE event lands, so any activity snaps the poll
|
|
274
|
+
* straight back to `baseIntervalMs`. A streak of 0 (something just
|
|
275
|
+
* happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
|
|
276
|
+
*
|
|
277
|
+
* This replaces {@link nextPollDelayMs}: the old helper slowed the poll
|
|
278
|
+
* only while LIVE was *delivering*, but the cross-session LIVE-permission
|
|
279
|
+
* gap means LIVE frequently never fires here, so it left a fully idle page
|
|
280
|
+
* polling every `base` ms forever (the "continuous queries while idle"
|
|
281
|
+
* symptom). Backing off on observed idleness instead covers the
|
|
282
|
+
* LIVE-healthy case for free (LIVE applies the change → the next poll sees
|
|
283
|
+
* nothing new → the streak grows → it backs off).
|
|
284
|
+
*/
|
|
285
|
+
export function listRefPollDelayMs(args: {
|
|
286
|
+
idleStreak: number;
|
|
287
|
+
baseIntervalMs: number;
|
|
288
|
+
maxIntervalMs?: number;
|
|
289
|
+
}): number {
|
|
290
|
+
const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
|
|
291
|
+
const cap = Math.max(baseIntervalMs, maxIntervalMs);
|
|
292
|
+
if (idleStreak <= 0) return baseIntervalMs;
|
|
293
|
+
// 2^streak grows fast; clamp the exponent so it can't overflow on a
|
|
294
|
+
// page left idle for a very long time.
|
|
295
|
+
const exponent = Math.min(idleStreak, 30);
|
|
296
|
+
return Math.min(baseIntervalMs * 2 ** exponent, cap);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Order-insensitive equality for two `RecordVersionArray`s (each a list of
|
|
301
|
+
* `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
|
|
302
|
+
* BY`, so row order can differ between polls without anything having
|
|
303
|
+
* actually changed — comparing as an id→version map avoids false
|
|
304
|
+
* "changed" verdicts that would defeat the idle backoff. Record ids are
|
|
305
|
+
* unique within a query's list_ref, so a map is a faithful representation.
|
|
306
|
+
*/
|
|
307
|
+
export function recordVersionArraysEqual(
|
|
308
|
+
a: RecordVersionArray,
|
|
309
|
+
b: RecordVersionArray
|
|
310
|
+
): boolean {
|
|
311
|
+
if (a === b) return true;
|
|
312
|
+
if (a.length !== b.length) return false;
|
|
313
|
+
const byId = new Map<string, number>();
|
|
314
|
+
for (const [id, version] of a) byId.set(id, version);
|
|
315
|
+
for (const [id, version] of b) {
|
|
316
|
+
// `get` returns undefined for a missing id, which never === a number.
|
|
317
|
+
if (byId.get(id) !== version) return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isLocalStoreOpenError } from './local';
|
|
3
|
+
|
|
4
|
+
// `LocalDatabaseService.connect` recovers (drops the store + reconnects, falling
|
|
5
|
+
// back to in-memory) only when the failure is the SurrealDB-WASM IndexedDB
|
|
6
|
+
// open/transaction error — NOT for unrelated errors. This pins the message match
|
|
7
|
+
// against the real error the engine throws so a recovery path doesn't silently
|
|
8
|
+
// stop triggering (or start swallowing unrelated failures).
|
|
9
|
+
describe('isLocalStoreOpenError', () => {
|
|
10
|
+
it('matches the real SurrealDB-WASM IndexedDB key-value store error', () => {
|
|
11
|
+
const real = new Error(
|
|
12
|
+
'There was a problem with the key-value store: There was a problem with a ' +
|
|
13
|
+
'transaction: An IndexedDB error occured: idb error'
|
|
14
|
+
);
|
|
15
|
+
expect(isLocalStoreOpenError(real)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('matches on the individual signals (indexeddb / idb error / key-value store)', () => {
|
|
19
|
+
expect(isLocalStoreOpenError(new Error('IndexedDB is not available'))).toBe(true);
|
|
20
|
+
expect(isLocalStoreOpenError(new Error('idb error'))).toBe(true);
|
|
21
|
+
expect(isLocalStoreOpenError(new Error('problem with the key-value store'))).toBe(true);
|
|
22
|
+
// Non-Error inputs are stringified.
|
|
23
|
+
expect(isLocalStoreOpenError('An IndexedDB error occured')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does NOT match unrelated errors (so we never clear the store spuriously)', () => {
|
|
27
|
+
expect(isLocalStoreOpenError(new Error('WebSocket connection refused'))).toBe(false);
|
|
28
|
+
expect(isLocalStoreOpenError(new Error('Parse error: unexpected token'))).toBe(false);
|
|
29
|
+
expect(isLocalStoreOpenError(new Error('permission denied'))).toBe(false);
|
|
30
|
+
expect(isLocalStoreOpenError(null)).toBe(false);
|
|
31
|
+
expect(isLocalStoreOpenError(undefined)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|