@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 +14 -0
- package/dist/index.js +51 -7
- package/package.json +3 -3
- package/src/modules/data/index.ts +28 -1
- package/src/modules/sync/sync.ts +18 -7
- package/src/services/database/remote.ts +46 -0
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 *
|
|
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
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
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.
|
|
7703
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
|
|
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;
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1878,13 +1878,24 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1878
1878
|
}
|
|
1879
1879
|
if (ids.length === 0) return;
|
|
1880
1880
|
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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}.
|