@spooky-sync/core 0.0.1-canary.190 → 0.0.1-canary.192
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 +4 -4
- package/package.json +3 -3
- package/src/modules/sync/sync.cleanup.test.ts +107 -0
- package/src/modules/sync/sync.ts +20 -8
package/dist/index.js
CHANGED
|
@@ -6676,7 +6676,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6676
6676
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
6677
6677
|
if (!queryState) return;
|
|
6678
6678
|
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
6679
|
-
await this.remote.query(
|
|
6679
|
+
await this.remote.query("fn::query::unsubscribe($id)", { id: queryState.config.id });
|
|
6680
6680
|
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
6681
6681
|
this.enqueueDownEvent({
|
|
6682
6682
|
type: "register",
|
|
@@ -7003,8 +7003,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7003
7003
|
|
|
7004
7004
|
//#endregion
|
|
7005
7005
|
//#region src/modules/devtools/index.ts
|
|
7006
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7007
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7006
|
+
const CORE_VERSION = "0.0.1-canary.192";
|
|
7007
|
+
const WASM_VERSION = "0.0.1-canary.192";
|
|
7008
7008
|
const SURREAL_VERSION = "3.0.3";
|
|
7009
7009
|
var DevToolsService = class DevToolsService {
|
|
7010
7010
|
eventsHistory = [];
|
|
@@ -11435,7 +11435,7 @@ var Sp00kyClient = class {
|
|
|
11435
11435
|
return new TabsCoordinator({
|
|
11436
11436
|
tabId,
|
|
11437
11437
|
fingerprint: computeTabsFingerprint({
|
|
11438
|
-
coreVersion: "0.0.1-canary.
|
|
11438
|
+
coreVersion: "0.0.1-canary.192",
|
|
11439
11439
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11440
11440
|
endpoint: this.config.database.endpoint ?? "",
|
|
11441
11441
|
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.192",
|
|
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.192",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.192",
|
|
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,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
|
|
5
|
+
// Guards the teardown half of shared views. A `_00_query` row can be shared by
|
|
6
|
+
// several sessions of the same user, so releasing it MUST go through
|
|
7
|
+
// `fn::query::unsubscribe` — which drops only this session from `subscribers`
|
|
8
|
+
// and deletes the row when it was the last one. A bare `DELETE $id` here would
|
|
9
|
+
// destroy the view and every `_00_list_ref` edge hanging off it for every other
|
|
10
|
+
// live tab, which reads to the user as a list that silently goes blank.
|
|
11
|
+
//
|
|
12
|
+
// This was previously unenforceable: `_00_query` granted no delete permission,
|
|
13
|
+
// so the old `DELETE $id` affected zero rows and did nothing at all. Now that
|
|
14
|
+
// the table grants delete, a regression here is destructive rather than inert.
|
|
15
|
+
|
|
16
|
+
function makeSync(opts: { hasSubscribers?: boolean[] } = {}) {
|
|
17
|
+
const logger: any = {
|
|
18
|
+
child: () => logger,
|
|
19
|
+
debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
|
|
20
|
+
};
|
|
21
|
+
const remote: any = { query: vi.fn().mockResolvedValue([{ released: true, remaining: 0 }]) };
|
|
22
|
+
const queryId = new RecordId('_00_query', 'h1');
|
|
23
|
+
const queryState: any = { config: { id: queryId } };
|
|
24
|
+
|
|
25
|
+
// Successive hasSubscribers() answers: [before the release, after it].
|
|
26
|
+
const answers = opts.hasSubscribers ?? [false, false];
|
|
27
|
+
let call = 0;
|
|
28
|
+
const hasSubscribers = vi.fn(() => answers[Math.min(call++, answers.length - 1)]);
|
|
29
|
+
|
|
30
|
+
const finalizeDeregister = vi.fn();
|
|
31
|
+
const dataModule: any = {
|
|
32
|
+
getQueryByHash: vi.fn().mockReturnValue(queryState),
|
|
33
|
+
hasSubscribers,
|
|
34
|
+
finalizeDeregister,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sync = new Sp00kySync(
|
|
38
|
+
{} as any, remote, {} as any, dataModule, {} as any, logger,
|
|
39
|
+
);
|
|
40
|
+
const enqueueDownEvent = vi.fn();
|
|
41
|
+
(sync as any).enqueueDownEvent = enqueueDownEvent;
|
|
42
|
+
|
|
43
|
+
const run = (hash: string) => (sync as any).cleanupQuery(hash) as Promise<void>;
|
|
44
|
+
return { remote, dataModule, queryId, finalizeDeregister, enqueueDownEvent, run };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('cleanupQuery — releasing a possibly-shared view', () => {
|
|
48
|
+
beforeEach(() => vi.clearAllMocks());
|
|
49
|
+
|
|
50
|
+
it('releases through fn::query::unsubscribe, never a bare DELETE', async () => {
|
|
51
|
+
const { remote, run, queryId } = makeSync();
|
|
52
|
+
|
|
53
|
+
await run('h1');
|
|
54
|
+
|
|
55
|
+
expect(remote.query).toHaveBeenCalledWith('fn::query::unsubscribe($id)', {
|
|
56
|
+
id: queryId,
|
|
57
|
+
});
|
|
58
|
+
// The regression that would blank other tabs' lists.
|
|
59
|
+
const sql = remote.query.mock.calls.map((c: any[]) => c[0]).join('\n');
|
|
60
|
+
expect(sql).not.toMatch(/\bDELETE\b/i);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('frees local state once released', async () => {
|
|
64
|
+
const { finalizeDeregister, enqueueDownEvent, run } = makeSync();
|
|
65
|
+
|
|
66
|
+
await run('h1');
|
|
67
|
+
|
|
68
|
+
expect(finalizeDeregister).toHaveBeenCalledWith('h1');
|
|
69
|
+
expect(enqueueDownEvent).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not touch the remote row if a subscriber reappeared before the release', async () => {
|
|
73
|
+
const { remote, finalizeDeregister, run } = makeSync({
|
|
74
|
+
hasSubscribers: [true],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await run('h1');
|
|
78
|
+
|
|
79
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
80
|
+
expect(finalizeDeregister).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('re-registers if a subscriber reappeared during the release await', async () => {
|
|
84
|
+
// Someone scrolled back / re-subscribed while the round trip was in flight.
|
|
85
|
+
// Re-registering covers both outcomes: recreate the view if we were the
|
|
86
|
+
// last subscriber, or re-add ourselves to `subscribers` if it survived.
|
|
87
|
+
const { finalizeDeregister, enqueueDownEvent, run } = makeSync({
|
|
88
|
+
hasSubscribers: [false, true],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await run('h1');
|
|
92
|
+
|
|
93
|
+
expect(enqueueDownEvent).toHaveBeenCalledWith({
|
|
94
|
+
type: 'register',
|
|
95
|
+
payload: { hash: 'h1' },
|
|
96
|
+
});
|
|
97
|
+
expect(finalizeDeregister).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('is tolerant of an already torn-down query', async () => {
|
|
101
|
+
const { remote, dataModule, run } = makeSync();
|
|
102
|
+
dataModule.getQueryByHash.mockReturnValue(undefined);
|
|
103
|
+
|
|
104
|
+
await expect(run('gone')).resolves.toBeUndefined();
|
|
105
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1751,11 +1751,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1751
1751
|
|
|
1752
1752
|
// Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
|
|
1753
1753
|
// e.g. a viewport-windowed list cancelling an off-screen window). Query ids
|
|
1754
|
-
// are a deterministic hash of (surql+params), so a
|
|
1755
|
-
// re-register (same id) could nuke a freshly-recreated view —
|
|
1756
|
-
// guards: abort if a subscriber reappeared BEFORE the
|
|
1757
|
-
// one reappears DURING the
|
|
1758
|
-
// missing/already-gone query (no throw).
|
|
1754
|
+
// are a deterministic hash of (surql+params), so a release racing a
|
|
1755
|
+
// scroll-back re-register (same id) could nuke a freshly-recreated view —
|
|
1756
|
+
// hence two guards: abort if a subscriber reappeared BEFORE the release;
|
|
1757
|
+
// re-register if one reappears DURING the release's network await. Tolerant
|
|
1758
|
+
// of a missing/already-gone query (no throw).
|
|
1759
|
+
//
|
|
1760
|
+
// Releases via `fn::query::unsubscribe` rather than deleting the row
|
|
1761
|
+
// outright. A `_00_query` row can be shared by several sessions of the same
|
|
1762
|
+
// user, so a bare `DELETE` would tear the view — and every `_00_list_ref`
|
|
1763
|
+
// edge hanging off it — out from under other live tabs. The function drops
|
|
1764
|
+
// only this session from `subscribers` and deletes the row when it was the
|
|
1765
|
+
// last one. (The old `DELETE $id` was harmless in practice only because the
|
|
1766
|
+
// table granted no delete permission and it silently affected zero rows.)
|
|
1759
1767
|
private async cleanupQuery(queryHash: string) {
|
|
1760
1768
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
1761
1769
|
if (!queryState) return; // already torn down / never registered
|
|
@@ -1763,10 +1771,14 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1763
1771
|
// Re-subscribed before the queued cleanup ran → keep everything as-is.
|
|
1764
1772
|
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
1765
1773
|
|
|
1766
|
-
await this.remote.query(
|
|
1774
|
+
await this.remote.query('fn::query::unsubscribe($id)', {
|
|
1775
|
+
id: queryState.config.id,
|
|
1776
|
+
});
|
|
1767
1777
|
|
|
1768
|
-
// Re-subscribed while we awaited the
|
|
1769
|
-
//
|
|
1778
|
+
// Re-subscribed while we awaited the release → re-register. Covers both
|
|
1779
|
+
// outcomes: if we were the last subscriber the remote view is gone and this
|
|
1780
|
+
// recreates it, and if it survived for other sessions this re-adds us to
|
|
1781
|
+
// `subscribers` so our heartbeats keep counting.
|
|
1770
1782
|
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
1771
1783
|
this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
|
|
1772
1784
|
return;
|