@spooky-sync/core 0.0.1-canary.197 → 0.0.1-canary.198

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
@@ -6746,7 +6746,18 @@ var Sp00kySync = class Sp00kySync {
6746
6746
  }, "Query to register not found");
6747
6747
  throw new Error("Query to register not found");
6748
6748
  }
6749
- await this.remote.query("fn::query::heartbeat($id)", { id: queryState.config.id });
6749
+ const result = await this.remote.query("fn::query::heartbeat($id)", { id: queryState.config.id });
6750
+ const updated = Array.isArray(result) ? result[0] : void 0;
6751
+ if (!(Array.isArray(updated) && updated.length === 0)) return;
6752
+ this.logger.warn({
6753
+ queryHash,
6754
+ id: String(queryState.config.id),
6755
+ Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
6756
+ }, "Query row was reclaimed while still in use; re-registering");
6757
+ this.enqueueDownEvent({
6758
+ type: "register",
6759
+ payload: { hash: queryHash }
6760
+ });
6750
6761
  }
6751
6762
  async cleanupQuery(queryHash) {
6752
6763
  const queryState = this.dataModule.getQueryByHash(queryHash);
@@ -7081,8 +7092,8 @@ function selfAllowlistedVariant(flag, userId) {
7081
7092
 
7082
7093
  //#endregion
7083
7094
  //#region src/modules/devtools/index.ts
7084
- const CORE_VERSION = "0.0.1-canary.197";
7085
- const WASM_VERSION = "0.0.1-canary.197";
7095
+ const CORE_VERSION = "0.0.1-canary.198";
7096
+ const WASM_VERSION = "0.0.1-canary.198";
7086
7097
  const SURREAL_VERSION = "3.0.3";
7087
7098
  var DevToolsService = class DevToolsService {
7088
7099
  eventsHistory = [];
@@ -11513,7 +11524,7 @@ var Sp00kyClient = class {
11513
11524
  return new TabsCoordinator({
11514
11525
  tabId,
11515
11526
  fingerprint: computeTabsFingerprint({
11516
- coreVersion: "0.0.1-canary.197",
11527
+ coreVersion: "0.0.1-canary.198",
11517
11528
  schemaHash: hash53(this.config.schemaSurql),
11518
11529
  endpoint: this.config.database.endpoint ?? "",
11519
11530
  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.197",
3
+ "version": "0.0.1-canary.198",
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.197",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.197",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.198",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.198",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+
5
+ /**
6
+ * `fn::query::heartbeat` is an `UPDATE $id SET ...`. Against a record that no
7
+ * longer exists it matches nothing and returns an empty array — it does NOT
8
+ * recreate the row. Verified against the deployed function:
9
+ *
10
+ * RETURN fn::query::heartbeat(_00_query:definitely_not_a_real_row_xyz);
11
+ * -- (0 rows)
12
+ *
13
+ * So an unchecked heartbeat cannot tell "refreshed" from "the row I am
14
+ * refreshing is gone", and a client whose row was reclaimed by the TTL sweep
15
+ * beats against nothing forever: no membership, no edges, no re-registration.
16
+ * The page then renders as though the data had been deleted — reported as
17
+ * "Game not found" on a game that was open and working.
18
+ *
19
+ * This is reachable in ordinary use: the sweep expires on `lastActiveAt + ttl`
20
+ * while the heartbeat runs on a timer browsers throttle hard in background
21
+ * tabs, so a second window left idle past its TTL is the normal way in.
22
+ */
23
+
24
+ function makeSync(heartbeatResult: unknown) {
25
+ const logger: any = {
26
+ child: () => logger,
27
+ debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
28
+ };
29
+ const remote: any = { query: vi.fn().mockResolvedValue(heartbeatResult) };
30
+ const queryState: any = { config: { id: new RecordId('_00_query', 'h1') } };
31
+ const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
32
+
33
+ const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
34
+ const enqueueDownEvent = vi.fn();
35
+ (sync as any).enqueueDownEvent = enqueueDownEvent;
36
+
37
+ return { sync, remote, dataModule, enqueueDownEvent };
38
+ }
39
+
40
+ describe('heartbeatQuery — noticing a reclaimed row', () => {
41
+ beforeEach(() => vi.clearAllMocks());
42
+
43
+ it('re-registers when the row it beat against is gone', async () => {
44
+ // `UPDATE` on a deleted record: one statement, zero updated records.
45
+ const { sync, enqueueDownEvent } = makeSync([[]]);
46
+
47
+ await sync.heartbeatQuery('h1');
48
+
49
+ expect(enqueueDownEvent).toHaveBeenCalledWith({
50
+ type: 'register',
51
+ payload: { hash: 'h1' },
52
+ });
53
+ });
54
+
55
+ it('does nothing extra on a healthy heartbeat', async () => {
56
+ const { sync, enqueueDownEvent } = makeSync([[{ id: 'x', lastActiveAt: 'now' }]]);
57
+
58
+ await sync.heartbeatQuery('h1');
59
+
60
+ expect(enqueueDownEvent).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('does not re-register on an unrecognised result shape', async () => {
64
+ // Only an explicitly EMPTY update result means "the row is gone". Anything
65
+ // else — a driver returning null, a shape change — must not be read as
66
+ // deletion, or every heartbeat would re-register the whole working set.
67
+ for (const shape of [null, undefined, [], [null], ['unexpected']]) {
68
+ const { sync, enqueueDownEvent } = makeSync(shape);
69
+ await sync.heartbeatQuery('h1');
70
+ expect(enqueueDownEvent, `shape ${JSON.stringify(shape)}`).not.toHaveBeenCalled();
71
+ }
72
+ });
73
+
74
+ it('still throws for a query that is no longer registered locally', async () => {
75
+ const { sync, dataModule } = makeSync([[]]);
76
+ dataModule.getQueryByHash.mockReturnValue(undefined);
77
+
78
+ await expect(sync.heartbeatQuery('gone')).rejects.toThrow();
79
+ });
80
+ });
@@ -1768,9 +1768,40 @@ export class Sp00kySync<S extends SchemaStructure> {
1768
1768
  );
1769
1769
  throw new Error('Query to register not found');
1770
1770
  }
1771
- await this.remote.query('fn::query::heartbeat($id)', {
1771
+ // `fn::query::heartbeat` is an `UPDATE $id SET ...`. On a record that no
1772
+ // longer exists that matches nothing and returns an empty array — it does
1773
+ // NOT recreate the row. So an unchecked heartbeat is indistinguishable from
1774
+ // a successful one, and a client whose row was reclaimed keeps beating
1775
+ // against nothing forever: no membership, no edges, no re-registration.
1776
+ // The page renders as if the data were deleted ("Game not found").
1777
+ //
1778
+ // A live query's row is reclaimed more easily than it looks. The sweep
1779
+ // expires on `lastActiveAt + ttl`, and this heartbeat runs on a timer that
1780
+ // browsers throttle hard in background tabs — so a second window left idle
1781
+ // past its TTL is the ordinary way to get here, not an edge case. Until
1782
+ // canary.194 the sweep could not actually remove the in-memory view (it
1783
+ // looked it up under the other of the two query-id spellings), which masked
1784
+ // this: the view survived its own row. Now reclamation is real, so the
1785
+ // client has to notice and rebuild.
1786
+ const result = await this.remote.query('fn::query::heartbeat($id)', {
1772
1787
  id: queryState.config.id,
1773
1788
  });
1789
+ const updated = Array.isArray(result) ? result[0] : undefined;
1790
+ const rowGone = Array.isArray(updated) && updated.length === 0;
1791
+ if (!rowGone) return;
1792
+
1793
+ this.logger.warn(
1794
+ {
1795
+ queryHash,
1796
+ id: String(queryState.config.id),
1797
+ Category: 'sp00ky-client::Sp00kySync::heartbeatQuery',
1798
+ },
1799
+ 'Query row was reclaimed while still in use; re-registering'
1800
+ );
1801
+ // Re-register rather than recreate the row here: the row alone is useless
1802
+ // without the SSP view behind it, and only registration rebuilds the view,
1803
+ // republishes `_00_list_ref` and writes `rowCount`.
1804
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
1774
1805
  }
1775
1806
 
1776
1807
  // Eager teardown of a deregistered query's remote `_00_query` view (opt-in,