@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.130

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.
Files changed (90) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1171 -372
  3. package/dist/index.js +5716 -1278
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +746 -0
  9. package/package.json +39 -8
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.hydration.test.ts +150 -0
  25. package/src/modules/data/data.rebind.test.ts +147 -0
  26. package/src/modules/data/data.recurring.test.ts +137 -0
  27. package/src/modules/data/data.status.test.ts +249 -0
  28. package/src/modules/data/index.ts +1228 -127
  29. package/src/modules/data/window-query.test.ts +52 -0
  30. package/src/modules/data/window-query.ts +154 -0
  31. package/src/modules/devtools/index.ts +191 -30
  32. package/src/modules/devtools/versions.test.ts +74 -0
  33. package/src/modules/devtools/versions.ts +81 -0
  34. package/src/modules/feature-flag/index.test.ts +120 -0
  35. package/src/modules/feature-flag/index.ts +209 -0
  36. package/src/modules/ref-tables.test.ts +91 -0
  37. package/src/modules/ref-tables.ts +88 -0
  38. package/src/modules/sync/engine.ts +101 -37
  39. package/src/modules/sync/events/index.ts +9 -2
  40. package/src/modules/sync/queue/queue-down.ts +12 -5
  41. package/src/modules/sync/queue/queue-up.ts +29 -14
  42. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  43. package/src/modules/sync/scheduler.ts +73 -7
  44. package/src/modules/sync/sync.health.test.ts +149 -0
  45. package/src/modules/sync/sync.subquery.test.ts +82 -0
  46. package/src/modules/sync/sync.ts +1017 -62
  47. package/src/modules/sync/utils.test.ts +269 -2
  48. package/src/modules/sync/utils.ts +182 -17
  49. package/src/otel/index.ts +127 -0
  50. package/src/services/database/cache-engine.ts +143 -0
  51. package/src/services/database/database.ts +11 -11
  52. package/src/services/database/engine-factory.ts +32 -0
  53. package/src/services/database/events/index.ts +2 -1
  54. package/src/services/database/index.ts +6 -0
  55. package/src/services/database/local-migrator.ts +28 -27
  56. package/src/services/database/local.test.ts +64 -0
  57. package/src/services/database/local.ts +452 -66
  58. package/src/services/database/plan-render.test.ts +133 -0
  59. package/src/services/database/plan-render.ts +100 -0
  60. package/src/services/database/relation-resolver.test.ts +413 -0
  61. package/src/services/database/relation-resolver.ts +0 -0
  62. package/src/services/database/remote.ts +13 -13
  63. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  64. package/src/services/database/sqlite-cache-engine.ts +699 -0
  65. package/src/services/database/sqlite-worker.ts +116 -0
  66. package/src/services/database/surql-translate.ts +291 -0
  67. package/src/services/database/surreal-cache-engine.ts +122 -0
  68. package/src/services/logger/index.ts +6 -101
  69. package/src/services/persistence/localstorage.ts +2 -2
  70. package/src/services/persistence/resilient.ts +41 -0
  71. package/src/services/persistence/surrealdb.ts +10 -10
  72. package/src/services/stream-processor/index.ts +295 -38
  73. package/src/services/stream-processor/permissions.test.ts +47 -0
  74. package/src/services/stream-processor/permissions.ts +53 -0
  75. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  76. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  77. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  78. package/src/services/stream-processor/wasm-types.ts +18 -2
  79. package/src/sp00ky.auth-order.test.ts +92 -0
  80. package/src/sp00ky.init-query.test.ts +185 -0
  81. package/src/sp00ky.ts +1016 -0
  82. package/src/types.ts +258 -15
  83. package/src/utils/error-classification.test.ts +44 -0
  84. package/src/utils/error-classification.ts +7 -0
  85. package/src/utils/index.ts +35 -13
  86. package/src/utils/parser.ts +3 -2
  87. package/src/utils/surql.ts +24 -15
  88. package/src/utils/withRetry.test.ts +1 -1
  89. package/tsdown.config.ts +77 -1
  90. package/src/spooky.ts +0 -392
@@ -1,33 +1,146 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { MutationEvent, RecordVersionArray } from '../../types';
1
+ import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
2
+ import type { RecordVersionArray, RecordVersionDiff, SyncHealth, SyncHealthStatus } from '../../types';
3
3
  import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
4
- import { Logger } from '../../services/logger/index';
5
- import { DownEvent, DownQueue, UpEvent, UpQueue } from './queue/index';
6
- import { RecordId, Uuid } from 'surrealdb';
7
- import { ArraySyncer, createDiffFromDbOp } from './utils';
4
+ import type { Logger } from '../../services/logger/index';
5
+ import type { DownEvent, UpEvent} from './queue/index';
6
+ import { DownQueue, UpQueue } from './queue/index';
7
+ import type { RecordId, Uuid } from 'surrealdb';
8
+ import {
9
+ ArraySyncer,
10
+ buildListRefSelect,
11
+ buildSubqueryListRefSelect,
12
+ createDiffFromDbOp,
13
+ diffRecordVersionArray,
14
+ listRefPollDelayMs,
15
+ recordVersionArraysEqual,
16
+ resolveListRefPollInterval,
17
+ } from './utils';
8
18
  import { SyncEngine } from './engine';
9
19
  import { SyncScheduler } from './scheduler';
10
- import { SchemaStructure } from '@spooky-sync/query-builder';
11
- import { CacheModule } from '../cache/index';
12
- import { DataModule } from '../data/index';
13
- import { encodeRecordId, extractTablePart, parseDuration, surql } from '../../utils/index';
20
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
21
+ import type { CacheModule } from '../cache/index';
22
+ import type { DataModule } from '../data/index';
23
+ import { classifySyncError, encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
24
+ import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
14
25
 
15
26
  /**
16
- * The main synchronization engine for Spooky.
27
+ * Tunables for `Sp00kySync` construction.
28
+ */
29
+ export interface Sp00kySyncOptions {
30
+ /**
31
+ * Cadence (ms) for the `_00_list_ref` poll fallback that catches
32
+ * cross-session UPDATEs the LIVE-permission gap drops. Non-positive
33
+ * values fall back to the default; see
34
+ * {@link resolveListRefPollInterval}.
35
+ */
36
+ refSyncIntervalMs?: number;
37
+ /**
38
+ * Enable realtime sync for unauthenticated clients against the shared
39
+ * `_00_list_ref_anon` table. See {@link Sp00kyConfig.enableAnonymousLiveQueries}.
40
+ * Defaults to `false`.
41
+ */
42
+ anonymousLiveQueries?: boolean;
43
+ /**
44
+ * Consecutive failed sync rounds before sync health flips to `degraded`.
45
+ * `0` disables degraded reporting. See {@link Sp00kyConfig.syncHealth}.
46
+ * Defaults to `3`.
47
+ */
48
+ degradeAfterConsecutiveFailures?: number;
49
+ }
50
+
51
+ /**
52
+ * The main synchronization engine for Sp00ky.
17
53
  * Handles the bidirectional synchronization between the local database and the remote backend.
18
54
  * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
55
  * @template S The schema structure type.
20
56
  */
21
- export class SpookySync<S extends SchemaStructure> {
22
- private clientId: string = '';
57
+ export class Sp00kySync<S extends SchemaStructure> {
23
58
  private upQueue: UpQueue;
24
59
  private downQueue: DownQueue;
25
60
  private isInit: boolean = false;
26
61
  private logger: Logger;
27
62
  private syncEngine: SyncEngine;
63
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
64
+ * from `this.events`, which carries Sp00kySync-level events like
65
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
66
+ public get engineEvents() {
67
+ return this.syncEngine.events;
68
+ }
28
69
  private scheduler: SyncScheduler;
70
+ private wasDisconnected: boolean = false;
29
71
  public events = createSyncEventSystem();
30
72
 
73
+ // Auth identity that drives per-user `_00_list_ref_user_<id>` routing
74
+ // in `RefMode.Dedicated`. Updated by `setCurrentUserId` from the auth
75
+ // subscription in `Sp00kyClient`; null when unauthenticated.
76
+ private currentUserId: string | null = null;
77
+
78
+ private refMode: RefMode = DEFAULT_REF_MODE;
79
+
80
+ // When true, an unauthenticated client still runs the `_00_list_ref` poll
81
+ // and LIVE subscription, routed to the shared `_00_list_ref_anon` table, so
82
+ // a logged-out page gets realtime `useQuery` updates. Off by default.
83
+ private readonly anonLiveEnabled: boolean;
84
+
85
+ // Bookkeeping for the LIVE subscription on `_00_list_ref[_user_*]`.
86
+ // SurrealDB binds the permission context at LIVE-registration time and
87
+ // the table name in dedicated mode depends on the authenticated user,
88
+ // so we have to re-register whenever auth state flips.
89
+ private currentLiveQueryUuid: Uuid | null = null;
90
+ private liveQueryUnsubscribe: (() => void) | null = null;
91
+
92
+ // Periodic re-poll of `_00_list_ref` as a safety net for missed LIVE
93
+ // notifications. SurrealDB v3 occasionally drops LIVE deliveries
94
+ // across sessions even when the row matches the permission rule;
95
+ // this catches those without requiring users to reload. The
96
+ // interval is configurable via the constructor; see
97
+ // `resolveListRefPollInterval` for fallback semantics.
98
+ //
99
+ // Self-rescheduling rather than setInterval so each tick can pick
100
+ // its own delay via `nextPollDelayMs` — slows the poll down when
101
+ // LIVE is delivering events and speeds it back up when LIVE quiets.
102
+ private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
103
+ private listRefPollRunning: boolean = false;
104
+ // The currently-executing poll tick, if any. `stopListRefPoll` only stops
105
+ // future ticks; a bucket switch must also AWAIT the in-flight one so its
106
+ // local writes land in the store it started against.
107
+ private listRefPollInFlight: Promise<void> | null = null;
108
+ public readonly refSyncIntervalMs: number;
109
+
110
+ // Consecutive poll cycles that observed NO list_ref change. Drives the
111
+ // adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
112
+ // page coasts from the fast base cadence toward the 5s cap, and any activity
113
+ // (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
114
+ // back to responsive. Replaces the old LIVE-liveness backoff, which kept the
115
+ // poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
116
+ // on a quiet page, thanks to the cross-session LIVE-permission gap).
117
+ private listRefIdleStreak: number = 0;
118
+
119
+ // `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
120
+ // remote" (left the query's list_ref but still exists upstream). Used to
121
+ // distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
122
+ // converged once it crosses the threshold) from a record that's merely
123
+ // mid-deletion (still-remote for ~one round, then gone) — which must NOT be
124
+ // converged, or it gets stranded in this window before its delete is observed.
125
+ private stillRemoteStreaks: Map<string, number> = new Map();
126
+
127
+ // Wall-clock timestamp (ms) of the most recent LIVE event delivered
128
+ // through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
129
+ // signal; the poll cadence is now driven by `listRefIdleStreak`.
130
+ private lastLiveEventAt: number | null = null;
131
+
132
+ // Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
133
+ // had to retry on `setCurrentUserId`. Stays at 0 when the SSP has
134
+ // pre-emptively created the user's dedicated tables; otherwise
135
+ // increments on each retry attempt until LIVE succeeds or attempts
136
+ // are exhausted. Surfaced as a diagnostic so the e2e suite can prove
137
+ // the pre-emptive table-creation path is keeping the first sign-in
138
+ // off the lazy-creation race.
139
+ private _liveRetryCount: number = 0;
140
+ public get liveRetryCount(): number {
141
+ return this._liveRetryCount;
142
+ }
143
+
31
144
  get isSyncing() {
32
145
  return this.scheduler.isSyncing;
33
146
  }
@@ -51,15 +164,183 @@ export class SpookySync<S extends SchemaStructure> {
51
164
  };
52
165
  }
53
166
 
167
+ // ---- Sync health -------------------------------------------------------
168
+ // `0` disables degraded reporting (config `syncHealth: false`). Resolved
169
+ // from config in Sp00kyClient and passed through the constructor options.
170
+ private readonly degradeAfterFailures: number;
171
+ private consecutiveSyncFailures = 0;
172
+ private syncHealthStatus: SyncHealthStatus = 'healthy';
173
+ private lastSyncErrorKind: 'network' | 'application' | undefined;
174
+ private lastSyncErrorMessage: string | undefined;
175
+ // Latched `true` on the first successful sync round; never reset. Lets a UI
176
+ // tell a cold-start "connecting" phase (never reached the server) apart from
177
+ // a real lost connection after a working session.
178
+ private hasSyncedOnce = false;
179
+
180
+ // Self-heal: while degraded, re-drive sync on an exponential backoff so the
181
+ // app recovers on its own — even when the socket never actually dropped (in
182
+ // that case no `connected` event fires, so this re-registration is the ONLY
183
+ // thing that re-probes the server). Started on the degrade transition,
184
+ // cleared on recovery. Capped cadence so a long outage doesn't busy-loop.
185
+ private selfHealTimer: ReturnType<typeof setTimeout> | null = null;
186
+ private selfHealAttempts = 0;
187
+ private static readonly SELF_HEAL_BASE_MS = 2_000;
188
+ private static readonly SELF_HEAL_MAX_MS = 30_000;
189
+
190
+ /** Current sync-health snapshot. */
191
+ get syncHealth(): SyncHealth {
192
+ return {
193
+ status: this.syncHealthStatus,
194
+ consecutiveFailures: this.consecutiveSyncFailures,
195
+ kind: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorKind : undefined,
196
+ error: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorMessage : undefined,
197
+ everConnected: this.hasSyncedOnce,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Observe sync health. The callback fires immediately with the current
203
+ * status and again on every healthy↔degraded transition. Returns an
204
+ * unsubscribe. Mirrors {@link subscribeToPendingMutations}.
205
+ */
206
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
207
+ cb(this.syncHealth);
208
+ const id = this.events.subscribe(SyncEventTypes.SyncHealthChanged, (event) =>
209
+ cb(event.payload)
210
+ );
211
+ return () => this.events.unsubscribe(id);
212
+ }
213
+
214
+ private emitSyncHealth(): void {
215
+ this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
216
+ }
217
+
218
+ /**
219
+ * Fed by the scheduler once per drained sync round. Individual failures are
220
+ * absorbed by the queue's retry; only a run of `degradeAfterFailures`
221
+ * consecutive failures flips the status to `degraded`, and the next clean
222
+ * round flips it back. No-op when reporting is disabled (`degradeAfterFailures`
223
+ * is 0).
224
+ */
225
+ private recordSyncOutcome(ok: boolean, error?: unknown): void {
226
+ if (this.degradeAfterFailures <= 0) return;
227
+ if (ok) {
228
+ // Latch first-ever success so a UI can drop the connecting phase. Set
229
+ // before the early return so a clean cold start (0 prior failures) counts.
230
+ this.hasSyncedOnce = true;
231
+ if (this.consecutiveSyncFailures === 0) return;
232
+ this.consecutiveSyncFailures = 0;
233
+ if (this.syncHealthStatus !== 'healthy') {
234
+ this.syncHealthStatus = 'healthy';
235
+ this.lastSyncErrorKind = undefined;
236
+ this.lastSyncErrorMessage = undefined;
237
+ this.stopSelfHeal();
238
+ this.logger.info(
239
+ { Category: 'sp00ky-client::Sp00kySync::syncHealth' },
240
+ 'Sync recovered; health back to healthy'
241
+ );
242
+ this.emitSyncHealth();
243
+ }
244
+ return;
245
+ }
246
+ this.consecutiveSyncFailures++;
247
+ this.lastSyncErrorKind = classifySyncError(error);
248
+ this.lastSyncErrorMessage = error instanceof Error ? error.message : String(error);
249
+ if (
250
+ this.syncHealthStatus !== 'degraded' &&
251
+ this.consecutiveSyncFailures >= this.degradeAfterFailures
252
+ ) {
253
+ this.syncHealthStatus = 'degraded';
254
+ this.logger.warn(
255
+ {
256
+ consecutiveFailures: this.consecutiveSyncFailures,
257
+ kind: this.lastSyncErrorKind,
258
+ error,
259
+ Category: 'sp00ky-client::Sp00kySync::syncHealth',
260
+ },
261
+ 'Sync degraded after sustained failures'
262
+ );
263
+ this.emitSyncHealth();
264
+ this.startSelfHeal();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Begin self-heal retries (no-op if already running). Started on the
270
+ * healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
271
+ */
272
+ private startSelfHeal(): void {
273
+ if (this.selfHealTimer !== null) return;
274
+ this.selfHealAttempts = 0;
275
+ this.scheduleSelfHeal();
276
+ }
277
+
278
+ private scheduleSelfHeal(): void {
279
+ const delay = Math.min(
280
+ Sp00kySync.SELF_HEAL_MAX_MS,
281
+ Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts
282
+ );
283
+ this.selfHealTimer = setTimeout(async () => {
284
+ this.selfHealTimer = null;
285
+ if (this.syncHealthStatus !== 'degraded') return;
286
+ this.selfHealAttempts++;
287
+ this.logger.debug(
288
+ { attempt: this.selfHealAttempts, delayMs: delay, Category: 'sp00ky-client::Sp00kySync::selfHeal' },
289
+ 'Self-heal: re-driving sync while degraded'
290
+ );
291
+ try {
292
+ // Retry whatever is still queued first; the failing op (register or
293
+ // mutation) was re-queued by the queue, so this re-probes the server
294
+ // and reports the outcome through the scheduler → recordSyncOutcome.
295
+ if (this.upQueue.size > 0) {
296
+ await this.scheduler.syncUp();
297
+ } else if (this.downQueue.size > 0) {
298
+ await this.scheduler.syncDown();
299
+ } else {
300
+ // Nothing queued (e.g. the failing op was rolled back + dropped):
301
+ // re-register active queries — mirroring the reconnect handler — so
302
+ // there's a concrete op whose success flips health. If there are no
303
+ // active queries either, probe connectivity directly.
304
+ const hashes = this.dataModule.getActiveQueryHashes();
305
+ if (hashes.length > 0) {
306
+ for (const hash of hashes) {
307
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
308
+ }
309
+ await this.scheduler.syncDown();
310
+ } else {
311
+ await this.remote.query('RETURN true');
312
+ this.recordSyncOutcome(true);
313
+ }
314
+ }
315
+ } catch (err) {
316
+ // Only the direct connectivity probe can throw here (syncUp/syncDown
317
+ // swallow + self-report); treat a probe failure as another failed round.
318
+ this.recordSyncOutcome(false, err);
319
+ }
320
+ // Keep retrying until recovery. recordSyncOutcome(true) calls stopSelfHeal
321
+ // (clearing any pending timer), so only continue while still degraded.
322
+ if (this.syncHealthStatus === 'degraded') this.scheduleSelfHeal();
323
+ }, delay);
324
+ }
325
+
326
+ private stopSelfHeal(): void {
327
+ if (this.selfHealTimer !== null) {
328
+ clearTimeout(this.selfHealTimer);
329
+ this.selfHealTimer = null;
330
+ }
331
+ this.selfHealAttempts = 0;
332
+ }
333
+
54
334
  constructor(
55
- private local: LocalDatabaseService,
335
+ private local: LocalStore,
56
336
  private remote: RemoteDatabaseService,
57
337
  private cache: CacheModule,
58
338
  private dataModule: DataModule<S>,
59
339
  private schema: S,
60
- logger: Logger
340
+ logger: Logger,
341
+ options?: Sp00kySyncOptions
61
342
  ) {
62
- this.logger = logger.child({ service: 'SpookySync' });
343
+ this.logger = logger.child({ service: 'Sp00kySync' });
63
344
  this.upQueue = new UpQueue(this.local, this.logger);
64
345
  this.downQueue = new DownQueue(this.local, this.logger);
65
346
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
@@ -69,43 +350,466 @@ export class SpookySync<S extends SchemaStructure> {
69
350
  this.processUpEvent.bind(this),
70
351
  this.processDownEvent.bind(this),
71
352
  this.logger,
72
- this.handleRollback.bind(this)
353
+ this.handleRollback.bind(this),
354
+ this.recordSyncOutcome.bind(this)
73
355
  );
356
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
357
+ this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
358
+ this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
74
359
  }
75
360
 
76
361
  /**
77
362
  * Initializes the synchronization system.
78
363
  * Starts the scheduler and initiates the initial sync cycles.
79
- * @param clientId The unique identifier for this client instance.
80
364
  * @throws Error if already initialized.
81
365
  */
82
- public async init(clientId: string) {
83
- if (this.isInit) throw new Error('SpookySync is already initialized');
84
- this.clientId = clientId;
366
+ public async init() {
367
+ if (this.isInit) throw new Error('Sp00kySync is already initialized');
85
368
  this.isInit = true;
86
369
  await this.scheduler.init();
87
- void this.scheduler.syncUp();
370
+ this.subscribeToReconnect();
88
371
  void this.scheduler.syncUp();
89
372
  void this.scheduler.syncDown();
90
- void this.startRefLiveQueries();
373
+ // No initial LIVE subscription — wait for `setCurrentUserId` to fire
374
+ // from the auth subscription. In dedicated mode the table name
375
+ // depends on the authenticated user, and an unauthenticated
376
+ // subscription wouldn't match any of the per-user tables anyway.
377
+ //
378
+ // Exception: when anonymous live queries are enabled, start realtime now
379
+ // against the shared `_00_list_ref_anon` table so a logged-out client
380
+ // syncs immediately. `setCurrentUserId` re-points LIVE to the per-user
381
+ // table on sign-in. Guard on `currentUserId` because the auth callback can
382
+ // fire (and authenticate) before `init()` runs — don't clobber that back
383
+ // to the anon table. `setCurrentUserId(null)` is a no-op on first load
384
+ // (it's already null), so this is the only place anon realtime starts.
385
+ if (this.anonLiveEnabled && !this.currentUserId) {
386
+ this.startListRefPoll();
387
+ this.restartRefLiveQuery().catch((err) => {
388
+ this.logger.debug(
389
+ { err, Category: 'sp00ky-client::Sp00kySync::init' },
390
+ 'Anonymous ref LIVE start failed; relying on periodic poll fallback'
391
+ );
392
+ });
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Quiesce all sync activity ahead of a local-bucket switch. After this
398
+ * resolves, nothing in the sync module writes to the local store: the poll
399
+ * loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
400
+ * timers are cancelled (their outbox rows are already persisted), and the
401
+ * scheduler has drained its in-flight queue item — including that item's
402
+ * outbox-row delete, which must land in the OLD bucket. Queued down-events
403
+ * are dropped (they reference old-bucket query rows; the post-switch rebind
404
+ * re-enqueues registrations). The old user's un-pushed outbox is deliberately
405
+ * NOT drained: the remote session already belongs to the next user.
406
+ */
407
+ public async prepareBucketSwitch(): Promise<void> {
408
+ this.stopSelfHeal();
409
+ this.stopListRefPoll();
410
+ if (this.listRefPollInFlight) await this.listRefPollInFlight;
411
+ await this.killRefLiveQuery();
412
+ this.upQueue.clearDebounceTimers();
413
+ await this.scheduler.pause();
414
+ this.downQueue.clear();
415
+ this.stillRemoteStreaks.clear();
416
+ this.logger.info(
417
+ { Category: 'sp00ky-client::Sp00kySync::prepareBucketSwitch' },
418
+ 'Sync quiesced for bucket switch'
419
+ );
420
+ }
421
+
422
+ /**
423
+ * Resume syncing against the freshly-opened bucket: reload the mutation
424
+ * outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
425
+ * offline work) and restart the scheduler. LIVE + the list_ref poll restart
426
+ * via the `setCurrentUserId` call that follows in the auth listener.
427
+ */
428
+ public async completeBucketSwitch(): Promise<void> {
429
+ await this.upQueue.loadFromDatabase();
430
+ this.scheduler.resume();
431
+ this.logger.info(
432
+ { Category: 'sp00ky-client::Sp00kySync::completeBucketSwitch' },
433
+ 'Sync resumed after bucket switch'
434
+ );
435
+ }
436
+
437
+ /**
438
+ * Push the authenticated user's record id from the parent client's
439
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
440
+ * any) and re-registers it under the new user's dedicated table so
441
+ * SurrealDB binds the permission rule under the post-flip auth
442
+ * context. Pass `null` on sign-out.
443
+ *
444
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
445
+ * the SSP when the first query registration arrives, which may be
446
+ * concurrent with this call. We retry the LIVE registration with a
447
+ * short backoff so a "table not found" race resolves without
448
+ * surfacing as a permanent auth-loading hang.
449
+ */
450
+ public async setCurrentUserId(userId: string | null): Promise<void> {
451
+ if (this.currentUserId === userId) return;
452
+ this.currentUserId = userId;
453
+ if (!userId) {
454
+ if (this.anonLiveEnabled) {
455
+ // Signed out but anonymous realtime is on: keep the poll running and
456
+ // re-point LIVE from the (now stale) per-user table to the shared
457
+ // `_00_list_ref_anon`. `startListRefPoll` is idempotent; the poll
458
+ // re-resolves `listRefTable()` each tick so it follows automatically.
459
+ this.startListRefPoll();
460
+ await this.restartRefLiveQuery().catch((err) => {
461
+ this.logger.debug(
462
+ { err, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
463
+ 'Anonymous ref LIVE restart failed; relying on periodic poll fallback'
464
+ );
465
+ });
466
+ return;
467
+ }
468
+ await this.killRefLiveQuery();
469
+ this.stopListRefPoll();
470
+ return;
471
+ }
472
+ // Start periodic polling FIRST so we have a deterministic fallback
473
+ // even when LIVE registration fails or SurrealDB drops a delivery.
474
+ this.startListRefPoll();
475
+ // Try to start LIVE with backoff for low-latency delivery on the
476
+ // happy path; the poll handles the rest.
477
+ const attemptDelays = [0, 250, 500, 1000, 2000];
478
+ for (let i = 0; i < attemptDelays.length; i++) {
479
+ if (attemptDelays[i] > 0) {
480
+ this._liveRetryCount++;
481
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
482
+ }
483
+ try {
484
+ await this.restartRefLiveQuery();
485
+ return;
486
+ } catch (err) {
487
+ this.logger.debug(
488
+ { err, attempt: i + 1, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
489
+ 'Ref LIVE start failed; relying on periodic poll fallback'
490
+ );
491
+ }
492
+ }
493
+ }
494
+
495
+ private startListRefPoll(): void {
496
+ if (this.listRefPollRunning) return;
497
+ this.listRefPollRunning = true;
498
+ this.logger.debug(
499
+ {
500
+ intervalMs: this.refSyncIntervalMs,
501
+ Category: 'sp00ky-client::Sp00kySync::startListRefPoll',
502
+ },
503
+ 'list_ref poll loop started'
504
+ );
505
+ const schedule = (delayMs: number) => {
506
+ this.listRefPollTimer = setTimeout(async () => {
507
+ if (!this.listRefPollRunning) return;
508
+ let changed = false;
509
+ const tick = (async () => {
510
+ changed = await this.pollListRefForActiveQueries();
511
+ })();
512
+ this.listRefPollInFlight = tick.catch(() => {});
513
+ try {
514
+ await tick;
515
+ } finally {
516
+ this.listRefPollInFlight = null;
517
+ if (!this.listRefPollRunning) return;
518
+ // Reset the idle streak on any observed change so the poll snaps
519
+ // back to the fast base cadence; otherwise grow it so a quiet page
520
+ // backs off toward the cap. (`handleRemoteListRefChange` also resets
521
+ // it when a LIVE event lands.)
522
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
523
+ const next = listRefPollDelayMs({
524
+ idleStreak: this.listRefIdleStreak,
525
+ baseIntervalMs: this.refSyncIntervalMs,
526
+ });
527
+ schedule(next);
528
+ }
529
+ }, delayMs);
530
+ };
531
+ schedule(this.refSyncIntervalMs);
532
+ }
533
+
534
+ private stopListRefPoll(): void {
535
+ this.listRefPollRunning = false;
536
+ if (this.listRefPollTimer !== null) {
537
+ clearTimeout(this.listRefPollTimer);
538
+ this.listRefPollTimer = null;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
544
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
545
+ * to drive the adaptive idle backoff.
546
+ *
547
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
548
+ * otherwise activity-driven (mutations/registrations via the scheduler,
549
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
550
+ * would linger until the next mutation and a genuine idle drop would be
551
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
552
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
553
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
554
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
555
+ */
556
+ private async pollListRefForActiveQueries(): Promise<boolean> {
557
+ const hashes = this.dataModule.getActiveQueryHashes();
558
+ if (hashes.length === 0) {
559
+ // No active queries to piggyback on, but health still needs a heartbeat —
560
+ // probe connectivity directly so an idle page with no live queries doesn't
561
+ // go blind. Cheap, and gated by the same adaptive backoff (≤5s idle cap).
562
+ try {
563
+ await this.remote.query('RETURN true');
564
+ this.recordSyncOutcome(true);
565
+ } catch (err) {
566
+ this.recordSyncOutcome(false, err);
567
+ }
568
+ return false;
569
+ }
570
+ let anyChanged = false;
571
+ // `reached` = the server answered at least once this cycle (a success, or an
572
+ // *application* error, which still proves reachability). `firstNetworkErr`
573
+ // holds the first network-classified failure. A cycle that only produced
574
+ // network errors reports the outcome as a down round; a mixed/app cycle counts
575
+ // as reached; an all-application cycle reports nothing (that's a query-shape
576
+ // fault owned by the registration path, not a reachability signal).
577
+ let reached = false;
578
+ let firstNetworkErr: unknown;
579
+ for (const hash of hashes) {
580
+ try {
581
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
582
+ reached = true;
583
+ } catch (err) {
584
+ if (classifySyncError(err) === 'network') {
585
+ if (firstNetworkErr === undefined) firstNetworkErr = err;
586
+ } else {
587
+ reached = true;
588
+ }
589
+ this.logger.debug(
590
+ { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
591
+ 'Per-query list_ref poll failed'
592
+ );
593
+ }
594
+ }
595
+ // Call the private outcome recorder directly rather than routing through the
596
+ // scheduler — the scheduler only reports on rounds that drained ≥1 queue item
597
+ // (`processedAny`), and this isn't a queue round.
598
+ if (reached) {
599
+ this.recordSyncOutcome(true);
600
+ } else if (firstNetworkErr !== undefined) {
601
+ this.recordSyncOutcome(false, firstNetworkErr);
602
+ }
603
+ return anyChanged;
604
+ }
605
+
606
+ /**
607
+ * Pull the upstream list_ref entries for `queryHash`, diff them
608
+ * against the local `remoteArray` cache, sync any added/updated rows
609
+ * through the SyncEngine, then persist the new remoteArray. This is
610
+ * the same shape `createRemoteQuery` does for its initial fetch and
611
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
612
+ * it on a timer as a fallback for missed LIVE notifications.
613
+ */
614
+ private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
615
+ const queryState = this.dataModule.getQueryByHash(queryHash);
616
+ if (!queryState) return false;
617
+ const listRefTbl = this.listRefTable();
618
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
619
+ buildListRefSelect(listRefTbl),
620
+ { in: queryState.config.id }
621
+ );
622
+ if (!Array.isArray(items)) return false;
623
+ const fresh: RecordVersionArray = items.map((item) => [
624
+ encodeRecordId(item.out),
625
+ item.version,
626
+ ]);
627
+ // Capture which ids LEFT the query's window (present in the cached
628
+ // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
629
+ // are cross-window deletes (or rows that scrolled out). They drive the
630
+ // forced re-render below.
631
+ const prevRemote = queryState.config.remoteArray ?? [];
632
+ const freshIds = new Set(fresh.map(([id]) => id));
633
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
634
+ // Idempotent poll: only persist the remoteArray when it actually changed.
635
+ // The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
636
+ // equals the cached array every tick — re-writing it (an `UPDATE _00_query`
637
+ // each cycle, per active query) was pure churn and the bulk of the idle
638
+ // traffic. `recordVersionArraysEqual` is order-insensitive because the
639
+ // list_ref SELECT has no `ORDER BY`.
640
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
641
+ if (changed) {
642
+ // Update the cached remoteArray so the next diff/sync sees the new state.
643
+ // `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
644
+ // the local DB row and ingests it into the in-browser SSP — the SSP's
645
+ // stream updates run `processStreamUpdate`, which re-queries the local DB
646
+ // and notifies subscribers. We skip an explicit `notifyQuerySynced`
647
+ // because that path races the stream-update path (can notify with stale
648
+ // records).
649
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
650
+ }
651
+ // Run `syncQuery` every tick regardless: it's a no-op when localArray has
652
+ // caught up to remoteArray (`if (!diff) return`, issues no query), but it
653
+ // covers the rare case where remoteArray is stable yet localArray is behind
654
+ // (a prior record fetch failed) — so a missed row still gets retried.
655
+ // For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
656
+ // confirmed-gone records from the local DB.
657
+ try {
658
+ await this.syncQuery(queryHash);
659
+ } catch (err) {
660
+ this.logger.info(
661
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
662
+ 'syncQuery failed during poll'
663
+ );
664
+ }
665
+ // Cross-session fallback for `.related()` child rows: the LIVE-permission
666
+ // gap can drop child-edge notifications, so converge their bodies on the
667
+ // poll too (idempotent — no-op when nothing changed).
668
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
669
+ this.logger.info(
670
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
671
+ 'Subquery child sync failed during poll'
672
+ );
673
+ });
674
+ // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
675
+ // get a re-render from the SSP stream on this code path reliably (and the
676
+ // non-windowed window-0 query re-queries the local DB rather than the id-set).
677
+ // Force a re-materialize + notify so the deleted row drops from the list in
678
+ // this (second) window — the reliable, LIVE-independent cross-window path.
679
+ if (removedIds.length > 0) {
680
+ try {
681
+ await this.dataModule.notifyQuerySynced(queryHash);
682
+ } catch (err) {
683
+ this.logger.info(
684
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
685
+ 'notifyQuerySynced failed during poll-removal re-render'
686
+ );
687
+ }
688
+ }
689
+ return changed;
690
+ }
691
+
692
+ /**
693
+ * Resolve the current `_00_list_ref` table name for the active auth
694
+ * context. Public so the `createRemoteQuery` initial-fetch path can
695
+ * read from the right per-user table.
696
+ *
697
+ * Reads the user id from `DataModule` rather than the local mirror,
698
+ * because `DataModule.setCurrentUserId` runs synchronously from the
699
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
700
+ * is async — the userQuery's initial fetch can fire between those
701
+ * two points and we need the correct table name immediately.
702
+ */
703
+ public listRefTable(): string {
704
+ const userId = this.dataModule.getCurrentUserId();
705
+ // Unauthenticated with the flag on → the shared `_00_list_ref_anon` table.
706
+ if (userId == null && this.anonLiveEnabled) {
707
+ return listRefTableFor(this.refMode, ANON_USER_ID);
708
+ }
709
+ return listRefTableFor(this.refMode, userId);
710
+ }
711
+
712
+ private async killRefLiveQuery(): Promise<void> {
713
+ if (this.liveQueryUnsubscribe) {
714
+ try { this.liveQueryUnsubscribe(); } catch { /* ignore */ }
715
+ this.liveQueryUnsubscribe = null;
716
+ }
717
+ if (this.currentLiveQueryUuid !== null) {
718
+ try {
719
+ await this.remote.query('KILL $u', { u: this.currentLiveQueryUuid });
720
+ } catch (err) {
721
+ this.logger.debug(
722
+ { err, Category: 'sp00ky-client::Sp00kySync::killRefLiveQuery' },
723
+ 'Prior LIVE KILL failed; continuing'
724
+ );
725
+ }
726
+ this.currentLiveQueryUuid = null;
727
+ }
728
+ }
729
+
730
+ private async restartRefLiveQuery(): Promise<void> {
731
+ await this.killRefLiveQuery();
732
+ await this.startRefLiveQueries();
733
+ }
734
+
735
+ // Only the connect that follows a prior disconnect counts as a
736
+ // reconnect; the initial connect after init() must not trigger a
737
+ // refetch storm.
738
+ private subscribeToReconnect() {
739
+ const client = this.remote.getClient();
740
+ client.subscribe('disconnected', () => {
741
+ this.wasDisconnected = true;
742
+ this.logger.info(
743
+ { Category: 'sp00ky-client::Sp00kySync::onDisconnect' },
744
+ 'Remote disconnected'
745
+ );
746
+ });
747
+ client.subscribe('connected', () => {
748
+ if (!this.wasDisconnected) return;
749
+ this.wasDisconnected = false;
750
+ this.logger.info(
751
+ { Category: 'sp00ky-client::Sp00kySync::onReconnect' },
752
+ 'Remote reconnected, refetching active queries'
753
+ );
754
+ for (const hash of this.dataModule.getActiveQueryHashes()) {
755
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
756
+ }
757
+ // The WS reconnect leaves the server-side LIVE subscription dead — the
758
+ // re-enqueued `register` events only re-fetch initial state, they don't
759
+ // re-subscribe. Without this, LIVE never recovers after a reconnect and
760
+ // the poll silently becomes the sole sync path (and never backs off).
761
+ // Authenticated → per-user table; signed-out with anon live enabled →
762
+ // the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
763
+ if (this.currentUserId || this.anonLiveEnabled) {
764
+ this.restartRefLiveQuery().catch((err) => {
765
+ this.logger.debug(
766
+ { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
767
+ 'LIVE restart after reconnect failed; relying on poll fallback'
768
+ );
769
+ });
770
+ }
771
+ });
91
772
  }
92
773
 
93
774
  private async startRefLiveQueries() {
775
+ const tableName = this.listRefTable();
94
776
  this.logger.debug(
95
- { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
777
+ { tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
96
778
  'Starting ref live queries'
97
779
  );
98
780
 
99
781
  const [queryUuid] = await this.remote.query<[Uuid]>(
100
- 'LIVE SELECT * FROM _spooky_list_ref'
782
+ `LIVE SELECT * FROM ${tableName}`
101
783
  );
784
+ this.currentLiveQueryUuid = queryUuid;
102
785
 
103
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
786
+ const live = await this.remote.getClient().liveOf(queryUuid);
787
+ this.liveQueryUnsubscribe = live.subscribe((message) => {
104
788
  this.logger.debug(
105
- { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
789
+ { message, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
106
790
  'Live update received'
107
791
  );
108
792
  if (message.action === 'KILLED') return;
793
+ // Subquery child edges (rows with `parent` set) are NOT primary window
794
+ // rows — the client's `RecordVersionArray` only tracks primary rows, so
795
+ // routing them through `handleRemoteListRefChange` would surface them as
796
+ // spurious "added" diffs and pollute the window. Instead route them to
797
+ // the dedicated child-body sync so `.related()` data stays realtime
798
+ // cross-session (the LIVE-permission gap otherwise leaves it to the poll).
799
+ if ((message.value as { parent?: unknown }).parent != null) {
800
+ this.handleRemoteSubqueryChange(
801
+ message.action,
802
+ message.value.in as RecordId<string>,
803
+ message.value.out as RecordId<string>,
804
+ message.value.version as number
805
+ ).catch((err) => {
806
+ this.logger.error(
807
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
808
+ 'Error handling remote subquery change'
809
+ );
810
+ });
811
+ return;
812
+ }
109
813
  this.handleRemoteListRefChange(
110
814
  message.action,
111
815
  message.value.in as RecordId<string>,
@@ -113,7 +817,7 @@ export class SpookySync<S extends SchemaStructure> {
113
817
  message.value.version as number
114
818
  ).catch((err) => {
115
819
  this.logger.error(
116
- { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
820
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
117
821
  'Error handling remote list ref change'
118
822
  );
119
823
  });
@@ -126,13 +830,26 @@ export class SpookySync<S extends SchemaStructure> {
126
830
  recordId: RecordId,
127
831
  version: number
128
832
  ) {
833
+ // Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
834
+ // query's window, or a notification for an unknown local query. Reset the
835
+ // poll's idle streak so it snaps back to the fast base cadence (the page
836
+ // is clearly not idle), and record the timestamp as a liveness diagnostic.
837
+ this.lastLiveEventAt = Date.now();
838
+ this.listRefIdleStreak = 0;
839
+
840
+ // NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
841
+ // this one) deletes a record, the server's SSP removes it from `_00_list_ref`
842
+ // and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
843
+ // turns it into a `removed: [recordId]` diff so the row drops from the window
844
+ // in realtime. (It was previously ignored, so other windows only caught up on
845
+ // reload / the slow poll.)
129
846
  const existing = this.dataModule.getQueryById(queryId);
130
847
 
131
848
  if (!existing) {
132
849
  this.logger.warn(
133
850
  {
134
851
  queryId: queryId.toString(),
135
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
852
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
136
853
  },
137
854
  'Received remote update for unknown local query'
138
855
  );
@@ -148,12 +865,55 @@ export class SpookySync<S extends SchemaStructure> {
148
865
  recordId,
149
866
  version,
150
867
  localArray,
151
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
868
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
152
869
  },
153
870
  'Live update is being processed'
154
871
  );
155
872
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
- await this.syncEngine.syncRecords(diff);
873
+ // `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
874
+ // (a SHA-256 over query content + sessionId) — the key DataModule uses.
875
+ const hash = extractIdPart(existing.config.id);
876
+ await this.runSyncForQuery(hash, diff);
877
+ }
878
+
879
+ /**
880
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
881
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
882
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
883
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
884
+ * subquery-table dependency re-materializes the parent view.
885
+ *
886
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
887
+ * no-op: a child leaving this query's set must not delete a body another
888
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
889
+ * a genuine record delete propagates via the normal delete path.
890
+ */
891
+ private async handleRemoteSubqueryChange(
892
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
893
+ queryId: RecordId,
894
+ childId: RecordId,
895
+ version: number
896
+ ) {
897
+ this.lastLiveEventAt = Date.now();
898
+ this.listRefIdleStreak = 0;
899
+
900
+ if (action === 'DELETE') return;
901
+
902
+ const existing = this.dataModule.getQueryById(queryId);
903
+ if (!existing) return;
904
+
905
+ const item = { id: childId, version };
906
+ await this.syncEngine.syncRecords(
907
+ action === 'CREATE'
908
+ ? { added: [item], updated: [], removed: [] }
909
+ : { added: [], updated: [item], removed: [] }
910
+ );
911
+
912
+ // Keep the in-memory child array in step so the poll's idempotent diff
913
+ // doesn't re-fetch this body on the next tick.
914
+ const key = encodeRecordId(childId);
915
+ const prev = existing.config.subqueryRemoteArray ?? [];
916
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
157
917
  }
158
918
 
159
919
  /**
@@ -166,12 +926,11 @@ export class SpookySync<S extends SchemaStructure> {
166
926
 
167
927
  private async processUpEvent(event: UpEvent) {
168
928
  this.logger.debug(
169
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
929
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
170
930
  'Processing up event'
171
931
  );
172
- console.log('xx1', event);
173
932
  switch (event.type) {
174
- case 'create':
933
+ case 'create': {
175
934
  const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
935
  const prefixedParams = Object.fromEntries(
177
936
  dataKeys.map(({ key, variable }) => [variable, event.data[key]])
@@ -182,6 +941,7 @@ export class SpookySync<S extends SchemaStructure> {
182
941
  ...prefixedParams,
183
942
  });
184
943
  break;
944
+ }
185
945
  case 'update':
186
946
  await this.remote.query(`UPDATE $id MERGE $data`, {
187
947
  id: event.record_id,
@@ -195,7 +955,7 @@ export class SpookySync<S extends SchemaStructure> {
195
955
  break;
196
956
  default:
197
957
  this.logger.error(
198
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
958
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
199
959
  'processUpEvent unknown event type'
200
960
  );
201
961
  return;
@@ -215,7 +975,7 @@ export class SpookySync<S extends SchemaStructure> {
215
975
  recordId,
216
976
  tableName,
217
977
  error: error.message,
218
- Category: 'spooky-client::SpookySync::handleRollback',
978
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
219
979
  },
220
980
  'Rolling back failed mutation'
221
981
  );
@@ -231,7 +991,7 @@ export class SpookySync<S extends SchemaStructure> {
231
991
  this.logger.warn(
232
992
  {
233
993
  recordId,
234
- Category: 'spooky-client::SpookySync::handleRollback',
994
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
235
995
  },
236
996
  'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
997
  );
@@ -241,7 +1001,7 @@ export class SpookySync<S extends SchemaStructure> {
241
1001
  this.logger.warn(
242
1002
  {
243
1003
  recordId,
244
- Category: 'spooky-client::SpookySync::handleRollback',
1004
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
245
1005
  },
246
1006
  'Delete rollback not implemented. Down-sync will reconcile.'
247
1007
  );
@@ -257,7 +1017,7 @@ export class SpookySync<S extends SchemaStructure> {
257
1017
 
258
1018
  private async processDownEvent(event: DownEvent) {
259
1019
  this.logger.debug(
260
- { event, Category: 'spooky-client::SpookySync::processDownEvent' },
1020
+ { event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
261
1021
  'Processing down event'
262
1022
  );
263
1023
  switch (event.type) {
@@ -281,7 +1041,7 @@ export class SpookySync<S extends SchemaStructure> {
281
1041
  const queryState = this.dataModule.getQueryByHash(hash);
282
1042
  if (!queryState) {
283
1043
  this.logger.warn(
284
- { hash, Category: 'spooky-client::SpookySync::syncQuery' },
1044
+ { hash, Category: 'sp00ky-client::Sp00kySync::syncQuery' },
285
1045
  'Query not found'
286
1046
  );
287
1047
  return;
@@ -295,7 +1055,115 @@ export class SpookySync<S extends SchemaStructure> {
295
1055
  if (!diff) {
296
1056
  return;
297
1057
  }
298
- return this.syncEngine.syncRecords(diff);
1058
+ return this.runSyncForQuery(hash, diff);
1059
+ }
1060
+
1061
+ /**
1062
+ * Run a sync for a single query while reflecting its fetch status. Marks the
1063
+ * query `fetching` for the duration when the diff actually pulls records
1064
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
1065
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
1066
+ * means the single resulting UI update lands after this completes.
1067
+ */
1068
+ private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
1069
+ // Don't let sync re-add a record the user just deleted locally. The remote
1070
+ // delete is queued in the outbox, so until it's processed the server's
1071
+ // `_00_list_ref` still lists the record — the diff then classifies it as
1072
+ // `added` (present remotely, absent locally) and `syncRecords` re-fetches +
1073
+ // re-inserts it, so a deleted database reappears a few seconds later. Drop
1074
+ // any id with a pending local DELETE from the re-add paths. Once the remote
1075
+ // delete lands, the pending row clears and the server drops it from
1076
+ // `_00_list_ref`, so this guard naturally stops applying.
1077
+ if (diff.added.length > 0 || diff.updated.length > 0) {
1078
+ const pendingDeletes = await this.getPendingDeleteIds();
1079
+ if (pendingDeletes.size > 0) {
1080
+ diff = {
1081
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
1082
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
1083
+ removed: diff.removed,
1084
+ };
1085
+ }
1086
+ }
1087
+
1088
+ const fetching = diff.added.length + diff.updated.length > 0;
1089
+ if (fetching) {
1090
+ this.dataModule.beginFetching(hash);
1091
+ }
1092
+ try {
1093
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
1094
+ if (fetching) {
1095
+ this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
1096
+ }
1097
+ // Converge localArray to the authoritative remoteArray for ids that left
1098
+ // the server's list_ref but still exist — a view-membership change, not a
1099
+ // delete — so the poll's diff stops re-flagging them every tick (the `job:`
1100
+ // churn). CRUCIAL: only converge after the id has been still-remote for
1101
+ // several CONSECUTIVE rounds. A record that's merely mid-deletion is
1102
+ // still-remote for ~one round (its delete hasn't committed when our
1103
+ // existence check races it) and is gone the next round → it never reaches
1104
+ // the threshold, so it's deleted normally instead of being stranded here.
1105
+ if (stillRemoteIds.length > 0) {
1106
+ const CONVERGE_AFTER = 3;
1107
+ const toConverge: string[] = [];
1108
+ for (const id of stillRemoteIds) {
1109
+ const key = `${hash}:${id}`;
1110
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
1111
+ if (n >= CONVERGE_AFTER) {
1112
+ this.stillRemoteStreaks.delete(key);
1113
+ toConverge.push(id);
1114
+ } else {
1115
+ this.stillRemoteStreaks.set(key, n);
1116
+ }
1117
+ }
1118
+ if (toConverge.length > 0) {
1119
+ const qs = this.dataModule.getQueryByHash(hash);
1120
+ const local = qs?.config.localArray;
1121
+ if (local && local.length > 0) {
1122
+ const drop = new Set(toConverge);
1123
+ const next = local.filter(([id]) => !drop.has(id));
1124
+ if (next.length !== local.length) {
1125
+ await this.dataModule.updateQueryLocalArray(hash, next);
1126
+ }
1127
+ }
1128
+ }
1129
+ }
1130
+ } finally {
1131
+ if (fetching) {
1132
+ // Land the coalesced result BEFORE flipping to idle: the final stream
1133
+ // update sits on a debounce timer, and an `idle` that races ahead of it
1134
+ // would let consumers treat a partially-filled window as authoritative.
1135
+ try {
1136
+ await this.dataModule.flushPendingStreamUpdate(hash);
1137
+ } catch (err) {
1138
+ this.logger.warn(
1139
+ { err, hash, Category: 'sp00ky-client::Sp00kySync::runSyncForQuery' },
1140
+ 'Failed to flush pending stream update before idle'
1141
+ );
1142
+ }
1143
+ this.dataModule.endFetching(hash);
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
1150
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
1151
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
1152
+ * would otherwise resurrect a just-deleted record.
1153
+ */
1154
+ private async getPendingDeleteIds(): Promise<Set<string>> {
1155
+ try {
1156
+ const [rows] = await this.local.query<[{ recordId: RecordId<string> }[]]>(
1157
+ "SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'"
1158
+ );
1159
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
1160
+ } catch (err) {
1161
+ this.logger.warn(
1162
+ { err, Category: 'sp00ky-client::Sp00kySync::getPendingDeleteIds' },
1163
+ 'Failed to read pending deletes; sync may briefly resurrect a just-deleted record'
1164
+ );
1165
+ return new Set();
1166
+ }
299
1167
  }
300
1168
 
301
1169
  /**
@@ -307,22 +1175,32 @@ export class SpookySync<S extends SchemaStructure> {
307
1175
  }
308
1176
 
309
1177
  private async registerQuery(queryHash: string) {
1178
+ // Hold `fetching` across the WHOLE registration (remote view creation +
1179
+ // initial sync + post-sync notify). A query is born `fetching` in
1180
+ // createNewQuery; this refcounted cycle is what resolves it to `idle` — so
1181
+ // consumers (e.g. useQuery's `isSettled`) never see an idle query whose
1182
+ // window is still empty/partially materialized.
1183
+ this.dataModule.beginFetching(queryHash);
310
1184
  try {
311
1185
  this.logger.debug(
312
- { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
1186
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
313
1187
  'Register Query state'
314
1188
  );
315
1189
  await this.createRemoteQuery(queryHash);
316
1190
  await this.syncQuery(queryHash);
317
- // Always notify after sync completes handles empty result sets
318
- // where no stream updates fire but the UI needs to stop loading
1191
+ // Land any still-debounced stream result, then always notify handles
1192
+ // empty result sets where no stream updates fire but the UI needs to
1193
+ // stop loading.
1194
+ await this.dataModule.flushPendingStreamUpdate(queryHash);
319
1195
  await this.dataModule.notifyQuerySynced(queryHash);
320
1196
  } catch (e) {
321
1197
  this.logger.error(
322
- { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
1198
+ { err: e, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
323
1199
  'registerQuery error'
324
1200
  );
325
1201
  throw e;
1202
+ } finally {
1203
+ this.dataModule.endFetching(queryHash);
326
1204
  }
327
1205
  }
328
1206
 
@@ -331,15 +1209,15 @@ export class SpookySync<S extends SchemaStructure> {
331
1209
 
332
1210
  if (!queryState) {
333
1211
  this.logger.warn(
334
- { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
1212
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
335
1213
  'Query to register not found'
336
1214
  );
337
1215
  throw new Error('Query to register not found');
338
1216
  }
339
- // Delegate to remote function which handles DBSP registration & persistence
1217
+ // Delegate to remote function which handles DBSP registration & persistence.
1218
+ // clientId is set server-side from session::id() — see fn::query::register.
340
1219
  await this.remote.query('fn::query::register($config)', {
341
1220
  config: {
342
- clientId: this.clientId,
343
1221
  id: queryState.config.id,
344
1222
  surql: queryState.config.surql,
345
1223
  params: queryState.config.params,
@@ -347,8 +1225,14 @@ export class SpookySync<S extends SchemaStructure> {
347
1225
  },
348
1226
  });
349
1227
 
1228
+ // Initial materialized-view fetch — pull from the same per-user
1229
+ // `_00_list_ref_user_<id>` (or global `_00_list_ref` in single
1230
+ // mode) that the LIVE subscription listens on, so the two stay in
1231
+ // sync. `parent IS NONE` excludes subquery entries; the
1232
+ // `localArray` cache only tracks primary records.
1233
+ const listRefTbl = this.listRefTable();
350
1234
  const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
351
- surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
1235
+ buildListRefSelect(listRefTbl),
352
1236
  {
353
1237
  in: queryState.config.id,
354
1238
  }
@@ -358,7 +1242,7 @@ export class SpookySync<S extends SchemaStructure> {
358
1242
  {
359
1243
  queryId: encodeRecordId(queryState.config.id),
360
1244
  items,
361
- Category: 'spooky-client::SpookySync::createRemoteQuery',
1245
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
362
1246
  },
363
1247
  'Got query record version array from remote'
364
1248
  );
@@ -369,7 +1253,7 @@ export class SpookySync<S extends SchemaStructure> {
369
1253
  {
370
1254
  queryId: encodeRecordId(queryState.config.id),
371
1255
  array,
372
- Category: 'spooky-client::SpookySync::createRemoteQuery',
1256
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
373
1257
  },
374
1258
  'createdRemoteQuery'
375
1259
  );
@@ -378,13 +1262,71 @@ export class SpookySync<S extends SchemaStructure> {
378
1262
  /// Incantation existed already
379
1263
  await this.dataModule.updateQueryRemoteArray(queryHash, array);
380
1264
  }
1265
+
1266
+ // Pull the bodies of any `.related()` subquery children into the local
1267
+ // cache. The primary fetch above (`parent IS NONE`) tracks only window
1268
+ // rows, so without this a cold-reload re-materialization of the
1269
+ // correlated surql finds no child rows and related fields come back
1270
+ // empty. Best-effort: never fail registration over it.
1271
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
1272
+ this.logger.info(
1273
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
1274
+ 'Subquery child sync failed during registration; poll will retry'
1275
+ );
1276
+ });
1277
+ }
1278
+
1279
+ /**
1280
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
1281
+ * local cache, separately from the primary window array. The SSP writes
1282
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
1283
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
1284
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
1285
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
1286
+ * them into the local DB AND the in-browser SSP, whose subquery-table
1287
+ * dependency then re-materializes the parent view (no explicit notify).
1288
+ *
1289
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
1290
+ * shared by other queries; letting `handleRemovedRecords` delete one that
1291
+ * merely left THIS query's child set would clobber data another query still
1292
+ * shows. Genuine record deletes flow through the normal delete path; a
1293
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
1294
+ *
1295
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
1296
+ * query to `fetching` or skew its DevTools timings.
1297
+ */
1298
+ private async syncSubqueryChildren(queryHash: string): Promise<void> {
1299
+ const queryState = this.dataModule.getQueryByHash(queryHash);
1300
+ if (!queryState) return;
1301
+
1302
+ const listRefTbl = this.listRefTable();
1303
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
1304
+ buildSubqueryListRefSelect(listRefTbl),
1305
+ { in: queryState.config.id }
1306
+ );
1307
+ if (!Array.isArray(items)) return;
1308
+
1309
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
1310
+ const prev = queryState.config.subqueryRemoteArray ?? [];
1311
+ if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
1312
+
1313
+ const diff = diffRecordVersionArray(prev, fresh);
1314
+ if (diff.added.length > 0 || diff.updated.length > 0) {
1315
+ await this.syncEngine.syncRecords({
1316
+ added: diff.added,
1317
+ updated: diff.updated,
1318
+ removed: [], // never delete child bodies here — see method doc
1319
+ });
1320
+ }
1321
+ // In-memory only — child rows must never enter the persisted primary array.
1322
+ queryState.config.subqueryRemoteArray = fresh;
381
1323
  }
382
1324
 
383
- private async heartbeatQuery(queryHash: string) {
1325
+ public async heartbeatQuery(queryHash: string) {
384
1326
  const queryState = this.dataModule.getQueryByHash(queryHash);
385
1327
  if (!queryState) {
386
1328
  this.logger.warn(
387
- { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
1329
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::heartbeatQuery' },
388
1330
  'Query to register not found'
389
1331
  );
390
1332
  throw new Error('Query to register not found');
@@ -394,17 +1336,30 @@ export class SpookySync<S extends SchemaStructure> {
394
1336
  });
395
1337
  }
396
1338
 
1339
+ // Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
1340
+ // e.g. a viewport-windowed list cancelling an off-screen window). Query ids
1341
+ // are a deterministic hash of (surql+params), so a DELETE racing a scroll-back
1342
+ // re-register (same id) could nuke a freshly-recreated view — hence two
1343
+ // guards: abort if a subscriber reappeared BEFORE the delete; re-register if
1344
+ // one reappears DURING the delete's network await. Tolerant of a
1345
+ // missing/already-gone query (no throw).
397
1346
  private async cleanupQuery(queryHash: string) {
398
1347
  const queryState = this.dataModule.getQueryByHash(queryHash);
399
- if (!queryState) {
400
- this.logger.warn(
401
- { queryHash, Category: 'spooky-client::SpookySync::cleanupQuery' },
402
- 'Query to register not found'
403
- );
404
- throw new Error('Query to register not found');
1348
+ if (!queryState) return; // already torn down / never registered
1349
+
1350
+ // Re-subscribed before the queued cleanup ran → keep everything as-is.
1351
+ if (this.dataModule.hasSubscribers(queryHash)) return;
1352
+
1353
+ await this.remote.query(`DELETE $id`, { id: queryState.config.id });
1354
+
1355
+ // Re-subscribed while we awaited the DELETE → the remote view is now gone
1356
+ // but a subscriber needs it; recreate it instead of leaving a zombie.
1357
+ if (this.dataModule.hasSubscribers(queryHash)) {
1358
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
1359
+ return;
405
1360
  }
406
- await this.remote.query(`DELETE $id`, {
407
- id: queryState.config.id,
408
- });
1361
+
1362
+ // No subscribers throughout → safe to free the local view + state.
1363
+ this.dataModule.finalizeDeregister(queryHash);
409
1364
  }
410
1365
  }