@spooky-sync/core 0.0.1-canary.221 → 0.0.1-canary.223

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
@@ -882,8 +882,21 @@ declare class DataModule<S extends SchemaStructure> {
882
882
  * rolls the mutation back and never reports it settled, so it vanishes at
883
883
  * once. This deadline only bounds the case where the write succeeded and its
884
884
  * membership never arrived at all.
885
+ *
886
+ * 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
887
+ * drain plus one slow database commit showed up as the user's own chat
888
+ * message vanishing and coming back a minute later. 30 s covers a drain and
889
+ * a stalled commit end to end; a membership that takes longer than that is
890
+ * a server fault the admin heartbeat reports, not something to paper over.
885
891
  */
886
892
  private static readonly SETTLED_WRITE_GRACE_MS;
893
+ /**
894
+ * Whether any settled write or delete is still waiting for membership to
895
+ * catch up. The sync poll keeps its fast cadence while this is true, so a
896
+ * missed LIVE notification for the edge is picked up on the next poll tick
897
+ * rather than after the idle backoff.
898
+ */
899
+ hasSettledWritesPending(): boolean;
887
900
  /**
888
901
  * Report that a mutation was accepted by the server and its outbox row
889
902
  * removed. Called only on the SUCCESS path — a rolled-back mutation must
package/dist/index.js CHANGED
@@ -3473,6 +3473,14 @@ function phaseStatOf(samples, lastMs) {
3473
3473
  count: samples.length
3474
3474
  };
3475
3475
  }
3476
+ /**
3477
+ * Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
3478
+ *
3479
+ * Must leave room for at least one retry: the server sweeps a view — and its
3480
+ * `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
3481
+ * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
3482
+ */
3483
+ const TTL_HEARTBEAT_FRACTION = .5;
3476
3484
  var DataModule = class DataModule {
3477
3485
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
3478
3486
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -3855,8 +3863,24 @@ var DataModule = class DataModule {
3855
3863
  * rolls the mutation back and never reports it settled, so it vanishes at
3856
3864
  * once. This deadline only bounds the case where the write succeeded and its
3857
3865
  * membership never arrived at all.
3866
+ *
3867
+ * 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
3868
+ * drain plus one slow database commit showed up as the user's own chat
3869
+ * message vanishing and coming back a minute later. 30 s covers a drain and
3870
+ * a stalled commit end to end; a membership that takes longer than that is
3871
+ * a server fault the admin heartbeat reports, not something to paper over.
3858
3872
  */
3859
- static SETTLED_WRITE_GRACE_MS = 1e4;
3873
+ static SETTLED_WRITE_GRACE_MS = 3e4;
3874
+ /**
3875
+ * Whether any settled write or delete is still waiting for membership to
3876
+ * catch up. The sync poll keeps its fast cadence while this is true, so a
3877
+ * missed LIVE notification for the edge is picked up on the next poll tick
3878
+ * rather than after the idle backoff.
3879
+ */
3880
+ hasSettledWritesPending() {
3881
+ this.pruneSettled(Date.now());
3882
+ return this.settledWrites.size > 0 || this.settledDeletes.size > 0;
3883
+ }
3860
3884
  /**
3861
3885
  * Report that a mutation was accepted by the server and its outbox row
3862
3886
  * removed. Called only on the SUCCESS path — a rolled-back mutation must
@@ -5023,7 +5047,7 @@ var DataModule = class DataModule {
5023
5047
  }
5024
5048
  startTTLHeartbeat(queryState, hash) {
5025
5049
  if (queryState.ttlTimer) return;
5026
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
5050
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
5027
5051
  queryState.ttlTimer = setTimeout(() => {
5028
5052
  queryState.ttlTimer = null;
5029
5053
  if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
@@ -6620,7 +6644,8 @@ var Sp00kySync = class Sp00kySync {
6620
6644
  } finally {
6621
6645
  this.listRefPollInFlight = null;
6622
6646
  if (!this.listRefPollRunning) return;
6623
- this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
6647
+ const awaitingMembership = this.dataModule.hasSettledWritesPending();
6648
+ this.listRefIdleStreak = changed || awaitingMembership ? 0 : this.listRefIdleStreak + 1;
6624
6649
  schedule(listRefPollDelayMs({
6625
6650
  idleStreak: this.listRefIdleStreak,
6626
6651
  baseIntervalMs: this.refSyncIntervalMs
@@ -7735,8 +7760,8 @@ function selfAllowlistedVariant(flag, userId) {
7735
7760
 
7736
7761
  //#endregion
7737
7762
  //#region src/modules/devtools/index.ts
7738
- const CORE_VERSION = "0.0.1-canary.221";
7739
- const WASM_VERSION = "0.0.1-canary.221";
7763
+ const CORE_VERSION = "0.0.1-canary.223";
7764
+ const WASM_VERSION = "0.0.1-canary.223";
7740
7765
  const SURREAL_VERSION = "3.0.3";
7741
7766
  var DevToolsService = class DevToolsService {
7742
7767
  eventsHistory = [];
@@ -12751,7 +12776,7 @@ var Sp00kyClient = class {
12751
12776
  return new TabsCoordinator({
12752
12777
  tabId,
12753
12778
  fingerprint: computeTabsFingerprint({
12754
- coreVersion: "0.0.1-canary.221",
12779
+ coreVersion: "0.0.1-canary.223",
12755
12780
  schemaHash: hash53(this.config.schemaSurql),
12756
12781
  endpoint: this.config.database.endpoint ?? "",
12757
12782
  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.221",
3
+ "version": "0.0.1-canary.223",
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.221",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.221",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.223",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.223",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -169,9 +169,38 @@ describe('settled-write grace window', () => {
169
169
  });
170
170
  dm.noteWriteSettled('comment:new', 'create');
171
171
  expect(ids(await materialize(dm, state))).toContain('comment:new');
172
+ expect(dm.hasSettledWritesPending()).toBe(true);
172
173
 
174
+ // A scheduler drain plus one stalled database commit fit inside the
175
+ // window: at 11 s the row is still the user's own message, not a ghost.
173
176
  vi.advanceTimersByTime(11_000);
177
+ expect(ids(await materialize(dm, state))).toContain('comment:new');
178
+ expect(dm.hasSettledWritesPending()).toBe(true);
179
+
180
+ vi.advanceTimersByTime(20_000);
174
181
  expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
182
+ expect(dm.hasSettledWritesPending()).toBe(false);
183
+ });
184
+
185
+ it('reports no pending settled write once membership has caught up', async () => {
186
+ const { dm, state } = setup({
187
+ remoteArray: [['comment:old', 1]],
188
+ localArray: [
189
+ ['comment:old', 1],
190
+ ['comment:new', 1],
191
+ ],
192
+ });
193
+ expect(dm.hasSettledWritesPending()).toBe(false);
194
+
195
+ dm.noteWriteSettled('comment:new', 'create');
196
+ expect(dm.hasSettledWritesPending()).toBe(true);
197
+
198
+ state.config.remoteArray = [
199
+ ['comment:old', 1],
200
+ ['comment:new', 1],
201
+ ];
202
+ await materialize(dm, state);
203
+ expect(dm.hasSettledWritesPending()).toBe(false);
175
204
  });
176
205
 
177
206
  it('keeps an accepted delete subtracted until membership drops the row', async () => {
@@ -90,6 +90,15 @@ export interface DurableMembership {
90
90
  confirmed: boolean;
91
91
  }
92
92
 
93
+ /**
94
+ * Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
95
+ *
96
+ * Must leave room for at least one retry: the server sweeps a view — and its
97
+ * `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
98
+ * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
99
+ */
100
+ const TTL_HEARTBEAT_FRACTION = 0.5;
101
+
93
102
  export class DataModule<S extends SchemaStructure> {
94
103
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
95
104
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -675,8 +684,25 @@ export class DataModule<S extends SchemaStructure> {
675
684
  * rolls the mutation back and never reports it settled, so it vanishes at
676
685
  * once. This deadline only bounds the case where the write succeeded and its
677
686
  * membership never arrived at all.
687
+ *
688
+ * 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
689
+ * drain plus one slow database commit showed up as the user's own chat
690
+ * message vanishing and coming back a minute later. 30 s covers a drain and
691
+ * a stalled commit end to end; a membership that takes longer than that is
692
+ * a server fault the admin heartbeat reports, not something to paper over.
693
+ */
694
+ private static readonly SETTLED_WRITE_GRACE_MS = 30_000;
695
+
696
+ /**
697
+ * Whether any settled write or delete is still waiting for membership to
698
+ * catch up. The sync poll keeps its fast cadence while this is true, so a
699
+ * missed LIVE notification for the edge is picked up on the next poll tick
700
+ * rather than after the idle backoff.
678
701
  */
679
- private static readonly SETTLED_WRITE_GRACE_MS = 10_000;
702
+ hasSettledWritesPending(): boolean {
703
+ this.pruneSettled(Date.now());
704
+ return this.settledWrites.size > 0 || this.settledDeletes.size > 0;
705
+ }
680
706
 
681
707
  /**
682
708
  * Report that a mutation was accepted by the server and its outbox row
@@ -2452,7 +2478,25 @@ export class DataModule<S extends SchemaStructure> {
2452
2478
  private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
2453
2479
  if (queryState.ttlTimer) return;
2454
2480
 
2455
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
2481
+ // Half the TTL, not 0.9 of it, so ONE failed beat is survivable.
2482
+ //
2483
+ // The timer re-arms after each beat, so expiry sits a full TTL after the
2484
+ // last SUCCESSFUL refresh. At 0.9 a beat that fails — the SSP restarting,
2485
+ // a reconnect, a slow database — left 10% of the TTL before the sweep
2486
+ // deleted the row AND its `_00_list_ref` edges, with no second attempt in
2487
+ // between. On a 10m TTL that is 60 seconds of slack against an SSP
2488
+ // bootstrap that takes ~2 minutes on a large tenant, so the view lost a
2489
+ // race it could not win.
2490
+ //
2491
+ // The user-visible form is content vanishing and coming back: the client
2492
+ // is LIVE on those edges, so it sees the sweep's deletes immediately, and
2493
+ // only restores them once its heartbeat notices the row is gone and
2494
+ // re-registers. Reported as chat messages disappearing for 10+ seconds.
2495
+ //
2496
+ // At 0.5 a single failure still leaves another attempt and half the TTL.
2497
+ // The cost is one extra beat per view per TTL, which is a single
2498
+ // `UPDATE ... SET lastActiveAt`.
2499
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
2456
2500
 
2457
2501
  queryState.ttlTimer = setTimeout(() => {
2458
2502
  queryState.ttlTimer = null;
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { Sp00kySync } from './sync';
3
+
4
+ // The list_ref poll backs off toward a 5s cap on a quiet page. A settled write
5
+ // that is still waiting for its membership edge must hold the poll at its base
6
+ // cadence instead: the row is rendered on the settled-write grace alone, so
7
+ // the edge has to be found on the first poll after it lands, not after the
8
+ // backoff has grown. Otherwise a missed LIVE notification during a server
9
+ // stall turns into "my message vanished" even though the edge arrived.
10
+
11
+ function makeSync(pending: () => boolean) {
12
+ const logger: any = {
13
+ child: () => logger,
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ trace: () => {},
19
+ };
20
+ const remote: any = { query: vi.fn().mockResolvedValue([true]) };
21
+ const dataModule: any = {
22
+ getActiveQueryHashes: () => ['h1'],
23
+ hasSettledWritesPending: pending,
24
+ };
25
+ const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
26
+ // A poll that never observes a change: the only thing driving the streak is
27
+ // the settled-write signal.
28
+ (sync as any).pollListRefForActiveQueries = vi.fn().mockResolvedValue(false);
29
+ return sync;
30
+ }
31
+
32
+ describe('list_ref poll cadence while a settled write awaits membership', () => {
33
+ afterEach(() => vi.useRealTimers());
34
+
35
+ it('stays at the base cadence while a write is pending, then backs off', async () => {
36
+ vi.useFakeTimers();
37
+ let pending = true;
38
+ const sync = makeSync(() => pending);
39
+ const base = sync.refSyncIntervalMs;
40
+
41
+ (sync as any).startListRefPoll();
42
+
43
+ // Three quiet ticks with a write outstanding: no backoff at all.
44
+ for (let i = 0; i < 3; i++) {
45
+ await vi.advanceTimersByTimeAsync(base);
46
+ expect((sync as any).listRefIdleStreak).toBe(0);
47
+ }
48
+
49
+ // Membership caught up: the ordinary idle backoff resumes.
50
+ pending = false;
51
+ await vi.advanceTimersByTimeAsync(base);
52
+ expect((sync as any).listRefIdleStreak).toBe(1);
53
+ await vi.advanceTimersByTimeAsync(base * 2);
54
+ expect((sync as any).listRefIdleStreak).toBe(2);
55
+
56
+ (sync as any).stopListRefPoll();
57
+ });
58
+ });
@@ -877,8 +877,12 @@ export class Sp00kySync<S extends SchemaStructure> {
877
877
  // Reset the idle streak on any observed change so the poll snaps
878
878
  // back to the fast base cadence; otherwise grow it so a quiet page
879
879
  // backs off toward the cap. (`handleRemoteListRefChange` also resets
880
- // it when a LIVE event lands.)
881
- this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
880
+ // it when a LIVE event lands.) A settled write still waiting for its
881
+ // membership also pins the fast cadence: the row is on borrowed time
882
+ // (the settled-write grace), so the edge must be found on the first
883
+ // poll after it lands, not after the backoff has grown to the cap.
884
+ const awaitingMembership = this.dataModule.hasSettledWritesPending();
885
+ this.listRefIdleStreak = changed || awaitingMembership ? 0 : this.listRefIdleStreak + 1;
882
886
  const next = listRefPollDelayMs({
883
887
  idleStreak: this.listRefIdleStreak,
884
888
  baseIntervalMs: this.refSyncIntervalMs,