@spooky-sync/core 0.0.1-canary.9 → 0.0.1-canary.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +907 -339
- package/dist/index.js +2837 -402
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +543 -0
- package/package.json +39 -9
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +732 -109
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +115 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.ts +166 -0
- package/src/modules/ref-tables.test.ts +66 -0
- package/src/modules/ref-tables.ts +69 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +40 -3
- package/src/modules/sync/sync.ts +893 -59
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +248 -37
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +648 -0
- package/src/types.ts +192 -15
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- package/src/spooky.ts +0 -392
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1,33 +1,142 @@
|
|
|
1
|
-
import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
|
|
2
|
-
import {
|
|
1
|
+
import type { LocalDatabaseService, 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,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
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
|
+
public readonly refSyncIntervalMs: number;
|
|
105
|
+
|
|
106
|
+
// Consecutive poll cycles that observed NO list_ref change. Drives the
|
|
107
|
+
// adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
|
|
108
|
+
// page coasts from the fast base cadence toward the 5s cap, and any activity
|
|
109
|
+
// (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
|
|
110
|
+
// back to responsive. Replaces the old LIVE-liveness backoff, which kept the
|
|
111
|
+
// poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
|
|
112
|
+
// on a quiet page, thanks to the cross-session LIVE-permission gap).
|
|
113
|
+
private listRefIdleStreak: number = 0;
|
|
114
|
+
|
|
115
|
+
// `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
|
|
116
|
+
// remote" (left the query's list_ref but still exists upstream). Used to
|
|
117
|
+
// distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
|
|
118
|
+
// converged once it crosses the threshold) from a record that's merely
|
|
119
|
+
// mid-deletion (still-remote for ~one round, then gone) — which must NOT be
|
|
120
|
+
// converged, or it gets stranded in this window before its delete is observed.
|
|
121
|
+
private stillRemoteStreaks: Map<string, number> = new Map();
|
|
122
|
+
|
|
123
|
+
// Wall-clock timestamp (ms) of the most recent LIVE event delivered
|
|
124
|
+
// through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
|
|
125
|
+
// signal; the poll cadence is now driven by `listRefIdleStreak`.
|
|
126
|
+
private lastLiveEventAt: number | null = null;
|
|
127
|
+
|
|
128
|
+
// Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
|
|
129
|
+
// had to retry on `setCurrentUserId`. Stays at 0 when the SSP has
|
|
130
|
+
// pre-emptively created the user's dedicated tables; otherwise
|
|
131
|
+
// increments on each retry attempt until LIVE succeeds or attempts
|
|
132
|
+
// are exhausted. Surfaced as a diagnostic so the e2e suite can prove
|
|
133
|
+
// the pre-emptive table-creation path is keeping the first sign-in
|
|
134
|
+
// off the lazy-creation race.
|
|
135
|
+
private _liveRetryCount: number = 0;
|
|
136
|
+
public get liveRetryCount(): number {
|
|
137
|
+
return this._liveRetryCount;
|
|
138
|
+
}
|
|
139
|
+
|
|
31
140
|
get isSyncing() {
|
|
32
141
|
return this.scheduler.isSyncing;
|
|
33
142
|
}
|
|
@@ -51,15 +160,175 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
51
160
|
};
|
|
52
161
|
}
|
|
53
162
|
|
|
163
|
+
// ---- Sync health -------------------------------------------------------
|
|
164
|
+
// `0` disables degraded reporting (config `syncHealth: false`). Resolved
|
|
165
|
+
// from config in Sp00kyClient and passed through the constructor options.
|
|
166
|
+
private readonly degradeAfterFailures: number;
|
|
167
|
+
private consecutiveSyncFailures = 0;
|
|
168
|
+
private syncHealthStatus: SyncHealthStatus = 'healthy';
|
|
169
|
+
private lastSyncErrorKind: 'network' | 'application' | undefined;
|
|
170
|
+
private lastSyncErrorMessage: string | undefined;
|
|
171
|
+
|
|
172
|
+
// Self-heal: while degraded, re-drive sync on an exponential backoff so the
|
|
173
|
+
// app recovers on its own — even when the socket never actually dropped (in
|
|
174
|
+
// that case no `connected` event fires, so this re-registration is the ONLY
|
|
175
|
+
// thing that re-probes the server). Started on the degrade transition,
|
|
176
|
+
// cleared on recovery. Capped cadence so a long outage doesn't busy-loop.
|
|
177
|
+
private selfHealTimer: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
+
private selfHealAttempts = 0;
|
|
179
|
+
private static readonly SELF_HEAL_BASE_MS = 2_000;
|
|
180
|
+
private static readonly SELF_HEAL_MAX_MS = 30_000;
|
|
181
|
+
|
|
182
|
+
/** Current sync-health snapshot. */
|
|
183
|
+
get syncHealth(): SyncHealth {
|
|
184
|
+
return {
|
|
185
|
+
status: this.syncHealthStatus,
|
|
186
|
+
consecutiveFailures: this.consecutiveSyncFailures,
|
|
187
|
+
kind: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorKind : undefined,
|
|
188
|
+
error: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorMessage : undefined,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Observe sync health. The callback fires immediately with the current
|
|
194
|
+
* status and again on every healthy↔degraded transition. Returns an
|
|
195
|
+
* unsubscribe. Mirrors {@link subscribeToPendingMutations}.
|
|
196
|
+
*/
|
|
197
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
198
|
+
cb(this.syncHealth);
|
|
199
|
+
const id = this.events.subscribe(SyncEventTypes.SyncHealthChanged, (event) =>
|
|
200
|
+
cb(event.payload)
|
|
201
|
+
);
|
|
202
|
+
return () => this.events.unsubscribe(id);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private emitSyncHealth(): void {
|
|
206
|
+
this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fed by the scheduler once per drained sync round. Individual failures are
|
|
211
|
+
* absorbed by the queue's retry; only a run of `degradeAfterFailures`
|
|
212
|
+
* consecutive failures flips the status to `degraded`, and the next clean
|
|
213
|
+
* round flips it back. No-op when reporting is disabled (`degradeAfterFailures`
|
|
214
|
+
* is 0).
|
|
215
|
+
*/
|
|
216
|
+
private recordSyncOutcome(ok: boolean, error?: unknown): void {
|
|
217
|
+
if (this.degradeAfterFailures <= 0) return;
|
|
218
|
+
if (ok) {
|
|
219
|
+
if (this.consecutiveSyncFailures === 0) return;
|
|
220
|
+
this.consecutiveSyncFailures = 0;
|
|
221
|
+
if (this.syncHealthStatus !== 'healthy') {
|
|
222
|
+
this.syncHealthStatus = 'healthy';
|
|
223
|
+
this.lastSyncErrorKind = undefined;
|
|
224
|
+
this.lastSyncErrorMessage = undefined;
|
|
225
|
+
this.stopSelfHeal();
|
|
226
|
+
this.logger.info(
|
|
227
|
+
{ Category: 'sp00ky-client::Sp00kySync::syncHealth' },
|
|
228
|
+
'Sync recovered; health back to healthy'
|
|
229
|
+
);
|
|
230
|
+
this.emitSyncHealth();
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.consecutiveSyncFailures++;
|
|
235
|
+
this.lastSyncErrorKind = classifySyncError(error);
|
|
236
|
+
this.lastSyncErrorMessage = error instanceof Error ? error.message : String(error);
|
|
237
|
+
if (
|
|
238
|
+
this.syncHealthStatus !== 'degraded' &&
|
|
239
|
+
this.consecutiveSyncFailures >= this.degradeAfterFailures
|
|
240
|
+
) {
|
|
241
|
+
this.syncHealthStatus = 'degraded';
|
|
242
|
+
this.logger.warn(
|
|
243
|
+
{
|
|
244
|
+
consecutiveFailures: this.consecutiveSyncFailures,
|
|
245
|
+
kind: this.lastSyncErrorKind,
|
|
246
|
+
error,
|
|
247
|
+
Category: 'sp00ky-client::Sp00kySync::syncHealth',
|
|
248
|
+
},
|
|
249
|
+
'Sync degraded after sustained failures'
|
|
250
|
+
);
|
|
251
|
+
this.emitSyncHealth();
|
|
252
|
+
this.startSelfHeal();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Begin self-heal retries (no-op if already running). Started on the
|
|
258
|
+
* healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
|
|
259
|
+
*/
|
|
260
|
+
private startSelfHeal(): void {
|
|
261
|
+
if (this.selfHealTimer !== null) return;
|
|
262
|
+
this.selfHealAttempts = 0;
|
|
263
|
+
this.scheduleSelfHeal();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private scheduleSelfHeal(): void {
|
|
267
|
+
const delay = Math.min(
|
|
268
|
+
Sp00kySync.SELF_HEAL_MAX_MS,
|
|
269
|
+
Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts
|
|
270
|
+
);
|
|
271
|
+
this.selfHealTimer = setTimeout(async () => {
|
|
272
|
+
this.selfHealTimer = null;
|
|
273
|
+
if (this.syncHealthStatus !== 'degraded') return;
|
|
274
|
+
this.selfHealAttempts++;
|
|
275
|
+
this.logger.debug(
|
|
276
|
+
{ attempt: this.selfHealAttempts, delayMs: delay, Category: 'sp00ky-client::Sp00kySync::selfHeal' },
|
|
277
|
+
'Self-heal: re-driving sync while degraded'
|
|
278
|
+
);
|
|
279
|
+
try {
|
|
280
|
+
// Retry whatever is still queued first; the failing op (register or
|
|
281
|
+
// mutation) was re-queued by the queue, so this re-probes the server
|
|
282
|
+
// and reports the outcome through the scheduler → recordSyncOutcome.
|
|
283
|
+
if (this.upQueue.size > 0) {
|
|
284
|
+
await this.scheduler.syncUp();
|
|
285
|
+
} else if (this.downQueue.size > 0) {
|
|
286
|
+
await this.scheduler.syncDown();
|
|
287
|
+
} else {
|
|
288
|
+
// Nothing queued (e.g. the failing op was rolled back + dropped):
|
|
289
|
+
// re-register active queries — mirroring the reconnect handler — so
|
|
290
|
+
// there's a concrete op whose success flips health. If there are no
|
|
291
|
+
// active queries either, probe connectivity directly.
|
|
292
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
293
|
+
if (hashes.length > 0) {
|
|
294
|
+
for (const hash of hashes) {
|
|
295
|
+
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
296
|
+
}
|
|
297
|
+
await this.scheduler.syncDown();
|
|
298
|
+
} else {
|
|
299
|
+
await this.remote.query('RETURN true');
|
|
300
|
+
this.recordSyncOutcome(true);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// Only the direct connectivity probe can throw here (syncUp/syncDown
|
|
305
|
+
// swallow + self-report); treat a probe failure as another failed round.
|
|
306
|
+
this.recordSyncOutcome(false, err);
|
|
307
|
+
}
|
|
308
|
+
// Keep retrying until recovery. recordSyncOutcome(true) calls stopSelfHeal
|
|
309
|
+
// (clearing any pending timer), so only continue while still degraded.
|
|
310
|
+
if (this.syncHealthStatus === 'degraded') this.scheduleSelfHeal();
|
|
311
|
+
}, delay);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private stopSelfHeal(): void {
|
|
315
|
+
if (this.selfHealTimer !== null) {
|
|
316
|
+
clearTimeout(this.selfHealTimer);
|
|
317
|
+
this.selfHealTimer = null;
|
|
318
|
+
}
|
|
319
|
+
this.selfHealAttempts = 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
54
322
|
constructor(
|
|
55
323
|
private local: LocalDatabaseService,
|
|
56
324
|
private remote: RemoteDatabaseService,
|
|
57
325
|
private cache: CacheModule,
|
|
58
326
|
private dataModule: DataModule<S>,
|
|
59
327
|
private schema: S,
|
|
60
|
-
logger: Logger
|
|
328
|
+
logger: Logger,
|
|
329
|
+
options?: Sp00kySyncOptions
|
|
61
330
|
) {
|
|
62
|
-
this.logger = logger.child({ service: '
|
|
331
|
+
this.logger = logger.child({ service: 'Sp00kySync' });
|
|
63
332
|
this.upQueue = new UpQueue(this.local, this.logger);
|
|
64
333
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
65
334
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
@@ -69,43 +338,378 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
69
338
|
this.processUpEvent.bind(this),
|
|
70
339
|
this.processDownEvent.bind(this),
|
|
71
340
|
this.logger,
|
|
72
|
-
this.handleRollback.bind(this)
|
|
341
|
+
this.handleRollback.bind(this),
|
|
342
|
+
this.recordSyncOutcome.bind(this)
|
|
73
343
|
);
|
|
344
|
+
this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
|
|
345
|
+
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
346
|
+
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
74
347
|
}
|
|
75
348
|
|
|
76
349
|
/**
|
|
77
350
|
* Initializes the synchronization system.
|
|
78
351
|
* Starts the scheduler and initiates the initial sync cycles.
|
|
79
|
-
* @param clientId The unique identifier for this client instance.
|
|
80
352
|
* @throws Error if already initialized.
|
|
81
353
|
*/
|
|
82
|
-
public async init(
|
|
83
|
-
if (this.isInit) throw new Error('
|
|
84
|
-
this.clientId = clientId;
|
|
354
|
+
public async init() {
|
|
355
|
+
if (this.isInit) throw new Error('Sp00kySync is already initialized');
|
|
85
356
|
this.isInit = true;
|
|
86
357
|
await this.scheduler.init();
|
|
87
|
-
|
|
358
|
+
this.subscribeToReconnect();
|
|
88
359
|
void this.scheduler.syncUp();
|
|
89
360
|
void this.scheduler.syncDown();
|
|
90
|
-
|
|
361
|
+
// No initial LIVE subscription — wait for `setCurrentUserId` to fire
|
|
362
|
+
// from the auth subscription. In dedicated mode the table name
|
|
363
|
+
// depends on the authenticated user, and an unauthenticated
|
|
364
|
+
// subscription wouldn't match any of the per-user tables anyway.
|
|
365
|
+
//
|
|
366
|
+
// Exception: when anonymous live queries are enabled, start realtime now
|
|
367
|
+
// against the shared `_00_list_ref_anon` table so a logged-out client
|
|
368
|
+
// syncs immediately. `setCurrentUserId` re-points LIVE to the per-user
|
|
369
|
+
// table on sign-in. Guard on `currentUserId` because the auth callback can
|
|
370
|
+
// fire (and authenticate) before `init()` runs — don't clobber that back
|
|
371
|
+
// to the anon table. `setCurrentUserId(null)` is a no-op on first load
|
|
372
|
+
// (it's already null), so this is the only place anon realtime starts.
|
|
373
|
+
if (this.anonLiveEnabled && !this.currentUserId) {
|
|
374
|
+
this.startListRefPoll();
|
|
375
|
+
this.restartRefLiveQuery().catch((err) => {
|
|
376
|
+
this.logger.debug(
|
|
377
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::init' },
|
|
378
|
+
'Anonymous ref LIVE start failed; relying on periodic poll fallback'
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Push the authenticated user's record id from the parent client's
|
|
386
|
+
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
387
|
+
* any) and re-registers it under the new user's dedicated table so
|
|
388
|
+
* SurrealDB binds the permission rule under the post-flip auth
|
|
389
|
+
* context. Pass `null` on sign-out.
|
|
390
|
+
*
|
|
391
|
+
* The dedicated `_00_list_ref_user_<id>` table is created lazily by
|
|
392
|
+
* the SSP when the first query registration arrives, which may be
|
|
393
|
+
* concurrent with this call. We retry the LIVE registration with a
|
|
394
|
+
* short backoff so a "table not found" race resolves without
|
|
395
|
+
* surfacing as a permanent auth-loading hang.
|
|
396
|
+
*/
|
|
397
|
+
public async setCurrentUserId(userId: string | null): Promise<void> {
|
|
398
|
+
if (this.currentUserId === userId) return;
|
|
399
|
+
this.currentUserId = userId;
|
|
400
|
+
if (!userId) {
|
|
401
|
+
if (this.anonLiveEnabled) {
|
|
402
|
+
// Signed out but anonymous realtime is on: keep the poll running and
|
|
403
|
+
// re-point LIVE from the (now stale) per-user table to the shared
|
|
404
|
+
// `_00_list_ref_anon`. `startListRefPoll` is idempotent; the poll
|
|
405
|
+
// re-resolves `listRefTable()` each tick so it follows automatically.
|
|
406
|
+
this.startListRefPoll();
|
|
407
|
+
await this.restartRefLiveQuery().catch((err) => {
|
|
408
|
+
this.logger.debug(
|
|
409
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
|
|
410
|
+
'Anonymous ref LIVE restart failed; relying on periodic poll fallback'
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await this.killRefLiveQuery();
|
|
416
|
+
this.stopListRefPoll();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// Start periodic polling FIRST so we have a deterministic fallback
|
|
420
|
+
// even when LIVE registration fails or SurrealDB drops a delivery.
|
|
421
|
+
this.startListRefPoll();
|
|
422
|
+
// Try to start LIVE with backoff for low-latency delivery on the
|
|
423
|
+
// happy path; the poll handles the rest.
|
|
424
|
+
const attemptDelays = [0, 250, 500, 1000, 2000];
|
|
425
|
+
for (let i = 0; i < attemptDelays.length; i++) {
|
|
426
|
+
if (attemptDelays[i] > 0) {
|
|
427
|
+
this._liveRetryCount++;
|
|
428
|
+
await new Promise((r) => setTimeout(r, attemptDelays[i]));
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await this.restartRefLiveQuery();
|
|
432
|
+
return;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
this.logger.debug(
|
|
435
|
+
{ err, attempt: i + 1, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
|
|
436
|
+
'Ref LIVE start failed; relying on periodic poll fallback'
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private startListRefPoll(): void {
|
|
443
|
+
if (this.listRefPollRunning) return;
|
|
444
|
+
this.listRefPollRunning = true;
|
|
445
|
+
this.logger.debug(
|
|
446
|
+
{
|
|
447
|
+
intervalMs: this.refSyncIntervalMs,
|
|
448
|
+
Category: 'sp00ky-client::Sp00kySync::startListRefPoll',
|
|
449
|
+
},
|
|
450
|
+
'list_ref poll loop started'
|
|
451
|
+
);
|
|
452
|
+
const schedule = (delayMs: number) => {
|
|
453
|
+
this.listRefPollTimer = setTimeout(async () => {
|
|
454
|
+
if (!this.listRefPollRunning) return;
|
|
455
|
+
let changed = false;
|
|
456
|
+
try {
|
|
457
|
+
changed = await this.pollListRefForActiveQueries();
|
|
458
|
+
} finally {
|
|
459
|
+
if (!this.listRefPollRunning) return;
|
|
460
|
+
// Reset the idle streak on any observed change so the poll snaps
|
|
461
|
+
// back to the fast base cadence; otherwise grow it so a quiet page
|
|
462
|
+
// backs off toward the cap. (`handleRemoteListRefChange` also resets
|
|
463
|
+
// it when a LIVE event lands.)
|
|
464
|
+
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
465
|
+
const next = listRefPollDelayMs({
|
|
466
|
+
idleStreak: this.listRefIdleStreak,
|
|
467
|
+
baseIntervalMs: this.refSyncIntervalMs,
|
|
468
|
+
});
|
|
469
|
+
schedule(next);
|
|
470
|
+
}
|
|
471
|
+
}, delayMs);
|
|
472
|
+
};
|
|
473
|
+
schedule(this.refSyncIntervalMs);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private stopListRefPoll(): void {
|
|
477
|
+
this.listRefPollRunning = false;
|
|
478
|
+
if (this.listRefPollTimer !== null) {
|
|
479
|
+
clearTimeout(this.listRefPollTimer);
|
|
480
|
+
this.listRefPollTimer = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* One poll cycle: refetch `_00_list_ref` for every active query. Returns
|
|
486
|
+
* whether ANY query's remoteArray actually changed — the scheduler uses this
|
|
487
|
+
* to drive the adaptive idle backoff.
|
|
488
|
+
*/
|
|
489
|
+
private async pollListRefForActiveQueries(): Promise<boolean> {
|
|
490
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
491
|
+
if (hashes.length === 0) return false;
|
|
492
|
+
let anyChanged = false;
|
|
493
|
+
for (const hash of hashes) {
|
|
494
|
+
try {
|
|
495
|
+
if (await this.refetchListRefForQuery(hash)) anyChanged = true;
|
|
496
|
+
} catch (err) {
|
|
497
|
+
this.logger.debug(
|
|
498
|
+
{ err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
|
|
499
|
+
'Per-query list_ref poll failed'
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return anyChanged;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Pull the upstream list_ref entries for `queryHash`, diff them
|
|
508
|
+
* against the local `remoteArray` cache, sync any added/updated rows
|
|
509
|
+
* through the SyncEngine, then persist the new remoteArray. This is
|
|
510
|
+
* the same shape `createRemoteQuery` does for its initial fetch and
|
|
511
|
+
* what `handleRemoteListRefChange` does per-LIVE-event — we reuse
|
|
512
|
+
* it on a timer as a fallback for missed LIVE notifications.
|
|
513
|
+
*/
|
|
514
|
+
private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
|
|
515
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
516
|
+
if (!queryState) return false;
|
|
517
|
+
const listRefTbl = this.listRefTable();
|
|
518
|
+
const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
|
|
519
|
+
buildListRefSelect(listRefTbl),
|
|
520
|
+
{ in: queryState.config.id }
|
|
521
|
+
);
|
|
522
|
+
if (!Array.isArray(items)) return false;
|
|
523
|
+
const fresh: RecordVersionArray = items.map((item) => [
|
|
524
|
+
encodeRecordId(item.out),
|
|
525
|
+
item.version,
|
|
526
|
+
]);
|
|
527
|
+
// Capture which ids LEFT the query's window (present in the cached
|
|
528
|
+
// remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
|
|
529
|
+
// are cross-window deletes (or rows that scrolled out). They drive the
|
|
530
|
+
// forced re-render below.
|
|
531
|
+
const prevRemote = queryState.config.remoteArray ?? [];
|
|
532
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
533
|
+
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
534
|
+
// Idempotent poll: only persist the remoteArray when it actually changed.
|
|
535
|
+
// The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
|
|
536
|
+
// equals the cached array every tick — re-writing it (an `UPDATE _00_query`
|
|
537
|
+
// each cycle, per active query) was pure churn and the bulk of the idle
|
|
538
|
+
// traffic. `recordVersionArraysEqual` is order-insensitive because the
|
|
539
|
+
// list_ref SELECT has no `ORDER BY`.
|
|
540
|
+
const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
541
|
+
if (changed) {
|
|
542
|
+
// Update the cached remoteArray so the next diff/sync sees the new state.
|
|
543
|
+
// `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
|
|
544
|
+
// the local DB row and ingests it into the in-browser SSP — the SSP's
|
|
545
|
+
// stream updates run `processStreamUpdate`, which re-queries the local DB
|
|
546
|
+
// and notifies subscribers. We skip an explicit `notifyQuerySynced`
|
|
547
|
+
// because that path races the stream-update path (can notify with stale
|
|
548
|
+
// records).
|
|
549
|
+
await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
|
|
550
|
+
}
|
|
551
|
+
// Run `syncQuery` every tick regardless: it's a no-op when localArray has
|
|
552
|
+
// caught up to remoteArray (`if (!diff) return`, issues no query), but it
|
|
553
|
+
// covers the rare case where remoteArray is stable yet localArray is behind
|
|
554
|
+
// (a prior record fetch failed) — so a missed row still gets retried.
|
|
555
|
+
// For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
|
|
556
|
+
// confirmed-gone records from the local DB.
|
|
557
|
+
try {
|
|
558
|
+
await this.syncQuery(queryHash);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
this.logger.info(
|
|
561
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
|
|
562
|
+
'syncQuery failed during poll'
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
// Cross-session fallback for `.related()` child rows: the LIVE-permission
|
|
566
|
+
// gap can drop child-edge notifications, so converge their bodies on the
|
|
567
|
+
// poll too (idempotent — no-op when nothing changed).
|
|
568
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
569
|
+
this.logger.info(
|
|
570
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
|
|
571
|
+
'Subquery child sync failed during poll'
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
// A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
|
|
575
|
+
// get a re-render from the SSP stream on this code path reliably (and the
|
|
576
|
+
// non-windowed window-0 query re-queries the local DB rather than the id-set).
|
|
577
|
+
// Force a re-materialize + notify so the deleted row drops from the list in
|
|
578
|
+
// this (second) window — the reliable, LIVE-independent cross-window path.
|
|
579
|
+
if (removedIds.length > 0) {
|
|
580
|
+
try {
|
|
581
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
this.logger.info(
|
|
584
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
|
|
585
|
+
'notifyQuerySynced failed during poll-removal re-render'
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return changed;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Resolve the current `_00_list_ref` table name for the active auth
|
|
594
|
+
* context. Public so the `createRemoteQuery` initial-fetch path can
|
|
595
|
+
* read from the right per-user table.
|
|
596
|
+
*
|
|
597
|
+
* Reads the user id from `DataModule` rather than the local mirror,
|
|
598
|
+
* because `DataModule.setCurrentUserId` runs synchronously from the
|
|
599
|
+
* auth callback (before any `await`), whereas `sync.setCurrentUserId`
|
|
600
|
+
* is async — the userQuery's initial fetch can fire between those
|
|
601
|
+
* two points and we need the correct table name immediately.
|
|
602
|
+
*/
|
|
603
|
+
public listRefTable(): string {
|
|
604
|
+
const userId = this.dataModule.getCurrentUserId();
|
|
605
|
+
// Unauthenticated with the flag on → the shared `_00_list_ref_anon` table.
|
|
606
|
+
if (userId == null && this.anonLiveEnabled) {
|
|
607
|
+
return listRefTableFor(this.refMode, ANON_USER_ID);
|
|
608
|
+
}
|
|
609
|
+
return listRefTableFor(this.refMode, userId);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async killRefLiveQuery(): Promise<void> {
|
|
613
|
+
if (this.liveQueryUnsubscribe) {
|
|
614
|
+
try { this.liveQueryUnsubscribe(); } catch { /* ignore */ }
|
|
615
|
+
this.liveQueryUnsubscribe = null;
|
|
616
|
+
}
|
|
617
|
+
if (this.currentLiveQueryUuid !== null) {
|
|
618
|
+
try {
|
|
619
|
+
await this.remote.query('KILL $u', { u: this.currentLiveQueryUuid });
|
|
620
|
+
} catch (err) {
|
|
621
|
+
this.logger.debug(
|
|
622
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::killRefLiveQuery' },
|
|
623
|
+
'Prior LIVE KILL failed; continuing'
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
this.currentLiveQueryUuid = null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async restartRefLiveQuery(): Promise<void> {
|
|
631
|
+
await this.killRefLiveQuery();
|
|
632
|
+
await this.startRefLiveQueries();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Only the connect that follows a prior disconnect counts as a
|
|
636
|
+
// reconnect; the initial connect after init() must not trigger a
|
|
637
|
+
// refetch storm.
|
|
638
|
+
private subscribeToReconnect() {
|
|
639
|
+
const client = this.remote.getClient();
|
|
640
|
+
client.subscribe('disconnected', () => {
|
|
641
|
+
this.wasDisconnected = true;
|
|
642
|
+
this.logger.info(
|
|
643
|
+
{ Category: 'sp00ky-client::Sp00kySync::onDisconnect' },
|
|
644
|
+
'Remote disconnected'
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
client.subscribe('connected', () => {
|
|
648
|
+
if (!this.wasDisconnected) return;
|
|
649
|
+
this.wasDisconnected = false;
|
|
650
|
+
this.logger.info(
|
|
651
|
+
{ Category: 'sp00ky-client::Sp00kySync::onReconnect' },
|
|
652
|
+
'Remote reconnected, refetching active queries'
|
|
653
|
+
);
|
|
654
|
+
for (const hash of this.dataModule.getActiveQueryHashes()) {
|
|
655
|
+
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
656
|
+
}
|
|
657
|
+
// The WS reconnect leaves the server-side LIVE subscription dead — the
|
|
658
|
+
// re-enqueued `register` events only re-fetch initial state, they don't
|
|
659
|
+
// re-subscribe. Without this, LIVE never recovers after a reconnect and
|
|
660
|
+
// the poll silently becomes the sole sync path (and never backs off).
|
|
661
|
+
// Authenticated → per-user table; signed-out with anon live enabled →
|
|
662
|
+
// the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
|
|
663
|
+
if (this.currentUserId || this.anonLiveEnabled) {
|
|
664
|
+
this.restartRefLiveQuery().catch((err) => {
|
|
665
|
+
this.logger.debug(
|
|
666
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
|
|
667
|
+
'LIVE restart after reconnect failed; relying on poll fallback'
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
});
|
|
91
672
|
}
|
|
92
673
|
|
|
93
674
|
private async startRefLiveQueries() {
|
|
675
|
+
const tableName = this.listRefTable();
|
|
94
676
|
this.logger.debug(
|
|
95
|
-
{
|
|
677
|
+
{ tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
96
678
|
'Starting ref live queries'
|
|
97
679
|
);
|
|
98
680
|
|
|
99
681
|
const [queryUuid] = await this.remote.query<[Uuid]>(
|
|
100
|
-
|
|
682
|
+
`LIVE SELECT * FROM ${tableName}`
|
|
101
683
|
);
|
|
684
|
+
this.currentLiveQueryUuid = queryUuid;
|
|
102
685
|
|
|
103
|
-
|
|
686
|
+
const live = await this.remote.getClient().liveOf(queryUuid);
|
|
687
|
+
this.liveQueryUnsubscribe = live.subscribe((message) => {
|
|
104
688
|
this.logger.debug(
|
|
105
|
-
{ message, Category: '
|
|
689
|
+
{ message, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
106
690
|
'Live update received'
|
|
107
691
|
);
|
|
108
692
|
if (message.action === 'KILLED') return;
|
|
693
|
+
// Subquery child edges (rows with `parent` set) are NOT primary window
|
|
694
|
+
// rows — the client's `RecordVersionArray` only tracks primary rows, so
|
|
695
|
+
// routing them through `handleRemoteListRefChange` would surface them as
|
|
696
|
+
// spurious "added" diffs and pollute the window. Instead route them to
|
|
697
|
+
// the dedicated child-body sync so `.related()` data stays realtime
|
|
698
|
+
// cross-session (the LIVE-permission gap otherwise leaves it to the poll).
|
|
699
|
+
if ((message.value as { parent?: unknown }).parent != null) {
|
|
700
|
+
this.handleRemoteSubqueryChange(
|
|
701
|
+
message.action,
|
|
702
|
+
message.value.in as RecordId<string>,
|
|
703
|
+
message.value.out as RecordId<string>,
|
|
704
|
+
message.value.version as number
|
|
705
|
+
).catch((err) => {
|
|
706
|
+
this.logger.error(
|
|
707
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
708
|
+
'Error handling remote subquery change'
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
109
713
|
this.handleRemoteListRefChange(
|
|
110
714
|
message.action,
|
|
111
715
|
message.value.in as RecordId<string>,
|
|
@@ -113,7 +717,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
113
717
|
message.value.version as number
|
|
114
718
|
).catch((err) => {
|
|
115
719
|
this.logger.error(
|
|
116
|
-
{ err, Category: '
|
|
720
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
117
721
|
'Error handling remote list ref change'
|
|
118
722
|
);
|
|
119
723
|
});
|
|
@@ -126,13 +730,26 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
126
730
|
recordId: RecordId,
|
|
127
731
|
version: number
|
|
128
732
|
) {
|
|
733
|
+
// Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
|
|
734
|
+
// query's window, or a notification for an unknown local query. Reset the
|
|
735
|
+
// poll's idle streak so it snaps back to the fast base cadence (the page
|
|
736
|
+
// is clearly not idle), and record the timestamp as a liveness diagnostic.
|
|
737
|
+
this.lastLiveEventAt = Date.now();
|
|
738
|
+
this.listRefIdleStreak = 0;
|
|
739
|
+
|
|
740
|
+
// NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
|
|
741
|
+
// this one) deletes a record, the server's SSP removes it from `_00_list_ref`
|
|
742
|
+
// and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
|
|
743
|
+
// turns it into a `removed: [recordId]` diff so the row drops from the window
|
|
744
|
+
// in realtime. (It was previously ignored, so other windows only caught up on
|
|
745
|
+
// reload / the slow poll.)
|
|
129
746
|
const existing = this.dataModule.getQueryById(queryId);
|
|
130
747
|
|
|
131
748
|
if (!existing) {
|
|
132
749
|
this.logger.warn(
|
|
133
750
|
{
|
|
134
751
|
queryId: queryId.toString(),
|
|
135
|
-
Category: '
|
|
752
|
+
Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
|
|
136
753
|
},
|
|
137
754
|
'Received remote update for unknown local query'
|
|
138
755
|
);
|
|
@@ -148,12 +765,55 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
148
765
|
recordId,
|
|
149
766
|
version,
|
|
150
767
|
localArray,
|
|
151
|
-
Category: '
|
|
768
|
+
Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
|
|
152
769
|
},
|
|
153
770
|
'Live update is being processed'
|
|
154
771
|
);
|
|
155
772
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
156
|
-
|
|
773
|
+
// `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
|
|
774
|
+
// (a SHA-256 over query content + sessionId) — the key DataModule uses.
|
|
775
|
+
const hash = extractIdPart(existing.config.id);
|
|
776
|
+
await this.runSyncForQuery(hash, diff);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
781
|
+
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
782
|
+
* must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
|
|
783
|
+
* keep the child BODY fresh in the local cache so the in-browser SSP's
|
|
784
|
+
* subquery-table dependency re-materializes the parent view.
|
|
785
|
+
*
|
|
786
|
+
* CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
|
|
787
|
+
* no-op: a child leaving this query's set must not delete a body another
|
|
788
|
+
* query may still show (see `syncSubqueryChildren` deletion-safety note);
|
|
789
|
+
* a genuine record delete propagates via the normal delete path.
|
|
790
|
+
*/
|
|
791
|
+
private async handleRemoteSubqueryChange(
|
|
792
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE',
|
|
793
|
+
queryId: RecordId,
|
|
794
|
+
childId: RecordId,
|
|
795
|
+
version: number
|
|
796
|
+
) {
|
|
797
|
+
this.lastLiveEventAt = Date.now();
|
|
798
|
+
this.listRefIdleStreak = 0;
|
|
799
|
+
|
|
800
|
+
if (action === 'DELETE') return;
|
|
801
|
+
|
|
802
|
+
const existing = this.dataModule.getQueryById(queryId);
|
|
803
|
+
if (!existing) return;
|
|
804
|
+
|
|
805
|
+
const item = { id: childId, version };
|
|
806
|
+
await this.syncEngine.syncRecords(
|
|
807
|
+
action === 'CREATE'
|
|
808
|
+
? { added: [item], updated: [], removed: [] }
|
|
809
|
+
: { added: [], updated: [item], removed: [] }
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
// Keep the in-memory child array in step so the poll's idempotent diff
|
|
813
|
+
// doesn't re-fetch this body on the next tick.
|
|
814
|
+
const key = encodeRecordId(childId);
|
|
815
|
+
const prev = existing.config.subqueryRemoteArray ?? [];
|
|
816
|
+
existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
|
|
157
817
|
}
|
|
158
818
|
|
|
159
819
|
/**
|
|
@@ -166,12 +826,11 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
166
826
|
|
|
167
827
|
private async processUpEvent(event: UpEvent) {
|
|
168
828
|
this.logger.debug(
|
|
169
|
-
{ event, Category: '
|
|
829
|
+
{ event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
|
|
170
830
|
'Processing up event'
|
|
171
831
|
);
|
|
172
|
-
console.log('xx1', event);
|
|
173
832
|
switch (event.type) {
|
|
174
|
-
case 'create':
|
|
833
|
+
case 'create': {
|
|
175
834
|
const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
|
|
176
835
|
const prefixedParams = Object.fromEntries(
|
|
177
836
|
dataKeys.map(({ key, variable }) => [variable, event.data[key]])
|
|
@@ -182,6 +841,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
182
841
|
...prefixedParams,
|
|
183
842
|
});
|
|
184
843
|
break;
|
|
844
|
+
}
|
|
185
845
|
case 'update':
|
|
186
846
|
await this.remote.query(`UPDATE $id MERGE $data`, {
|
|
187
847
|
id: event.record_id,
|
|
@@ -195,7 +855,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
195
855
|
break;
|
|
196
856
|
default:
|
|
197
857
|
this.logger.error(
|
|
198
|
-
{ event, Category: '
|
|
858
|
+
{ event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
|
|
199
859
|
'processUpEvent unknown event type'
|
|
200
860
|
);
|
|
201
861
|
return;
|
|
@@ -215,7 +875,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
215
875
|
recordId,
|
|
216
876
|
tableName,
|
|
217
877
|
error: error.message,
|
|
218
|
-
Category: '
|
|
878
|
+
Category: 'sp00ky-client::Sp00kySync::handleRollback',
|
|
219
879
|
},
|
|
220
880
|
'Rolling back failed mutation'
|
|
221
881
|
);
|
|
@@ -231,7 +891,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
231
891
|
this.logger.warn(
|
|
232
892
|
{
|
|
233
893
|
recordId,
|
|
234
|
-
Category: '
|
|
894
|
+
Category: 'sp00ky-client::Sp00kySync::handleRollback',
|
|
235
895
|
},
|
|
236
896
|
'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
|
|
237
897
|
);
|
|
@@ -241,7 +901,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
241
901
|
this.logger.warn(
|
|
242
902
|
{
|
|
243
903
|
recordId,
|
|
244
|
-
Category: '
|
|
904
|
+
Category: 'sp00ky-client::Sp00kySync::handleRollback',
|
|
245
905
|
},
|
|
246
906
|
'Delete rollback not implemented. Down-sync will reconcile.'
|
|
247
907
|
);
|
|
@@ -257,7 +917,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
257
917
|
|
|
258
918
|
private async processDownEvent(event: DownEvent) {
|
|
259
919
|
this.logger.debug(
|
|
260
|
-
{ event, Category: '
|
|
920
|
+
{ event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
|
|
261
921
|
'Processing down event'
|
|
262
922
|
);
|
|
263
923
|
switch (event.type) {
|
|
@@ -281,7 +941,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
281
941
|
const queryState = this.dataModule.getQueryByHash(hash);
|
|
282
942
|
if (!queryState) {
|
|
283
943
|
this.logger.warn(
|
|
284
|
-
{ hash, Category: '
|
|
944
|
+
{ hash, Category: 'sp00ky-client::Sp00kySync::syncQuery' },
|
|
285
945
|
'Query not found'
|
|
286
946
|
);
|
|
287
947
|
return;
|
|
@@ -295,7 +955,104 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
295
955
|
if (!diff) {
|
|
296
956
|
return;
|
|
297
957
|
}
|
|
298
|
-
return this.
|
|
958
|
+
return this.runSyncForQuery(hash, diff);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Run a sync for a single query while reflecting its fetch status. Marks the
|
|
963
|
+
* query `fetching` for the duration when the diff actually pulls records
|
|
964
|
+
* (added/updated), then resets to `idle` in a `finally` so a failed sync
|
|
965
|
+
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
966
|
+
* means the single resulting UI update lands after this completes.
|
|
967
|
+
*/
|
|
968
|
+
private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
|
|
969
|
+
// Don't let sync re-add a record the user just deleted locally. The remote
|
|
970
|
+
// delete is queued in the outbox, so until it's processed the server's
|
|
971
|
+
// `_00_list_ref` still lists the record — the diff then classifies it as
|
|
972
|
+
// `added` (present remotely, absent locally) and `syncRecords` re-fetches +
|
|
973
|
+
// re-inserts it, so a deleted database reappears a few seconds later. Drop
|
|
974
|
+
// any id with a pending local DELETE from the re-add paths. Once the remote
|
|
975
|
+
// delete lands, the pending row clears and the server drops it from
|
|
976
|
+
// `_00_list_ref`, so this guard naturally stops applying.
|
|
977
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
978
|
+
const pendingDeletes = await this.getPendingDeleteIds();
|
|
979
|
+
if (pendingDeletes.size > 0) {
|
|
980
|
+
diff = {
|
|
981
|
+
added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
982
|
+
updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
983
|
+
removed: diff.removed,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const fetching = diff.added.length + diff.updated.length > 0;
|
|
989
|
+
if (fetching) {
|
|
990
|
+
this.dataModule.setQueryStatus(hash, 'fetching');
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
994
|
+
if (fetching) {
|
|
995
|
+
this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
996
|
+
}
|
|
997
|
+
// Converge localArray to the authoritative remoteArray for ids that left
|
|
998
|
+
// the server's list_ref but still exist — a view-membership change, not a
|
|
999
|
+
// delete — so the poll's diff stops re-flagging them every tick (the `job:`
|
|
1000
|
+
// churn). CRUCIAL: only converge after the id has been still-remote for
|
|
1001
|
+
// several CONSECUTIVE rounds. A record that's merely mid-deletion is
|
|
1002
|
+
// still-remote for ~one round (its delete hasn't committed when our
|
|
1003
|
+
// existence check races it) and is gone the next round → it never reaches
|
|
1004
|
+
// the threshold, so it's deleted normally instead of being stranded here.
|
|
1005
|
+
if (stillRemoteIds.length > 0) {
|
|
1006
|
+
const CONVERGE_AFTER = 3;
|
|
1007
|
+
const toConverge: string[] = [];
|
|
1008
|
+
for (const id of stillRemoteIds) {
|
|
1009
|
+
const key = `${hash}:${id}`;
|
|
1010
|
+
const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
|
|
1011
|
+
if (n >= CONVERGE_AFTER) {
|
|
1012
|
+
this.stillRemoteStreaks.delete(key);
|
|
1013
|
+
toConverge.push(id);
|
|
1014
|
+
} else {
|
|
1015
|
+
this.stillRemoteStreaks.set(key, n);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (toConverge.length > 0) {
|
|
1019
|
+
const qs = this.dataModule.getQueryByHash(hash);
|
|
1020
|
+
const local = qs?.config.localArray;
|
|
1021
|
+
if (local && local.length > 0) {
|
|
1022
|
+
const drop = new Set(toConverge);
|
|
1023
|
+
const next = local.filter(([id]) => !drop.has(id));
|
|
1024
|
+
if (next.length !== local.length) {
|
|
1025
|
+
await this.dataModule.updateQueryLocalArray(hash, next);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
} finally {
|
|
1031
|
+
if (fetching) {
|
|
1032
|
+
this.dataModule.setQueryStatus(hash, 'idle');
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
|
|
1039
|
+
* Sync must not re-fetch/re-insert these — the remote delete is async, so the
|
|
1040
|
+
* server's `_00_list_ref` still lists them until it's processed, and the diff
|
|
1041
|
+
* would otherwise resurrect a just-deleted record.
|
|
1042
|
+
*/
|
|
1043
|
+
private async getPendingDeleteIds(): Promise<Set<string>> {
|
|
1044
|
+
try {
|
|
1045
|
+
const [rows] = await this.local.query<[{ recordId: RecordId<string> }[]]>(
|
|
1046
|
+
"SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'"
|
|
1047
|
+
);
|
|
1048
|
+
return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
this.logger.warn(
|
|
1051
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::getPendingDeleteIds' },
|
|
1052
|
+
'Failed to read pending deletes; sync may briefly resurrect a just-deleted record'
|
|
1053
|
+
);
|
|
1054
|
+
return new Set();
|
|
1055
|
+
}
|
|
299
1056
|
}
|
|
300
1057
|
|
|
301
1058
|
/**
|
|
@@ -309,7 +1066,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
309
1066
|
private async registerQuery(queryHash: string) {
|
|
310
1067
|
try {
|
|
311
1068
|
this.logger.debug(
|
|
312
|
-
{ queryHash, Category: '
|
|
1069
|
+
{ queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
|
|
313
1070
|
'Register Query state'
|
|
314
1071
|
);
|
|
315
1072
|
await this.createRemoteQuery(queryHash);
|
|
@@ -319,7 +1076,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
319
1076
|
await this.dataModule.notifyQuerySynced(queryHash);
|
|
320
1077
|
} catch (e) {
|
|
321
1078
|
this.logger.error(
|
|
322
|
-
{ err: e, Category: '
|
|
1079
|
+
{ err: e, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
|
|
323
1080
|
'registerQuery error'
|
|
324
1081
|
);
|
|
325
1082
|
throw e;
|
|
@@ -331,15 +1088,15 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
331
1088
|
|
|
332
1089
|
if (!queryState) {
|
|
333
1090
|
this.logger.warn(
|
|
334
|
-
{ queryHash, Category: '
|
|
1091
|
+
{ queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
|
|
335
1092
|
'Query to register not found'
|
|
336
1093
|
);
|
|
337
1094
|
throw new Error('Query to register not found');
|
|
338
1095
|
}
|
|
339
|
-
// Delegate to remote function which handles DBSP registration & persistence
|
|
1096
|
+
// Delegate to remote function which handles DBSP registration & persistence.
|
|
1097
|
+
// clientId is set server-side from session::id() — see fn::query::register.
|
|
340
1098
|
await this.remote.query('fn::query::register($config)', {
|
|
341
1099
|
config: {
|
|
342
|
-
clientId: this.clientId,
|
|
343
1100
|
id: queryState.config.id,
|
|
344
1101
|
surql: queryState.config.surql,
|
|
345
1102
|
params: queryState.config.params,
|
|
@@ -347,8 +1104,14 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
347
1104
|
},
|
|
348
1105
|
});
|
|
349
1106
|
|
|
1107
|
+
// Initial materialized-view fetch — pull from the same per-user
|
|
1108
|
+
// `_00_list_ref_user_<id>` (or global `_00_list_ref` in single
|
|
1109
|
+
// mode) that the LIVE subscription listens on, so the two stay in
|
|
1110
|
+
// sync. `parent IS NONE` excludes subquery entries; the
|
|
1111
|
+
// `localArray` cache only tracks primary records.
|
|
1112
|
+
const listRefTbl = this.listRefTable();
|
|
350
1113
|
const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
|
|
351
|
-
|
|
1114
|
+
buildListRefSelect(listRefTbl),
|
|
352
1115
|
{
|
|
353
1116
|
in: queryState.config.id,
|
|
354
1117
|
}
|
|
@@ -358,7 +1121,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
358
1121
|
{
|
|
359
1122
|
queryId: encodeRecordId(queryState.config.id),
|
|
360
1123
|
items,
|
|
361
|
-
Category: '
|
|
1124
|
+
Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
|
|
362
1125
|
},
|
|
363
1126
|
'Got query record version array from remote'
|
|
364
1127
|
);
|
|
@@ -369,7 +1132,7 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
369
1132
|
{
|
|
370
1133
|
queryId: encodeRecordId(queryState.config.id),
|
|
371
1134
|
array,
|
|
372
|
-
Category: '
|
|
1135
|
+
Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
|
|
373
1136
|
},
|
|
374
1137
|
'createdRemoteQuery'
|
|
375
1138
|
);
|
|
@@ -378,13 +1141,71 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
378
1141
|
/// Incantation existed already
|
|
379
1142
|
await this.dataModule.updateQueryRemoteArray(queryHash, array);
|
|
380
1143
|
}
|
|
1144
|
+
|
|
1145
|
+
// Pull the bodies of any `.related()` subquery children into the local
|
|
1146
|
+
// cache. The primary fetch above (`parent IS NONE`) tracks only window
|
|
1147
|
+
// rows, so without this a cold-reload re-materialization of the
|
|
1148
|
+
// correlated surql finds no child rows and related fields come back
|
|
1149
|
+
// empty. Best-effort: never fail registration over it.
|
|
1150
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
1151
|
+
this.logger.info(
|
|
1152
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
|
|
1153
|
+
'Subquery child sync failed during registration; poll will retry'
|
|
1154
|
+
);
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Sync the BODIES of a `.related()` query's subquery child rows into the
|
|
1160
|
+
* local cache, separately from the primary window array. The SSP writes
|
|
1161
|
+
* each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
|
|
1162
|
+
* `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
|
|
1163
|
+
* nesting depth). We diff against the in-memory `subqueryRemoteArray` and
|
|
1164
|
+
* fetch added/updated bodies through the SyncEngine — which `saveBatch`s
|
|
1165
|
+
* them into the local DB AND the in-browser SSP, whose subquery-table
|
|
1166
|
+
* dependency then re-materializes the parent view (no explicit notify).
|
|
1167
|
+
*
|
|
1168
|
+
* Deletion safety: we pass `removed: []` deliberately. A child body can be
|
|
1169
|
+
* shared by other queries; letting `handleRemovedRecords` delete one that
|
|
1170
|
+
* merely left THIS query's child set would clobber data another query still
|
|
1171
|
+
* shows. Genuine record deletes flow through the normal delete path; a
|
|
1172
|
+
* lingering orphan body is invisible (the correlated WHERE stops matching).
|
|
1173
|
+
*
|
|
1174
|
+
* Kept off `runSyncForQuery` on purpose so child fetches never flip the
|
|
1175
|
+
* query to `fetching` or skew its DevTools timings.
|
|
1176
|
+
*/
|
|
1177
|
+
private async syncSubqueryChildren(queryHash: string): Promise<void> {
|
|
1178
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
1179
|
+
if (!queryState) return;
|
|
1180
|
+
|
|
1181
|
+
const listRefTbl = this.listRefTable();
|
|
1182
|
+
const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
|
|
1183
|
+
buildSubqueryListRefSelect(listRefTbl),
|
|
1184
|
+
{ in: queryState.config.id }
|
|
1185
|
+
);
|
|
1186
|
+
if (!Array.isArray(items)) return;
|
|
1187
|
+
|
|
1188
|
+
const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
1189
|
+
const prev = queryState.config.subqueryRemoteArray ?? [];
|
|
1190
|
+
if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
|
|
1191
|
+
|
|
1192
|
+
const diff = diffRecordVersionArray(prev, fresh);
|
|
1193
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
1194
|
+
await this.syncEngine.syncRecords({
|
|
1195
|
+
added: diff.added,
|
|
1196
|
+
updated: diff.updated,
|
|
1197
|
+
removed: [], // never delete child bodies here — see method doc
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
// In-memory only — child rows must never enter the persisted primary array.
|
|
1201
|
+
queryState.config.subqueryRemoteArray = fresh;
|
|
381
1202
|
}
|
|
382
1203
|
|
|
383
|
-
|
|
1204
|
+
public async heartbeatQuery(queryHash: string) {
|
|
384
1205
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
385
1206
|
if (!queryState) {
|
|
386
1207
|
this.logger.warn(
|
|
387
|
-
{ queryHash, Category: '
|
|
1208
|
+
{ queryHash, Category: 'sp00ky-client::Sp00kySync::heartbeatQuery' },
|
|
388
1209
|
'Query to register not found'
|
|
389
1210
|
);
|
|
390
1211
|
throw new Error('Query to register not found');
|
|
@@ -394,17 +1215,30 @@ export class SpookySync<S extends SchemaStructure> {
|
|
|
394
1215
|
});
|
|
395
1216
|
}
|
|
396
1217
|
|
|
1218
|
+
// Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
|
|
1219
|
+
// e.g. a viewport-windowed list cancelling an off-screen window). Query ids
|
|
1220
|
+
// are a deterministic hash of (surql+params), so a DELETE racing a scroll-back
|
|
1221
|
+
// re-register (same id) could nuke a freshly-recreated view — hence two
|
|
1222
|
+
// guards: abort if a subscriber reappeared BEFORE the delete; re-register if
|
|
1223
|
+
// one reappears DURING the delete's network await. Tolerant of a
|
|
1224
|
+
// missing/already-gone query (no throw).
|
|
397
1225
|
private async cleanupQuery(queryHash: string) {
|
|
398
1226
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
399
|
-
if (!queryState)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1227
|
+
if (!queryState) return; // already torn down / never registered
|
|
1228
|
+
|
|
1229
|
+
// Re-subscribed before the queued cleanup ran → keep everything as-is.
|
|
1230
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
1231
|
+
|
|
1232
|
+
await this.remote.query(`DELETE $id`, { id: queryState.config.id });
|
|
1233
|
+
|
|
1234
|
+
// Re-subscribed while we awaited the DELETE → the remote view is now gone
|
|
1235
|
+
// but a subscriber needs it; recreate it instead of leaving a zombie.
|
|
1236
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
1237
|
+
this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
|
|
1238
|
+
return;
|
|
405
1239
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1240
|
+
|
|
1241
|
+
// No subscribers throughout → safe to free the local view + state.
|
|
1242
|
+
this.dataModule.finalizeDeregister(queryHash);
|
|
409
1243
|
}
|
|
410
1244
|
}
|