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

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
@@ -79,6 +79,20 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
79
79
  private authToken;
80
80
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
81
81
  getConfig(): Sp00kyConfig<any>['database'];
82
+ /**
83
+ * Send one SurrealQL statement so that it survives the page going away.
84
+ *
85
+ * A WebSocket `send()` during `pagehide` is not guaranteed to flush — the
86
+ * browser may tear the socket down first, and the frame is simply lost.
87
+ * Measured: an unload-time release over the live socket reached the server
88
+ * zero times out of one. `fetch` with `keepalive` is the primitive the
89
+ * platform actually guarantees here, so this goes over SurrealDB's HTTP
90
+ * `/sql` endpoint instead of the RPC socket.
91
+ *
92
+ * Best-effort by design: no await, no retry, errors swallowed. Every caller
93
+ * must have a server-side fallback that makes a lost beacon a non-event.
94
+ */
95
+ beaconSql(sql: string): void;
82
96
  /**
83
97
  * Record the token every future connect should authenticate with, or `null`
84
98
  * on sign-out. See {@link authToken}.
package/dist/index.js CHANGED
@@ -1044,6 +1044,42 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1044
1044
  return this.config;
1045
1045
  }
1046
1046
  /**
1047
+ * Send one SurrealQL statement so that it survives the page going away.
1048
+ *
1049
+ * A WebSocket `send()` during `pagehide` is not guaranteed to flush — the
1050
+ * browser may tear the socket down first, and the frame is simply lost.
1051
+ * Measured: an unload-time release over the live socket reached the server
1052
+ * zero times out of one. `fetch` with `keepalive` is the primitive the
1053
+ * platform actually guarantees here, so this goes over SurrealDB's HTTP
1054
+ * `/sql` endpoint instead of the RPC socket.
1055
+ *
1056
+ * Best-effort by design: no await, no retry, errors swallowed. Every caller
1057
+ * must have a server-side fallback that makes a lost beacon a non-event.
1058
+ */
1059
+ beaconSql(sql) {
1060
+ try {
1061
+ const { endpoint, namespace, database } = this.getConfig();
1062
+ if (!endpoint || typeof fetch !== "function") return;
1063
+ const url = new URL(endpoint);
1064
+ url.protocol = url.protocol === "wss:" ? "https:" : url.protocol === "ws:" ? "http:" : url.protocol;
1065
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, "") + "/sql";
1066
+ const headers = {
1067
+ Accept: "application/json",
1068
+ "Content-Type": "text/plain"
1069
+ };
1070
+ if (namespace) headers["surreal-ns"] = namespace;
1071
+ if (database) headers["surreal-db"] = database;
1072
+ if (this.authToken) headers.Authorization = `Bearer ${this.authToken}`;
1073
+ fetch(url.toString(), {
1074
+ method: "POST",
1075
+ headers,
1076
+ body: sql,
1077
+ keepalive: true,
1078
+ credentials: "omit"
1079
+ }).catch(() => {});
1080
+ } catch {}
1081
+ }
1082
+ /**
1047
1083
  * Record the token every future connect should authenticate with, or `null`
1048
1084
  * on sign-out. See {@link authToken}.
1049
1085
  *
@@ -3437,6 +3473,14 @@ function phaseStatOf(samples, lastMs) {
3437
3473
  count: samples.length
3438
3474
  };
3439
3475
  }
3476
+ /**
3477
+ * Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
3478
+ *
3479
+ * Must leave room for at least one retry: the server sweeps a view — and its
3480
+ * `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
3481
+ * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
3482
+ */
3483
+ const TTL_HEARTBEAT_FRACTION = .5;
3440
3484
  var DataModule = class DataModule {
3441
3485
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
3442
3486
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -4987,7 +5031,7 @@ var DataModule = class DataModule {
4987
5031
  }
4988
5032
  startTTLHeartbeat(queryState, hash) {
4989
5033
  if (queryState.ttlTimer) return;
4990
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
5034
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
4991
5035
  queryState.ttlTimer = setTimeout(() => {
4992
5036
  queryState.ttlTimer = null;
4993
5037
  if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
@@ -7246,9 +7290,9 @@ var Sp00kySync = class Sp00kySync {
7246
7290
  return;
7247
7291
  }
7248
7292
  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 {}
7293
+ const list = ids.map((id) => String(id)).filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id)).join(", ");
7294
+ if (!list) return;
7295
+ this.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
7252
7296
  }
7253
7297
  async registerQuery(queryHash) {
7254
7298
  this.dataModule.beginFetching(queryHash);
@@ -7699,8 +7743,8 @@ function selfAllowlistedVariant(flag, userId) {
7699
7743
 
7700
7744
  //#endregion
7701
7745
  //#region src/modules/devtools/index.ts
7702
- const CORE_VERSION = "0.0.1-canary.220";
7703
- const WASM_VERSION = "0.0.1-canary.220";
7746
+ const CORE_VERSION = "0.0.1-canary.222";
7747
+ const WASM_VERSION = "0.0.1-canary.222";
7704
7748
  const SURREAL_VERSION = "3.0.3";
7705
7749
  var DevToolsService = class DevToolsService {
7706
7750
  eventsHistory = [];
@@ -12715,7 +12759,7 @@ var Sp00kyClient = class {
12715
12759
  return new TabsCoordinator({
12716
12760
  tabId,
12717
12761
  fingerprint: computeTabsFingerprint({
12718
- coreVersion: "0.0.1-canary.220",
12762
+ coreVersion: "0.0.1-canary.222",
12719
12763
  schemaHash: hash53(this.config.schemaSurql),
12720
12764
  endpoint: this.config.database.endpoint ?? "",
12721
12765
  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.220",
3
+ "version": "0.0.1-canary.222",
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.220",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.220",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.222",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.222",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -90,6 +90,15 @@ export interface DurableMembership {
90
90
  confirmed: boolean;
91
91
  }
92
92
 
93
+ /**
94
+ * Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
95
+ *
96
+ * Must leave room for at least one retry: the server sweeps a view — and its
97
+ * `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
98
+ * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
99
+ */
100
+ const TTL_HEARTBEAT_FRACTION = 0.5;
101
+
93
102
  export class DataModule<S extends SchemaStructure> {
94
103
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
95
104
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -2452,7 +2461,25 @@ export class DataModule<S extends SchemaStructure> {
2452
2461
  private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
2453
2462
  if (queryState.ttlTimer) return;
2454
2463
 
2455
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
2464
+ // Half the TTL, not 0.9 of it, so ONE failed beat is survivable.
2465
+ //
2466
+ // The timer re-arms after each beat, so expiry sits a full TTL after the
2467
+ // last SUCCESSFUL refresh. At 0.9 a beat that fails — the SSP restarting,
2468
+ // a reconnect, a slow database — left 10% of the TTL before the sweep
2469
+ // deleted the row AND its `_00_list_ref` edges, with no second attempt in
2470
+ // between. On a 10m TTL that is 60 seconds of slack against an SSP
2471
+ // bootstrap that takes ~2 minutes on a large tenant, so the view lost a
2472
+ // race it could not win.
2473
+ //
2474
+ // The user-visible form is content vanishing and coming back: the client
2475
+ // is LIVE on those edges, so it sees the sweep's deletes immediately, and
2476
+ // only restores them once its heartbeat notices the row is gone and
2477
+ // re-registers. Reported as chat messages disappearing for 10+ seconds.
2478
+ //
2479
+ // At 0.5 a single failure still leaves another attempt and half the TTL.
2480
+ // The cost is one extra beat per view per TTL, which is a single
2481
+ // `UPDATE ... SET lastActiveAt`.
2482
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
2456
2483
 
2457
2484
  queryState.ttlTimer = setTimeout(() => {
2458
2485
  queryState.ttlTimer = null;
@@ -1878,13 +1878,24 @@ export class Sp00kySync<S extends SchemaStructure> {
1878
1878
  }
1879
1879
  if (ids.length === 0) return;
1880
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
- }
1881
+ // Over HTTP with `keepalive`, NOT the live socket. A WebSocket send during
1882
+ // `pagehide` is not guaranteed to flush — the browser may tear the socket
1883
+ // down first and the frame is lost. Measured on staging: releasing over the
1884
+ // socket reached the server zero times; `fetch`/`keepalive` is the only
1885
+ // primitive the platform promises to finish after the page is gone.
1886
+ //
1887
+ // Ids are inlined rather than bound because this is a bare statement, not
1888
+ // an RPC call with a params channel. They are `_00_query:<sha256>` record
1889
+ // ids the client itself derived, so the only interpolation is a hex digest.
1890
+ const list = ids
1891
+ .map((id) => String(id))
1892
+ .filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
1893
+ .join(', ');
1894
+ if (!list) return;
1895
+
1896
+ this.remote.beaconSql(
1897
+ `FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`
1898
+ );
1888
1899
  }
1889
1900
 
1890
1901
  private async registerQuery(queryHash: string) {
@@ -96,6 +96,52 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
96
96
  return this.config;
97
97
  }
98
98
 
99
+ /**
100
+ * Send one SurrealQL statement so that it survives the page going away.
101
+ *
102
+ * A WebSocket `send()` during `pagehide` is not guaranteed to flush — the
103
+ * browser may tear the socket down first, and the frame is simply lost.
104
+ * Measured: an unload-time release over the live socket reached the server
105
+ * zero times out of one. `fetch` with `keepalive` is the primitive the
106
+ * platform actually guarantees here, so this goes over SurrealDB's HTTP
107
+ * `/sql` endpoint instead of the RPC socket.
108
+ *
109
+ * Best-effort by design: no await, no retry, errors swallowed. Every caller
110
+ * must have a server-side fallback that makes a lost beacon a non-event.
111
+ */
112
+ beaconSql(sql: string): void {
113
+ try {
114
+ const { endpoint, namespace, database } = this.getConfig();
115
+ if (!endpoint || typeof fetch !== 'function') return;
116
+
117
+ // `ws(s)://host/rpc` is the socket; `http(s)://host/sql` is the same
118
+ // server's statement endpoint.
119
+ const url = new URL(endpoint);
120
+ url.protocol = url.protocol === 'wss:' ? 'https:' : url.protocol === 'ws:' ? 'http:' : url.protocol;
121
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
122
+
123
+ const headers: Record<string, string> = {
124
+ Accept: 'application/json',
125
+ 'Content-Type': 'text/plain',
126
+ };
127
+ if (namespace) headers['surreal-ns'] = namespace;
128
+ if (database) headers['surreal-db'] = database;
129
+ // Without this the statement runs unauthenticated and `$auth.id` is NONE,
130
+ // which for a per-user release means it matches nothing.
131
+ if (this.authToken) headers.Authorization = `Bearer ${this.authToken}`;
132
+
133
+ void fetch(url.toString(), {
134
+ method: 'POST',
135
+ headers,
136
+ body: sql,
137
+ keepalive: true,
138
+ credentials: 'omit',
139
+ }).catch(() => {});
140
+ } catch {
141
+ // Malformed endpoint, no fetch, blocked by CSP: the caller's fallback owns it.
142
+ }
143
+ }
144
+
99
145
  /**
100
146
  * Record the token every future connect should authenticate with, or `null`
101
147
  * on sign-out. See {@link authToken}.