@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.71

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 (64) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +299 -369
  3. package/dist/index.js +2278 -399
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +460 -0
  7. package/package.json +37 -7
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +3 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +17 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +662 -108
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +89 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/ref-tables.test.ts +56 -0
  30. package/src/modules/ref-tables.ts +57 -0
  31. package/src/modules/sync/engine.ts +97 -37
  32. package/src/modules/sync/events/index.ts +3 -2
  33. package/src/modules/sync/queue/queue-down.ts +5 -4
  34. package/src/modules/sync/queue/queue-up.ts +14 -13
  35. package/src/modules/sync/scheduler.ts +2 -2
  36. package/src/modules/sync/sync.ts +553 -58
  37. package/src/modules/sync/utils.test.ts +239 -2
  38. package/src/modules/sync/utils.ts +166 -17
  39. package/src/otel/index.ts +127 -0
  40. package/src/services/database/database.ts +11 -11
  41. package/src/services/database/events/index.ts +2 -1
  42. package/src/services/database/local-migrator.ts +21 -21
  43. package/src/services/database/local.test.ts +33 -0
  44. package/src/services/database/local.ts +192 -32
  45. package/src/services/database/remote.ts +13 -13
  46. package/src/services/logger/index.ts +6 -101
  47. package/src/services/persistence/localstorage.ts +2 -2
  48. package/src/services/persistence/resilient.ts +41 -0
  49. package/src/services/persistence/surrealdb.ts +9 -9
  50. package/src/services/stream-processor/index.ts +205 -36
  51. package/src/services/stream-processor/permissions.test.ts +47 -0
  52. package/src/services/stream-processor/permissions.ts +53 -0
  53. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  54. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  55. package/src/services/stream-processor/wasm-types.ts +18 -2
  56. package/src/sp00ky.auth-order.test.ts +65 -0
  57. package/src/sp00ky.ts +582 -0
  58. package/src/types.ts +132 -13
  59. package/src/utils/index.ts +35 -13
  60. package/src/utils/parser.ts +3 -2
  61. package/src/utils/surql.ts +24 -15
  62. package/src/utils/withRetry.test.ts +1 -1
  63. package/tsdown.config.ts +55 -1
  64. package/src/spooky.ts +0 -392
@@ -1,33 +1,123 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { MutationEvent, RecordVersionArray } from '../../types';
1
+ import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
+ import type { RecordVersionArray, RecordVersionDiff } 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
+ createDiffFromDbOp,
12
+ listRefPollDelayMs,
13
+ recordVersionArraysEqual,
14
+ resolveListRefPollInterval,
15
+ } from './utils';
8
16
  import { SyncEngine } from './engine';
9
17
  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';
18
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
19
+ import type { CacheModule } from '../cache/index';
20
+ import type { DataModule } from '../data/index';
21
+ import { encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
22
+ import { DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
14
23
 
15
24
  /**
16
- * The main synchronization engine for Spooky.
25
+ * Tunables for `Sp00kySync` construction.
26
+ */
27
+ export interface Sp00kySyncOptions {
28
+ /**
29
+ * Cadence (ms) for the `_00_list_ref` poll fallback that catches
30
+ * cross-session UPDATEs the LIVE-permission gap drops. Non-positive
31
+ * values fall back to the default; see
32
+ * {@link resolveListRefPollInterval}.
33
+ */
34
+ refSyncIntervalMs?: number;
35
+ }
36
+
37
+ /**
38
+ * The main synchronization engine for Sp00ky.
17
39
  * Handles the bidirectional synchronization between the local database and the remote backend.
18
40
  * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
41
  * @template S The schema structure type.
20
42
  */
21
- export class SpookySync<S extends SchemaStructure> {
22
- private clientId: string = '';
43
+ export class Sp00kySync<S extends SchemaStructure> {
23
44
  private upQueue: UpQueue;
24
45
  private downQueue: DownQueue;
25
46
  private isInit: boolean = false;
26
47
  private logger: Logger;
27
48
  private syncEngine: SyncEngine;
49
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
50
+ * from `this.events`, which carries Sp00kySync-level events like
51
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
52
+ public get engineEvents() {
53
+ return this.syncEngine.events;
54
+ }
28
55
  private scheduler: SyncScheduler;
56
+ private wasDisconnected: boolean = false;
29
57
  public events = createSyncEventSystem();
30
58
 
59
+ // Auth identity that drives per-user `_00_list_ref_user_<id>` routing
60
+ // in `RefMode.Dedicated`. Updated by `setCurrentUserId` from the auth
61
+ // subscription in `Sp00kyClient`; null when unauthenticated.
62
+ private currentUserId: string | null = null;
63
+
64
+ private refMode: RefMode = DEFAULT_REF_MODE;
65
+
66
+ // Bookkeeping for the LIVE subscription on `_00_list_ref[_user_*]`.
67
+ // SurrealDB binds the permission context at LIVE-registration time and
68
+ // the table name in dedicated mode depends on the authenticated user,
69
+ // so we have to re-register whenever auth state flips.
70
+ private currentLiveQueryUuid: Uuid | null = null;
71
+ private liveQueryUnsubscribe: (() => void) | null = null;
72
+
73
+ // Periodic re-poll of `_00_list_ref` as a safety net for missed LIVE
74
+ // notifications. SurrealDB v3 occasionally drops LIVE deliveries
75
+ // across sessions even when the row matches the permission rule;
76
+ // this catches those without requiring users to reload. The
77
+ // interval is configurable via the constructor; see
78
+ // `resolveListRefPollInterval` for fallback semantics.
79
+ //
80
+ // Self-rescheduling rather than setInterval so each tick can pick
81
+ // its own delay via `nextPollDelayMs` — slows the poll down when
82
+ // LIVE is delivering events and speeds it back up when LIVE quiets.
83
+ private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
84
+ private listRefPollRunning: boolean = false;
85
+ public readonly refSyncIntervalMs: number;
86
+
87
+ // Consecutive poll cycles that observed NO list_ref change. Drives the
88
+ // adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
89
+ // page coasts from the fast base cadence toward the 5s cap, and any activity
90
+ // (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
91
+ // back to responsive. Replaces the old LIVE-liveness backoff, which kept the
92
+ // poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
93
+ // on a quiet page, thanks to the cross-session LIVE-permission gap).
94
+ private listRefIdleStreak: number = 0;
95
+
96
+ // `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
97
+ // remote" (left the query's list_ref but still exists upstream). Used to
98
+ // distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
99
+ // converged once it crosses the threshold) from a record that's merely
100
+ // mid-deletion (still-remote for ~one round, then gone) — which must NOT be
101
+ // converged, or it gets stranded in this window before its delete is observed.
102
+ private stillRemoteStreaks: Map<string, number> = new Map();
103
+
104
+ // Wall-clock timestamp (ms) of the most recent LIVE event delivered
105
+ // through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
106
+ // signal; the poll cadence is now driven by `listRefIdleStreak`.
107
+ private lastLiveEventAt: number | null = null;
108
+
109
+ // Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
110
+ // had to retry on `setCurrentUserId`. Stays at 0 when the SSP has
111
+ // pre-emptively created the user's dedicated tables; otherwise
112
+ // increments on each retry attempt until LIVE succeeds or attempts
113
+ // are exhausted. Surfaced as a diagnostic so the e2e suite can prove
114
+ // the pre-emptive table-creation path is keeping the first sign-in
115
+ // off the lazy-creation race.
116
+ private _liveRetryCount: number = 0;
117
+ public get liveRetryCount(): number {
118
+ return this._liveRetryCount;
119
+ }
120
+
31
121
  get isSyncing() {
32
122
  return this.scheduler.isSyncing;
33
123
  }
@@ -57,9 +147,10 @@ export class SpookySync<S extends SchemaStructure> {
57
147
  private cache: CacheModule,
58
148
  private dataModule: DataModule<S>,
59
149
  private schema: S,
60
- logger: Logger
150
+ logger: Logger,
151
+ options?: Sp00kySyncOptions
61
152
  ) {
62
- this.logger = logger.child({ service: 'SpookySync' });
153
+ this.logger = logger.child({ service: 'Sp00kySync' });
63
154
  this.upQueue = new UpQueue(this.local, this.logger);
64
155
  this.downQueue = new DownQueue(this.local, this.logger);
65
156
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
@@ -71,41 +162,313 @@ export class SpookySync<S extends SchemaStructure> {
71
162
  this.logger,
72
163
  this.handleRollback.bind(this)
73
164
  );
165
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
74
166
  }
75
167
 
76
168
  /**
77
169
  * Initializes the synchronization system.
78
170
  * Starts the scheduler and initiates the initial sync cycles.
79
- * @param clientId The unique identifier for this client instance.
80
171
  * @throws Error if already initialized.
81
172
  */
82
- public async init(clientId: string) {
83
- if (this.isInit) throw new Error('SpookySync is already initialized');
84
- this.clientId = clientId;
173
+ public async init() {
174
+ if (this.isInit) throw new Error('Sp00kySync is already initialized');
85
175
  this.isInit = true;
86
176
  await this.scheduler.init();
87
- void this.scheduler.syncUp();
177
+ this.subscribeToReconnect();
88
178
  void this.scheduler.syncUp();
89
179
  void this.scheduler.syncDown();
90
- void this.startRefLiveQueries();
180
+ // No initial LIVE subscription — wait for `setCurrentUserId` to fire
181
+ // from the auth subscription. In dedicated mode the table name
182
+ // depends on the authenticated user, and an unauthenticated
183
+ // subscription wouldn't match any of the per-user tables anyway.
184
+ }
185
+
186
+ /**
187
+ * Push the authenticated user's record id from the parent client's
188
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
189
+ * any) and re-registers it under the new user's dedicated table so
190
+ * SurrealDB binds the permission rule under the post-flip auth
191
+ * context. Pass `null` on sign-out.
192
+ *
193
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
194
+ * the SSP when the first query registration arrives, which may be
195
+ * concurrent with this call. We retry the LIVE registration with a
196
+ * short backoff so a "table not found" race resolves without
197
+ * surfacing as a permanent auth-loading hang.
198
+ */
199
+ public async setCurrentUserId(userId: string | null): Promise<void> {
200
+ if (this.currentUserId === userId) return;
201
+ this.currentUserId = userId;
202
+ if (!userId) {
203
+ await this.killRefLiveQuery();
204
+ this.stopListRefPoll();
205
+ return;
206
+ }
207
+ // Start periodic polling FIRST so we have a deterministic fallback
208
+ // even when LIVE registration fails or SurrealDB drops a delivery.
209
+ this.startListRefPoll();
210
+ // Try to start LIVE with backoff for low-latency delivery on the
211
+ // happy path; the poll handles the rest.
212
+ const attemptDelays = [0, 250, 500, 1000, 2000];
213
+ for (let i = 0; i < attemptDelays.length; i++) {
214
+ if (attemptDelays[i] > 0) {
215
+ this._liveRetryCount++;
216
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
217
+ }
218
+ try {
219
+ await this.restartRefLiveQuery();
220
+ return;
221
+ } catch (err) {
222
+ this.logger.debug(
223
+ { err, attempt: i + 1, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
224
+ 'Ref LIVE start failed; relying on periodic poll fallback'
225
+ );
226
+ }
227
+ }
228
+ }
229
+
230
+ private startListRefPoll(): void {
231
+ if (this.listRefPollRunning) return;
232
+ this.listRefPollRunning = true;
233
+ this.logger.debug(
234
+ {
235
+ intervalMs: this.refSyncIntervalMs,
236
+ Category: 'sp00ky-client::Sp00kySync::startListRefPoll',
237
+ },
238
+ 'list_ref poll loop started'
239
+ );
240
+ const schedule = (delayMs: number) => {
241
+ this.listRefPollTimer = setTimeout(async () => {
242
+ if (!this.listRefPollRunning) return;
243
+ let changed = false;
244
+ try {
245
+ changed = await this.pollListRefForActiveQueries();
246
+ } finally {
247
+ if (!this.listRefPollRunning) return;
248
+ // Reset the idle streak on any observed change so the poll snaps
249
+ // back to the fast base cadence; otherwise grow it so a quiet page
250
+ // backs off toward the cap. (`handleRemoteListRefChange` also resets
251
+ // it when a LIVE event lands.)
252
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
253
+ const next = listRefPollDelayMs({
254
+ idleStreak: this.listRefIdleStreak,
255
+ baseIntervalMs: this.refSyncIntervalMs,
256
+ });
257
+ schedule(next);
258
+ }
259
+ }, delayMs);
260
+ };
261
+ schedule(this.refSyncIntervalMs);
262
+ }
263
+
264
+ private stopListRefPoll(): void {
265
+ this.listRefPollRunning = false;
266
+ if (this.listRefPollTimer !== null) {
267
+ clearTimeout(this.listRefPollTimer);
268
+ this.listRefPollTimer = null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
274
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
275
+ * to drive the adaptive idle backoff.
276
+ */
277
+ private async pollListRefForActiveQueries(): Promise<boolean> {
278
+ const hashes = this.dataModule.getActiveQueryHashes();
279
+ if (hashes.length === 0) return false;
280
+ let anyChanged = false;
281
+ for (const hash of hashes) {
282
+ try {
283
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
284
+ } catch (err) {
285
+ this.logger.debug(
286
+ { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
287
+ 'Per-query list_ref poll failed'
288
+ );
289
+ }
290
+ }
291
+ return anyChanged;
292
+ }
293
+
294
+ /**
295
+ * Pull the upstream list_ref entries for `queryHash`, diff them
296
+ * against the local `remoteArray` cache, sync any added/updated rows
297
+ * through the SyncEngine, then persist the new remoteArray. This is
298
+ * the same shape `createRemoteQuery` does for its initial fetch and
299
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
300
+ * it on a timer as a fallback for missed LIVE notifications.
301
+ */
302
+ private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
303
+ const queryState = this.dataModule.getQueryByHash(queryHash);
304
+ if (!queryState) return false;
305
+ const listRefTbl = this.listRefTable();
306
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
307
+ buildListRefSelect(listRefTbl),
308
+ { in: queryState.config.id }
309
+ );
310
+ if (!Array.isArray(items)) return false;
311
+ const fresh: RecordVersionArray = items.map((item) => [
312
+ encodeRecordId(item.out),
313
+ item.version,
314
+ ]);
315
+ // Capture which ids LEFT the query's window (present in the cached
316
+ // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
317
+ // are cross-window deletes (or rows that scrolled out). They drive the
318
+ // forced re-render below.
319
+ const prevRemote = queryState.config.remoteArray ?? [];
320
+ const freshIds = new Set(fresh.map(([id]) => id));
321
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
322
+ // Idempotent poll: only persist the remoteArray when it actually changed.
323
+ // The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
324
+ // equals the cached array every tick — re-writing it (an `UPDATE _00_query`
325
+ // each cycle, per active query) was pure churn and the bulk of the idle
326
+ // traffic. `recordVersionArraysEqual` is order-insensitive because the
327
+ // list_ref SELECT has no `ORDER BY`.
328
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
329
+ if (changed) {
330
+ // Update the cached remoteArray so the next diff/sync sees the new state.
331
+ // `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
332
+ // the local DB row and ingests it into the in-browser SSP — the SSP's
333
+ // stream updates run `processStreamUpdate`, which re-queries the local DB
334
+ // and notifies subscribers. We skip an explicit `notifyQuerySynced`
335
+ // because that path races the stream-update path (can notify with stale
336
+ // records).
337
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
338
+ }
339
+ // Run `syncQuery` every tick regardless: it's a no-op when localArray has
340
+ // caught up to remoteArray (`if (!diff) return`, issues no query), but it
341
+ // covers the rare case where remoteArray is stable yet localArray is behind
342
+ // (a prior record fetch failed) — so a missed row still gets retried.
343
+ // For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
344
+ // confirmed-gone records from the local DB.
345
+ try {
346
+ await this.syncQuery(queryHash);
347
+ } catch (err) {
348
+ this.logger.info(
349
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
350
+ 'syncQuery failed during poll'
351
+ );
352
+ }
353
+ // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
354
+ // get a re-render from the SSP stream on this code path reliably (and the
355
+ // non-windowed window-0 query re-queries the local DB rather than the id-set).
356
+ // Force a re-materialize + notify so the deleted row drops from the list in
357
+ // this (second) window — the reliable, LIVE-independent cross-window path.
358
+ if (removedIds.length > 0) {
359
+ try {
360
+ await this.dataModule.notifyQuerySynced(queryHash);
361
+ } catch (err) {
362
+ this.logger.info(
363
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
364
+ 'notifyQuerySynced failed during poll-removal re-render'
365
+ );
366
+ }
367
+ }
368
+ return changed;
369
+ }
370
+
371
+ /**
372
+ * Resolve the current `_00_list_ref` table name for the active auth
373
+ * context. Public so the `createRemoteQuery` initial-fetch path can
374
+ * read from the right per-user table.
375
+ *
376
+ * Reads the user id from `DataModule` rather than the local mirror,
377
+ * because `DataModule.setCurrentUserId` runs synchronously from the
378
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
379
+ * is async — the userQuery's initial fetch can fire between those
380
+ * two points and we need the correct table name immediately.
381
+ */
382
+ public listRefTable(): string {
383
+ return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
384
+ }
385
+
386
+ private async killRefLiveQuery(): Promise<void> {
387
+ if (this.liveQueryUnsubscribe) {
388
+ try { this.liveQueryUnsubscribe(); } catch { /* ignore */ }
389
+ this.liveQueryUnsubscribe = null;
390
+ }
391
+ if (this.currentLiveQueryUuid !== null) {
392
+ try {
393
+ await this.remote.query('KILL $u', { u: this.currentLiveQueryUuid });
394
+ } catch (err) {
395
+ this.logger.debug(
396
+ { err, Category: 'sp00ky-client::Sp00kySync::killRefLiveQuery' },
397
+ 'Prior LIVE KILL failed; continuing'
398
+ );
399
+ }
400
+ this.currentLiveQueryUuid = null;
401
+ }
402
+ }
403
+
404
+ private async restartRefLiveQuery(): Promise<void> {
405
+ await this.killRefLiveQuery();
406
+ await this.startRefLiveQueries();
407
+ }
408
+
409
+ // Only the connect that follows a prior disconnect counts as a
410
+ // reconnect; the initial connect after init() must not trigger a
411
+ // refetch storm.
412
+ private subscribeToReconnect() {
413
+ const client = this.remote.getClient();
414
+ client.subscribe('disconnected', () => {
415
+ this.wasDisconnected = true;
416
+ this.logger.info(
417
+ { Category: 'sp00ky-client::Sp00kySync::onDisconnect' },
418
+ 'Remote disconnected'
419
+ );
420
+ });
421
+ client.subscribe('connected', () => {
422
+ if (!this.wasDisconnected) return;
423
+ this.wasDisconnected = false;
424
+ this.logger.info(
425
+ { Category: 'sp00ky-client::Sp00kySync::onReconnect' },
426
+ 'Remote reconnected, refetching active queries'
427
+ );
428
+ for (const hash of this.dataModule.getActiveQueryHashes()) {
429
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
430
+ }
431
+ // The WS reconnect leaves the server-side LIVE subscription dead — the
432
+ // re-enqueued `register` events only re-fetch initial state, they don't
433
+ // re-subscribe. Without this, LIVE never recovers after a reconnect and
434
+ // the poll silently becomes the sole sync path (and never backs off).
435
+ // Only when authenticated: a signed-out reconnect has no per-user table.
436
+ if (this.currentUserId) {
437
+ this.restartRefLiveQuery().catch((err) => {
438
+ this.logger.debug(
439
+ { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
440
+ 'LIVE restart after reconnect failed; relying on poll fallback'
441
+ );
442
+ });
443
+ }
444
+ });
91
445
  }
92
446
 
93
447
  private async startRefLiveQueries() {
448
+ const tableName = this.listRefTable();
94
449
  this.logger.debug(
95
- { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
450
+ { tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
96
451
  'Starting ref live queries'
97
452
  );
98
453
 
99
454
  const [queryUuid] = await this.remote.query<[Uuid]>(
100
- 'LIVE SELECT * FROM _spooky_list_ref'
455
+ `LIVE SELECT * FROM ${tableName}`
101
456
  );
457
+ this.currentLiveQueryUuid = queryUuid;
102
458
 
103
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
459
+ const live = await this.remote.getClient().liveOf(queryUuid);
460
+ this.liveQueryUnsubscribe = live.subscribe((message) => {
104
461
  this.logger.debug(
105
- { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
462
+ { message, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
106
463
  'Live update received'
107
464
  );
108
465
  if (message.action === 'KILLED') return;
466
+ // Skip subquery entries (rows with `parent` set) — they're synthetic
467
+ // CRDT/cursor metadata rows. The poll filters them with `parent IS NONE`
468
+ // (the client's RecordVersionArray only tracks primary rows); the LIVE
469
+ // feed delivers every row, so without this they'd surface as spurious
470
+ // "added" diffs.
471
+ if ((message.value as { parent?: unknown }).parent != null) return;
109
472
  this.handleRemoteListRefChange(
110
473
  message.action,
111
474
  message.value.in as RecordId<string>,
@@ -113,7 +476,7 @@ export class SpookySync<S extends SchemaStructure> {
113
476
  message.value.version as number
114
477
  ).catch((err) => {
115
478
  this.logger.error(
116
- { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
479
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
117
480
  'Error handling remote list ref change'
118
481
  );
119
482
  });
@@ -126,13 +489,26 @@ export class SpookySync<S extends SchemaStructure> {
126
489
  recordId: RecordId,
127
490
  version: number
128
491
  ) {
492
+ // Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
493
+ // query's window, or a notification for an unknown local query. Reset the
494
+ // poll's idle streak so it snaps back to the fast base cadence (the page
495
+ // is clearly not idle), and record the timestamp as a liveness diagnostic.
496
+ this.lastLiveEventAt = Date.now();
497
+ this.listRefIdleStreak = 0;
498
+
499
+ // NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
500
+ // this one) deletes a record, the server's SSP removes it from `_00_list_ref`
501
+ // and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
502
+ // turns it into a `removed: [recordId]` diff so the row drops from the window
503
+ // in realtime. (It was previously ignored, so other windows only caught up on
504
+ // reload / the slow poll.)
129
505
  const existing = this.dataModule.getQueryById(queryId);
130
506
 
131
507
  if (!existing) {
132
508
  this.logger.warn(
133
509
  {
134
510
  queryId: queryId.toString(),
135
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
511
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
136
512
  },
137
513
  'Received remote update for unknown local query'
138
514
  );
@@ -148,12 +524,15 @@ export class SpookySync<S extends SchemaStructure> {
148
524
  recordId,
149
525
  version,
150
526
  localArray,
151
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
527
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
152
528
  },
153
529
  'Live update is being processed'
154
530
  );
155
531
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
- await this.syncEngine.syncRecords(diff);
532
+ // `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
533
+ // (a SHA-256 over query content + sessionId) — the key DataModule uses.
534
+ const hash = extractIdPart(existing.config.id);
535
+ await this.runSyncForQuery(hash, diff);
157
536
  }
158
537
 
159
538
  /**
@@ -166,12 +545,11 @@ export class SpookySync<S extends SchemaStructure> {
166
545
 
167
546
  private async processUpEvent(event: UpEvent) {
168
547
  this.logger.debug(
169
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
548
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
170
549
  'Processing up event'
171
550
  );
172
- console.log('xx1', event);
173
551
  switch (event.type) {
174
- case 'create':
552
+ case 'create': {
175
553
  const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
554
  const prefixedParams = Object.fromEntries(
177
555
  dataKeys.map(({ key, variable }) => [variable, event.data[key]])
@@ -182,6 +560,7 @@ export class SpookySync<S extends SchemaStructure> {
182
560
  ...prefixedParams,
183
561
  });
184
562
  break;
563
+ }
185
564
  case 'update':
186
565
  await this.remote.query(`UPDATE $id MERGE $data`, {
187
566
  id: event.record_id,
@@ -195,7 +574,7 @@ export class SpookySync<S extends SchemaStructure> {
195
574
  break;
196
575
  default:
197
576
  this.logger.error(
198
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
577
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
199
578
  'processUpEvent unknown event type'
200
579
  );
201
580
  return;
@@ -215,7 +594,7 @@ export class SpookySync<S extends SchemaStructure> {
215
594
  recordId,
216
595
  tableName,
217
596
  error: error.message,
218
- Category: 'spooky-client::SpookySync::handleRollback',
597
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
219
598
  },
220
599
  'Rolling back failed mutation'
221
600
  );
@@ -231,7 +610,7 @@ export class SpookySync<S extends SchemaStructure> {
231
610
  this.logger.warn(
232
611
  {
233
612
  recordId,
234
- Category: 'spooky-client::SpookySync::handleRollback',
613
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
235
614
  },
236
615
  'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
616
  );
@@ -241,7 +620,7 @@ export class SpookySync<S extends SchemaStructure> {
241
620
  this.logger.warn(
242
621
  {
243
622
  recordId,
244
- Category: 'spooky-client::SpookySync::handleRollback',
623
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
245
624
  },
246
625
  'Delete rollback not implemented. Down-sync will reconcile.'
247
626
  );
@@ -257,7 +636,7 @@ export class SpookySync<S extends SchemaStructure> {
257
636
 
258
637
  private async processDownEvent(event: DownEvent) {
259
638
  this.logger.debug(
260
- { event, Category: 'spooky-client::SpookySync::processDownEvent' },
639
+ { event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
261
640
  'Processing down event'
262
641
  );
263
642
  switch (event.type) {
@@ -281,7 +660,7 @@ export class SpookySync<S extends SchemaStructure> {
281
660
  const queryState = this.dataModule.getQueryByHash(hash);
282
661
  if (!queryState) {
283
662
  this.logger.warn(
284
- { hash, Category: 'spooky-client::SpookySync::syncQuery' },
663
+ { hash, Category: 'sp00ky-client::Sp00kySync::syncQuery' },
285
664
  'Query not found'
286
665
  );
287
666
  return;
@@ -295,7 +674,104 @@ export class SpookySync<S extends SchemaStructure> {
295
674
  if (!diff) {
296
675
  return;
297
676
  }
298
- return this.syncEngine.syncRecords(diff);
677
+ return this.runSyncForQuery(hash, diff);
678
+ }
679
+
680
+ /**
681
+ * Run a sync for a single query while reflecting its fetch status. Marks the
682
+ * query `fetching` for the duration when the diff actually pulls records
683
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
684
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
685
+ * means the single resulting UI update lands after this completes.
686
+ */
687
+ private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
688
+ // Don't let sync re-add a record the user just deleted locally. The remote
689
+ // delete is queued in the outbox, so until it's processed the server's
690
+ // `_00_list_ref` still lists the record — the diff then classifies it as
691
+ // `added` (present remotely, absent locally) and `syncRecords` re-fetches +
692
+ // re-inserts it, so a deleted database reappears a few seconds later. Drop
693
+ // any id with a pending local DELETE from the re-add paths. Once the remote
694
+ // delete lands, the pending row clears and the server drops it from
695
+ // `_00_list_ref`, so this guard naturally stops applying.
696
+ if (diff.added.length > 0 || diff.updated.length > 0) {
697
+ const pendingDeletes = await this.getPendingDeleteIds();
698
+ if (pendingDeletes.size > 0) {
699
+ diff = {
700
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
701
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
702
+ removed: diff.removed,
703
+ };
704
+ }
705
+ }
706
+
707
+ const fetching = diff.added.length + diff.updated.length > 0;
708
+ if (fetching) {
709
+ this.dataModule.setQueryStatus(hash, 'fetching');
710
+ }
711
+ try {
712
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
713
+ if (fetching) {
714
+ this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
715
+ }
716
+ // Converge localArray to the authoritative remoteArray for ids that left
717
+ // the server's list_ref but still exist — a view-membership change, not a
718
+ // delete — so the poll's diff stops re-flagging them every tick (the `job:`
719
+ // churn). CRUCIAL: only converge after the id has been still-remote for
720
+ // several CONSECUTIVE rounds. A record that's merely mid-deletion is
721
+ // still-remote for ~one round (its delete hasn't committed when our
722
+ // existence check races it) and is gone the next round → it never reaches
723
+ // the threshold, so it's deleted normally instead of being stranded here.
724
+ if (stillRemoteIds.length > 0) {
725
+ const CONVERGE_AFTER = 3;
726
+ const toConverge: string[] = [];
727
+ for (const id of stillRemoteIds) {
728
+ const key = `${hash}:${id}`;
729
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
730
+ if (n >= CONVERGE_AFTER) {
731
+ this.stillRemoteStreaks.delete(key);
732
+ toConverge.push(id);
733
+ } else {
734
+ this.stillRemoteStreaks.set(key, n);
735
+ }
736
+ }
737
+ if (toConverge.length > 0) {
738
+ const qs = this.dataModule.getQueryByHash(hash);
739
+ const local = qs?.config.localArray;
740
+ if (local && local.length > 0) {
741
+ const drop = new Set(toConverge);
742
+ const next = local.filter(([id]) => !drop.has(id));
743
+ if (next.length !== local.length) {
744
+ await this.dataModule.updateQueryLocalArray(hash, next);
745
+ }
746
+ }
747
+ }
748
+ }
749
+ } finally {
750
+ if (fetching) {
751
+ this.dataModule.setQueryStatus(hash, 'idle');
752
+ }
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
758
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
759
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
760
+ * would otherwise resurrect a just-deleted record.
761
+ */
762
+ private async getPendingDeleteIds(): Promise<Set<string>> {
763
+ try {
764
+ const [rows] = await this.local.query<[{ recordId: RecordId<string> }[]]>(
765
+ "SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'"
766
+ );
767
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
768
+ } catch (err) {
769
+ this.logger.warn(
770
+ { err, Category: 'sp00ky-client::Sp00kySync::getPendingDeleteIds' },
771
+ 'Failed to read pending deletes; sync may briefly resurrect a just-deleted record'
772
+ );
773
+ return new Set();
774
+ }
299
775
  }
300
776
 
301
777
  /**
@@ -309,7 +785,7 @@ export class SpookySync<S extends SchemaStructure> {
309
785
  private async registerQuery(queryHash: string) {
310
786
  try {
311
787
  this.logger.debug(
312
- { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
788
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
313
789
  'Register Query state'
314
790
  );
315
791
  await this.createRemoteQuery(queryHash);
@@ -319,7 +795,7 @@ export class SpookySync<S extends SchemaStructure> {
319
795
  await this.dataModule.notifyQuerySynced(queryHash);
320
796
  } catch (e) {
321
797
  this.logger.error(
322
- { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
798
+ { err: e, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
323
799
  'registerQuery error'
324
800
  );
325
801
  throw e;
@@ -331,15 +807,15 @@ export class SpookySync<S extends SchemaStructure> {
331
807
 
332
808
  if (!queryState) {
333
809
  this.logger.warn(
334
- { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
810
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
335
811
  'Query to register not found'
336
812
  );
337
813
  throw new Error('Query to register not found');
338
814
  }
339
- // Delegate to remote function which handles DBSP registration & persistence
815
+ // Delegate to remote function which handles DBSP registration & persistence.
816
+ // clientId is set server-side from session::id() — see fn::query::register.
340
817
  await this.remote.query('fn::query::register($config)', {
341
818
  config: {
342
- clientId: this.clientId,
343
819
  id: queryState.config.id,
344
820
  surql: queryState.config.surql,
345
821
  params: queryState.config.params,
@@ -347,8 +823,14 @@ export class SpookySync<S extends SchemaStructure> {
347
823
  },
348
824
  });
349
825
 
826
+ // Initial materialized-view fetch — pull from the same per-user
827
+ // `_00_list_ref_user_<id>` (or global `_00_list_ref` in single
828
+ // mode) that the LIVE subscription listens on, so the two stay in
829
+ // sync. `parent IS NONE` excludes subquery entries; the
830
+ // `localArray` cache only tracks primary records.
831
+ const listRefTbl = this.listRefTable();
350
832
  const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
351
- surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
833
+ buildListRefSelect(listRefTbl),
352
834
  {
353
835
  in: queryState.config.id,
354
836
  }
@@ -358,7 +840,7 @@ export class SpookySync<S extends SchemaStructure> {
358
840
  {
359
841
  queryId: encodeRecordId(queryState.config.id),
360
842
  items,
361
- Category: 'spooky-client::SpookySync::createRemoteQuery',
843
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
362
844
  },
363
845
  'Got query record version array from remote'
364
846
  );
@@ -369,7 +851,7 @@ export class SpookySync<S extends SchemaStructure> {
369
851
  {
370
852
  queryId: encodeRecordId(queryState.config.id),
371
853
  array,
372
- Category: 'spooky-client::SpookySync::createRemoteQuery',
854
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
373
855
  },
374
856
  'createdRemoteQuery'
375
857
  );
@@ -380,11 +862,11 @@ export class SpookySync<S extends SchemaStructure> {
380
862
  }
381
863
  }
382
864
 
383
- private async heartbeatQuery(queryHash: string) {
865
+ public async heartbeatQuery(queryHash: string) {
384
866
  const queryState = this.dataModule.getQueryByHash(queryHash);
385
867
  if (!queryState) {
386
868
  this.logger.warn(
387
- { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
869
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::heartbeatQuery' },
388
870
  'Query to register not found'
389
871
  );
390
872
  throw new Error('Query to register not found');
@@ -394,17 +876,30 @@ export class SpookySync<S extends SchemaStructure> {
394
876
  });
395
877
  }
396
878
 
879
+ // Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
880
+ // e.g. a viewport-windowed list cancelling an off-screen window). Query ids
881
+ // are a deterministic hash of (surql+params), so a DELETE racing a scroll-back
882
+ // re-register (same id) could nuke a freshly-recreated view — hence two
883
+ // guards: abort if a subscriber reappeared BEFORE the delete; re-register if
884
+ // one reappears DURING the delete's network await. Tolerant of a
885
+ // missing/already-gone query (no throw).
397
886
  private async cleanupQuery(queryHash: string) {
398
887
  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');
888
+ if (!queryState) return; // already torn down / never registered
889
+
890
+ // Re-subscribed before the queued cleanup ran → keep everything as-is.
891
+ if (this.dataModule.hasSubscribers(queryHash)) return;
892
+
893
+ await this.remote.query(`DELETE $id`, { id: queryState.config.id });
894
+
895
+ // Re-subscribed while we awaited the DELETE → the remote view is now gone
896
+ // but a subscriber needs it; recreate it instead of leaving a zombie.
897
+ if (this.dataModule.hasSubscribers(queryHash)) {
898
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
899
+ return;
405
900
  }
406
- await this.remote.query(`DELETE $id`, {
407
- id: queryState.config.id,
408
- });
901
+
902
+ // No subscribers throughout → safe to free the local view + state.
903
+ this.dataModule.finalizeDeregister(queryHash);
409
904
  }
410
905
  }