@spooky-sync/core 0.0.1-canary.192 → 0.0.1-canary.193

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.d.ts CHANGED
@@ -1180,6 +1180,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
1180
1180
  private startSelfHeal;
1181
1181
  private scheduleSelfHeal;
1182
1182
  private stopSelfHeal;
1183
+ /**
1184
+ * Release a deregistered query's remote view immediately instead of leaving
1185
+ * it to the TTL sweep. Off by default; see the reasoning in
1186
+ * {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
1187
+ * can be re-enabled in a test once the subquery-body repair path exists.
1188
+ */
1189
+ private readonly releaseQueriesEagerly;
1183
1190
  constructor(local: LocalStore, remote: RemoteDatabaseService, cache: CacheModule, dataModule: DataModule<S>, schema: S, logger: Logger$1, options?: Sp00kySyncOptions);
1184
1191
  /**
1185
1192
  * Initializes the synchronization system.
package/dist/index.js CHANGED
@@ -5784,6 +5784,13 @@ var Sp00kySync = class Sp00kySync {
5784
5784
  }
5785
5785
  this.selfHealAttempts = 0;
5786
5786
  }
5787
+ /**
5788
+ * Release a deregistered query's remote view immediately instead of leaving
5789
+ * it to the TTL sweep. Off by default; see the reasoning in
5790
+ * {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
5791
+ * can be re-enabled in a test once the subquery-body repair path exists.
5792
+ */
5793
+ releaseQueriesEagerly = false;
5787
5794
  constructor(local, remote, cache, dataModule, schema, logger, options) {
5788
5795
  this.local = local;
5789
5796
  this.remote = remote;
@@ -6676,13 +6683,15 @@ var Sp00kySync = class Sp00kySync {
6676
6683
  const queryState = this.dataModule.getQueryByHash(queryHash);
6677
6684
  if (!queryState) return;
6678
6685
  if (this.dataModule.hasSubscribers(queryHash)) return;
6679
- await this.remote.query("fn::query::unsubscribe($id)", { id: queryState.config.id });
6680
- if (this.dataModule.hasSubscribers(queryHash)) {
6681
- this.enqueueDownEvent({
6682
- type: "register",
6683
- payload: { hash: queryHash }
6684
- });
6685
- return;
6686
+ if (this.releaseQueriesEagerly) {
6687
+ await this.remote.query("fn::query::unsubscribe($id)", { id: queryState.config.id });
6688
+ if (this.dataModule.hasSubscribers(queryHash)) {
6689
+ this.enqueueDownEvent({
6690
+ type: "register",
6691
+ payload: { hash: queryHash }
6692
+ });
6693
+ return;
6694
+ }
6686
6695
  }
6687
6696
  this.dataModule.finalizeDeregister(queryHash);
6688
6697
  }
@@ -7003,8 +7012,8 @@ function selfAllowlistedVariant(flag, userId) {
7003
7012
 
7004
7013
  //#endregion
7005
7014
  //#region src/modules/devtools/index.ts
7006
- const CORE_VERSION = "0.0.1-canary.192";
7007
- const WASM_VERSION = "0.0.1-canary.192";
7015
+ const CORE_VERSION = "0.0.1-canary.193";
7016
+ const WASM_VERSION = "0.0.1-canary.193";
7008
7017
  const SURREAL_VERSION = "3.0.3";
7009
7018
  var DevToolsService = class DevToolsService {
7010
7019
  eventsHistory = [];
@@ -11435,7 +11444,7 @@ var Sp00kyClient = class {
11435
11444
  return new TabsCoordinator({
11436
11445
  tabId,
11437
11446
  fingerprint: computeTabsFingerprint({
11438
- coreVersion: "0.0.1-canary.192",
11447
+ coreVersion: "0.0.1-canary.193",
11439
11448
  schemaHash: hash53(this.config.schemaSurql),
11440
11449
  endpoint: this.config.database.endpoint ?? "",
11441
11450
  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.192",
3
+ "version": "0.0.1-canary.193",
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.192",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.192",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.193",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.193",
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",
@@ -47,15 +47,26 @@ function makeSync(opts: { hasSubscribers?: boolean[] } = {}) {
47
47
  describe('cleanupQuery — releasing a possibly-shared view', () => {
48
48
  beforeEach(() => vi.clearAllMocks());
49
49
 
50
- it('releases through fn::query::unsubscribe, never a bare DELETE', async () => {
51
- const { remote, run, queryId } = makeSync();
50
+ it('does not touch the remote view at all; the TTL sweep reclaims it', async () => {
51
+ // Eager release is deliberately disabled. It was inert for months (the
52
+ // table granted no delete permission), and making it real turned every
53
+ // best-effort guard misfire into a live delete of the row and all its
54
+ // edges. TTL remains the only reclamation that has actually run.
55
+ const { remote, run } = makeSync();
56
+
57
+ await run('h1');
58
+
59
+ expect(remote.query).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('never issues a bare DELETE', async () => {
63
+ // Belt and braces: if the eager path is ever re-enabled, it must go through
64
+ // the refcounted `fn::query::unsubscribe`, never a raw DELETE, which would
65
+ // tear the view out from under other sessions sharing the row.
66
+ const { remote, run } = makeSync();
52
67
 
53
68
  await run('h1');
54
69
 
55
- expect(remote.query).toHaveBeenCalledWith('fn::query::unsubscribe($id)', {
56
- id: queryId,
57
- });
58
- // The regression that would blank other tabs' lists.
59
70
  const sql = remote.query.mock.calls.map((c: any[]) => c[0]).join('\n');
60
71
  expect(sql).not.toMatch(/\bDELETE\b/i);
61
72
  });
@@ -80,21 +91,19 @@ describe('cleanupQuery — releasing a possibly-shared view', () => {
80
91
  expect(finalizeDeregister).not.toHaveBeenCalled();
81
92
  });
82
93
 
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({
94
+ it('still frees local state when a subscriber reappears mid-cleanup', async () => {
95
+ // With no remote round trip there is no window to lose a re-subscribe in,
96
+ // so this collapses to the ordinary local free. The remote view survives
97
+ // regardless (TTL owns it), which is precisely why the reappearing
98
+ // subscriber is safe: a re-register finds the row still there.
99
+ const { remote, finalizeDeregister, run } = makeSync({
88
100
  hasSubscribers: [false, true],
89
101
  });
90
102
 
91
103
  await run('h1');
92
104
 
93
- expect(enqueueDownEvent).toHaveBeenCalledWith({
94
- type: 'register',
95
- payload: { hash: 'h1' },
96
- });
97
- expect(finalizeDeregister).not.toHaveBeenCalled();
105
+ expect(remote.query).not.toHaveBeenCalled();
106
+ expect(finalizeDeregister).toHaveBeenCalledWith('h1');
98
107
  });
99
108
 
100
109
  it('is tolerant of an already torn-down query', async () => {
@@ -424,6 +424,14 @@ export class Sp00kySync<S extends SchemaStructure> {
424
424
  this.selfHealAttempts = 0;
425
425
  }
426
426
 
427
+ /**
428
+ * Release a deregistered query's remote view immediately instead of leaving
429
+ * it to the TTL sweep. Off by default; see the reasoning in
430
+ * {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
431
+ * can be re-enabled in a test once the subquery-body repair path exists.
432
+ */
433
+ private readonly releaseQueriesEagerly = false;
434
+
427
435
  constructor(
428
436
  private local: LocalStore,
429
437
  private remote: RemoteDatabaseService,
@@ -1771,17 +1779,41 @@ export class Sp00kySync<S extends SchemaStructure> {
1771
1779
  // Re-subscribed before the queued cleanup ran → keep everything as-is.
1772
1780
  if (this.dataModule.hasSubscribers(queryHash)) return;
1773
1781
 
1774
- await this.remote.query('fn::query::unsubscribe($id)', {
1775
- id: queryState.config.id,
1776
- });
1782
+ // EAGER REMOTE RELEASE IS DISABLED. Deliberate, and not a leak: the TTL
1783
+ // sweep reclaims the row and its edges on `lastActiveAt + ttl`, which is the
1784
+ // ONLY reclamation that has ever actually run in production.
1785
+ //
1786
+ // Until canary.190 `_00_query` granted no delete permission, so the bare
1787
+ // `DELETE $id` this used to issue affected zero rows. .190 granted delete
1788
+ // and .191 wired `fn::query::unsubscribe`, which made teardown real for the
1789
+ // first time -- and the guards above are best-effort by construction
1790
+ // (`hasSubscribers` can be momentarily false during a rebind or a windowed
1791
+ // list re-flow). Every misfire that had been silently inert for months
1792
+ // became a live delete of the row AND every `_00_list_ref` edge on it.
1793
+ //
1794
+ // That matches a report of chat suddenly rendering raw record ids instead
1795
+ // of users, with the message list re-flowing underneath. Server state was
1796
+ // measured intact at the time (`rowCount` equalled the actual edge count on
1797
+ // every row), so the damage is on the client side of a teardown, not in the
1798
+ // materialization.
1799
+ //
1800
+ // Re-enable only together with a repair path that can re-fetch subquery
1801
+ // child bodies whose `subqueryRemoteArray` entry claims they are already
1802
+ // synced -- otherwise a torn-down-and-recreated view never restores the
1803
+ // related records it dropped, because the idempotence check skips them.
1804
+ if (this.releaseQueriesEagerly) {
1805
+ await this.remote.query('fn::query::unsubscribe($id)', {
1806
+ id: queryState.config.id,
1807
+ });
1777
1808
 
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.
1782
- if (this.dataModule.hasSubscribers(queryHash)) {
1783
- this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
1784
- return;
1809
+ // Re-subscribed while we awaited the release → re-register. Covers both
1810
+ // outcomes: if we were the last subscriber the remote view is gone and
1811
+ // this recreates it, and if it survived for other sessions this re-adds
1812
+ // us to `subscribers` so our heartbeats keep counting.
1813
+ if (this.dataModule.hasSubscribers(queryHash)) {
1814
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
1815
+ return;
1816
+ }
1785
1817
  }
1786
1818
 
1787
1819
  // No subscribers throughout → safe to free the local view + state.