@spooky-sync/core 0.0.1-canary.350 → 0.0.1-canary.352

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -919,6 +919,9 @@ type RuntimeEvent = {
919
919
  type: 'RegisterRemote';
920
920
  hash: QueryHash;
921
921
  retry?: boolean;
922
+ } | {
923
+ type: 'ReleaseSuperseded';
924
+ ids: RecordId$1<string>[];
922
925
  } | {
923
926
  type: 'SyncOutcome';
924
927
  ok: boolean;
@@ -1252,6 +1255,8 @@ interface ServiceCalls {
1252
1255
  /** The platform reports no network at all (a browser's `navigator.onLine === false`). */
1253
1256
  'net.offline': () => boolean;
1254
1257
  'remote.releaseViews': (ids: unknown[]) => void;
1258
+ /** `session::id()` of every socket this page has had, oldest first. */
1259
+ 'remote.sessionIds': () => readonly string[];
1255
1260
  'supervisor.start': () => void;
1256
1261
  'tabs.start': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
1257
1262
  'tabs.moveToBucket': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
package/dist/index.js CHANGED
@@ -2126,7 +2126,7 @@ var FlagsAdminService = class {
2126
2126
  }
2127
2127
  /**
2128
2128
  * Add or remove a user from `$key`'s allowlist for `$variant`, then
2129
- * re-materialize. Defaults to the signed-in user, so the common case
2129
+ * re-evaluate that one user (nobody else's variant can change). Defaults to the signed-in user, so the common case
2130
2130
  * ("turn this on for me, for real") needs no user picker.
2131
2131
  */
2132
2132
  async setFlagUserVariant(key, variant, remove, userId) {
@@ -2286,8 +2286,8 @@ function trimHistory(history, cap) {
2286
2286
 
2287
2287
  //#endregion
2288
2288
  //#region src/modules/devtools/index.ts
2289
- const CORE_VERSION = "0.0.1-canary.350";
2290
- const WASM_VERSION = "0.0.1-canary.350";
2289
+ const CORE_VERSION = "0.0.1-canary.352";
2290
+ const WASM_VERSION = "0.0.1-canary.352";
2291
2291
  const SURREAL_VERSION = "3.1.0";
2292
2292
  var DevToolsService = class DevToolsService {
2293
2293
  version = CORE_VERSION;
@@ -4480,6 +4480,24 @@ function heartbeatBatch(ids) {
4480
4480
  vars
4481
4481
  };
4482
4482
  }
4483
+ /**
4484
+ * Hand back view ids this page no longer answers to. Per id,
4485
+ * `fn::query::unsubscribe` drops this socket's entry and deletes the row when
4486
+ * nobody else watches it; `fn::query::leave` then drops the entries of this
4487
+ * page's earlier sockets (a reconnect re-registered under a new session id)
4488
+ * and pulls the expiry in when none remain. Result index 2i answers the
4489
+ * unsubscribe of id i, 2i + 1 its leave.
4490
+ */
4491
+ function releaseBatch(ids, sessionIds) {
4492
+ const vars = { sids: [...sessionIds] };
4493
+ return {
4494
+ sql: ids.flatMap((id, i) => {
4495
+ vars[`id${i}`] = id;
4496
+ return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
4497
+ }).join(";\n"),
4498
+ vars
4499
+ };
4500
+ }
4483
4501
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
4484
4502
  const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
4485
4503
  const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
@@ -5281,10 +5299,39 @@ const stmt = (results, i) => results[i];
5281
5299
  */
5282
5300
  const isNotAllowlisted = (error) => /not_allowlisted|\b403\b|forbidden/i.test(String(error?.message ?? error));
5283
5301
  /**
5302
+ * The entry no longer names the remote id this registration sent: a salt
5303
+ * rotation (`rebindQueries`) re-keyed it while the request was out, and
5304
+ * seeded a fresh lifecycle for the new id.
5305
+ */
5306
+ const movedOff = (current, sent) => String(current.def.id) !== String(sent.def.id);
5307
+ /**
5308
+ * Release the id a superseded registration sent and register the new one.
5309
+ * The re-key's own `RegisterRemote` for this hash joined the run still in
5310
+ * flight (the lane dedupes per hash), so it is armed again on a timer, which
5311
+ * fires once this run has left the lane.
5312
+ */
5313
+ function* handOff(hash, sent) {
5314
+ yield fx.dispatch({
5315
+ type: "ReleaseSuperseded",
5316
+ ids: [sent.def.id]
5317
+ });
5318
+ yield fx.timer.set(`register:${hash}`, 0, {
5319
+ type: "RegisterRemote",
5320
+ hash
5321
+ });
5322
+ }
5323
+ /**
5284
5324
  * One remote registration: `fn::query::register` plus the edge/meta/children
5285
5325
  * read in ONE request, then membership application. Concurrent with every
5286
5326
  * other registration; retried on its own backoff; stops when the entry is
5287
5327
  * gone.
5328
+ *
5329
+ * An answer that lands after the query moved to a new id applies nothing:
5330
+ * the membership and the `registered` flag belong to the id it sent, which
5331
+ * this page no longer uses, so it releases that id and leaves the new one to
5332
+ * the registration the re-key dispatched. Marking the new id registered from
5333
+ * the old id's answer meant the new id was never registered, and nothing
5334
+ * ever named the old one again.
5288
5335
  */
5289
5336
  function* registerRemote(env, hash, retry = false) {
5290
5337
  const state = yield fx.state.read((s) => s);
@@ -5293,6 +5340,7 @@ function* registerRemote(env, hash, retry = false) {
5293
5340
  const { remote } = entry.lifecycle;
5294
5341
  if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
5295
5342
  const background = entry.lifecycle.phase !== "cold";
5343
+ let superseded = false;
5296
5344
  yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, {
5297
5345
  type: "fetch-begin",
5298
5346
  background
@@ -5306,7 +5354,13 @@ function* registerRemote(env, hash, retry = false) {
5306
5354
  ttl: entry.def.ttl,
5307
5355
  ...state.ownerSecret ? { owner: state.ownerSecret } : {}
5308
5356
  }), env.remoteTimeoutMs);
5309
- if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
5357
+ const answered = yield fx.state.read((s) => s.queries.get(hash));
5358
+ if (!answered) return;
5359
+ superseded = movedOff(answered, entry);
5360
+ if (superseded) {
5361
+ yield* handOff(hash, entry);
5362
+ return;
5363
+ }
5310
5364
  const register = stmt(results, 0);
5311
5365
  if (!register || register.status === "ERR") throw new Error(register?.status === "ERR" ? register.error : "register returned nothing");
5312
5366
  const answer = (r) => r?.status === "OK" ? r.result : FAILED;
@@ -5340,6 +5394,11 @@ function* registerRemote(env, hash, retry = false) {
5340
5394
  } catch (error) {
5341
5395
  const current = yield fx.state.read((s) => s.queries.get(hash));
5342
5396
  if (!current) return;
5397
+ superseded = movedOff(current, entry);
5398
+ if (superseded) {
5399
+ yield* handOff(hash, entry);
5400
+ return;
5401
+ }
5343
5402
  if (isNotAllowlisted(error)) {
5344
5403
  yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
5345
5404
  yield fx.emit({
@@ -5385,12 +5444,39 @@ function* registerRemote(env, hash, retry = false) {
5385
5444
  retry: true
5386
5445
  });
5387
5446
  } finally {
5388
- if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, {
5447
+ if ((yield fx.state.read((s) => s.queries.has(hash))) && !superseded) yield fx.state.update(applyLifecycle(hash, {
5389
5448
  type: "fetch-end",
5390
5449
  background
5391
5450
  }));
5392
5451
  }
5393
5452
  }
5453
+ /**
5454
+ * Hand back remote ids this page registered and no longer uses: a query
5455
+ * moved to a new salt (sign-up, sign-in, sign-out, impersonation) while its
5456
+ * old id was registered or on its way. Nothing names the old id again, not
5457
+ * the heartbeat, not the pagehide beacon, so it used to hold a live view
5458
+ * until its TTL ran out, and the SSP fanned every change to a row it held out
5459
+ * to it. Best-effort: a row the caller may no longer touch (another
5460
+ * principal's) or a failed request is left to the TTL sweep, as before.
5461
+ */
5462
+ function* releaseSuperseded(env, ids) {
5463
+ if (ids.length === 0) return;
5464
+ const sessionIds = yield fx.service("remote.sessionIds");
5465
+ const batch = releaseBatch(ids, sessionIds);
5466
+ try {
5467
+ yield fx.remote.query(batch.sql, batch.vars, env.remoteTimeoutMs);
5468
+ } catch (error) {
5469
+ yield fx.emit({
5470
+ type: "log",
5471
+ level: "debug",
5472
+ message: "superseded view release failed; the TTL sweep reclaims it",
5473
+ data: {
5474
+ ids: ids.map(String),
5475
+ error
5476
+ }
5477
+ });
5478
+ }
5479
+ }
5394
5480
 
5395
5481
  //#endregion
5396
5482
  //#region src/mutation/write.saga.ts
@@ -7317,9 +7403,21 @@ function* dropImpersonatedBucket(left) {
7317
7403
  });
7318
7404
  }
7319
7405
  }
7320
- /** Re-seed every active query from the new store and rebuild its SSP view. */
7406
+ /**
7407
+ * Re-seed every active query from the new store and rebuild its SSP view.
7408
+ *
7409
+ * Under a new salt each query gets a new remote id, and the id it leaves
7410
+ * goes back to the server: nothing names it again (not the heartbeat, not
7411
+ * the pagehide beacon), so it otherwise held a live view for its whole TTL.
7412
+ * A sign-up left one such row per query, because the page's queries mount
7413
+ * as the sign-up resolves and register under the anonymous salt on a socket
7414
+ * that is already signed in. Ids still in flight are released by their
7415
+ * registration when it lands (`registerRemote`); releasing them here too
7416
+ * covers a request that timed out and landed anyway.
7417
+ */
7321
7418
  function* rebindQueries() {
7322
7419
  const [entries, sessionId] = yield fx.state.read((s) => [[...s.queries.values()], s.sessionId]);
7420
+ const superseded = [];
7323
7421
  for (const entry of entries) {
7324
7422
  let view = null;
7325
7423
  try {
@@ -7352,8 +7450,10 @@ function* rebindQueries() {
7352
7450
  });
7353
7451
  }
7354
7452
  const lifecycle = seedLifecycle(isResolvedBefore(view));
7453
+ const id = new RecordId$1("_00_query", hash);
7454
+ if (entry.lifecycle.remote !== "unregistered" && String(id) !== String(entry.def.id)) superseded.push(entry.def.id);
7355
7455
  yield fx.state.update(rebindQuery(entry.def.hash, {
7356
- id: new RecordId$1("_00_query", hash),
7456
+ id,
7357
7457
  lifecycle,
7358
7458
  remoteArray: view?.ids ?? [],
7359
7459
  localArray
@@ -7364,6 +7464,10 @@ function* rebindQueries() {
7364
7464
  known: false
7365
7465
  });
7366
7466
  }
7467
+ if (superseded.length > 0) yield fx.dispatch({
7468
+ type: "ReleaseSuperseded",
7469
+ ids: superseded
7470
+ });
7367
7471
  }
7368
7472
 
7369
7473
  //#endregion
@@ -7483,6 +7587,10 @@ function route(env, event) {
7483
7587
  saga: registerRemote(env, event.hash, event.retry),
7484
7588
  lane: dedupe(`register:${event.hash}`)
7485
7589
  };
7590
+ case "ReleaseSuperseded": return {
7591
+ saga: releaseSuperseded(env, event.ids),
7592
+ lane: serial("release")
7593
+ };
7486
7594
  case "RecoverLostView": return {
7487
7595
  saga: recoverLostView(event.hash),
7488
7596
  lane: dedupe(`view-lost:${event.hash}`)
@@ -15922,7 +16030,7 @@ function createTabsCoordinator(config, s, host) {
15922
16030
  return new TabsCoordinator({
15923
16031
  tabId: s.tabId,
15924
16032
  fingerprint: computeTabsFingerprint({
15925
- coreVersion: "0.0.1-canary.350",
16033
+ coreVersion: "0.0.1-canary.352",
15926
16034
  schemaHash: hash53(config.schemaSurql),
15927
16035
  endpoint: config.database.endpoint ?? "",
15928
16036
  namespace: config.database.namespace,
@@ -16086,6 +16194,7 @@ function createAdapters(config, s, host, lateModules) {
16086
16194
  const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
16087
16195
  if (sql) s.remote.beaconSql(sql);
16088
16196
  },
16197
+ "remote.sessionIds": () => s.remote.sessionIds(),
16089
16198
  "supervisor.start": () => s.connectionSupervisor.start(),
16090
16199
  "net.offline": () => typeof navigator !== "undefined" && navigator.onLine === false,
16091
16200
  "tabs.start": (bucketId) => {
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { B as listRefTableFor, L as ANON_USER_ID, O as registerSelect, R as DEFAULT_REF_MODE, S as listRefLive, U as sha256Hex, W as queryHashInput, a as metaFromRow, c as mintOwnerSecret, d as withTimeout, f as parseQueryParams, g as bodySelect, j as singleSnapshotSelect, k as registerVars, l as parseDuration, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, y as heartbeatRowGone } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
package/dist/pure.d.ts CHANGED
@@ -258,6 +258,18 @@ declare function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): {
258
258
  sql: string;
259
259
  vars: Vars;
260
260
  };
261
+ /**
262
+ * Hand back view ids this page no longer answers to. Per id,
263
+ * `fn::query::unsubscribe` drops this socket's entry and deletes the row when
264
+ * nobody else watches it; `fn::query::leave` then drops the entries of this
265
+ * page's earlier sockets (a reconnect re-registered under a new session id)
266
+ * and pulls the expiry in when none remain. Result index 2i answers the
267
+ * unsubscribe of id i, 2i + 1 its leave.
268
+ */
269
+ declare function releaseBatch(ids: ReadonlyArray<RecordId<string>>, sessionIds: ReadonlyArray<string>): {
270
+ sql: string;
271
+ vars: Vars;
272
+ };
261
273
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
262
274
  declare const heartbeatRowGone: (result: unknown) => boolean;
263
275
  /**
@@ -386,4 +398,4 @@ declare function sha256Hex(input: string): Promise<string>;
386
398
  */
387
399
  declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
388
400
  //#endregion
389
- 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 };
401
+ 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, releaseBatch, 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 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";
1
+ import { A as releaseBatch, B as listRefTableFor, C as listRefSelect, D as readViewRows, E as readLegacyViewRows, F as viewRow, G as viewKeyInput, H as surql, I as ANON_LIST_REF_TABLE, L as ANON_USER_ID, M as subqueryListRefSelect, N as upsertBodiesTx, O as registerSelect, P as viewRecordId, R as DEFAULT_REF_MODE, S as listRefLive, T as queryRowCountSelect, U as sha256Hex, V as sanitizeUserId, W as queryHashInput, _ as countViewRows, a as metaFromRow, b as leaveViewsBeacon, f as parseQueryParams, g as bodySelect, h as batchSnapshotSelect, i as dedupeRecordVersions, j as singleSnapshotSelect, 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 bucketIdForUser } 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_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 };
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, releaseBatch, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
@@ -239,6 +239,24 @@ function heartbeatBatch(ids) {
239
239
  vars
240
240
  };
241
241
  }
242
+ /**
243
+ * Hand back view ids this page no longer answers to. Per id,
244
+ * `fn::query::unsubscribe` drops this socket's entry and deletes the row when
245
+ * nobody else watches it; `fn::query::leave` then drops the entries of this
246
+ * page's earlier sockets (a reconnect re-registered under a new session id)
247
+ * and pulls the expiry in when none remain. Result index 2i answers the
248
+ * unsubscribe of id i, 2i + 1 its leave.
249
+ */
250
+ function releaseBatch(ids, sessionIds) {
251
+ const vars = { sids: [...sessionIds] };
252
+ return {
253
+ sql: ids.flatMap((id, i) => {
254
+ vars[`id${i}`] = id;
255
+ return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
256
+ }).join(";\n"),
257
+ vars
258
+ };
259
+ }
242
260
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
243
261
  const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
244
262
  const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
@@ -477,4 +495,4 @@ function rowOfEdge(value) {
477
495
  }
478
496
 
479
497
  //#endregion
480
- export { singleSnapshotSelect as A, sanitizeUserId as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, ANON_LIST_REF_TABLE as F, sha256Hex as H, ANON_USER_ID as I, DEFAULT_REF_MODE as L, upsertBodiesTx as M, viewRecordId as N, registerSelect as O, viewRow as P, bucketIdForUser as R, listRefLive as S, queryRowCountSelect as T, queryHashInput as U, surql as V, viewKeyInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, subqueryListRefSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, listRefTableFor as z };
498
+ export { releaseBatch as A, listRefTableFor as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, viewRow as F, viewKeyInput as G, surql as H, ANON_LIST_REF_TABLE as I, ANON_USER_ID as L, subqueryListRefSelect as M, upsertBodiesTx as N, registerSelect as O, viewRecordId as P, DEFAULT_REF_MODE as R, listRefLive as S, queryRowCountSelect as T, sha256Hex as U, sanitizeUserId as V, queryHashInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, singleSnapshotSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, bucketIdForUser as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.350",
3
+ "version": "0.0.1-canary.352",
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.350",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.350",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.352",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.352",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.1.0",
82
82
  "@surrealdb/wasm-native": "^3.3.4",
@@ -66,6 +66,45 @@ describe('authFlip', () => {
66
66
  expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
67
67
  expect(id).not.toBe(before);
68
68
  });
69
+ it('a sign-up releases the id each query held under the anonymous salt, so the page owns one remote row per query', async () => {
70
+ // threads-e2e, canary.350: the list mounted as the sign-up resolved, so
71
+ // its queries registered under the anonymous salt on a socket already
72
+ // signed in, then the flip re-keyed them. Every query left a second row
73
+ // (same surql, same auth_id) that the pagehide beacon never named and
74
+ // that fanned out every change for its whole TTL.
75
+ const svc = fakeServices({
76
+ 'auth.sessionAuthId': () => 'user:u1',
77
+ 'auth.access': () => 'account',
78
+ 'local.currentBucketId': () => 'anon',
79
+ 'local.beginSwitch': () => () => undefined,
80
+ 'local.usesSurqlSchema': () => false,
81
+ 'auth.token': () => 'jwt',
82
+ 'auth.currentUser': () => null,
83
+ });
84
+ const threads = { surql: 'SELECT * FROM thread', params: {} };
85
+ const user = { surql: 'SELECT * FROM user WHERE id = $id', params: { id: 'user:u1' } };
86
+ const anonId = async (def: typeof threads) => new RecordId('_00_query', await sha256Hex(queryHashInput(def, 'anon-salt')));
87
+ const [threadsId, userId] = [await anonId(threads), await anonId(user)];
88
+ const state = R.compose(
89
+ R.putQuery(buildEntry({ def: { hash: 't', ...threads, id: threadsId }, lifecycle: { phase: 'live', remote: 'registered' } })),
90
+ R.putQuery(buildEntry({ def: { hash: 'u', ...user, id: userId }, lifecycle: { phase: 'cold', remote: 'registering' } })),
91
+ R.putQuery(buildEntry({ def: { hash: 'n', surql: 'SELECT * FROM comment' }, lifecycle: { phase: 'cold', remote: 'unregistered' } }))
92
+ )({ ...buildState(), sessionId: 'anon-salt', saltUserId: null, bucketId: 'anon' });
93
+ const out = await runPure(authFlip(env, 'user:u1'), {
94
+ state,
95
+ handlers: {
96
+ service: svc.handler,
97
+ 'local.getById': () => null,
98
+ 'ssp.register': () => ({ localArray: [], timings: {} }),
99
+ 'local.query': () => [],
100
+ },
101
+ });
102
+ expect(String(out.state.queries.get('t')!.def.id.id)).toBe(await sha256Hex(queryHashInput(threads, 'salt-1')));
103
+ expect(String(out.state.queries.get('u')!.def.id.id)).toBe(await sha256Hex(queryHashInput(user, 'salt-1')));
104
+ // Registered and in-flight ids may exist on the server; a never-registered one cannot.
105
+ expect(out.dispatched.filter((d) => d.type === 'ReleaseSuperseded')).toEqual([{ type: 'ReleaseSuperseded', ids: [threadsId, userId] }]);
106
+ expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
107
+ });
69
108
  it('a new principal on the same bucket still re-keys its queries', async () => {
70
109
  const svc = fakeServices({
71
110
  'auth.sessionAuthId': () => 'user:b',
@@ -72,7 +72,9 @@ describe('bucketSwitch', () => {
72
72
  expect(String(e.def.id.table)).toBe('_00_query');
73
73
  // PollTick is load-bearing: the switch cleared the poll timer, and only the
74
74
  // tick re-arms it, so a sign-in without it leaves the client on LIVE alone.
75
- expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
75
+ expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'ReleaseSuperseded', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
76
+ // The id the query held under the previous salt goes back to the server.
77
+ expect(out.dispatched.find((d) => d.type === 'ReleaseSuperseded')).toEqual({ type: 'ReleaseSuperseded', ids: [new RecordId('_00_query', 'a')] });
76
78
  });
77
79
  it('shared tabs: moves namespaces, falls back to solo on failure; blob clear on sign-out; failures are logged and the gate always reopens', async () => {
78
80
  let gateReleased = false;
@@ -100,9 +100,21 @@ export function* dropImpersonatedBucket(left: string): Saga<void> {
100
100
  }
101
101
  }
102
102
 
103
- /** Re-seed every active query from the new store and rebuild its SSP view. */
103
+ /**
104
+ * Re-seed every active query from the new store and rebuild its SSP view.
105
+ *
106
+ * Under a new salt each query gets a new remote id, and the id it leaves
107
+ * goes back to the server: nothing names it again (not the heartbeat, not
108
+ * the pagehide beacon), so it otherwise held a live view for its whole TTL.
109
+ * A sign-up left one such row per query, because the page's queries mount
110
+ * as the sign-up resolves and register under the anonymous salt on a socket
111
+ * that is already signed in. Ids still in flight are released by their
112
+ * registration when it lands (`registerRemote`); releasing them here too
113
+ * covers a request that timed out and landed anyway.
114
+ */
104
115
  export function* rebindQueries(): Saga<void> {
105
116
  const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
117
+ const superseded: QueryEntry['def']['id'][] = [];
106
118
  for (const entry of entries) {
107
119
  let view: ReturnType<typeof parseViewRow> = null;
108
120
  try {
@@ -125,7 +137,9 @@ export function* rebindQueries(): Saga<void> {
125
137
  yield fx.emit({ type: 'log', level: 'warn', message: 'local view rebuild failed after bucket switch', data: { hash: entry.def.hash, error } });
126
138
  }
127
139
  const lifecycle = seedLifecycle(isResolvedBefore(view));
128
- yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
140
+ const id = new RecordId('_00_query', hash);
141
+ if (entry.lifecycle.remote !== 'unregistered' && String(id) !== String(entry.def.id)) superseded.push(entry.def.id);
142
+ yield fx.state.update(R.rebindQuery(entry.def.hash, { id, lifecycle, remoteArray: view?.ids ?? [], localArray }));
129
143
  // A re-seed is never answered, not even from a durable view: subscribers
130
144
  // still hold the previous store's rows until `materialize` paints the new
131
145
  // store's, and only that render flips authority back on.
@@ -133,4 +147,5 @@ export function* rebindQueries(): Saga<void> {
133
147
  yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
134
148
  }
135
149
  }
150
+ if (superseded.length > 0) yield fx.dispatch({ type: 'ReleaseSuperseded', ids: superseded });
136
151
  }
@@ -1,7 +1,7 @@
1
1
  import type { RuntimeEvent } from '../kernel/events';
2
2
  import type { Lane, Saga } from '../kernel/saga';
3
3
  import type { SagaEnv } from '../query/env';
4
- import { ensureRegistered, registerRemote } from '../query/register.saga';
4
+ import { ensureRegistered, registerRemote, releaseSuperseded } from '../query/register.saga';
5
5
  import { readDirtyMembership, readMembership, recoverLostView } from '../query/membership.saga';
6
6
  import { fetchRows } from '../query/fetch.saga';
7
7
  import { materialize, streamUpdate } from '../query/materialize.saga';
@@ -37,6 +37,8 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
37
37
  return { saga: ensureRegistered(env, { requireAuth: event.requireAuth, attempt: event.attempt }), lane: serial('ensure') };
38
38
  case 'RegisterRemote':
39
39
  return { saga: registerRemote(env, event.hash, event.retry), lane: dedupe(`register:${event.hash}`) };
40
+ case 'ReleaseSuperseded':
41
+ return { saga: releaseSuperseded(env, event.ids), lane: serial('release') };
40
42
  case 'RecoverLostView':
41
43
  return { saga: recoverLostView(event.hash), lane: dedupe(`view-lost:${event.hash}`) };
42
44
  case 'ReadDirtyMembership':
@@ -7,6 +7,7 @@ import type { Saga } from '../kernel/saga';
7
7
  import * as R from '../state/reducers';
8
8
  import { Runtime } from './runtime';
9
9
  import { rebindQueries } from '../boot/bucket-switch.saga';
10
+ import { registerLocal } from '../query/register.saga';
10
11
 
11
12
  const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
12
13
  const noop = () => {};
@@ -233,3 +234,66 @@ describe('Runtime state hooks', () => {
233
234
  expect(rt.state.dirty.has('q')).toBe(true);
234
235
  });
235
236
  });
237
+
238
+ describe('Runtime sign-up', () => {
239
+ it('the page ends up owning one remote row per query when its registration races the auth flip', async () => {
240
+ // threads-e2e, canary.350: the list mounts as the sign-up resolves and
241
+ // registers under the anonymous salt on a socket already signed in; the
242
+ // flip re-keys it while that request is out. The fake server keeps the
243
+ // `_00_query` rows the way `fn::query::register` / `unsubscribe` do.
244
+ const rows = new Set<string>();
245
+ let land!: () => void;
246
+ const held = new Promise<void>((r) => (land = r));
247
+ let first = true;
248
+ const ok = (result: unknown) => ({ status: 'OK' as const, result });
249
+ let bucket = 'anon';
250
+ const { a, rt } = make(
251
+ {
252
+ remote: {
253
+ queryResponses: async (sql: string, vars?: Record<string, any>) => {
254
+ if (sql.startsWith('fn::query::register($config)')) {
255
+ rows.add(String(vars!.config.id));
256
+ if (first) {
257
+ first = false;
258
+ await held;
259
+ }
260
+ return [ok(null), ok([]), ok({ rowCount: 0, state: 'ready' }), ok([])];
261
+ }
262
+ if (sql.includes('fn::query::unsubscribe')) {
263
+ for (const [k, v] of Object.entries(vars ?? {})) if (k.startsWith('id')) rows.delete(String(v));
264
+ return [];
265
+ }
266
+ return [];
267
+ },
268
+ },
269
+ services: {
270
+ 'auth.sessionAuthId': () => 'user:u1',
271
+ 'auth.access': () => 'account',
272
+ 'local.currentBucketId': () => bucket,
273
+ 'local.beginSwitch': () => () => undefined,
274
+ 'local.switchStore': async (target: string) => void (bucket = target),
275
+ 'local.usesSurqlSchema': () => false,
276
+ 'auth.currentUser': () => null,
277
+ 'remote.sessionIds': () => ['s1'],
278
+ },
279
+ },
280
+ { ...buildState(), sessionId: 'anon-salt', saltUserId: null, bucketId: 'anon', primed: true }
281
+ );
282
+ const hash = await rt.run(registerLocal(env, { tableName: 'thread', surql: 'SELECT * FROM thread', params: {}, ttl: '10m' }));
283
+ await tick();
284
+ expect(rows.size).toBe(1);
285
+ const anonId = [...rows][0];
286
+
287
+ await rt.dispatch({ type: 'AuthFlip', userId: 'user:u1' });
288
+ land();
289
+ for (let i = 0; i < 10; i++) {
290
+ await tick();
291
+ a.timers.fire(`register:${hash}`);
292
+ }
293
+
294
+ const entry = rt.state.queries.get(hash)!;
295
+ expect(String(entry.def.id)).not.toBe(anonId);
296
+ expect([...rows]).toEqual([String(entry.def.id)]);
297
+ expect(entry.lifecycle.remote).toBe('registered');
298
+ });
299
+ });
@@ -330,6 +330,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
330
330
  const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
331
331
  if (sql) s.remote.beaconSql(sql);
332
332
  },
333
+ 'remote.sessionIds': () => s.remote.sessionIds(),
333
334
  'supervisor.start': () => s.connectionSupervisor.start(),
334
335
  'net.offline': () => typeof navigator !== 'undefined' && navigator.onLine === false,
335
336
  'tabs.start': (bucketId) => {
@@ -103,6 +103,8 @@ export interface ServiceCalls {
103
103
  /** The platform reports no network at all (a browser's `navigator.onLine === false`). */
104
104
  'net.offline': () => boolean;
105
105
  'remote.releaseViews': (ids: unknown[]) => void;
106
+ /** `session::id()` of every socket this page has had, oldest first. */
107
+ 'remote.sessionIds': () => readonly string[];
106
108
  'supervisor.start': () => void;
107
109
  'tabs.start': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
108
110
  'tabs.moveToBucket': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
@@ -1,3 +1,4 @@
1
+ import type { RecordId } from 'surrealdb';
1
2
  import type { ConnectionState, EdgeDelta, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
2
3
  import type { StreamUpdate } from '../services/stream-processor/index';
3
4
 
@@ -9,6 +10,7 @@ import type { StreamUpdate } from '../services/stream-processor/index';
9
10
  export type RuntimeEvent =
10
11
  | { type: 'EnsureRegistered'; requireAuth?: boolean; attempt?: number }
11
12
  | { type: 'RegisterRemote'; hash: QueryHash; retry?: boolean }
13
+ | { type: 'ReleaseSuperseded'; ids: RecordId<string>[] }
12
14
  | { type: 'SyncOutcome'; ok: boolean; error?: unknown }
13
15
  | { type: 'AckPrune' }
14
16
  | { type: 'ReadDirtyMembership' }
@@ -242,7 +242,7 @@ export class FlagsAdminService {
242
242
 
243
243
  /**
244
244
  * Add or remove a user from `$key`'s allowlist for `$variant`, then
245
- * re-materialize. Defaults to the signed-in user, so the common case
245
+ * re-evaluate that one user (nobody else's variant can change). Defaults to the signed-in user, so the common case
246
246
  * ("turn this on for me, for real") needs no user picker.
247
247
  */
248
248
  async setFlagUserVariant(
@@ -4,7 +4,9 @@ import { runPure, sha256Hex } from '../testing/run-pure';
4
4
  import { buildEntry, buildState } from '../testing/build';
5
5
  import { defaultEnv } from './env';
6
6
  import { queryHashInput } from './hash';
7
- import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote } from './register.saga';
7
+ import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote, releaseSuperseded } from './register.saga';
8
+ import { seedLifecycle } from '../state/lifecycle';
9
+ import { fakeServices } from '../testing/services';
8
10
  import * as R from '../state/reducers';
9
11
  import { emptyState } from '../state/client-state';
10
12
  import type { StatementResult } from '../kernel/effects';
@@ -309,4 +311,59 @@ describe('registerRemote', () => {
309
311
  });
310
312
  expect(gone.dispatched).toEqual([]);
311
313
  });
314
+ it('a registration that lands after its query moved to a new id releases the id it registered and leaves the new one to register', async () => {
315
+ // A sign-up: the page registered under the anonymous salt with the socket
316
+ // already signed in, and the auth flip re-keyed the query while that
317
+ // request was out. Before the fix the answer marked the NEW id registered,
318
+ // so it was never registered, and nothing ever released the old one.
319
+ const moved = new RecordId('_00_query', 'b'.repeat(64));
320
+ for (const fail of [false, true]) {
321
+ const out = await runPure(registerRemote(env, 'a'), {
322
+ state: buildState([entry]),
323
+ handlers: {
324
+ 'remote.query': (_e, ctx) => {
325
+ ctx.state = R.rebindQuery('a', { id: moved, lifecycle: seedLifecycle(false), remoteArray: [], localArray: [] })(ctx.state);
326
+ if (fail) throw new Error('Remote request timed out');
327
+ return happy();
328
+ },
329
+ 'local.upsert': () => undefined,
330
+ },
331
+ });
332
+ const e = out.state.queries.get('a')!;
333
+ expect(e.def.id).toBe(moved);
334
+ expect(e.lifecycle).toMatchObject({ remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0 });
335
+ expect(e.remoteArray).toEqual([]);
336
+ expect(e.registerAttempts).toBe(0);
337
+ expect(out.dispatched).toEqual([{ type: 'ReleaseSuperseded', ids: [entry.def.id] }]);
338
+ // The re-key's RegisterRemote joined this run on the per-hash lane: arm it again.
339
+ expect([...out.timers]).toEqual([['register:a', { ms: 0, event: { type: 'RegisterRemote', hash: 'a' } }]]);
340
+ }
341
+ });
342
+ });
343
+
344
+ describe('releaseSuperseded', () => {
345
+ it("per id: unsubscribe as this socket, then leave for this page's earlier sockets, in one request; a failure is only logged", async () => {
346
+ const ids = [new RecordId('_00_query', 'a'.repeat(64)), new RecordId('_00_query', 'b'.repeat(64))];
347
+ const out = await runPure(releaseSuperseded(env, ids), {
348
+ handlers: {
349
+ service: fakeServices({ 'remote.sessionIds': () => ['s1', 's2'] }).handler,
350
+ 'remote.query': () => [okStmt({ released: true }), okStmt({ leaving: false }), okStmt({ released: true }), okStmt({ leaving: false })],
351
+ },
352
+ });
353
+ const req = out.log.find((x) => x.kind === 'remote.query') as any;
354
+ expect(req.sql).toBe('fn::query::unsubscribe($id0);\nfn::query::leave($id0, $sids);\nfn::query::unsubscribe($id1);\nfn::query::leave($id1, $sids)');
355
+ expect(req.vars).toEqual({ id0: ids[0], id1: ids[1], sids: ['s1', 's2'] });
356
+ expect(req.timeoutMs).toBe(env.remoteTimeoutMs);
357
+ const failing = await runPure(releaseSuperseded(env, ids), {
358
+ handlers: {
359
+ service: fakeServices({ 'remote.sessionIds': () => [] }).handler,
360
+ 'remote.query': () => {
361
+ throw new Error('offline');
362
+ },
363
+ },
364
+ });
365
+ expect(failing.emitted).toEqual([expect.objectContaining({ type: 'log', level: 'debug' })]);
366
+ const none = await runPure(releaseSuperseded(env, []), {});
367
+ expect(none.log).toEqual([]);
368
+ });
312
369
  });
@@ -139,11 +139,36 @@ const stmt = (results: StatementResult[], i: number): StatementResult | undefine
139
139
  export const isNotAllowlisted = (error: unknown): boolean =>
140
140
  /not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
141
141
 
142
+ /**
143
+ * The entry no longer names the remote id this registration sent: a salt
144
+ * rotation (`rebindQueries`) re-keyed it while the request was out, and
145
+ * seeded a fresh lifecycle for the new id.
146
+ */
147
+ const movedOff = (current: QueryEntry, sent: QueryEntry): boolean => String(current.def.id) !== String(sent.def.id);
148
+
149
+ /**
150
+ * Release the id a superseded registration sent and register the new one.
151
+ * The re-key's own `RegisterRemote` for this hash joined the run still in
152
+ * flight (the lane dedupes per hash), so it is armed again on a timer, which
153
+ * fires once this run has left the lane.
154
+ */
155
+ function* handOff(hash: QueryHash, sent: QueryEntry): Saga<void> {
156
+ yield fx.dispatch({ type: 'ReleaseSuperseded', ids: [sent.def.id] });
157
+ yield fx.timer.set(`register:${hash}`, 0, { type: 'RegisterRemote', hash });
158
+ }
159
+
142
160
  /**
143
161
  * One remote registration: `fn::query::register` plus the edge/meta/children
144
162
  * read in ONE request, then membership application. Concurrent with every
145
163
  * other registration; retried on its own backoff; stops when the entry is
146
164
  * gone.
165
+ *
166
+ * An answer that lands after the query moved to a new id applies nothing:
167
+ * the membership and the `registered` flag belong to the id it sent, which
168
+ * this page no longer uses, so it releases that id and leaves the new one to
169
+ * the registration the re-key dispatched. Marking the new id registered from
170
+ * the old id's answer meant the new id was never registered, and nothing
171
+ * ever named the old one again.
147
172
  */
148
173
  export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
149
174
  const state = (yield fx.state.read((s) => s)) as ClientState;
@@ -154,6 +179,7 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
154
179
  // A query that already shows rows (cached or live) only re-verifies them
155
180
  // here: the cycle holds `fetching` but is not traffic the user waits on.
156
181
  const background = entry.lifecycle.phase !== 'cold';
182
+ let superseded = false;
157
183
  yield fx.state.update(
158
184
  R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
159
185
  );
@@ -170,8 +196,13 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
170
196
  }),
171
197
  env.remoteTimeoutMs
172
198
  )) as StatementResult[];
173
- const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
174
- if (!stillThere) return;
199
+ const answered = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
200
+ if (!answered) return;
201
+ superseded = movedOff(answered, entry);
202
+ if (superseded) {
203
+ yield* handOff(hash, entry);
204
+ return;
205
+ }
175
206
  const register = stmt(results, 0);
176
207
  if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
177
208
  const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
@@ -202,6 +233,12 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
202
233
  } catch (error) {
203
234
  const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
204
235
  if (!current) return;
236
+ // A request that timed out may still land: release its id all the same.
237
+ superseded = movedOff(current, entry);
238
+ if (superseded) {
239
+ yield* handOff(hash, entry);
240
+ return;
241
+ }
205
242
  if (isNotAllowlisted(error)) {
206
243
  yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
207
244
  yield fx.emit({
@@ -229,7 +266,28 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
229
266
  yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
230
267
  }
231
268
  } finally {
269
+ // A re-key seeded a fresh lifecycle: this cycle's begin is not in it.
232
270
  const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
233
- if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
271
+ if (stillThere && !superseded) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Hand back remote ids this page registered and no longer uses: a query
277
+ * moved to a new salt (sign-up, sign-in, sign-out, impersonation) while its
278
+ * old id was registered or on its way. Nothing names the old id again, not
279
+ * the heartbeat, not the pagehide beacon, so it used to hold a live view
280
+ * until its TTL ran out, and the SSP fanned every change to a row it held out
281
+ * to it. Best-effort: a row the caller may no longer touch (another
282
+ * principal's) or a failed request is left to the TTL sweep, as before.
283
+ */
284
+ export function* releaseSuperseded(env: SagaEnv, ids: ReadonlyArray<QueryEntry['def']['id']>): Saga<void> {
285
+ if (ids.length === 0) return;
286
+ const sessionIds = (yield fx.service('remote.sessionIds')) as readonly string[];
287
+ const batch = sql.releaseBatch(ids, sessionIds);
288
+ try {
289
+ yield fx.remote.query(batch.sql, batch.vars, env.remoteTimeoutMs);
290
+ } catch (error) {
291
+ yield fx.emit({ type: 'log', level: 'debug', message: 'superseded view release failed; the TTL sweep reclaims it', data: { ids: ids.map(String), error } });
234
292
  }
235
293
  }
package/src/query/sql.ts CHANGED
@@ -118,6 +118,23 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
118
118
  return { sql: stmts.join(';\n'), vars };
119
119
  }
120
120
 
121
+ /**
122
+ * Hand back view ids this page no longer answers to. Per id,
123
+ * `fn::query::unsubscribe` drops this socket's entry and deletes the row when
124
+ * nobody else watches it; `fn::query::leave` then drops the entries of this
125
+ * page's earlier sockets (a reconnect re-registered under a new session id)
126
+ * and pulls the expiry in when none remain. Result index 2i answers the
127
+ * unsubscribe of id i, 2i + 1 its leave.
128
+ */
129
+ export function releaseBatch(ids: ReadonlyArray<RecordId<string>>, sessionIds: ReadonlyArray<string>): { sql: string; vars: Vars } {
130
+ const vars: Vars = { sids: [...sessionIds] };
131
+ const stmts = ids.flatMap((id, i) => {
132
+ vars[`id${i}`] = id;
133
+ return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
134
+ });
135
+ return { sql: stmts.join(';\n'), vars };
136
+ }
137
+
121
138
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
122
139
  export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
123
140