@spooky-sync/core 0.0.1-canary.320 → 0.0.1-canary.321

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
@@ -2178,8 +2178,8 @@ function trimHistory(history, cap) {
2178
2178
 
2179
2179
  //#endregion
2180
2180
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.320";
2182
- const WASM_VERSION = "0.0.1-canary.320";
2181
+ const CORE_VERSION = "0.0.1-canary.321";
2182
+ const WASM_VERSION = "0.0.1-canary.321";
2183
2183
  const SURREAL_VERSION = "3.0.3";
2184
2184
  var DevToolsService = class DevToolsService {
2185
2185
  version = CORE_VERSION;
@@ -3726,6 +3726,8 @@ function phaseTimings(e) {
3726
3726
  * both sides resolve it to the shared `_00_list_ref_anon` table.
3727
3727
  */
3728
3728
  const ANON_USER_ID = "anon";
3729
+ /** The one `_00_list_ref` table every signed-out session shares. */
3730
+ const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
3729
3731
  /**
3730
3732
  * Default ref-storage mode for this client build. Mirrors the SSP's
3731
3733
  * default (`RefMode::Dedicated`) so cross-session sync works out of the
@@ -3774,7 +3776,7 @@ function bucketIdForUser(userId) {
3774
3776
  * single mode.
3775
3777
  */
3776
3778
  function listRefTableFor(mode, userId) {
3777
- if (userId === ANON_USER_ID) return "_00_list_ref_anon";
3779
+ if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
3778
3780
  if (mode === "single") return "_00_list_ref";
3779
3781
  const uid = sanitizeUserId(userId);
3780
3782
  return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
@@ -4350,15 +4352,44 @@ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
4350
4352
  /** Copy every legacy `_00_window` row into `_00_view` once. */
4351
4353
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
4352
4354
  const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
4353
- const listRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
4354
- const subqueryListRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
4355
+ const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
4356
+ const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
4355
4357
  const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
4356
- const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
4358
+ const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
4357
4359
  const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
4358
4360
  /** The single-query read: edges, meta, subquery children in one request. */
4359
4361
  const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
4360
4362
  /** The many-query read: all edges + all metas in one request. */
4361
4363
  const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
4364
+ /**
4365
+ * The session's one LIVE subscription on its `_00_list_ref` table.
4366
+ *
4367
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
4368
+ * notification so the body arrives with the doorbell.
4369
+ *
4370
+ * On the anon table it is filtered to this client's own edges. Every
4371
+ * signed-out session shares that table, so unfiltered each one received (and
4372
+ * the server fetched a body for) every anonymous edge change of every other
4373
+ * session, and the client then dropped all but its own. An edge carries the
4374
+ * `session::id()` of whichever socket last registered its view, which is a
4375
+ * socket of this client but not necessarily the current one: a view stays
4376
+ * registered across a reconnect, and a reload keeps its salt and its views,
4377
+ * so their older edges still name an earlier socket. `sessionIds` are those
4378
+ * earlier sockets. The current one is matched by `session::id()` itself,
4379
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
4380
+ * holds before the client has learned its new id.
4381
+ *
4382
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
4383
+ * and without it nothing matches. Per-user tables need no filter.
4384
+ */
4385
+ function listRefLive(table, opts) {
4386
+ const join = opts.inlineBodies ? " FETCH out" : "";
4387
+ if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
4388
+ return {
4389
+ sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
4390
+ vars: { sids: [...opts.sessionIds] }
4391
+ };
4392
+ }
4362
4393
  /** Register + read back edges/meta/children in ONE request. */
4363
4394
  const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
4364
4395
  const registerVars = (payload) => ({
@@ -6657,9 +6688,9 @@ function* landInlineRows(env, rows) {
6657
6688
  //#region src/sync/connection.saga.ts
6658
6689
  /**
6659
6690
  * Transport state changes. A drop invalidates the live query and arms a
6660
- * resubscribe; the next `connected` (outside the burst cooldown) drops every
6661
- * remote registration so `ensureRegistered` rebuilds them behind the
6662
- * `$auth.id` gate, and restarts LIVE.
6691
+ * resubscribe. The next `connected` restarts LIVE and drains the outbox;
6692
+ * outside the burst cooldown it also drops every remote registration so
6693
+ * `ensureRegistered` rebuilds them behind the `$auth.id` gate.
6663
6694
  */
6664
6695
  function* connectionChanged(env, connection) {
6665
6696
  const state = yield fx.state.read((s) => s);
@@ -6672,18 +6703,17 @@ function* connectionChanged(env, connection) {
6672
6703
  if (connection !== "connected" || !state.sync.needsResubscribe) return;
6673
6704
  const now = yield fx.now();
6674
6705
  const last = state.sync.lastReconnectRefetchAt;
6675
- if (last !== null && now - last < RECONNECT_REFETCH_COOLDOWN_MS) {
6676
- yield fx.state.update(patchSync({ needsResubscribe: false }));
6677
- return;
6706
+ if (last !== null && now - last < RECONNECT_REFETCH_COOLDOWN_MS) yield fx.state.update(patchSync({ needsResubscribe: false }));
6707
+ else {
6708
+ yield fx.state.update(compose(patchSync({
6709
+ needsResubscribe: false,
6710
+ lastReconnectRefetchAt: now
6711
+ }), ...[...state.queries.values()].filter((e) => e.lifecycle.remote !== "unregistered").map((e) => applyLifecycle(e.def.hash, { type: "remote-dropped" }))));
6712
+ yield fx.dispatch({
6713
+ type: "EnsureRegistered",
6714
+ requireAuth: true
6715
+ });
6678
6716
  }
6679
- yield fx.state.update(compose(patchSync({
6680
- needsResubscribe: false,
6681
- lastReconnectRefetchAt: now
6682
- }), ...[...state.queries.values()].filter((e) => e.lifecycle.remote !== "unregistered").map((e) => applyLifecycle(e.def.hash, { type: "remote-dropped" }))));
6683
- yield fx.dispatch({
6684
- type: "EnsureRegistered",
6685
- requireAuth: true
6686
- });
6687
6717
  yield fx.dispatch({ type: "LiveStart" });
6688
6718
  yield fx.dispatch({ type: "Drain" });
6689
6719
  }
@@ -8213,8 +8243,12 @@ function resolveReconnectConfig(input) {
8213
8243
  */
8214
8244
  const REMOTE_MAX_CONCURRENT_QUERIES = 6;
8215
8245
  /**
8216
- * Session ids a page remembers for its pagehide release. Only the sessions of
8217
- * the last ttl matter; this many reconnects inside one ttl is a broken network.
8246
+ * Session ids a page remembers for its pagehide release and its anonymous LIVE
8247
+ * filter. The release only needs the sessions of the last ttl; this many
8248
+ * reconnects inside one ttl is a broken network. The LIVE filter would like
8249
+ * every session that stamped an edge still held, but the SSP restamps an edge
8250
+ * whenever its row changes, so an id that falls off costs at most the LIVE
8251
+ * removal of a row untouched since, which the membership poll still catches.
8218
8252
  */
8219
8253
  const MAX_SESSION_IDS = 16;
8220
8254
  var RemoteDatabaseService = class extends AbstractDatabaseService {
@@ -8256,6 +8290,10 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8256
8290
  * whose own session id matches none of them, so it names these instead. Kept
8257
8291
  * for the page, not the socket: a view registered before a reconnect still
8258
8292
  * carries the old id until its next heartbeat.
8293
+ *
8294
+ * The anonymous LIVE filters on the same ids (see `listRefLive`): its edges
8295
+ * name the socket that registered their view, which may be an earlier one.
8296
+ * A reload hands its ids to the next page with the salt.
8259
8297
  */
8260
8298
  sessionIdsSeen = [];
8261
8299
  constructor(config, logger) {
@@ -8281,6 +8319,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8281
8319
  return this.sessionIdsSeen;
8282
8320
  }
8283
8321
  /**
8322
+ * Take on the session ids of the page this one reloaded from. It kept that
8323
+ * page's salt, so the views it re-registers still carry edges stamped by
8324
+ * those sockets. Older than anything this page has seen, so they go first.
8325
+ */
8326
+ adoptSessionIds(ids) {
8327
+ this.sessionIdsSeen = [...ids.filter((sid) => typeof sid === "string" && sid !== "" && !this.sessionIdsSeen.includes(sid)), ...this.sessionIdsSeen].slice(-MAX_SESSION_IDS);
8328
+ }
8329
+ /**
8284
8330
  * Ask the socket that just connected for its session id. Straight to the
8285
8331
  * SDK: it needs no namespace or identity, so it need not wait behind an
8286
8332
  * in-flight connect or take a slot from the query queue.
@@ -14707,17 +14753,29 @@ function takeReloadSalt(authId) {
14707
14753
  if (raw === null) return null;
14708
14754
  sessionStorage.removeItem(RELOAD_SALT_KEY);
14709
14755
  const left = JSON.parse(raw);
14710
- return typeof left.salt === "string" && left.salt !== "" && (left.authId ?? null) === authId ? left.salt : null;
14756
+ if (typeof left.salt !== "string" || left.salt === "" || (left.authId ?? null) !== authId) return null;
14757
+ const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid) => typeof sid === "string") : [];
14758
+ return {
14759
+ salt: left.salt,
14760
+ sessionIds
14761
+ };
14711
14762
  } catch {
14712
14763
  return null;
14713
14764
  }
14714
14765
  }
14715
- /** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
14716
- function leaveReloadSalt(salt, authId) {
14766
+ /**
14767
+ * Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}.
14768
+ *
14769
+ * The session ids of this page's sockets go with it. The views the next page
14770
+ * re-registers keep the edges this page's sockets stamped, and the anonymous
14771
+ * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
14772
+ */
14773
+ function leaveReloadSalt(salt, authId, sessionIds = []) {
14717
14774
  try {
14718
14775
  if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
14719
14776
  salt,
14720
- authId
14777
+ authId,
14778
+ sessionIds
14721
14779
  }));
14722
14780
  } catch {}
14723
14781
  }
@@ -14782,7 +14840,7 @@ function createTabsCoordinator(config, s, host) {
14782
14840
  return new TabsCoordinator({
14783
14841
  tabId: s.tabId,
14784
14842
  fingerprint: computeTabsFingerprint({
14785
- coreVersion: "0.0.1-canary.320",
14843
+ coreVersion: "0.0.1-canary.321",
14786
14844
  schemaHash: hash53(config.schemaSurql),
14787
14845
  endpoint: config.database.endpoint ?? "",
14788
14846
  namespace: config.database.namespace,
@@ -14886,8 +14944,13 @@ function createAdapters(config, s, host, lateModules) {
14886
14944
  const services = {
14887
14945
  "hint.read": readBootBucketHint,
14888
14946
  "hint.write": writeBootBucketHint,
14889
- "salt.take": takeReloadSalt,
14890
- "salt.leave": leaveReloadSalt,
14947
+ "salt.take": (authId) => {
14948
+ const left = takeReloadSalt(authId);
14949
+ if (!left) return null;
14950
+ s.remote.adoptSessionIds(left.sessionIds);
14951
+ return left.salt;
14952
+ },
14953
+ "salt.leave": (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
14891
14954
  "local.connect": (bucketId) => s.local.connect(bucketId),
14892
14955
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14893
14956
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -14983,8 +15046,11 @@ function createAdapters(config, s, host, lateModules) {
14983
15046
  remote: {
14984
15047
  queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
14985
15048
  live: async (table, onChange) => {
14986
- const join = config.liveInlineBodies ? " FETCH out" : "";
14987
- const [uuid] = await s.remote.query(`LIVE SELECT * FROM ${table}${join}`);
15049
+ const stmt = listRefLive(table, {
15050
+ inlineBodies: config.liveInlineBodies === true,
15051
+ sessionIds: s.remote.sessionIds()
15052
+ });
15053
+ const [uuid] = await s.remote.query(stmt.sql, stmt.vars);
14988
15054
  (await s.remote.getClient().liveOf(uuid)).subscribe((message) => {
14989
15055
  if (message.action === "KILLED") return;
14990
15056
  const hash = hashOfEdge(message.value);
package/dist/live.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as RefMode } from "./worker-ref-tables.js";
1
+ import { i as RefMode } from "./worker-ref-tables.js";
2
2
  import { SchemaStructure } from "@spooky-sync/query-builder";
3
3
 
4
4
  //#region src/live/index.d.ts
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as queryHashInput, C as heartbeatRowGone, M as registerVars, N as singleSnapshotSelect, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as registerSelect, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout, z as sha256Hex } from "./worker-edges.js";
1
+ import { D as registerSelect, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, O as registerVars, R as listRefTableFor, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, c as parseDuration, d as parseQueryParams, h as bodySelect, k as singleSnapshotSelect, l as parseRecordIdString, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as withTimeout, v as heartbeatRowGone, x as listRefLive } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
@@ -18,7 +18,8 @@ import { RecordId, Surreal } from "surrealdb";
18
18
  * params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
19
19
  * - bodies with `SELECT * FROM $ids`;
20
20
  * - one `LIVE SELECT * FROM <the user's _00_list_ref table> FETCH out` per
21
- * feed, so a changed row usually arrives inline with its edge; an edge whose
21
+ * feed (on the shared anon table, filtered to the feed's own edges; see
22
+ * `listRefLive`), so a changed row usually arrives inline with its edge; an edge whose
22
23
  * `out` is a bare id (a server that ignores FETCH) costs a body read, one
23
24
  * whose `out` is null (unreadable or deleted row) a membership re-read;
24
25
  * - `fn::query::heartbeat` at half the ttl, re-registering a reclaimed view;
@@ -45,6 +46,7 @@ const FLUSH_MS = 10;
45
46
  const FETCH_BATCH_MS = 15;
46
47
  const REREAD_MS = 50;
47
48
  const RELEASE_TIMEOUT_MS = 2e3;
49
+ const MAX_SOCKETS = 16;
48
50
  function deferred() {
49
51
  let resolve;
50
52
  let reject;
@@ -184,6 +186,8 @@ var Feed = class {
184
186
  liveStarting = null;
185
187
  subs = /* @__PURE__ */ new Map();
186
188
  sessionId;
189
+ /** `session::id()` of every socket this feed has had, oldest first. */
190
+ sockets = [];
187
191
  ttl;
188
192
  timeoutMs;
189
193
  isClosed = false;
@@ -273,9 +277,23 @@ var Feed = class {
273
277
  }));
274
278
  }
275
279
  this.db = db;
280
+ this.noteSession();
281
+ }
282
+ /**
283
+ * Remember the `session::id()` of the socket now open. Views are registered
284
+ * once and not again after a reconnect, so their edges keep naming the socket
285
+ * that registered them; the anonymous LIVE has to keep hearing those.
286
+ */
287
+ async noteSession() {
288
+ try {
289
+ const [sid] = await this.q("RETURN <string>session::id()");
290
+ if (typeof sid !== "string" || this.sockets.includes(sid)) return;
291
+ this.sockets = [...this.sockets, sid].slice(-MAX_SOCKETS);
292
+ } catch {}
276
293
  }
277
294
  /** After a reconnect: LIVE again, then re-read every view (events in the gap are lost). */
278
295
  async recover() {
296
+ this.noteSession();
279
297
  try {
280
298
  await this.ensureLive();
281
299
  for (const sub of this.subs.values()) if (sub.loaded) this.scheduleReread(sub);
@@ -310,12 +328,18 @@ var Feed = class {
310
328
  if (this.live || this.isClosed) return false;
311
329
  if (!this.liveStarting) this.liveStarting = (async () => {
312
330
  try {
331
+ const live = (inlineBodies) => listRefLive(this.table, {
332
+ inlineBodies,
333
+ sessionIds: this.sockets
334
+ });
313
335
  let id;
314
336
  try {
315
- [id] = await this.q(`LIVE SELECT * FROM ${this.table} FETCH out`);
337
+ const { sql, vars } = live(true);
338
+ [id] = await this.q(sql, vars);
316
339
  } catch (error) {
317
340
  this.report(error);
318
- [id] = await this.q(`LIVE SELECT * FROM ${this.table}`);
341
+ const { sql, vars } = live(false);
342
+ [id] = await this.q(sql, vars);
319
343
  }
320
344
  const stream = await this.db.liveOf(id);
321
345
  const off = stream.subscribe((message) => this.onLive(message));
package/dist/pure.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as listRefTableFor, i as bucketIdForUser, n as DEFAULT_REF_MODE, o as sanitizeUserId, r as RefMode, t as ANON_USER_ID } from "./worker-ref-tables.js";
1
+ import { a as bucketIdForUser, i as RefMode, n as ANON_USER_ID, o as listRefTableFor, r as DEFAULT_REF_MODE, s as sanitizeUserId, t as ANON_LIST_REF_TABLE } from "./worker-ref-tables.js";
2
2
  import { a as PushNotificationAction, c as PushPayloadKind, d as base64UrlToBytes, f as bytesToBase64Url, i as PushNotification, l as PushUrgency, m as sameBytes, n as BridgeTokenMessage, o as PushOp, p as isPushPayload, r as PUSH_PAYLOAD_VERSION, s as PushPayload, t as BridgeSignOutMessage, u as WebPushClientMessage } from "./worker-types.js";
3
3
  import { Duration, RecordId } from "surrealdb";
4
4
  import { ColumnSchema, RecordId as RecordId$1, SchemaStructure } from "@spooky-sync/query-builder";
@@ -201,6 +201,34 @@ declare const queryRowCountBatchSelect: () => string;
201
201
  declare const singleSnapshotSelect: (table: string) => string;
202
202
  /** The many-query read: all edges + all metas in one request. */
203
203
  declare const batchSnapshotSelect: (table: string) => string;
204
+ /**
205
+ * The session's one LIVE subscription on its `_00_list_ref` table.
206
+ *
207
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
208
+ * notification so the body arrives with the doorbell.
209
+ *
210
+ * On the anon table it is filtered to this client's own edges. Every
211
+ * signed-out session shares that table, so unfiltered each one received (and
212
+ * the server fetched a body for) every anonymous edge change of every other
213
+ * session, and the client then dropped all but its own. An edge carries the
214
+ * `session::id()` of whichever socket last registered its view, which is a
215
+ * socket of this client but not necessarily the current one: a view stays
216
+ * registered across a reconnect, and a reload keeps its salt and its views,
217
+ * so their older edges still name an earlier socket. `sessionIds` are those
218
+ * earlier sockets. The current one is matched by `session::id()` itself,
219
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
220
+ * holds before the client has learned its new id.
221
+ *
222
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
223
+ * and without it nothing matches. Per-user tables need no filter.
224
+ */
225
+ declare function listRefLive(table: string, opts: {
226
+ inlineBodies: boolean;
227
+ sessionIds: ReadonlyArray<string>;
228
+ }): {
229
+ sql: string;
230
+ vars?: Vars;
231
+ };
204
232
  interface RegisterPayload {
205
233
  id: RecordId<string>;
206
234
  surql: string;
@@ -334,4 +362,4 @@ declare function sha256Hex(input: string): Promise<string>;
334
362
  */
335
363
  declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
336
364
  //#endregion
337
- export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
365
+ export { ANON_LIST_REF_TABLE, ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefLive, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
package/dist/pure.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as readViewRows, B as queryHashInput, C as heartbeatRowGone, D as queryRowCountBatchSelect, E as listRefSelect, F as upsertBodiesTx, I as viewRecordId, L as viewRow, M as registerVars, N as singleSnapshotSelect, O as queryRowCountSelect, P as subqueryListRefSelect, R as surql, S as heartbeatBatch, T as listRefBatchSelect, V as viewKeyInput, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as registerSelect, k as readLegacyViewRows, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as leaveViewsBeacon, x as countViewRows, y as batchSnapshotSelect, z as sha256Hex } from "./worker-edges.js";
1
+ import { A as subqueryListRefSelect, B as surql, C as queryRowCountBatchSelect, D as registerSelect, E as readViewRows, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, L as bucketIdForUser, M as viewRecordId, N as viewRow, O as registerVars, P as ANON_LIST_REF_TABLE, R as listRefTableFor, S as listRefSelect, T as readLegacyViewRows, U as viewKeyInput, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, b as listRefBatchSelect, c as parseDuration, d as parseQueryParams, f as LEGACY_VIEW_TABLE, g as countViewRows, h as bodySelect, i as dedupeRecordVersions, j as upsertBodiesTx, k as singleSnapshotSelect, l as parseRecordIdString, m as batchSnapshotSelect, n as rowOfEdge, o as snapshotFromSingle, p as VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as heartbeatRowGone, w as queryRowCountSelect, x as listRefLive, y as leaveViewsBeacon, z as sanitizeUserId } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
3
3
  import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
4
4
 
@@ -21,4 +21,4 @@ function queryKey(input, sessionId) {
21
21
  }
22
22
 
23
23
  //#endregion
24
- export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
24
+ export { ANON_LIST_REF_TABLE, ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefLive, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
package/dist/types.d.ts CHANGED
@@ -668,11 +668,21 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
668
668
  * whose own session id matches none of them, so it names these instead. Kept
669
669
  * for the page, not the socket: a view registered before a reconnect still
670
670
  * carries the old id until its next heartbeat.
671
+ *
672
+ * The anonymous LIVE filters on the same ids (see `listRefLive`): its edges
673
+ * name the socket that registered their view, which may be an earlier one.
674
+ * A reload hands its ids to the next page with the salt.
671
675
  */
672
676
  private sessionIdsSeen;
673
677
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
674
678
  /** See {@link sessionIdsSeen}. */
675
679
  sessionIds(): readonly string[];
680
+ /**
681
+ * Take on the session ids of the page this one reloaded from. It kept that
682
+ * page's salt, so the views it re-registers still carry edges stamped by
683
+ * those sockets. Older than anything this page has seen, so they go first.
684
+ */
685
+ adoptSessionIds(ids: ReadonlyArray<string>): void;
676
686
  /**
677
687
  * Ask the socket that just connected for its session id. Straight to the
678
688
  * SDK: it needs no namespace or identity, so it need not wait behind an
@@ -97,6 +97,71 @@ const surql = {
97
97
  }
98
98
  };
99
99
 
100
+ //#endregion
101
+ //#region src/modules/ref-tables.ts
102
+ /**
103
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
104
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
105
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
106
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
107
+ */
108
+ const ANON_USER_ID = "anon";
109
+ /** The one `_00_list_ref` table every signed-out session shares. */
110
+ const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
111
+ /**
112
+ * Default ref-storage mode for this client build. Mirrors the SSP's
113
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
114
+ * box.
115
+ */
116
+ const DEFAULT_REF_MODE = "dedicated";
117
+ /**
118
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
119
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
120
+ * the id is missing the `user:` prefix or contains characters that
121
+ * aren't valid in a SurrealDB table identifier — the server-side
122
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
123
+ *
124
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
125
+ * objects (which only stringify cleanly via `.toString()`), since
126
+ * `AuthService` passes the record-id object as-is to its subscribers.
127
+ */
128
+ function sanitizeUserId(userId) {
129
+ if (userId === null || userId === void 0) return null;
130
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
131
+ if (!asString) return null;
132
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
133
+ if (raw.length === 0) return null;
134
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
135
+ return raw;
136
+ }
137
+ /**
138
+ * Resolve the LOCAL storage bucket id for a user. Every user gets their own
139
+ * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
140
+ * query state, and the mutation outbox never leak across accounts on a shared
141
+ * device. Signed-out sessions share the `anon` bucket.
142
+ *
143
+ * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
144
+ * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
145
+ * authenticated user in the shared bucket and recreate the cross-user leak.
146
+ */
147
+ function bucketIdForUser(userId) {
148
+ if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
149
+ const uid = sanitizeUserId(userId);
150
+ if (uid) return uid;
151
+ return `u${cyrb53(String(userId)).toString(16)}`;
152
+ }
153
+ /**
154
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
155
+ * back to the global `_00_list_ref` when sanitization fails or in
156
+ * single mode.
157
+ */
158
+ function listRefTableFor(mode, userId) {
159
+ if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
160
+ if (mode === "single") return "_00_list_ref";
161
+ const uid = sanitizeUserId(userId);
162
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
163
+ }
164
+
100
165
  //#endregion
101
166
  //#region src/query/sql.ts
102
167
  /** Every SurrealQL statement the query side sends, as pure builders. */
@@ -119,15 +184,44 @@ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
119
184
  /** Copy every legacy `_00_window` row into `_00_view` once. */
120
185
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
121
186
  const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
122
- const listRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
123
- const subqueryListRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
187
+ const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
188
+ const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
124
189
  const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
125
- const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
190
+ const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
126
191
  const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
127
192
  /** The single-query read: edges, meta, subquery children in one request. */
128
193
  const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
129
194
  /** The many-query read: all edges + all metas in one request. */
130
195
  const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
196
+ /**
197
+ * The session's one LIVE subscription on its `_00_list_ref` table.
198
+ *
199
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
200
+ * notification so the body arrives with the doorbell.
201
+ *
202
+ * On the anon table it is filtered to this client's own edges. Every
203
+ * signed-out session shares that table, so unfiltered each one received (and
204
+ * the server fetched a body for) every anonymous edge change of every other
205
+ * session, and the client then dropped all but its own. An edge carries the
206
+ * `session::id()` of whichever socket last registered its view, which is a
207
+ * socket of this client but not necessarily the current one: a view stays
208
+ * registered across a reconnect, and a reload keeps its salt and its views,
209
+ * so their older edges still name an earlier socket. `sessionIds` are those
210
+ * earlier sockets. The current one is matched by `session::id()` itself,
211
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
212
+ * holds before the client has learned its new id.
213
+ *
214
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
215
+ * and without it nothing matches. Per-user tables need no filter.
216
+ */
217
+ function listRefLive(table, opts) {
218
+ const join = opts.inlineBodies ? " FETCH out" : "";
219
+ if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
220
+ return {
221
+ sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
222
+ vars: { sids: [...opts.sessionIds] }
223
+ };
224
+ }
131
225
  /** Register + read back edges/meta/children in ONE request. */
132
226
  const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
133
227
  const registerVars = (payload) => ({
@@ -178,69 +272,6 @@ function upsertBodiesTx(records) {
178
272
  };
179
273
  }
180
274
 
181
- //#endregion
182
- //#region src/modules/ref-tables.ts
183
- /**
184
- * Sentinel user id for unauthenticated clients when anonymous live queries are
185
- * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
186
- * so it can never collide with a real user id (those arrive as `user:<id>`);
187
- * both sides resolve it to the shared `_00_list_ref_anon` table.
188
- */
189
- const ANON_USER_ID = "anon";
190
- /**
191
- * Default ref-storage mode for this client build. Mirrors the SSP's
192
- * default (`RefMode::Dedicated`) so cross-session sync works out of the
193
- * box.
194
- */
195
- const DEFAULT_REF_MODE = "dedicated";
196
- /**
197
- * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
198
- * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
199
- * the id is missing the `user:` prefix or contains characters that
200
- * aren't valid in a SurrealDB table identifier — the server-side
201
- * `ssp_protocol::sanitize_user_id` uses the same predicate.
202
- *
203
- * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
204
- * objects (which only stringify cleanly via `.toString()`), since
205
- * `AuthService` passes the record-id object as-is to its subscribers.
206
- */
207
- function sanitizeUserId(userId) {
208
- if (userId === null || userId === void 0) return null;
209
- const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
210
- if (!asString) return null;
211
- const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
212
- if (raw.length === 0) return null;
213
- if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
214
- return raw;
215
- }
216
- /**
217
- * Resolve the LOCAL storage bucket id for a user. Every user gets their own
218
- * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
219
- * query state, and the mutation outbox never leak across accounts on a shared
220
- * device. Signed-out sessions share the `anon` bucket.
221
- *
222
- * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
223
- * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
224
- * authenticated user in the shared bucket and recreate the cross-user leak.
225
- */
226
- function bucketIdForUser(userId) {
227
- if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
228
- const uid = sanitizeUserId(userId);
229
- if (uid) return uid;
230
- return `u${cyrb53(String(userId)).toString(16)}`;
231
- }
232
- /**
233
- * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
234
- * back to the global `_00_list_ref` when sanitization fails or in
235
- * single mode.
236
- */
237
- function listRefTableFor(mode, userId) {
238
- if (userId === ANON_USER_ID) return "_00_list_ref_anon";
239
- if (mode === "single") return "_00_list_ref";
240
- const uid = sanitizeUserId(userId);
241
- return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
242
- }
243
-
244
275
  //#endregion
245
276
  //#region src/utils/parser.ts
246
277
  /**
@@ -436,4 +467,4 @@ function rowOfEdge(value) {
436
467
  }
437
468
 
438
469
  //#endregion
439
- export { readViewRows as A, queryHashInput as B, heartbeatRowGone as C, queryRowCountBatchSelect as D, listRefSelect as E, upsertBodiesTx as F, viewRecordId as I, viewRow as L, registerVars as M, singleSnapshotSelect as N, queryRowCountSelect as O, subqueryListRefSelect as P, surql as R, heartbeatBatch as S, listRefBatchSelect as T, viewKeyInput as V, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, registerSelect as j, readLegacyViewRows as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, leaveViewsBeacon as w, countViewRows as x, batchSnapshotSelect as y, sha256Hex as z };
470
+ export { subqueryListRefSelect as A, surql as B, queryRowCountBatchSelect as C, registerSelect as D, readViewRows as E, ANON_USER_ID as F, queryHashInput as H, DEFAULT_REF_MODE as I, bucketIdForUser as L, viewRecordId as M, viewRow as N, registerVars as O, ANON_LIST_REF_TABLE as P, listRefTableFor as R, listRefSelect as S, readLegacyViewRows as T, viewKeyInput as U, sha256Hex as V, heartbeatBatch as _, metaFromRow as a, listRefBatchSelect as b, parseDuration as c, parseQueryParams as d, LEGACY_VIEW_TABLE as f, countViewRows as g, bodySelect as h, dedupeRecordVersions as i, upsertBodiesTx as j, singleSnapshotSelect as k, parseRecordIdString as l, batchSnapshotSelect as m, rowOfEdge as n, snapshotFromSingle as o, VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, heartbeatRowGone as v, queryRowCountSelect as w, listRefLive as x, leaveViewsBeacon as y, sanitizeUserId as z };
@@ -7,6 +7,8 @@ type RefMode = 'single' | 'dedicated';
7
7
  * both sides resolve it to the shared `_00_list_ref_anon` table.
8
8
  */
9
9
  declare const ANON_USER_ID = "anon";
10
+ /** The one `_00_list_ref` table every signed-out session shares. */
11
+ declare const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
10
12
  /**
11
13
  * Default ref-storage mode for this client build. Mirrors the SSP's
12
14
  * default (`RefMode::Dedicated`) so cross-session sync works out of the
@@ -43,4 +45,4 @@ declare function bucketIdForUser(userId: unknown): string;
43
45
  */
44
46
  declare function listRefTableFor(mode: RefMode, userId: unknown): string;
45
47
  //#endregion
46
- export { listRefTableFor as a, bucketIdForUser as i, DEFAULT_REF_MODE as n, sanitizeUserId as o, RefMode as r, ANON_USER_ID as t };
48
+ export { bucketIdForUser as a, RefMode as i, ANON_USER_ID as n, listRefTableFor as o, DEFAULT_REF_MODE as r, sanitizeUserId as s, ANON_LIST_REF_TABLE as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.320",
3
+ "version": "0.0.1-canary.321",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.320",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.320",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.321",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.321",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",