@spooky-sync/core 0.0.1-canary.224 → 0.0.1-canary.226

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 CHANGED
@@ -814,7 +814,7 @@ declare class DataModule<S extends SchemaStructure> {
814
814
  * No-op when nothing is pending. The pending entry is removed before the
815
815
  * await so a concurrently-firing timer can't process it twice.
816
816
  */
817
- flushPendingStreamUpdate(queryHash: string): Promise<void>;
817
+ flushPendingStreamUpdate(queryHash: string): Promise<boolean>;
818
818
  /**
819
819
  * Materialize a query's result rows from the local store.
820
820
  *
package/dist/index.js CHANGED
@@ -3753,9 +3753,10 @@ var DataModule = class DataModule {
3753
3753
  this.debounceTimers.delete(queryHash);
3754
3754
  }
3755
3755
  const pending = this.pendingStreamUpdates.get(queryHash);
3756
- if (!pending) return;
3756
+ if (!pending) return false;
3757
3757
  this.pendingStreamUpdates.delete(queryHash);
3758
3758
  await this.processStreamUpdate(pending);
3759
+ return true;
3759
3760
  }
3760
3761
  /**
3761
3762
  * Materialize a query's result rows from the local store.
@@ -7244,7 +7245,10 @@ var Sp00kySync = class Sp00kySync {
7244
7245
  } finally {
7245
7246
  if (fetching) {
7246
7247
  try {
7247
- await this.dataModule.flushPendingStreamUpdate(hash);
7248
+ if (!await this.dataModule.flushPendingStreamUpdate(hash)) {
7249
+ this.dataModule.scheduleRematerialize(hash);
7250
+ await this.dataModule.flushPendingStreamUpdate(hash);
7251
+ }
7248
7252
  } catch (err) {
7249
7253
  this.logger.warn({
7250
7254
  err,
@@ -7760,8 +7764,8 @@ function selfAllowlistedVariant(flag, userId) {
7760
7764
 
7761
7765
  //#endregion
7762
7766
  //#region src/modules/devtools/index.ts
7763
- const CORE_VERSION = "0.0.1-canary.224";
7764
- const WASM_VERSION = "0.0.1-canary.224";
7767
+ const CORE_VERSION = "0.0.1-canary.226";
7768
+ const WASM_VERSION = "0.0.1-canary.226";
7765
7769
  const SURREAL_VERSION = "3.0.3";
7766
7770
  var DevToolsService = class DevToolsService {
7767
7771
  eventsHistory = [];
@@ -12776,7 +12780,7 @@ var Sp00kyClient = class {
12776
12780
  return new TabsCoordinator({
12777
12781
  tabId,
12778
12782
  fingerprint: computeTabsFingerprint({
12779
- coreVersion: "0.0.1-canary.224",
12783
+ coreVersion: "0.0.1-canary.226",
12780
12784
  schemaHash: hash53(this.config.schemaSurql),
12781
12785
  endpoint: this.config.database.endpoint ?? "",
12782
12786
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.224",
3
+ "version": "0.0.1-canary.226",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.224",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.224",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.226",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.226",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -492,16 +492,17 @@ export class DataModule<S extends SchemaStructure> {
492
492
  * No-op when nothing is pending. The pending entry is removed before the
493
493
  * await so a concurrently-firing timer can't process it twice.
494
494
  */
495
- async flushPendingStreamUpdate(queryHash: string): Promise<void> {
495
+ async flushPendingStreamUpdate(queryHash: string): Promise<boolean> {
496
496
  const timer = this.debounceTimers.get(queryHash);
497
497
  if (timer) {
498
498
  clearTimeout(timer);
499
499
  this.debounceTimers.delete(queryHash);
500
500
  }
501
501
  const pending = this.pendingStreamUpdates.get(queryHash);
502
- if (!pending) return;
502
+ if (!pending) return false;
503
503
  this.pendingStreamUpdates.delete(queryHash);
504
504
  await this.processStreamUpdate(pending);
505
+ return true;
505
506
  }
506
507
 
507
508
  /**
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+
5
+ // A LIVE (or poll) UPDATE for a row already in membership fetches the new body
6
+ // into the local store — and that can be the end of it: the circuit sees the
7
+ // same id-set and emits no stream update, and nothing else re-reads the query.
8
+ // Measured on staging: a `conversation` row reached the store at `_00_rv` 8
9
+ // while the query kept rendering version 2 for minutes. `runSyncForQuery`
10
+ // must therefore land a synthetic re-materialize whenever a fetch produced
11
+ // no stream update of its own.
12
+
13
+ function makeSync(opts: { streamUpdatePending: boolean }) {
14
+ const logger: any = {
15
+ child: () => logger,
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ trace: () => {},
21
+ };
22
+ const queryState: any = {
23
+ config: {
24
+ id: new RecordId('_00_query', 'h1'),
25
+ // Membership already knows the newer version; the circuit still holds 1.
26
+ localArray: [['conversation:c1', 1]],
27
+ remoteArray: [['conversation:c1', 8]],
28
+ membershipKnown: true,
29
+ },
30
+ };
31
+ const calls: string[] = [];
32
+ const dataModule: any = {
33
+ getQueryByHash: vi.fn().mockReturnValue(queryState),
34
+ getPendingRecordIds: async () => ({ writes: new Set(), deletes: new Set() }),
35
+ beginFetching: vi.fn(() => calls.push('beginFetching')),
36
+ endFetching: vi.fn(() => calls.push('endFetching')),
37
+ recordRemoteFetch: vi.fn(),
38
+ updateQueryLocalArray: vi.fn(),
39
+ scheduleRematerialize: vi.fn(() => calls.push('scheduleRematerialize')),
40
+ // First flush reports whether a real stream update was pending; a second
41
+ // flush (after the synthetic schedule) always lands something.
42
+ flushPendingStreamUpdate: vi
43
+ .fn()
44
+ .mockImplementationOnce(async () => {
45
+ calls.push('flush');
46
+ return opts.streamUpdatePending;
47
+ })
48
+ .mockImplementation(async () => {
49
+ calls.push('flush');
50
+ return true;
51
+ }),
52
+ };
53
+ const sync = new Sp00kySync({} as any, { query: vi.fn() } as any, {} as any, dataModule, {} as any, logger);
54
+ (sync as any).syncEngine = {
55
+ syncRecords: vi.fn(async () => {
56
+ calls.push('syncRecords');
57
+ return { remoteFetchMs: 3, stillRemoteIds: [] };
58
+ }),
59
+ };
60
+ const run = () =>
61
+ (sync as any).runSyncForQuery('h1', {
62
+ added: [],
63
+ updated: [{ id: 'conversation:c1', version: 8 }],
64
+ removed: [],
65
+ }) as Promise<void>;
66
+ return { run, dataModule, calls };
67
+ }
68
+
69
+ describe('runSyncForQuery re-materializes a fetched row the circuit stayed silent about', () => {
70
+ it('schedules and lands a synthetic re-materialize before going idle', async () => {
71
+ const { run, dataModule, calls } = makeSync({ streamUpdatePending: false });
72
+ await run();
73
+ expect(dataModule.scheduleRematerialize).toHaveBeenCalledWith('h1');
74
+ expect(dataModule.flushPendingStreamUpdate).toHaveBeenCalledTimes(2);
75
+ // The re-render lands BEFORE `endFetching`, so `idle` never races ahead
76
+ // of the rows it describes.
77
+ expect(calls).toEqual([
78
+ 'beginFetching',
79
+ 'syncRecords',
80
+ 'flush',
81
+ 'scheduleRematerialize',
82
+ 'flush',
83
+ 'endFetching',
84
+ ]);
85
+ });
86
+
87
+ it('leaves a real stream update alone', async () => {
88
+ const { run, dataModule } = makeSync({ streamUpdatePending: true });
89
+ await run();
90
+ expect(dataModule.scheduleRematerialize).not.toHaveBeenCalled();
91
+ expect(dataModule.flushPendingStreamUpdate).toHaveBeenCalledTimes(1);
92
+ });
93
+ });
@@ -1803,8 +1803,21 @@ export class Sp00kySync<S extends SchemaStructure> {
1803
1803
  // Land the coalesced result BEFORE flipping to idle: the final stream
1804
1804
  // update sits on a debounce timer, and an `idle` that races ahead of it
1805
1805
  // would let consumers treat a partially-filled window as authoritative.
1806
+ //
1807
+ // A fetch that changed a row already in membership can come with no
1808
+ // stream update at all: the body lands in the local store, but the
1809
+ // circuit sees the same id-set and stays silent (measured: a peer's
1810
+ // UPDATE to a `conversation` row reached the store at `_00_rv` 8 while
1811
+ // the query kept rendering version 2 indefinitely). Nothing else
1812
+ // re-reads the query then, so ask for a synthetic re-materialize and
1813
+ // land it here too. It is one local select, and a no-op when a real
1814
+ // update was pending, which the first flush already processed.
1806
1815
  try {
1807
- await this.dataModule.flushPendingStreamUpdate(hash);
1816
+ const landed = await this.dataModule.flushPendingStreamUpdate(hash);
1817
+ if (!landed) {
1818
+ this.dataModule.scheduleRematerialize(hash);
1819
+ await this.dataModule.flushPendingStreamUpdate(hash);
1820
+ }
1808
1821
  } catch (err) {
1809
1822
  this.logger.warn(
1810
1823
  { err, hash, Category: 'sp00ky-client::Sp00kySync::runSyncForQuery' },