@spooky-sync/core 0.0.1-canary.246 → 0.0.1-canary.248

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.js CHANGED
@@ -1073,8 +1073,8 @@ function selfAllowlistedVariant(flag, userId) {
1073
1073
 
1074
1074
  //#endregion
1075
1075
  //#region src/modules/devtools/index.ts
1076
- const CORE_VERSION = "0.0.1-canary.246";
1077
- const WASM_VERSION = "0.0.1-canary.246";
1076
+ const CORE_VERSION = "0.0.1-canary.248";
1077
+ const WASM_VERSION = "0.0.1-canary.248";
1078
1078
  const SURREAL_VERSION = "3.0.3";
1079
1079
  var DevToolsService = class DevToolsService {
1080
1080
  eventsHistory = [];
@@ -5098,9 +5098,16 @@ function* liveInvalidate() {
5098
5098
  }
5099
5099
  /** One or more edges of these queries changed on the server. */
5100
5100
  function* liveChange(env, hashes, rows) {
5101
- const [known, role] = yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole]);
5101
+ const [known, role, streak] = yield fx.state.read((s) => [
5102
+ hashes.filter((h) => s.queries.has(h)),
5103
+ s.tabRole,
5104
+ s.sync.pollIdleStreak
5105
+ ]);
5102
5106
  if (known.length === 0) return;
5103
- yield fx.state.update(patchSync({ pollIdleStreak: 0 }));
5107
+ if (streak > 0) {
5108
+ yield fx.state.update(patchSync({ pollIdleStreak: 0 }));
5109
+ yield fx.timer.set("poll", env.pollBaseMs, { type: "PollTick" });
5110
+ }
5104
5111
  if (rows && rows.length > 0) yield* landInlineRows(env, rows);
5105
5112
  yield* markMembershipDirty(known);
5106
5113
  if (role === "leader") yield fx.emit({
@@ -12509,7 +12516,7 @@ function createTabsCoordinator(config, s, host) {
12509
12516
  return new TabsCoordinator({
12510
12517
  tabId: s.tabId,
12511
12518
  fingerprint: computeTabsFingerprint({
12512
- coreVersion: "0.0.1-canary.246",
12519
+ coreVersion: "0.0.1-canary.248",
12513
12520
  schemaHash: hash53(config.schemaSurql),
12514
12521
  endpoint: config.database.endpoint ?? "",
12515
12522
  namespace: 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.246",
3
+ "version": "0.0.1-canary.248",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,8 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.246",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.246",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.248",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.248",
68
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
69
  "@surrealdb/wasm": "^3.0.3",
70
70
  "blurhash": "^2.0.5",
@@ -68,6 +68,28 @@ describe('liveChange', () => {
68
68
  const none = await runPure(liveChange(env, ['zz']), { state: s });
69
69
  expect(none.timers.size).toBe(0);
70
70
  });
71
+
72
+ it('pulls a backed-off poll back to the base cadence, and leaves an already-base one alone', async () => {
73
+ const entry = buildEntry({ def: { hash: 'a' } });
74
+ // Coasted up to the cap: the armed timer is the reason a change the poll
75
+ // has to catch could wait 5s, so the event has to re-arm it, not just zero
76
+ // the streak the next tick would read.
77
+ const backedOff = R.patchSync({ pollIdleStreak: 4 })(buildState([entry]));
78
+ const out = await runPure(liveChange(env, ['a']), { state: backedOff });
79
+ expect(out.timers.get('poll')).toEqual({ ms: env.pollBaseMs, event: { type: 'PollTick' } });
80
+
81
+ // Already at base: re-arming here would push the poll out by another
82
+ // `pollBaseMs` on every notification and starve it under sustained LIVE.
83
+ const atBase = R.patchSync({ pollIdleStreak: 0 })(buildState([entry]));
84
+ const quiet = await runPure(liveChange(env, ['a']), { state: atBase });
85
+ expect(quiet.timers.has('poll')).toBe(false);
86
+ expect(quiet.state.membershipDirty.has('a')).toBe(true);
87
+
88
+ // An unknown hash is not activity: nothing is touched.
89
+ const unknown = await runPure(liveChange(env, ['zz']), { state: backedOff });
90
+ expect(unknown.timers.has('poll')).toBe(false);
91
+ expect(unknown.state.sync.pollIdleStreak).toBe(4);
92
+ });
71
93
  });
72
94
 
73
95
  describe('rowOfEdge', () => {
@@ -120,6 +142,25 @@ describe('liveChange with the body joined on', () => {
120
142
  expect([...out.state.membershipDirty]).toEqual(['a']);
121
143
  });
122
144
 
145
+ it('leaves a failed inline body for the fetch path without advancing its version', async () => {
146
+ const s = buildState([buildEntry({ def: { hash: 'a' } })]);
147
+ const out = await runPure(liveChange(env, ['a'], [row(7)]), {
148
+ state: s,
149
+ handlers: {
150
+ 'local.execute': () => { throw new Error('stale local epoch'); },
151
+ },
152
+ });
153
+ expect(out.state.versions.has('game:g1')).toBe(false);
154
+ expect([...out.state.membershipDirty]).toEqual(['a']);
155
+ expect(out.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(0);
156
+ expect(out.emitted).toContainEqual({
157
+ type: 'log',
158
+ level: 'debug',
159
+ message: 'inline live body not landed; the fetch path will pull it',
160
+ data: { ids: ['game:g1'] },
161
+ });
162
+ });
163
+
123
164
  it('writes one copy when the same row arrives for several views', async () => {
124
165
  const s = buildState([buildEntry({ def: { hash: 'a' } })]);
125
166
  const out = await runPure(liveChange(env, ['a'], [row(7), row(7), row(8)]), { state: s, handlers });
@@ -47,9 +47,29 @@ export function* liveInvalidate(): Saga<void> {
47
47
 
48
48
  /** One or more edges of these queries changed on the server. */
49
49
  export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
50
- const [known, role] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole])) as [QueryHash[], ClientState['tabRole']];
50
+ const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
51
+ QueryHash[],
52
+ ClientState['tabRole'],
53
+ number,
54
+ ];
51
55
  if (known.length === 0) return;
52
- yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
56
+ // Zeroing the streak only decides the delay the NEXT tick picks: `pollTick`
57
+ // arms its timer at the end of a tick, so a page that had coasted up to the
58
+ // 5s cap still waited out that armed timer. When the poll is the thing that
59
+ // catches a change (a notification the server dropped, a row LIVE never
60
+ // carried) that made the worst case 5s even though LIVE had just proved the
61
+ // page was active. Re-arm at the base cadence too, so the safety net is back
62
+ // under us immediately.
63
+ //
64
+ // Only when the poll had actually backed off. A `set` on the same key
65
+ // replaces the pending timer, so re-arming on every event while LIVE is
66
+ // delivering faster than `pollBaseMs` would push the poll out forever and
67
+ // starve the full-membership reconciliation it exists to provide. A streak
68
+ // of 0 means the cadence is already base and there is nothing to correct.
69
+ if (streak > 0) {
70
+ yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
71
+ yield fx.timer.set('poll', env.pollBaseMs, { type: 'PollTick' });
72
+ }
53
73
  // Before marking membership dirty: recording the version here is what makes
54
74
  // the re-read's `planFetch` find nothing left to pull for this row.
55
75
  if (rows && rows.length > 0) yield* landInlineRows(env, rows);