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

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/AGENTS.md CHANGED
@@ -42,7 +42,7 @@ re-materialized from state, never patched in place.
42
42
  - Types: `Sp00kyConfig` (client-solid wraps it as `SyncedDbConfig`), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`, `PreloadOptions`.
43
43
  - Subpath: `@spooky-sync/core/otel` — `createOtelTransmit(endpoint)` for piping pino logs to OpenTelemetry.
44
44
  - `client.webPush` (`WebPushModule`, `db.webPush` in the Solid bindings) - Web Push on the page: `support()`, `info()`, `subscribe(opts)` (call from a click), `unsubscribe(opts)`, `sync()`, `isSubscribed()`, `update(opts)`, `devices()`, `notify(msg)`, `cancel(id)`, `test()`, `bridge()`, `onMessage(cb)`. Server calls are `fn::push::*`; typed `WebPushError`; config `webPush` in `types.ts`. Sign-out runs through `AuthService.onBeforeSignOut`.
45
- - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
45
+ - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK; a feed that shares views with another through the same `sessionId` must also pass the same `ownerSecret`, or a signed-out feed's second registration is refused 409 `owner_mismatch`), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
46
46
 
47
47
  ## Common gotchas
48
48
 
package/dist/index.d.ts CHANGED
@@ -1123,7 +1123,7 @@ type Effect = {
1123
1123
  kind: 'now';
1124
1124
  } | {
1125
1125
  kind: 'id';
1126
- scope: 'mutation' | 'salt';
1126
+ scope: IdScope;
1127
1127
  } | {
1128
1128
  kind: 'hash';
1129
1129
  input: string;
@@ -1141,6 +1141,8 @@ type Effect = {
1141
1141
  name: ServiceName;
1142
1142
  args: unknown[];
1143
1143
  };
1144
+ /** `mutation`: an outbox row id. `salt`: a query-key salt. `owner`: a page's anonymous-view owner secret. */
1145
+ type IdScope = 'mutation' | 'salt' | 'owner';
1144
1146
  /**
1145
1147
  * Calls into the legacy services that are adapters, not state: auth, tabs,
1146
1148
  * blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
@@ -1149,8 +1151,12 @@ type Effect = {
1149
1151
  interface ServiceCalls {
1150
1152
  'hint.read': () => string | null;
1151
1153
  'hint.write': (bucketId: string) => void;
1152
- 'salt.take': (authId: string | null) => string | null;
1153
- 'salt.leave': (salt: string, authId: string | null) => void;
1154
+ /** The salt (and owner secret) this tab's previous page left, if minted for `authId`. */
1155
+ 'salt.take': (authId: string | null) => {
1156
+ salt: string;
1157
+ owner: string | null;
1158
+ } | null;
1159
+ 'salt.leave': (salt: string, authId: string | null, owner: string | null) => void;
1154
1160
  'local.connect': (bucketId: string) => Promise<void>;
1155
1161
  'local.switchStore': (bucketId: string) => Promise<void>;
1156
1162
  'local.beginSwitch': () => () => void;
@@ -1309,6 +1315,13 @@ interface ClientState {
1309
1315
  readonly userId: string | null;
1310
1316
  /** The principal the current session salt was minted for. */
1311
1317
  readonly saltUserId: string | null;
1318
+ /**
1319
+ * This page's secret, sent with every registration (`$config.owner`). An
1320
+ * anonymous view answers only to the secret it was registered with, so it
1321
+ * lives as long as the page's views: a reconnect keeps it, and a reload
1322
+ * that keeps the salt keeps it too.
1323
+ */
1324
+ readonly ownerSecret: string | null;
1312
1325
  /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
1313
1326
  readonly pendingBucket: string | null;
1314
1327
  readonly tabId: string;
@@ -1428,10 +1441,7 @@ interface Adapters {
1428
1441
  clock: {
1429
1442
  now(): number;
1430
1443
  };
1431
- ids: {
1432
- mutation(): string;
1433
- salt(): string;
1434
- };
1444
+ ids: Record<IdScope, () => string>;
1435
1445
  hash(input: string): Promise<string>;
1436
1446
  services: ServiceCalls;
1437
1447
  }
package/dist/index.js CHANGED
@@ -569,6 +569,16 @@ function generateId() {
569
569
  return Uuid.v4().toString().replace(/-/g, "");
570
570
  }
571
571
  /**
572
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
573
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
574
+ * than `randomUUID`, which a page served over plain http does not have.
575
+ */
576
+ function mintOwnerSecret() {
577
+ const bytes = new Uint8Array(32);
578
+ globalThis.crypto.getRandomValues(bytes);
579
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
580
+ }
581
+ /**
572
582
  * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
573
583
  * releases expose it under `milliseconds` (current) or the older private
574
584
  * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
@@ -2178,8 +2188,8 @@ function trimHistory(history, cap) {
2178
2188
 
2179
2189
  //#endregion
2180
2190
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.320";
2182
- const WASM_VERSION = "0.0.1-canary.320";
2191
+ const CORE_VERSION = "0.0.1-canary.322";
2192
+ const WASM_VERSION = "0.0.1-canary.322";
2183
2193
  const SURREAL_VERSION = "3.0.3";
2184
2194
  var DevToolsService = class DevToolsService {
2185
2195
  version = CORE_VERSION;
@@ -3726,6 +3736,8 @@ function phaseTimings(e) {
3726
3736
  * both sides resolve it to the shared `_00_list_ref_anon` table.
3727
3737
  */
3728
3738
  const ANON_USER_ID = "anon";
3739
+ /** The one `_00_list_ref` table every signed-out session shares. */
3740
+ const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
3729
3741
  /**
3730
3742
  * Default ref-storage mode for this client build. Mirrors the SSP's
3731
3743
  * default (`RefMode::Dedicated`) so cross-session sync works out of the
@@ -3774,7 +3786,7 @@ function bucketIdForUser(userId) {
3774
3786
  * single mode.
3775
3787
  */
3776
3788
  function listRefTableFor(mode, userId) {
3777
- if (userId === ANON_USER_ID) return "_00_list_ref_anon";
3789
+ if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
3778
3790
  if (mode === "single") return "_00_list_ref";
3779
3791
  const uid = sanitizeUserId(userId);
3780
3792
  return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
@@ -3987,6 +3999,7 @@ function emptyState(init) {
3987
3999
  sessionId: null,
3988
4000
  userId: null,
3989
4001
  saltUserId: null,
4002
+ ownerSecret: null,
3990
4003
  pendingBucket: null,
3991
4004
  tabId: init.tabId,
3992
4005
  tabRole: "solo",
@@ -4350,15 +4363,44 @@ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
4350
4363
  /** Copy every legacy `_00_window` row into `_00_view` once. */
4351
4364
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
4352
4365
  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`;
4366
+ const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
4367
+ const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
4355
4368
  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`;
4369
+ const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
4357
4370
  const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
4358
4371
  /** The single-query read: edges, meta, subquery children in one request. */
4359
4372
  const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
4360
4373
  /** The many-query read: all edges + all metas in one request. */
4361
4374
  const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
4375
+ /**
4376
+ * The session's one LIVE subscription on its `_00_list_ref` table.
4377
+ *
4378
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
4379
+ * notification so the body arrives with the doorbell.
4380
+ *
4381
+ * On the anon table it is filtered to this client's own edges. Every
4382
+ * signed-out session shares that table, so unfiltered each one received (and
4383
+ * the server fetched a body for) every anonymous edge change of every other
4384
+ * session, and the client then dropped all but its own. An edge carries the
4385
+ * `session::id()` of whichever socket last registered its view, which is a
4386
+ * socket of this client but not necessarily the current one: a view stays
4387
+ * registered across a reconnect, and a reload keeps its salt and its views,
4388
+ * so their older edges still name an earlier socket. `sessionIds` are those
4389
+ * earlier sockets. The current one is matched by `session::id()` itself,
4390
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
4391
+ * holds before the client has learned its new id.
4392
+ *
4393
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
4394
+ * and without it nothing matches. Per-user tables need no filter.
4395
+ */
4396
+ function listRefLive(table, opts) {
4397
+ const join = opts.inlineBodies ? " FETCH out" : "";
4398
+ if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
4399
+ return {
4400
+ sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
4401
+ vars: { sids: [...opts.sessionIds] }
4402
+ };
4403
+ }
4362
4404
  /** Register + read back edges/meta/children in ONE request. */
4363
4405
  const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
4364
4406
  const registerVars = (payload) => ({
@@ -4794,7 +4836,8 @@ function* registerRemote(env, hash, retry = false) {
4794
4836
  id: entry.def.id,
4795
4837
  surql: entry.def.surql,
4796
4838
  params: { ...entry.def.params },
4797
- ttl: entry.def.ttl
4839
+ ttl: entry.def.ttl,
4840
+ ...state.ownerSecret ? { owner: state.ownerSecret } : {}
4798
4841
  }), env.remoteTimeoutMs);
4799
4842
  if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4800
4843
  const register = stmt(results, 0);
@@ -5842,11 +5885,14 @@ function* boot(env, opts) {
5842
5885
  yield fx.dispatch({ type: "PrimeCircuit" });
5843
5886
  const restoredUserId = yield fx.service("auth.restoreSession");
5844
5887
  const authId = yield fx.service("auth.sessionAuthId");
5845
- const salt = (yield fx.service("salt.take", authId)) ?? (yield fx.id("salt"));
5888
+ const left = yield fx.service("salt.take", authId);
5889
+ const salt = left?.salt ?? (yield fx.id("salt"));
5890
+ const ownerSecret = left?.owner ?? (yield fx.id("owner"));
5846
5891
  yield fx.state.update(setIdentity({
5847
5892
  sessionId: salt,
5848
5893
  userId: restoredUserId,
5849
- saltUserId: authId
5894
+ saltUserId: authId,
5895
+ ownerSecret
5850
5896
  }));
5851
5897
  yield fx.service("crdt.setSessionId", salt);
5852
5898
  if (restoredUserId) yield fx.service("ssp.setSessionAuth", authId, yield fx.service("auth.access"));
@@ -5941,12 +5987,13 @@ function* versionsPrimed(entries) {
5941
5987
  * grace finds its views still warm.
5942
5988
  */
5943
5989
  function* pageHide() {
5944
- const [salt, saltUserId, ids] = yield fx.state.read((s) => [
5990
+ const [salt, saltUserId, ownerSecret, ids] = yield fx.state.read((s) => [
5945
5991
  s.sessionId,
5946
5992
  s.saltUserId,
5993
+ s.ownerSecret,
5947
5994
  [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id)
5948
5995
  ]);
5949
- if (salt) yield fx.service("salt.leave", salt, saltUserId);
5996
+ if (salt) yield fx.service("salt.leave", salt, saltUserId, ownerSecret);
5950
5997
  if (ids.length > 0) yield fx.service("remote.releaseViews", ids);
5951
5998
  }
5952
5999
  function* warmBlobs() {
@@ -6053,7 +6100,7 @@ function createInterpreter(adapters, host) {
6053
6100
  }
6054
6101
  case "state.wait": return host.waitFor(effect.until, ctx?.signal);
6055
6102
  case "now": return adapters.clock.now();
6056
- case "id": return effect.scope === "mutation" ? adapters.ids.mutation() : adapters.ids.salt();
6103
+ case "id": return adapters.ids[effect.scope]();
6057
6104
  case "hash": return adapters.hash(effect.input);
6058
6105
  case "emit": return host.emit(effect.event);
6059
6106
  case "dispatch": return host.dispatch(effect.event);
@@ -6657,9 +6704,9 @@ function* landInlineRows(env, rows) {
6657
6704
  //#region src/sync/connection.saga.ts
6658
6705
  /**
6659
6706
  * 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.
6707
+ * resubscribe. The next `connected` restarts LIVE and drains the outbox;
6708
+ * outside the burst cooldown it also drops every remote registration so
6709
+ * `ensureRegistered` rebuilds them behind the `$auth.id` gate.
6663
6710
  */
6664
6711
  function* connectionChanged(env, connection) {
6665
6712
  const state = yield fx.state.read((s) => s);
@@ -6672,18 +6719,17 @@ function* connectionChanged(env, connection) {
6672
6719
  if (connection !== "connected" || !state.sync.needsResubscribe) return;
6673
6720
  const now = yield fx.now();
6674
6721
  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;
6722
+ if (last !== null && now - last < RECONNECT_REFETCH_COOLDOWN_MS) yield fx.state.update(patchSync({ needsResubscribe: false }));
6723
+ else {
6724
+ yield fx.state.update(compose(patchSync({
6725
+ needsResubscribe: false,
6726
+ lastReconnectRefetchAt: now
6727
+ }), ...[...state.queries.values()].filter((e) => e.lifecycle.remote !== "unregistered").map((e) => applyLifecycle(e.def.hash, { type: "remote-dropped" }))));
6728
+ yield fx.dispatch({
6729
+ type: "EnsureRegistered",
6730
+ requireAuth: true
6731
+ });
6678
6732
  }
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
6733
  yield fx.dispatch({ type: "LiveStart" });
6688
6734
  yield fx.dispatch({ type: "Drain" });
6689
6735
  }
@@ -8213,8 +8259,12 @@ function resolveReconnectConfig(input) {
8213
8259
  */
8214
8260
  const REMOTE_MAX_CONCURRENT_QUERIES = 6;
8215
8261
  /**
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.
8262
+ * Session ids a page remembers for its pagehide release and its anonymous LIVE
8263
+ * filter. The release only needs the sessions of the last ttl; this many
8264
+ * reconnects inside one ttl is a broken network. The LIVE filter would like
8265
+ * every session that stamped an edge still held, but the SSP restamps an edge
8266
+ * whenever its row changes, so an id that falls off costs at most the LIVE
8267
+ * removal of a row untouched since, which the membership poll still catches.
8218
8268
  */
8219
8269
  const MAX_SESSION_IDS = 16;
8220
8270
  var RemoteDatabaseService = class extends AbstractDatabaseService {
@@ -8256,6 +8306,10 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8256
8306
  * whose own session id matches none of them, so it names these instead. Kept
8257
8307
  * for the page, not the socket: a view registered before a reconnect still
8258
8308
  * carries the old id until its next heartbeat.
8309
+ *
8310
+ * The anonymous LIVE filters on the same ids (see `listRefLive`): its edges
8311
+ * name the socket that registered their view, which may be an earlier one.
8312
+ * A reload hands its ids to the next page with the salt.
8259
8313
  */
8260
8314
  sessionIdsSeen = [];
8261
8315
  constructor(config, logger) {
@@ -8281,6 +8335,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8281
8335
  return this.sessionIdsSeen;
8282
8336
  }
8283
8337
  /**
8338
+ * Take on the session ids of the page this one reloaded from. It kept that
8339
+ * page's salt, so the views it re-registers still carry edges stamped by
8340
+ * those sockets. Older than anything this page has seen, so they go first.
8341
+ */
8342
+ adoptSessionIds(ids) {
8343
+ this.sessionIdsSeen = [...ids.filter((sid) => typeof sid === "string" && sid !== "" && !this.sessionIdsSeen.includes(sid)), ...this.sessionIdsSeen].slice(-MAX_SESSION_IDS);
8344
+ }
8345
+ /**
8284
8346
  * Ask the socket that just connected for its session id. Straight to the
8285
8347
  * SDK: it needs no namespace or identity, so it need not wait behind an
8286
8348
  * in-flight connect or take a slot from the query queue.
@@ -14696,6 +14758,11 @@ const RELOAD_SALT_KEY = "sp00ky:salt";
14696
14758
  * query onto the `_00_query` row it already had, which the SSP answers from
14697
14759
  * the live view instead of building a new one.
14698
14760
  *
14761
+ * The page's owner secret travels with it: an anonymous row answers only to
14762
+ * the secret it was registered with, so the next page needs the same one to
14763
+ * rejoin those views. `null` from a page that predates it; the next page then
14764
+ * mints one, and the rows it left (which hold no secret) take the new one.
14765
+ *
14699
14766
  * Taken, not read: the key is absent while a page lives, so a duplicated tab
14700
14767
  * (which copies sessionStorage) mints its own salt rather than sharing this
14701
14768
  * tab's remote views.
@@ -14707,17 +14774,33 @@ function takeReloadSalt(authId) {
14707
14774
  if (raw === null) return null;
14708
14775
  sessionStorage.removeItem(RELOAD_SALT_KEY);
14709
14776
  const left = JSON.parse(raw);
14710
- return typeof left.salt === "string" && left.salt !== "" && (left.authId ?? null) === authId ? left.salt : null;
14777
+ if (typeof left.salt !== "string" || left.salt === "" || (left.authId ?? null) !== authId) return null;
14778
+ const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid) => typeof sid === "string") : [];
14779
+ const owner = typeof left.owner === "string" && left.owner !== "" ? left.owner : null;
14780
+ return {
14781
+ salt: left.salt,
14782
+ sessionIds,
14783
+ owner
14784
+ };
14711
14785
  } catch {
14712
14786
  return null;
14713
14787
  }
14714
14788
  }
14715
- /** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
14716
- function leaveReloadSalt(salt, authId) {
14789
+ /**
14790
+ * Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}.
14791
+ *
14792
+ * The session ids of this page's sockets go with it. The views the next page
14793
+ * re-registers keep the edges this page's sockets stamped, and the anonymous
14794
+ * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
14795
+ * So does the page's owner secret, which those views answer to.
14796
+ */
14797
+ function leaveReloadSalt(salt, authId, sessionIds = [], owner = null) {
14717
14798
  try {
14718
14799
  if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
14719
14800
  salt,
14720
- authId
14801
+ authId,
14802
+ sessionIds,
14803
+ owner
14721
14804
  }));
14722
14805
  } catch {}
14723
14806
  }
@@ -14782,7 +14865,7 @@ function createTabsCoordinator(config, s, host) {
14782
14865
  return new TabsCoordinator({
14783
14866
  tabId: s.tabId,
14784
14867
  fingerprint: computeTabsFingerprint({
14785
- coreVersion: "0.0.1-canary.320",
14868
+ coreVersion: "0.0.1-canary.322",
14786
14869
  schemaHash: hash53(config.schemaSurql),
14787
14870
  endpoint: config.database.endpoint ?? "",
14788
14871
  namespace: config.database.namespace,
@@ -14886,8 +14969,16 @@ function createAdapters(config, s, host, lateModules) {
14886
14969
  const services = {
14887
14970
  "hint.read": readBootBucketHint,
14888
14971
  "hint.write": writeBootBucketHint,
14889
- "salt.take": takeReloadSalt,
14890
- "salt.leave": leaveReloadSalt,
14972
+ "salt.take": (authId) => {
14973
+ const left = takeReloadSalt(authId);
14974
+ if (!left) return null;
14975
+ s.remote.adoptSessionIds(left.sessionIds);
14976
+ return {
14977
+ salt: left.salt,
14978
+ owner: left.owner
14979
+ };
14980
+ },
14981
+ "salt.leave": (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
14891
14982
  "local.connect": (bucketId) => s.local.connect(bucketId),
14892
14983
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14893
14984
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -14983,8 +15074,11 @@ function createAdapters(config, s, host, lateModules) {
14983
15074
  remote: {
14984
15075
  queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
14985
15076
  live: async (table, onChange) => {
14986
- const join = config.liveInlineBodies ? " FETCH out" : "";
14987
- const [uuid] = await s.remote.query(`LIVE SELECT * FROM ${table}${join}`);
15077
+ const stmt = listRefLive(table, {
15078
+ inlineBodies: config.liveInlineBodies === true,
15079
+ sessionIds: s.remote.sessionIds()
15080
+ });
15081
+ const [uuid] = await s.remote.query(stmt.sql, stmt.vars);
14988
15082
  (await s.remote.getClient().liveOf(uuid)).subscribe((message) => {
14989
15083
  if (message.action === "KILLED") return;
14990
15084
  const hash = hashOfEdge(message.value);
@@ -15021,7 +15115,8 @@ function createAdapters(config, s, host, lateModules) {
15021
15115
  clock: { now: () => Date.now() },
15022
15116
  ids: {
15023
15117
  mutation: () => mintMutationId(s.tabId),
15024
- salt: mintSalt
15118
+ salt: mintSalt,
15119
+ owner: mintOwnerSecret
15025
15120
  },
15026
15121
  hash: sha256Hex,
15027
15122
  services
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
@@ -43,6 +43,14 @@ interface LiveFeedOptions {
43
43
  ttl?: string;
44
44
  /** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
45
45
  sessionId?: string;
46
+ /**
47
+ * The secret the feed's registrations prove they own their views with
48
+ * (`$config.owner`): a signed-out feed's view answers only to the secret it
49
+ * was registered with. Default: a fresh one per feed. Pass the same one
50
+ * wherever you pass the same `sessionId`, or the second registrant of a
51
+ * shared anonymous view is refused.
52
+ */
53
+ ownerSecret?: string;
46
54
  /**
47
55
  * The generated schema. Used to type query params (record ids, datetimes)
48
56
  * exactly like the full client does before registering. Taken from a query
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 { A as singleSnapshotSelect, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, O as registerSelect, S as listRefLive, U as queryHashInput, a as metaFromRow, c as mintOwnerSecret, d as withTimeout, f as parseQueryParams, g as bodySelect, k as registerVars, l as parseDuration, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, y as heartbeatRowGone, z as listRefTableFor } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
@@ -15,10 +15,12 @@ import { RecordId, Surreal } from "surrealdb";
15
15
  * - `fn::query::register($config)` plus the membership read-back in one
16
16
  * request (`registerSelect` / `registerVars`, the same builders the full
17
17
  * client's `registerRemote` uses; `$config = { id: _00_query:<key>, surql,
18
- * params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
18
+ * params, ttl, owner }`, key = sha256(JSON.stringify({ surql, params,
19
+ * sessionId })), owner = the feed's `ownerSecret`);
19
20
  * - bodies with `SELECT * FROM $ids`;
20
21
  * - 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
22
+ * feed (on the shared anon table, filtered to the feed's own edges; see
23
+ * `listRefLive`), so a changed row usually arrives inline with its edge; an edge whose
22
24
  * `out` is a bare id (a server that ignores FETCH) costs a body read, one
23
25
  * whose `out` is null (unreadable or deleted row) a membership re-read;
24
26
  * - `fn::query::heartbeat` at half the ttl, re-registering a reclaimed view;
@@ -45,6 +47,7 @@ const FLUSH_MS = 10;
45
47
  const FETCH_BATCH_MS = 15;
46
48
  const REREAD_MS = 50;
47
49
  const RELEASE_TIMEOUT_MS = 2e3;
50
+ const MAX_SOCKETS = 16;
48
51
  function deferred() {
49
52
  let resolve;
50
53
  let reject;
@@ -184,6 +187,9 @@ var Feed = class {
184
187
  liveStarting = null;
185
188
  subs = /* @__PURE__ */ new Map();
186
189
  sessionId;
190
+ ownerSecret;
191
+ /** `session::id()` of every socket this feed has had, oldest first. */
192
+ sockets = [];
187
193
  ttl;
188
194
  timeoutMs;
189
195
  isClosed = false;
@@ -198,6 +204,7 @@ var Feed = class {
198
204
  constructor(options) {
199
205
  this.options = options;
200
206
  this.sessionId = options.sessionId ?? randomId();
207
+ this.ownerSecret = options.ownerSecret ?? mintOwnerSecret();
201
208
  this.ttl = options.ttl ?? "10m";
202
209
  this.timeoutMs = options.timeoutMs ?? 15e3;
203
210
  }
@@ -273,9 +280,23 @@ var Feed = class {
273
280
  }));
274
281
  }
275
282
  this.db = db;
283
+ this.noteSession();
284
+ }
285
+ /**
286
+ * Remember the `session::id()` of the socket now open. Views are registered
287
+ * once and not again after a reconnect, so their edges keep naming the socket
288
+ * that registered them; the anonymous LIVE has to keep hearing those.
289
+ */
290
+ async noteSession() {
291
+ try {
292
+ const [sid] = await this.q("RETURN <string>session::id()");
293
+ if (typeof sid !== "string" || this.sockets.includes(sid)) return;
294
+ this.sockets = [...this.sockets, sid].slice(-MAX_SOCKETS);
295
+ } catch {}
276
296
  }
277
297
  /** After a reconnect: LIVE again, then re-read every view (events in the gap are lost). */
278
298
  async recover() {
299
+ this.noteSession();
279
300
  try {
280
301
  await this.ensureLive();
281
302
  for (const sub of this.subs.values()) if (sub.loaded) this.scheduleReread(sub);
@@ -310,12 +331,18 @@ var Feed = class {
310
331
  if (this.live || this.isClosed) return false;
311
332
  if (!this.liveStarting) this.liveStarting = (async () => {
312
333
  try {
334
+ const live = (inlineBodies) => listRefLive(this.table, {
335
+ inlineBodies,
336
+ sessionIds: this.sockets
337
+ });
313
338
  let id;
314
339
  try {
315
- [id] = await this.q(`LIVE SELECT * FROM ${this.table} FETCH out`);
340
+ const { sql, vars } = live(true);
341
+ [id] = await this.q(sql, vars);
316
342
  } catch (error) {
317
343
  this.report(error);
318
- [id] = await this.q(`LIVE SELECT * FROM ${this.table}`);
344
+ const { sql, vars } = live(false);
345
+ [id] = await this.q(sql, vars);
319
346
  }
320
347
  const stream = await this.db.liveOf(id);
321
348
  const off = stream.subscribe((message) => this.onLive(message));
@@ -515,7 +542,10 @@ var Feed = class {
515
542
  }
516
543
  /** Register (or re-register) a view and read its membership back, in one request. */
517
544
  async register(sub) {
518
- let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars(sub.payload)), 1);
545
+ let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars({
546
+ ...sub.payload,
547
+ owner: this.ownerSecret
548
+ })), 1);
519
549
  for (let i = 0; snap && snap.state === "materializing" && i < MATERIALIZE_RETRIES; i++) {
520
550
  await sleep(MATERIALIZE_RETRY_MS);
521
551
  snap = await this.readSnapshot(sub);
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,11 +201,47 @@ 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;
207
235
  params: Record<string, unknown>;
208
236
  ttl: string;
237
+ /**
238
+ * The registering page's secret. `fn::query::register` forwards only its
239
+ * hash, and the SSP lets a registration onto an existing anonymous view
240
+ * only with the hash that view was registered with: every signed-out
241
+ * visitor is `anon`, and anyone can read an anonymous view's id. Kept out
242
+ * of `params`, which name the computation the SSP shares between views.
243
+ */
244
+ owner?: string;
209
245
  }
210
246
  /** Register + read back edges/meta/children in ONE request. */
211
247
  declare const registerSelect: (table: string) => string;
@@ -334,4 +370,4 @@ declare function sha256Hex(input: string): Promise<string>;
334
370
  */
335
371
  declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
336
372
  //#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 };
373
+ 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 singleSnapshotSelect, B as sanitizeUserId, C as listRefSelect, D as readViewRows, E as readLegacyViewRows, F as ANON_LIST_REF_TABLE, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, M as upsertBodiesTx, N as viewRecordId, O as registerSelect, P as viewRow, R as bucketIdForUser, S as listRefLive, T as queryRowCountSelect, U as queryHashInput, V as surql, W as viewKeyInput, _ as countViewRows, a as metaFromRow, b as leaveViewsBeacon, f as parseQueryParams, g as bodySelect, h as batchSnapshotSelect, i as dedupeRecordVersions, j as subqueryListRefSelect, k as registerVars, l as parseDuration, m as VIEW_TABLE, n as rowOfEdge, o as snapshotFromSingle, p as LEGACY_VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, w as queryRowCountBatchSelect, x as listRefBatchSelect, y as heartbeatRowGone, z as listRefTableFor } 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