@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 +15 -4
- package/package.json +3 -3
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.ts +32 -1
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.
|
|
7085
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|