@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 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(`DELETE $id`, { id: queryState.config.id });
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.190";
7007
- const WASM_VERSION = "0.0.1-canary.190";
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.190",
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.190",
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.190",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.190",
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
+ });
@@ -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 DELETE racing a scroll-back
1755
- // re-register (same id) could nuke a freshly-recreated view — hence two
1756
- // guards: abort if a subscriber reappeared BEFORE the delete; re-register if
1757
- // one reappears DURING the delete's network await. Tolerant of a
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(`DELETE $id`, { id: queryState.config.id });
1774
+ await this.remote.query('fn::query::unsubscribe($id)', {
1775
+ id: queryState.config.id,
1776
+ });
1767
1777
 
1768
- // Re-subscribed while we awaited the DELETEthe remote view is now gone
1769
- // but a subscriber needs it; recreate it instead of leaving a zombie.
1778
+ // Re-subscribed while we awaited the releasere-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;