@spooky-sync/core 0.0.1-canary.215 → 0.0.1-canary.217

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
@@ -1314,6 +1314,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
1314
1314
  * that a genuine drop minutes later still refetches.
1315
1315
  */
1316
1316
  private static readonly RECONNECT_REFETCH_COOLDOWN_MS;
1317
+ /** Poll interval while waiting for a reconnected session to re-authenticate. */
1318
+ private static readonly AUTH_READY_RETRY_MS;
1319
+ /** Attempts before giving up on re-auth and skipping the refetch entirely. */
1320
+ private static readonly AUTH_READY_MAX_ATTEMPTS;
1317
1321
  events: SyncEventSystem;
1318
1322
  private currentUserId;
1319
1323
  private tabRole;
@@ -1555,6 +1559,34 @@ declare class Sp00kySync<S extends SchemaStructure> {
1555
1559
  */
1556
1560
  private invalidateRefLiveQuery;
1557
1561
  private subscribeToReconnect;
1562
+ /**
1563
+ * Whether the REMOTE SESSION currently carries `$auth.id`.
1564
+ *
1565
+ * Not the same question as `currentUserId`, and that gap is the whole point:
1566
+ * `currentUserId` is this client's own record of who signed in and survives a
1567
+ * socket drop untouched, while `$auth` lives on the WebSocket session and has
1568
+ * to be re-applied after every reconnect. Registering in the window between
1569
+ * the two is silently destructive, because `fn::query::register` sends
1570
+ * `<string>($auth.id OR '')` and the SSP stores that value write-once: the
1571
+ * view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
1572
+ * which that table's own permission rule (`auth_id = $auth.id`) makes
1573
+ * unreadable to the very user who registered it.
1574
+ *
1575
+ * Signed-out clients answer `true`: `''` is the honest identity there, not a
1576
+ * race.
1577
+ */
1578
+ private remoteAuthEstablished;
1579
+ /**
1580
+ * Wait for the reconnected session to carry an identity again, then
1581
+ * re-register every active query and re-bind LIVE.
1582
+ *
1583
+ * Giving up without registering is deliberately better than registering
1584
+ * anyway: a registration made with no `$auth.id` produces a view its own
1585
+ * owner cannot read, and it is write-once, so it stays that way. Skipping
1586
+ * leaves the query unregistered and visibly loading, which the next
1587
+ * reconnect or heartbeat retries.
1588
+ */
1589
+ private refetchAfterReconnect;
1558
1590
  private startRefLiveQueries;
1559
1591
  private handleRemoteListRefChange;
1560
1592
  /**
package/dist/index.js CHANGED
@@ -5338,9 +5338,23 @@ var DownQueue = class {
5338
5338
  return this.queue.length;
5339
5339
  }
5340
5340
  push(event) {
5341
+ if (event.type === "register" && this.lastQueuedTypeFor(event.payload.hash) === "register") {
5342
+ this.logger.debug({
5343
+ hash: event.payload.hash,
5344
+ Category: "sp00ky-client::DownQueue::push"
5345
+ }, "Register already queued for this hash; coalescing");
5346
+ return;
5347
+ }
5341
5348
  this.queue.push(event);
5342
5349
  this.emitPushEvent();
5343
5350
  }
5351
+ /** Type of the last queued event for `hash`, or `undefined` if none. */
5352
+ lastQueuedTypeFor(hash) {
5353
+ for (let i = this.queue.length - 1; i >= 0; i--) {
5354
+ const queued = this.queue[i];
5355
+ if (queued.payload.hash === hash) return queued.type;
5356
+ }
5357
+ }
5344
5358
  emitPushEvent() {
5345
5359
  this._events.addEvent({
5346
5360
  type: SyncQueueEventTypes.QueryItemEnqueued,
@@ -6012,6 +6026,10 @@ var Sp00kySync = class Sp00kySync {
6012
6026
  * that a genuine drop minutes later still refetches.
6013
6027
  */
6014
6028
  static RECONNECT_REFETCH_COOLDOWN_MS = 1e4;
6029
+ /** Poll interval while waiting for a reconnected session to re-authenticate. */
6030
+ static AUTH_READY_RETRY_MS = 500;
6031
+ /** Attempts before giving up on re-auth and skipping the refetch entirely. */
6032
+ static AUTH_READY_MAX_ATTEMPTS = 10;
6015
6033
  events = createSyncEventSystem();
6016
6034
  currentUserId = null;
6017
6035
  tabRole = "solo";
@@ -6176,7 +6194,7 @@ var Sp00kySync = class Sp00kySync {
6176
6194
  else if (this.downQueue.size > 0) await this.scheduler.syncDown();
6177
6195
  else {
6178
6196
  const hashes = this.dataModule.getActiveQueryHashes();
6179
- if (hashes.length > 0) {
6197
+ if (hashes.length > 0 && await this.remoteAuthEstablished()) {
6180
6198
  for (const hash of hashes) this.scheduler.enqueueDownEvent({
6181
6199
  type: "register",
6182
6200
  payload: { hash }
@@ -6725,21 +6743,75 @@ var Sp00kySync = class Sp00kySync {
6725
6743
  return;
6726
6744
  }
6727
6745
  this.lastReconnectRefetchAt = Date.now();
6728
- const hashes = this.dataModule.getActiveQueryHashes();
6729
- this.logger.info({
6730
- queries: hashes.length,
6731
- Category: "sp00ky-client::Sp00kySync::onReconnect"
6732
- }, "Remote reconnected, refetching active queries");
6733
- for (const hash of hashes) this.scheduler.enqueueDownEvent({
6734
- type: "register",
6735
- payload: { hash }
6736
- });
6737
- if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
6738
- this.logger.debug({
6739
- err,
6746
+ this.refetchAfterReconnect();
6747
+ });
6748
+ }
6749
+ /**
6750
+ * Whether the REMOTE SESSION currently carries `$auth.id`.
6751
+ *
6752
+ * Not the same question as `currentUserId`, and that gap is the whole point:
6753
+ * `currentUserId` is this client's own record of who signed in and survives a
6754
+ * socket drop untouched, while `$auth` lives on the WebSocket session and has
6755
+ * to be re-applied after every reconnect. Registering in the window between
6756
+ * the two is silently destructive, because `fn::query::register` sends
6757
+ * `<string>($auth.id OR '')` and the SSP stores that value write-once: the
6758
+ * view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
6759
+ * which that table's own permission rule (`auth_id = $auth.id`) makes
6760
+ * unreadable to the very user who registered it.
6761
+ *
6762
+ * Signed-out clients answer `true`: `''` is the honest identity there, not a
6763
+ * race.
6764
+ */
6765
+ async remoteAuthEstablished() {
6766
+ if (!this.currentUserId) return true;
6767
+ try {
6768
+ const result = await this.remote.query("RETURN <string>($auth.id OR '')");
6769
+ const authId = Array.isArray(result) ? result[0] : void 0;
6770
+ return typeof authId === "string" && authId.length > 0;
6771
+ } catch (err) {
6772
+ this.logger.debug({
6773
+ err,
6774
+ Category: "sp00ky-client::Sp00kySync::remoteAuthEstablished"
6775
+ }, "Auth probe failed; treating the session as not yet authenticated");
6776
+ return false;
6777
+ }
6778
+ }
6779
+ /**
6780
+ * Wait for the reconnected session to carry an identity again, then
6781
+ * re-register every active query and re-bind LIVE.
6782
+ *
6783
+ * Giving up without registering is deliberately better than registering
6784
+ * anyway: a registration made with no `$auth.id` produces a view its own
6785
+ * owner cannot read, and it is write-once, so it stays that way. Skipping
6786
+ * leaves the query unregistered and visibly loading, which the next
6787
+ * reconnect or heartbeat retries.
6788
+ */
6789
+ async refetchAfterReconnect() {
6790
+ for (let attempt = 1; attempt <= Sp00kySync.AUTH_READY_MAX_ATTEMPTS; attempt++) {
6791
+ if (await this.remoteAuthEstablished()) break;
6792
+ if (attempt === Sp00kySync.AUTH_READY_MAX_ATTEMPTS) {
6793
+ this.logger.warn({
6794
+ attempts: attempt,
6740
6795
  Category: "sp00ky-client::Sp00kySync::onReconnect"
6741
- }, "LIVE restart after reconnect failed; relying on poll fallback");
6742
- });
6796
+ }, "Reconnected but the session still carries no $auth.id; not re-registering (a registration now would stamp every view with an empty identity)");
6797
+ return;
6798
+ }
6799
+ await new Promise((resolve) => setTimeout(resolve, Sp00kySync.AUTH_READY_RETRY_MS));
6800
+ }
6801
+ const hashes = this.dataModule.getActiveQueryHashes();
6802
+ this.logger.info({
6803
+ queries: hashes.length,
6804
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
6805
+ }, "Remote reconnected, refetching active queries");
6806
+ for (const hash of hashes) this.scheduler.enqueueDownEvent({
6807
+ type: "register",
6808
+ payload: { hash }
6809
+ });
6810
+ if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
6811
+ this.logger.debug({
6812
+ err,
6813
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
6814
+ }, "LIVE restart after reconnect failed; relying on poll fallback");
6743
6815
  });
6744
6816
  }
6745
6817
  async startRefLiveQueries() {
@@ -7560,8 +7632,8 @@ function selfAllowlistedVariant(flag, userId) {
7560
7632
 
7561
7633
  //#endregion
7562
7634
  //#region src/modules/devtools/index.ts
7563
- const CORE_VERSION = "0.0.1-canary.215";
7564
- const WASM_VERSION = "0.0.1-canary.215";
7635
+ const CORE_VERSION = "0.0.1-canary.217";
7636
+ const WASM_VERSION = "0.0.1-canary.217";
7565
7637
  const SURREAL_VERSION = "3.0.3";
7566
7638
  var DevToolsService = class DevToolsService {
7567
7639
  eventsHistory = [];
@@ -12572,7 +12644,7 @@ var Sp00kyClient = class {
12572
12644
  return new TabsCoordinator({
12573
12645
  tabId,
12574
12646
  fingerprint: computeTabsFingerprint({
12575
- coreVersion: "0.0.1-canary.215",
12647
+ coreVersion: "0.0.1-canary.217",
12576
12648
  schemaHash: hash53(this.config.schemaSurql),
12577
12649
  endpoint: this.config.database.endpoint ?? "",
12578
12650
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.215",
3
+ "version": "0.0.1-canary.217",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.215",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.215",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.217",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.217",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -12,6 +12,7 @@ const silentLogger = {
12
12
  } as any;
13
13
 
14
14
  const register = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
15
+ const cleanup = (hash: string) => ({ type: 'cleanup', payload: { hash } }) as DownEvent;
15
16
 
16
17
  const hashOf = (e: DownEvent) => e.payload.hash;
17
18
 
@@ -129,7 +130,7 @@ describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
129
130
  // queue and is simply passed over.
130
131
  const q = makeQueue();
131
132
  q.push(register('a'));
132
- q.push(register('a'));
133
+ q.push(cleanup('a'));
133
134
  q.push(register('b'));
134
135
 
135
136
  const busy = new Set(['a']);
@@ -138,7 +139,7 @@ describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
138
139
 
139
140
  // Once 'a' frees up, its two events come back out in their original order.
140
141
  const rest = [q.takeNext(new Set())!, q.takeNext(new Set())!];
141
- expect(rest.map(hashOf)).toEqual(['a', 'a']);
142
+ expect(rest.map((e) => e.type)).toEqual(['register', 'cleanup']);
142
143
  expect(q.size).toBe(0);
143
144
  });
144
145
 
@@ -178,3 +179,50 @@ describe('DownQueue.run', () => {
178
179
  expect(q.size).toBe(0);
179
180
  });
180
181
  });
182
+
183
+ describe('DownQueue.push register coalescing', () => {
184
+ it('drops a register for a hash that already has one queued', () => {
185
+ // Four paths re-enqueue `register` for every active hash (reconnect,
186
+ // self-heal, re-mount, and a re-headed failure) and they stack: one tab was
187
+ // measured issuing 84 registrations in 93 seconds for 38 hashes. A register
188
+ // carries nothing but the hash, so the second is identical work.
189
+ const q = makeQueue();
190
+ q.push(register('a'));
191
+ q.push(register('a'));
192
+ q.push(register('a'));
193
+
194
+ expect(q.size).toBe(1);
195
+ });
196
+
197
+ it('keeps registers for DIFFERENT hashes', () => {
198
+ const q = makeQueue();
199
+ q.push(register('a'));
200
+ q.push(register('b'));
201
+
202
+ expect(q.size).toBe(2);
203
+ });
204
+
205
+ it('does NOT coalesce a register queued behind a cleanup for the same hash', () => {
206
+ // Ordering is per hash: dropping this register would let the cleanup tear
207
+ // the query down with nothing left to re-establish it.
208
+ const q = makeQueue();
209
+ q.push(register('a'));
210
+ q.push(cleanup('a'));
211
+ q.push(register('a'));
212
+
213
+ expect(q.size).toBe(3);
214
+ expect([q.takeNext(new Set())!, q.takeNext(new Set())!, q.takeNext(new Set())!].map((e) => e.type)).toEqual([
215
+ 'register',
216
+ 'cleanup',
217
+ 'register',
218
+ ]);
219
+ });
220
+
221
+ it('leaves non-register events alone', () => {
222
+ const q = makeQueue();
223
+ q.push(cleanup('a'));
224
+ q.push(cleanup('a'));
225
+
226
+ expect(q.size).toBe(2);
227
+ });
228
+ });
@@ -70,10 +70,38 @@ export class DownQueue {
70
70
  }
71
71
 
72
72
  push(event: DownEvent) {
73
+ // Coalesce repeat registrations. Four separate paths re-enqueue `register`
74
+ // for every active hash — reconnect, self-heal while degraded, a
75
+ // re-mounted subscription, and a failed event re-headed by `run` — and
76
+ // they stack, so one tab was observed issuing 84 `fn::query::register`
77
+ // round trips in 93 seconds for 38 hashes. A register is idempotent and
78
+ // carries no payload beyond the hash, so a second one queued behind the
79
+ // first would re-do identical work.
80
+ //
81
+ // Only when the LAST queued event for the hash is itself a `register`.
82
+ // Ordering never mattered globally but it does per hash (see `takeNext`):
83
+ // dropping a register queued behind a `cleanup` would let the cleanup tear
84
+ // the query down with nothing to re-establish it.
85
+ if (event.type === 'register' && this.lastQueuedTypeFor(event.payload.hash) === 'register') {
86
+ this.logger.debug(
87
+ { hash: event.payload.hash, Category: 'sp00ky-client::DownQueue::push' },
88
+ 'Register already queued for this hash; coalescing'
89
+ );
90
+ return;
91
+ }
73
92
  this.queue.push(event);
74
93
  this.emitPushEvent();
75
94
  }
76
95
 
96
+ /** Type of the last queued event for `hash`, or `undefined` if none. */
97
+ private lastQueuedTypeFor(hash: string): DownEvent['type'] | undefined {
98
+ for (let i = this.queue.length - 1; i >= 0; i--) {
99
+ const queued = this.queue[i]!;
100
+ if (queued.payload.hash === hash) return queued.type;
101
+ }
102
+ return undefined;
103
+ }
104
+
77
105
  private emitPushEvent() {
78
106
  this._events.addEvent({
79
107
  type: SyncQueueEventTypes.QueryItemEnqueued,
@@ -126,6 +126,10 @@ export class Sp00kySync<S extends SchemaStructure> {
126
126
  * that a genuine drop minutes later still refetches.
127
127
  */
128
128
  private static readonly RECONNECT_REFETCH_COOLDOWN_MS = 10_000;
129
+ /** Poll interval while waiting for a reconnected session to re-authenticate. */
130
+ private static readonly AUTH_READY_RETRY_MS = 500;
131
+ /** Attempts before giving up on re-auth and skipping the refetch entirely. */
132
+ private static readonly AUTH_READY_MAX_ATTEMPTS = 10;
129
133
  public events = createSyncEventSystem();
130
134
 
131
135
  // Auth identity that drives per-user `_00_list_ref_user_<id>` routing
@@ -405,8 +409,14 @@ export class Sp00kySync<S extends SchemaStructure> {
405
409
  // re-register active queries — mirroring the reconnect handler — so
406
410
  // there's a concrete op whose success flips health. If there are no
407
411
  // active queries either, probe connectivity directly.
412
+ // Same identity gate as the reconnect path: self-heal fires while
413
+ // sync is degraded, which is exactly when a session is most likely
414
+ // to have lost its `$auth`, and a register issued there stamps the
415
+ // view with an empty identity permanently. See
416
+ // `remoteAuthEstablished`.
408
417
  const hashes = this.dataModule.getActiveQueryHashes();
409
- if (hashes.length > 0) {
418
+ const canRegister = hashes.length > 0 && (await this.remoteAuthEstablished());
419
+ if (canRegister) {
410
420
  for (const hash of hashes) {
411
421
  this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
412
422
  }
@@ -1177,29 +1187,89 @@ export class Sp00kySync<S extends SchemaStructure> {
1177
1187
  return;
1178
1188
  }
1179
1189
  this.lastReconnectRefetchAt = Date.now();
1180
- const hashes = this.dataModule.getActiveQueryHashes();
1181
- this.logger.info(
1182
- { queries: hashes.length, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1183
- 'Remote reconnected, refetching active queries'
1190
+ void this.refetchAfterReconnect();
1191
+ });
1192
+ }
1193
+
1194
+ /**
1195
+ * Whether the REMOTE SESSION currently carries `$auth.id`.
1196
+ *
1197
+ * Not the same question as `currentUserId`, and that gap is the whole point:
1198
+ * `currentUserId` is this client's own record of who signed in and survives a
1199
+ * socket drop untouched, while `$auth` lives on the WebSocket session and has
1200
+ * to be re-applied after every reconnect. Registering in the window between
1201
+ * the two is silently destructive, because `fn::query::register` sends
1202
+ * `<string>($auth.id OR '')` and the SSP stores that value write-once: the
1203
+ * view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
1204
+ * which that table's own permission rule (`auth_id = $auth.id`) makes
1205
+ * unreadable to the very user who registered it.
1206
+ *
1207
+ * Signed-out clients answer `true`: `''` is the honest identity there, not a
1208
+ * race.
1209
+ */
1210
+ private async remoteAuthEstablished(): Promise<boolean> {
1211
+ if (!this.currentUserId) return true;
1212
+ try {
1213
+ const result = await this.remote.query<[string]>("RETURN <string>($auth.id OR '')");
1214
+ const authId = Array.isArray(result) ? result[0] : undefined;
1215
+ return typeof authId === 'string' && authId.length > 0;
1216
+ } catch (err) {
1217
+ this.logger.debug(
1218
+ { err, Category: 'sp00ky-client::Sp00kySync::remoteAuthEstablished' },
1219
+ 'Auth probe failed; treating the session as not yet authenticated'
1184
1220
  );
1185
- for (const hash of hashes) {
1186
- this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
1187
- }
1188
- // The WS reconnect leaves the server-side LIVE subscription dead — the
1189
- // re-enqueued `register` events only re-fetch initial state, they don't
1190
- // re-subscribe. Without this, LIVE never recovers after a reconnect and
1191
- // the poll silently becomes the sole sync path (and never backs off).
1192
- // Authenticated → per-user table; signed-out with anon live enabled →
1193
- // the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
1194
- if (this.currentUserId || this.anonLiveEnabled) {
1195
- this.restartRefLiveQuery().catch((err) => {
1196
- this.logger.debug(
1197
- { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1198
- 'LIVE restart after reconnect failed; relying on poll fallback'
1199
- );
1200
- });
1221
+ return false;
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Wait for the reconnected session to carry an identity again, then
1227
+ * re-register every active query and re-bind LIVE.
1228
+ *
1229
+ * Giving up without registering is deliberately better than registering
1230
+ * anyway: a registration made with no `$auth.id` produces a view its own
1231
+ * owner cannot read, and it is write-once, so it stays that way. Skipping
1232
+ * leaves the query unregistered and visibly loading, which the next
1233
+ * reconnect or heartbeat retries.
1234
+ */
1235
+ private async refetchAfterReconnect(): Promise<void> {
1236
+ for (let attempt = 1; attempt <= Sp00kySync.AUTH_READY_MAX_ATTEMPTS; attempt++) {
1237
+ if (await this.remoteAuthEstablished()) break;
1238
+ if (attempt === Sp00kySync.AUTH_READY_MAX_ATTEMPTS) {
1239
+ this.logger.warn(
1240
+ {
1241
+ attempts: attempt,
1242
+ Category: 'sp00ky-client::Sp00kySync::onReconnect',
1243
+ },
1244
+ 'Reconnected but the session still carries no $auth.id; not re-registering (a registration now would stamp every view with an empty identity)'
1245
+ );
1246
+ return;
1201
1247
  }
1202
- });
1248
+ await new Promise((resolve) => setTimeout(resolve, Sp00kySync.AUTH_READY_RETRY_MS));
1249
+ }
1250
+
1251
+ const hashes = this.dataModule.getActiveQueryHashes();
1252
+ this.logger.info(
1253
+ { queries: hashes.length, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1254
+ 'Remote reconnected, refetching active queries'
1255
+ );
1256
+ for (const hash of hashes) {
1257
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
1258
+ }
1259
+ // The WS reconnect leaves the server-side LIVE subscription dead — the
1260
+ // re-enqueued `register` events only re-fetch initial state, they don't
1261
+ // re-subscribe. Without this, LIVE never recovers after a reconnect and
1262
+ // the poll silently becomes the sole sync path (and never backs off).
1263
+ // Authenticated → per-user table; signed-out with anon live enabled →
1264
+ // the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
1265
+ if (this.currentUserId || this.anonLiveEnabled) {
1266
+ this.restartRefLiveQuery().catch((err) => {
1267
+ this.logger.debug(
1268
+ { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1269
+ 'LIVE restart after reconnect failed; relying on poll fallback'
1270
+ );
1271
+ });
1272
+ }
1203
1273
  }
1204
1274
 
1205
1275
  private async startRefLiveQueries() {