@spooky-sync/core 0.0.1-canary.219 → 0.0.1-canary.220

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
@@ -1692,6 +1692,30 @@ declare class Sp00kySync<S extends SchemaStructure> {
1692
1692
  * @param mutations Array of UpEvents (create/update/delete) to enqueue.
1693
1693
  */
1694
1694
  enqueueMutation(mutations: UpEvent[]): Promise<void>;
1695
+ /**
1696
+ * Best-effort release of THIS tab's views as the page goes away.
1697
+ *
1698
+ * Without it a closed tab's views stay materialized on the SSP for a full
1699
+ * TTL (10 minutes by default), because nothing else tears them down:
1700
+ * `releaseQueriesEagerly` is off, so the TTL sweep is the only reclaim path.
1701
+ * On a busy tenant that is a generation of live views per reload, each still
1702
+ * being stepped by every ingest, for ten minutes after the last human left.
1703
+ *
1704
+ * This is NOT `releaseQueriesEagerly` re-enabled. That released views
1705
+ * mid-session on a viewport change and tore the window out from under a live
1706
+ * page (see `cleanupQuery`); this runs only when the page itself is going
1707
+ * away and nothing is left to render.
1708
+ *
1709
+ * Safe against other tabs by construction: `fn::query::unsubscribe` drops
1710
+ * only this session from `subscribers` and deletes the row solely when it
1711
+ * was the last one, so a second tab of the same user keeps its view.
1712
+ *
1713
+ * One statement, not one per id: an unload handler gets a few milliseconds,
1714
+ * and N round trips would not survive it. Fire-and-forget — if the frame
1715
+ * does not make it out, the TTL sweep still reclaims exactly as before, so
1716
+ * the worst case is today's behaviour.
1717
+ */
1718
+ releaseViewsOnUnload(): void;
1695
1719
  private registerQuery;
1696
1720
  private createRemoteQuery;
1697
1721
  /**
@@ -2565,6 +2589,8 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2565
2589
  * outbox, and every query re-registered remotely to refill from the server.
2566
2590
  */
2567
2591
  private doSwitchBucket;
2592
+ /** Removes the `pagehide` listener installed in `init`; see `close`. */
2593
+ private detachUnloadRelease;
2568
2594
  close(): Promise<void>;
2569
2595
  /**
2570
2596
  * Subscribe to a feature flag for the current user. Returns a
package/dist/index.js CHANGED
@@ -7215,6 +7215,41 @@ var Sp00kySync = class Sp00kySync {
7215
7215
  }
7216
7216
  this.scheduler.enqueueMutation(mutations);
7217
7217
  }
7218
+ /**
7219
+ * Best-effort release of THIS tab's views as the page goes away.
7220
+ *
7221
+ * Without it a closed tab's views stay materialized on the SSP for a full
7222
+ * TTL (10 minutes by default), because nothing else tears them down:
7223
+ * `releaseQueriesEagerly` is off, so the TTL sweep is the only reclaim path.
7224
+ * On a busy tenant that is a generation of live views per reload, each still
7225
+ * being stepped by every ingest, for ten minutes after the last human left.
7226
+ *
7227
+ * This is NOT `releaseQueriesEagerly` re-enabled. That released views
7228
+ * mid-session on a viewport change and tore the window out from under a live
7229
+ * page (see `cleanupQuery`); this runs only when the page itself is going
7230
+ * away and nothing is left to render.
7231
+ *
7232
+ * Safe against other tabs by construction: `fn::query::unsubscribe` drops
7233
+ * only this session from `subscribers` and deletes the row solely when it
7234
+ * was the last one, so a second tab of the same user keeps its view.
7235
+ *
7236
+ * One statement, not one per id: an unload handler gets a few milliseconds,
7237
+ * and N round trips would not survive it. Fire-and-forget — if the frame
7238
+ * does not make it out, the TTL sweep still reclaims exactly as before, so
7239
+ * the worst case is today's behaviour.
7240
+ */
7241
+ releaseViewsOnUnload() {
7242
+ let ids;
7243
+ try {
7244
+ ids = this.dataModule.getActiveQueryHashes().map((hash) => this.dataModule.getQueryByHash(hash)?.config.id).filter((id) => id != null);
7245
+ } catch {
7246
+ return;
7247
+ }
7248
+ if (ids.length === 0) return;
7249
+ try {
7250
+ this.remote.query("FOR $id IN $ids { LET $_released = fn::query::unsubscribe($id); };", { ids }).catch(() => {});
7251
+ } catch {}
7252
+ }
7218
7253
  async registerQuery(queryHash) {
7219
7254
  this.dataModule.beginFetching(queryHash);
7220
7255
  try {
@@ -7664,8 +7699,8 @@ function selfAllowlistedVariant(flag, userId) {
7664
7699
 
7665
7700
  //#endregion
7666
7701
  //#region src/modules/devtools/index.ts
7667
- const CORE_VERSION = "0.0.1-canary.219";
7668
- const WASM_VERSION = "0.0.1-canary.219";
7702
+ const CORE_VERSION = "0.0.1-canary.220";
7703
+ const WASM_VERSION = "0.0.1-canary.220";
7669
7704
  const SURREAL_VERSION = "3.0.3";
7670
7705
  var DevToolsService = class DevToolsService {
7671
7706
  eventsHistory = [];
@@ -12680,7 +12715,7 @@ var Sp00kyClient = class {
12680
12715
  return new TabsCoordinator({
12681
12716
  tabId,
12682
12717
  fingerprint: computeTabsFingerprint({
12683
- coreVersion: "0.0.1-canary.219",
12718
+ coreVersion: "0.0.1-canary.220",
12684
12719
  schemaHash: hash53(this.config.schemaSurql),
12685
12720
  endpoint: this.config.database.endpoint ?? "",
12686
12721
  namespace: this.config.database.namespace,
@@ -12874,6 +12909,14 @@ var Sp00kyClient = class {
12874
12909
  });
12875
12910
  await this.sync.init();
12876
12911
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
12912
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
12913
+ const onPageHide = (event) => {
12914
+ if (event.persisted) return;
12915
+ this.sync.releaseViewsOnUnload();
12916
+ };
12917
+ window.addEventListener("pagehide", onPageHide);
12918
+ this.detachUnloadRelease = () => window.removeEventListener("pagehide", onPageHide);
12919
+ }
12877
12920
  this.featureFlags.init();
12878
12921
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
12879
12922
  this.appReleases.init();
@@ -13033,6 +13076,8 @@ var Sp00kyClient = class {
13033
13076
  Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
13034
13077
  }, "Local bucket switch complete");
13035
13078
  }
13079
+ /** Removes the `pagehide` listener installed in `init`; see `close`. */
13080
+ detachUnloadRelease = null;
13036
13081
  async close() {
13037
13082
  this.connectionSupervisor.dispose();
13038
13083
  await this.featureFlags.closeAll();
@@ -13042,6 +13087,8 @@ var Sp00kyClient = class {
13042
13087
  await this.blobs.close().catch(() => {});
13043
13088
  await this.streamProcessor.checkpoint("close").catch(() => {});
13044
13089
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
13090
+ this.detachUnloadRelease?.();
13091
+ this.detachUnloadRelease = null;
13045
13092
  await this.local.close();
13046
13093
  await this.remote.close();
13047
13094
  this.streamProcessor.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.219",
3
+ "version": "0.0.1-canary.220",
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.219",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.219",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.220",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.220",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -1843,6 +1843,50 @@ export class Sp00kySync<S extends SchemaStructure> {
1843
1843
  this.scheduler.enqueueMutation(mutations);
1844
1844
  }
1845
1845
 
1846
+ /**
1847
+ * Best-effort release of THIS tab's views as the page goes away.
1848
+ *
1849
+ * Without it a closed tab's views stay materialized on the SSP for a full
1850
+ * TTL (10 minutes by default), because nothing else tears them down:
1851
+ * `releaseQueriesEagerly` is off, so the TTL sweep is the only reclaim path.
1852
+ * On a busy tenant that is a generation of live views per reload, each still
1853
+ * being stepped by every ingest, for ten minutes after the last human left.
1854
+ *
1855
+ * This is NOT `releaseQueriesEagerly` re-enabled. That released views
1856
+ * mid-session on a viewport change and tore the window out from under a live
1857
+ * page (see `cleanupQuery`); this runs only when the page itself is going
1858
+ * away and nothing is left to render.
1859
+ *
1860
+ * Safe against other tabs by construction: `fn::query::unsubscribe` drops
1861
+ * only this session from `subscribers` and deletes the row solely when it
1862
+ * was the last one, so a second tab of the same user keeps its view.
1863
+ *
1864
+ * One statement, not one per id: an unload handler gets a few milliseconds,
1865
+ * and N round trips would not survive it. Fire-and-forget — if the frame
1866
+ * does not make it out, the TTL sweep still reclaims exactly as before, so
1867
+ * the worst case is today's behaviour.
1868
+ */
1869
+ releaseViewsOnUnload(): void {
1870
+ let ids: unknown[];
1871
+ try {
1872
+ ids = this.dataModule
1873
+ .getActiveQueryHashes()
1874
+ .map((hash) => this.dataModule.getQueryByHash(hash)?.config.id)
1875
+ .filter((id): id is NonNullable<typeof id> => id != null);
1876
+ } catch {
1877
+ return;
1878
+ }
1879
+ if (ids.length === 0) return;
1880
+
1881
+ try {
1882
+ void this.remote
1883
+ .query('FOR $id IN $ids { LET $_released = fn::query::unsubscribe($id); };', { ids })
1884
+ .catch(() => {});
1885
+ } catch {
1886
+ // Socket already gone: the TTL sweep is the fallback, as before.
1887
+ }
1888
+ }
1889
+
1846
1890
  private async registerQuery(queryHash: string) {
1847
1891
  // Hold `fetching` across the WHOLE registration (remote view creation +
1848
1892
  // initial sync + post-sync notify). A query is born `fetching` in
package/src/sp00ky.ts CHANGED
@@ -981,6 +981,24 @@ export class Sp00kyClient<S extends SchemaStructure> {
981
981
  await this.sync.init();
982
982
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
983
983
 
984
+ // Hand this tab's views back when the page goes away. Without it they
985
+ // stay materialized on the SSP for a whole TTL after the tab is gone —
986
+ // see `Sp00kySync.releaseViewsOnUnload`.
987
+ //
988
+ // `pagehide` rather than `beforeunload`: it is the one teardown event
989
+ // that also fires on mobile, where tabs are discarded rather than
990
+ // closed. `persisted` means the page went into the bfcache and can come
991
+ // BACK, so releasing there would tear down views a live page still
992
+ // needs.
993
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
994
+ const onPageHide = (event: PageTransitionEvent) => {
995
+ if (event.persisted) return;
996
+ this.sync.releaseViewsOnUnload();
997
+ };
998
+ window.addEventListener('pagehide', onPageHide);
999
+ this.detachUnloadRelease = () => window.removeEventListener('pagehide', onPageHide);
1000
+ }
1001
+
984
1002
  this.featureFlags.init();
985
1003
  this.logger.debug(
986
1004
  { Category: 'sp00ky-client::Sp00kyClient::init' },
@@ -1229,6 +1247,9 @@ export class Sp00kyClient<S extends SchemaStructure> {
1229
1247
  );
1230
1248
  }
1231
1249
 
1250
+ /** Removes the `pagehide` listener installed in `init`; see `close`. */
1251
+ private detachUnloadRelease: (() => void) | null = null;
1252
+
1232
1253
  async close() {
1233
1254
  // Before anything else: a live supervisor would read the intentional
1234
1255
  // `remote.close()` below as a failure and immediately reconnect.
@@ -1245,6 +1266,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
1245
1266
  // Leaving the broker first hands leadership to another tab (and releases
1246
1267
  // the OPFS handles via the worker shutdown) before the store closes.
1247
1268
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
1269
+ this.detachUnloadRelease?.();
1270
+ this.detachUnloadRelease = null;
1248
1271
  await this.local.close();
1249
1272
  await this.remote.close();
1250
1273
  // Free the wasm circuit explicitly. V8 cannot see wasm-internal bytes, so