@spooky-sync/core 0.0.1-canary.1 → 0.0.1-canary.100
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 +938 -339
- package/dist/index.js +2978 -423
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +548 -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 -1
- 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 +848 -134
- 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.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -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 +896 -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 +197 -15
- package/src/utils/index.ts +42 -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 -346
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
-
import {
|
|
2
|
+
import type {
|
|
3
3
|
SchemaStructure,
|
|
4
4
|
TableNames,
|
|
5
5
|
BackendNames,
|
|
6
6
|
BackendRoutes,
|
|
7
7
|
RoutePayload,
|
|
8
8
|
} from '@spooky-sync/query-builder';
|
|
9
|
-
import { LocalDatabaseService } from '../../services/database/index';
|
|
10
|
-
import { CacheModule, RecordWithId } from '../cache/index';
|
|
11
|
-
import { Logger } from '../../services/logger/index';
|
|
12
|
-
import { StreamUpdate } from '../../services/stream-processor/index';
|
|
13
|
-
import {
|
|
14
|
-
MutationEvent,
|
|
9
|
+
import type { LocalDatabaseService } from '../../services/database/index';
|
|
10
|
+
import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
|
|
11
|
+
import type { Logger } from '../../services/logger/index';
|
|
12
|
+
import type { StreamUpdate } from '../../services/stream-processor/index';
|
|
13
|
+
import type {
|
|
15
14
|
QueryConfig,
|
|
16
15
|
QueryHash,
|
|
17
16
|
QueryState,
|
|
17
|
+
QueryStatus,
|
|
18
|
+
QueryStatusCallback,
|
|
18
19
|
QueryTimeToLive,
|
|
19
20
|
QueryUpdateCallback,
|
|
20
21
|
MutationCallback,
|
|
21
22
|
RecordVersionArray,
|
|
22
23
|
QueryConfigRecord,
|
|
23
24
|
UpdateOptions,
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
QueryTimings,
|
|
26
|
+
PhaseStat,
|
|
27
|
+
RegistrationTimings,
|
|
28
|
+
RunOptions} from '../../types';
|
|
29
|
+
import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
|
|
26
30
|
import {
|
|
27
31
|
parseRecordIdString,
|
|
28
32
|
extractIdPart,
|
|
@@ -31,11 +35,29 @@ import {
|
|
|
31
35
|
withRetry,
|
|
32
36
|
surql,
|
|
33
37
|
parseParams,
|
|
38
|
+
cleanRecord,
|
|
34
39
|
extractTablePart,
|
|
35
40
|
generateId,
|
|
36
41
|
} from '../../utils/index';
|
|
37
|
-
import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
38
|
-
import { PushEventOptions } from '../../events/index';
|
|
42
|
+
import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
43
|
+
import type { PushEventOptions } from '../../events/index';
|
|
44
|
+
import { buildWindowMaterialization } from './window-query';
|
|
45
|
+
|
|
46
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
47
|
+
function pushSample(samples: number[], ms: number): void {
|
|
48
|
+
samples.push(ms);
|
|
49
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
53
|
+
function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
54
|
+
if (samples.length === 0) {
|
|
55
|
+
return { lastMs, p50: null, p90: null, p99: null, count: 0 };
|
|
56
|
+
}
|
|
57
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
58
|
+
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
59
|
+
return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
|
|
60
|
+
}
|
|
39
61
|
|
|
40
62
|
/**
|
|
41
63
|
* DataModule - Unified query and mutation management
|
|
@@ -45,23 +67,97 @@ import { PushEventOptions } from '../../events/index';
|
|
|
45
67
|
*/
|
|
46
68
|
export class DataModule<S extends SchemaStructure> {
|
|
47
69
|
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
70
|
+
private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
|
|
48
71
|
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
72
|
+
private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
|
|
49
73
|
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
50
74
|
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
51
75
|
private logger: Logger;
|
|
76
|
+
/**
|
|
77
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
78
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
79
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
80
|
+
* constructed after DataModule.
|
|
81
|
+
*/
|
|
82
|
+
public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
|
|
83
|
+
/**
|
|
84
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
85
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
86
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
87
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
88
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
89
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
90
|
+
*/
|
|
91
|
+
public onHeartbeat?: (hash: QueryHash) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
94
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
95
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
96
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
97
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
98
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
99
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
100
|
+
*/
|
|
101
|
+
public onDeregister?: (hash: QueryHash) => void;
|
|
102
|
+
// Salt for query-id hashing. Set from SurrealDB's session::id() so two
|
|
103
|
+
// browser sessions registering the same logical query (same surql + params)
|
|
104
|
+
// don't collide on the same `_00_query` row — each session gets its own.
|
|
105
|
+
// Empty string until init(sessionId) is called.
|
|
106
|
+
private sessionId: string = '';
|
|
107
|
+
// Authenticated user record id (e.g. `"user:abc"`). Updated by
|
|
108
|
+
// `setCurrentUserId` from the auth subscription. null when
|
|
109
|
+
// unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
|
|
110
|
+
// poll and LIVE subscription target the same per-user
|
|
111
|
+
// `_00_list_ref_user_<id>` table the sync engine writes to.
|
|
112
|
+
private currentUserId: string | null = null;
|
|
52
113
|
|
|
53
114
|
constructor(
|
|
54
115
|
private cache: CacheModule,
|
|
55
116
|
private local: LocalDatabaseService,
|
|
56
117
|
private schema: S,
|
|
57
118
|
logger: Logger,
|
|
58
|
-
|
|
119
|
+
// Client-side SSP aggregation throttle: coalesces the in-browser
|
|
120
|
+
// StreamProcessor's per-record stream updates per query before notifying
|
|
121
|
+
// readers, so a burst of synced records repaints the UI once per window
|
|
122
|
+
// rather than row-by-row. 50ms keeps it responsive while still batching the
|
|
123
|
+
// initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
|
|
124
|
+
private streamDebounceTime: number = 50
|
|
59
125
|
) {
|
|
60
126
|
this.logger = logger.child({ service: 'DataModule' });
|
|
61
127
|
}
|
|
62
128
|
|
|
63
|
-
async init(): Promise<void> {
|
|
64
|
-
this.
|
|
129
|
+
async init(sessionId: string): Promise<void> {
|
|
130
|
+
this.sessionId = sessionId;
|
|
131
|
+
this.logger.info(
|
|
132
|
+
{ sessionId, Category: 'sp00ky-client::DataModule::init' },
|
|
133
|
+
'DataModule initialized'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
139
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
140
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
141
|
+
*/
|
|
142
|
+
setSessionId(sessionId: string): void {
|
|
143
|
+
this.sessionId = sessionId;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
148
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
149
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
150
|
+
* SSP writes to.
|
|
151
|
+
*/
|
|
152
|
+
setCurrentUserId(userId: string | null): void {
|
|
153
|
+
this.currentUserId = userId;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
157
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
158
|
+
* same table name DataModule does. */
|
|
159
|
+
getCurrentUserId(): string | null {
|
|
160
|
+
return this.currentUserId;
|
|
65
161
|
}
|
|
66
162
|
|
|
67
163
|
// ==================== QUERY MANAGEMENT ====================
|
|
@@ -77,58 +173,45 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
77
173
|
): Promise<QueryHash> {
|
|
78
174
|
const hash = await this.calculateHash({ surql: surqlString, params });
|
|
79
175
|
this.logger.debug(
|
|
80
|
-
{ hash, Category: '
|
|
176
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
81
177
|
'Query Initialization: started'
|
|
82
178
|
);
|
|
83
179
|
|
|
84
|
-
|
|
180
|
+
// `_00_query` stays the single shared registration table in both
|
|
181
|
+
// ref-modes; the per-user split happens only on `_00_list_ref`.
|
|
182
|
+
const recordId = new RecordId('_00_query', hash);
|
|
85
183
|
|
|
86
184
|
if (this.activeQueries.has(hash)) {
|
|
87
185
|
this.logger.debug(
|
|
88
|
-
{ hash, Category: '
|
|
186
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
89
187
|
'Query Initialization: exists, returning'
|
|
90
188
|
);
|
|
91
189
|
return hash;
|
|
92
190
|
}
|
|
93
191
|
|
|
192
|
+
// Another call is already creating this query — wait for it
|
|
193
|
+
if (this.pendingQueries.has(hash)) {
|
|
194
|
+
this.logger.debug(
|
|
195
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
196
|
+
'Query Initialization: pending, waiting for existing creation'
|
|
197
|
+
);
|
|
198
|
+
await this.pendingQueries.get(hash);
|
|
199
|
+
return hash;
|
|
200
|
+
}
|
|
201
|
+
|
|
94
202
|
this.logger.debug(
|
|
95
|
-
{ hash, Category: '
|
|
203
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
96
204
|
'Query Initialization: not found, creating new query'
|
|
97
205
|
);
|
|
98
|
-
const queryState = await this.createNewQuery<T>({
|
|
99
|
-
recordId,
|
|
100
|
-
surql: surqlString,
|
|
101
|
-
params,
|
|
102
|
-
ttl,
|
|
103
|
-
tableName,
|
|
104
|
-
});
|
|
105
206
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await withRetry(this.logger, () =>
|
|
115
|
-
this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
116
|
-
id: recordId,
|
|
117
|
-
localArray,
|
|
118
|
-
})
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
this.activeQueries.set(hash, queryState);
|
|
122
|
-
this.startTTLHeartbeat(queryState);
|
|
123
|
-
this.logger.debug(
|
|
124
|
-
{
|
|
125
|
-
hash,
|
|
126
|
-
tableName,
|
|
127
|
-
recordCount: queryState.records.length,
|
|
128
|
-
Category: 'spooky-client::DataModule::query',
|
|
129
|
-
},
|
|
130
|
-
'Query registered'
|
|
131
|
-
);
|
|
207
|
+
// Create the query and track the pending promise
|
|
208
|
+
const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
|
|
209
|
+
this.pendingQueries.set(hash, promise);
|
|
210
|
+
try {
|
|
211
|
+
await promise;
|
|
212
|
+
} finally {
|
|
213
|
+
this.pendingQueries.delete(hash);
|
|
214
|
+
}
|
|
132
215
|
|
|
133
216
|
return hash;
|
|
134
217
|
}
|
|
@@ -161,11 +244,70 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
161
244
|
subs.delete(callback);
|
|
162
245
|
if (subs.size === 0) {
|
|
163
246
|
this.subscriptions.delete(queryHash);
|
|
247
|
+
// NOTE: intentionally do NOT tear down the query / free its in-browser
|
|
248
|
+
// SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
|
|
249
|
+
// already self-stops once subscribers hit 0, so an abandoned query
|
|
250
|
+
// stops being kept alive and the server's TTL sweep removes it. Freeing
|
|
251
|
+
// the local view on every last-unsubscribe caused re-registration churn
|
|
252
|
+
// on navigation (open A → leave → open A again re-registers), which is
|
|
253
|
+
// a flakiness risk for no real benefit — keep the local view resident.
|
|
164
254
|
}
|
|
165
255
|
}
|
|
166
256
|
};
|
|
167
257
|
}
|
|
168
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
261
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
262
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
263
|
+
*/
|
|
264
|
+
subscribeStatus(
|
|
265
|
+
queryHash: string,
|
|
266
|
+
callback: QueryStatusCallback,
|
|
267
|
+
options: { immediate?: boolean } = {}
|
|
268
|
+
): () => void {
|
|
269
|
+
if (!this.statusSubscriptions.has(queryHash)) {
|
|
270
|
+
this.statusSubscriptions.set(queryHash, new Set());
|
|
271
|
+
}
|
|
272
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
273
|
+
|
|
274
|
+
if (options.immediate) {
|
|
275
|
+
callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return () => {
|
|
279
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
280
|
+
if (subs) {
|
|
281
|
+
subs.delete(callback);
|
|
282
|
+
if (subs.size === 0) {
|
|
283
|
+
this.statusSubscriptions.delete(queryHash);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
291
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
292
|
+
* query is unknown.
|
|
293
|
+
*/
|
|
294
|
+
setQueryStatus(queryHash: string, status: QueryStatus): void {
|
|
295
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
296
|
+
if (!queryState || queryState.status === status) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
queryState.status = status;
|
|
300
|
+
|
|
301
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
302
|
+
|
|
303
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
304
|
+
if (subs) {
|
|
305
|
+
for (const callback of subs) {
|
|
306
|
+
callback(status);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
169
311
|
/**
|
|
170
312
|
* Subscribe to mutations (for sync)
|
|
171
313
|
*/
|
|
@@ -182,53 +324,165 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
182
324
|
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
183
325
|
const { queryHash, op } = update;
|
|
184
326
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
327
|
+
// DELETE propagates immediately — a removed row should disappear without
|
|
328
|
+
// waiting on a debounce.
|
|
329
|
+
//
|
|
330
|
+
// CREATE and UPDATE are coalesced per query on a trailing timer. A list's
|
|
331
|
+
// rows stream in from sync as many small `_00_list_ref` diffs, each its own
|
|
332
|
+
// `cache.saveBatch` → one stream update per chunk. `ingestMany` only
|
|
333
|
+
// coalesces records ingested synchronously together, so chunks spread over
|
|
334
|
+
// time each re-materialize and re-notify — a 50-row window can fire 30+
|
|
335
|
+
// updates as it fills. Each StreamUpdate carries the full materialized
|
|
336
|
+
// `localArray`, so the latest one already reflects every prior chunk: keep
|
|
337
|
+
// only it and fire once on the trailing edge, settling the query in a couple
|
|
338
|
+
// of notifications instead of one per chunk.
|
|
339
|
+
if (op === 'DELETE') {
|
|
340
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
341
|
+
if (existing) {
|
|
342
|
+
clearTimeout(existing);
|
|
343
|
+
this.debounceTimers.delete(queryHash);
|
|
191
344
|
}
|
|
345
|
+
await this.processStreamUpdate(update);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
192
348
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
349
|
+
// Clear existing timer if any
|
|
350
|
+
if (this.debounceTimers.has(queryHash)) {
|
|
351
|
+
// oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
|
|
352
|
+
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
353
|
+
}
|
|
198
354
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
355
|
+
// Set new timer
|
|
356
|
+
const timer = setTimeout(async () => {
|
|
357
|
+
this.debounceTimers.delete(queryHash);
|
|
202
358
|
await this.processStreamUpdate(update);
|
|
359
|
+
}, this.streamDebounceTime);
|
|
360
|
+
|
|
361
|
+
this.debounceTimers.set(queryHash, timer);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Materialize a query's result rows from the local DB. For a windowed query
|
|
365
|
+
// (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
|
|
366
|
+
// `START m` against the shared local store would skip the window's own rows
|
|
367
|
+
// (sparse windowing) and return nothing. Instead we select the window's
|
|
368
|
+
// record id-set directly, preferring the server's `list_ref` (`remoteArray`,
|
|
369
|
+
// authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
|
|
370
|
+
// the original ORDER BY for stable order.
|
|
371
|
+
private async materializeRecords(
|
|
372
|
+
queryState: QueryState,
|
|
373
|
+
sspArray?: Array<[string, number]>
|
|
374
|
+
): Promise<Record<string, any>[]> {
|
|
375
|
+
const t0 = performance.now();
|
|
376
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
377
|
+
let records: Record<string, any>[];
|
|
378
|
+
if (windowMat) {
|
|
379
|
+
const win =
|
|
380
|
+
(queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
|
|
381
|
+
(sspArray?.length && sspArray) ||
|
|
382
|
+
queryState.config.localArray ||
|
|
383
|
+
[];
|
|
384
|
+
const winIds = win.map(([id]) => parseRecordIdString(id));
|
|
385
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
386
|
+
...queryState.config.params,
|
|
387
|
+
__win: winIds,
|
|
388
|
+
});
|
|
389
|
+
records = rows || [];
|
|
390
|
+
} else {
|
|
391
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
392
|
+
queryState.config.surql,
|
|
393
|
+
queryState.config.params
|
|
394
|
+
);
|
|
395
|
+
records = rows || [];
|
|
203
396
|
}
|
|
397
|
+
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
398
|
+
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
399
|
+
return records;
|
|
204
400
|
}
|
|
205
401
|
|
|
206
402
|
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
207
|
-
const { queryHash, localArray } = update;
|
|
403
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
208
404
|
const queryState = this.activeQueries.get(queryHash);
|
|
209
405
|
if (!queryState) {
|
|
210
406
|
this.logger.warn(
|
|
211
|
-
{ queryHash, Category: '
|
|
407
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
212
408
|
'Received update for unknown query. Skipping...'
|
|
213
409
|
);
|
|
214
410
|
return;
|
|
215
411
|
}
|
|
216
412
|
|
|
413
|
+
// Update the rolling materialization-sample window before the work that
|
|
414
|
+
// could throw, so the percentiles still move when the downstream local
|
|
415
|
+
// query fails (the materialization step itself ran).
|
|
416
|
+
if (typeof materializationTimeMs === 'number') {
|
|
417
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
418
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
|
|
419
|
+
queryState.materializationSamples.shift();
|
|
420
|
+
}
|
|
421
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
422
|
+
}
|
|
423
|
+
// Record the SSP internal sub-phase timings (from the WASM binding) so
|
|
424
|
+
// DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
|
|
425
|
+
if (typeof update.storeApplyMs === 'number')
|
|
426
|
+
this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
|
|
427
|
+
if (typeof update.circuitStepMs === 'number')
|
|
428
|
+
this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
|
|
429
|
+
if (typeof update.transformMs === 'number')
|
|
430
|
+
this.recordPhase(queryState, 'sspTransform', update.transformMs);
|
|
431
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
432
|
+
|
|
217
433
|
try {
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
434
|
+
// Materialize the query's rows. For a windowed (offset) query, re-running
|
|
435
|
+
// the original surql would re-apply `START n` against the shared local DB
|
|
436
|
+
// and skip the window's rows entirely; instead select the SSP's
|
|
437
|
+
// materialized window id-set (`localArray`) directly, re-applying the
|
|
438
|
+
// original ORDER BY for stable display order. Non-offset queries keep the
|
|
439
|
+
// normal re-query path.
|
|
440
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
441
|
+
queryState.config.localArray = localArray;
|
|
442
|
+
|
|
443
|
+
const prevJson = JSON.stringify(queryState.records);
|
|
444
|
+
const newJson = JSON.stringify(newRecords);
|
|
445
|
+
queryState.records = newRecords;
|
|
446
|
+
const recordsChanged = prevJson !== newJson;
|
|
447
|
+
|
|
448
|
+
// updateCount counts user-visible updates (matches the prior semantic),
|
|
449
|
+
// while the materialization sample/percentiles already moved above for
|
|
450
|
+
// every observed engine step.
|
|
451
|
+
if (recordsChanged) {
|
|
452
|
+
queryState.updateCount++;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
await this.local.query(
|
|
456
|
+
surql.seal(
|
|
457
|
+
surql.updateSet('id', [
|
|
458
|
+
'localArray',
|
|
459
|
+
'rowCount',
|
|
460
|
+
'updateCount',
|
|
461
|
+
'lastIngestLatency',
|
|
462
|
+
'materializationP55',
|
|
463
|
+
'materializationP90',
|
|
464
|
+
'materializationP99',
|
|
465
|
+
])
|
|
466
|
+
),
|
|
467
|
+
{
|
|
468
|
+
id: queryState.config.id,
|
|
469
|
+
localArray,
|
|
470
|
+
rowCount: localArray.length,
|
|
471
|
+
updateCount: queryState.updateCount,
|
|
472
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
473
|
+
materializationP55: percentiles.p55,
|
|
474
|
+
materializationP90: percentiles.p90,
|
|
475
|
+
materializationP99: percentiles.p99,
|
|
476
|
+
}
|
|
222
477
|
);
|
|
223
478
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
});
|
|
479
|
+
if (!recordsChanged) {
|
|
480
|
+
this.logger.debug(
|
|
481
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
482
|
+
'Query records unchanged, skipping notification'
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
232
486
|
|
|
233
487
|
// Notify subscribers
|
|
234
488
|
const subscribers = this.subscriptions.get(queryHash);
|
|
@@ -241,19 +495,103 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
241
495
|
this.logger.debug(
|
|
242
496
|
{
|
|
243
497
|
queryHash,
|
|
244
|
-
recordCount:
|
|
245
|
-
Category: '
|
|
498
|
+
recordCount: newRecords?.length,
|
|
499
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
246
500
|
},
|
|
247
501
|
'Query updated from stream'
|
|
248
502
|
);
|
|
249
503
|
} catch (err) {
|
|
504
|
+
queryState.errorCount++;
|
|
250
505
|
this.logger.error(
|
|
251
|
-
{ err, queryHash, Category: '
|
|
506
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
252
507
|
'Failed to fetch records for stream update'
|
|
253
508
|
);
|
|
509
|
+
// Best-effort persist of the bumped errorCount; swallow secondary
|
|
510
|
+
// failures to avoid masking the original error in logs.
|
|
511
|
+
try {
|
|
512
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
|
|
513
|
+
id: queryState.config.id,
|
|
514
|
+
errorCount: queryState.errorCount,
|
|
515
|
+
});
|
|
516
|
+
} catch (persistErr) {
|
|
517
|
+
this.logger.warn(
|
|
518
|
+
{
|
|
519
|
+
err: persistErr,
|
|
520
|
+
queryHash,
|
|
521
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
522
|
+
},
|
|
523
|
+
'Failed to persist incremented errorCount'
|
|
524
|
+
);
|
|
525
|
+
}
|
|
254
526
|
}
|
|
255
527
|
}
|
|
256
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
531
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
532
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
533
|
+
*/
|
|
534
|
+
private computeMaterializationPercentiles(samples: number[]): {
|
|
535
|
+
p55: number | null;
|
|
536
|
+
p90: number | null;
|
|
537
|
+
p99: number | null;
|
|
538
|
+
} {
|
|
539
|
+
if (samples.length === 0) {
|
|
540
|
+
return { p55: null, p90: null, p99: null };
|
|
541
|
+
}
|
|
542
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
543
|
+
const pick = (q: number) => {
|
|
544
|
+
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
|
|
545
|
+
return sorted[idx]!;
|
|
546
|
+
};
|
|
547
|
+
return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
551
|
+
private recordPhase(qs: QueryState, phase: string, ms: number): void {
|
|
552
|
+
if (!Number.isFinite(ms)) return;
|
|
553
|
+
const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
|
|
554
|
+
pushSample(arr, ms);
|
|
555
|
+
qs.phaseLast[phase] = ms;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
559
|
+
recordRemoteFetch(hash: string, ms: number): void {
|
|
560
|
+
const qs = this.activeQueries.get(hash);
|
|
561
|
+
if (qs) this.recordPhase(qs, 'remoteFetch', ms);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
566
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
567
|
+
*/
|
|
568
|
+
recordFrontendTiming(hash: string, ms: number): void {
|
|
569
|
+
const qs = this.activeQueries.get(hash);
|
|
570
|
+
if (qs) this.recordPhase(qs, 'frontend', ms);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
575
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
576
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
577
|
+
*/
|
|
578
|
+
phaseTimings(q: QueryState): QueryTimings {
|
|
579
|
+
const stat = (phase: string) =>
|
|
580
|
+
phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
581
|
+
return {
|
|
582
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
583
|
+
sspStoreApply: stat('sspStoreApply'),
|
|
584
|
+
sspCircuitStep: stat('sspCircuitStep'),
|
|
585
|
+
sspTransform: stat('sspTransform'),
|
|
586
|
+
localFetch: stat('localFetch'),
|
|
587
|
+
remoteFetch: stat('remoteFetch'),
|
|
588
|
+
frontend: stat('frontend'),
|
|
589
|
+
registration: q.registrationTimings,
|
|
590
|
+
updateCount: q.updateCount,
|
|
591
|
+
errorCount: q.errorCount,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
257
595
|
/**
|
|
258
596
|
* Get query state (for sync and devtools)
|
|
259
597
|
*/
|
|
@@ -261,6 +599,162 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
261
599
|
return this.activeQueries.get(hash);
|
|
262
600
|
}
|
|
263
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
604
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
605
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
606
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
607
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
608
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
609
|
+
*/
|
|
610
|
+
isCold(hash: string): boolean {
|
|
611
|
+
const qs = this.activeQueries.get(hash);
|
|
612
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
617
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
618
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
619
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
620
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
621
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
622
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
623
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
624
|
+
* `seen` dedupes within the batch.
|
|
625
|
+
*/
|
|
626
|
+
private collectEmbeddedChildren(
|
|
627
|
+
record: Record<string, any>,
|
|
628
|
+
batch: CacheRecord[],
|
|
629
|
+
seen: Set<string>
|
|
630
|
+
): void {
|
|
631
|
+
const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
|
|
632
|
+
!!v &&
|
|
633
|
+
typeof v === 'object' &&
|
|
634
|
+
!(v instanceof RecordId) &&
|
|
635
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
636
|
+
|
|
637
|
+
for (const value of Object.values(record)) {
|
|
638
|
+
const children = Array.isArray(value)
|
|
639
|
+
? value.filter(isEmbeddedRecord)
|
|
640
|
+
: isEmbeddedRecord(value)
|
|
641
|
+
? [value]
|
|
642
|
+
: [];
|
|
643
|
+
for (const child of children) {
|
|
644
|
+
const key = encodeRecordId(child.id);
|
|
645
|
+
if (seen.has(key)) continue;
|
|
646
|
+
seen.add(key);
|
|
647
|
+
// Recurse FIRST so nested grandchildren are captured before `cleanRecord`
|
|
648
|
+
// strips this child's alias fields.
|
|
649
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
650
|
+
const table = child.id.table.toString();
|
|
651
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
652
|
+
batch.push({
|
|
653
|
+
table,
|
|
654
|
+
op: 'CREATE',
|
|
655
|
+
record: (tableSchema
|
|
656
|
+
? cleanRecord(tableSchema.columns, child)
|
|
657
|
+
: child) as RecordWithId,
|
|
658
|
+
version: (child._00_rv as number) || 1,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
666
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
667
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
668
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
669
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
670
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
671
|
+
*/
|
|
672
|
+
async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
|
|
673
|
+
const queryState = this.activeQueries.get(hash);
|
|
674
|
+
if (!queryState) return;
|
|
675
|
+
queryState.hydrated = true; // run-once, even when the remote returns nothing
|
|
676
|
+
if (rows.length === 0) return;
|
|
677
|
+
|
|
678
|
+
const tableName = queryState.config.tableName;
|
|
679
|
+
const batch: CacheRecord[] = rows.map((record) => ({
|
|
680
|
+
table: tableName,
|
|
681
|
+
op: 'CREATE' as const,
|
|
682
|
+
record,
|
|
683
|
+
version: (record._00_rv as number) || 1,
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
// Flicker-free related data on cold first paint: a `.related()` query's
|
|
687
|
+
// rows arrive with their child rows EMBEDDED (the correlated subquery,
|
|
688
|
+
// e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
|
|
689
|
+
// primary batch above persists only the parent table, so the immediate
|
|
690
|
+
// `materializeRecords` below would re-run the correlated surql against a
|
|
691
|
+
// local DB with no child rows and overwrite the embedded result with
|
|
692
|
+
// empties. Extract those embedded children (any nesting depth) and
|
|
693
|
+
// persist them as their own records so the re-materialization finds them.
|
|
694
|
+
const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
|
|
695
|
+
for (const record of rows) {
|
|
696
|
+
this.collectEmbeddedChildren(record, batch, seen);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
await this.cache.saveBatch(batch);
|
|
700
|
+
|
|
701
|
+
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
702
|
+
// prefers it for windowed queries (correct window) and it feeds the version
|
|
703
|
+
// dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
|
|
704
|
+
queryState.config.remoteArray = rows.map(
|
|
705
|
+
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
709
|
+
const subscribers = this.subscriptions.get(hash);
|
|
710
|
+
if (subscribers) {
|
|
711
|
+
for (const cb of subscribers) cb(queryState.records);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
716
|
+
hasSubscribers(hash: string): boolean {
|
|
717
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
722
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
723
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
724
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
725
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
726
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
727
|
+
*
|
|
728
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
729
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
730
|
+
*/
|
|
731
|
+
deregisterQuery(hash: string): void {
|
|
732
|
+
if (this.hasSubscribers(hash)) return;
|
|
733
|
+
if (!this.activeQueries.has(hash)) return;
|
|
734
|
+
this.onDeregister?.(hash);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
739
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
740
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
741
|
+
*/
|
|
742
|
+
finalizeDeregister(hash: string): void {
|
|
743
|
+
const qs = this.activeQueries.get(hash);
|
|
744
|
+
if (qs?.ttlTimer) {
|
|
745
|
+
clearTimeout(qs.ttlTimer);
|
|
746
|
+
qs.ttlTimer = null;
|
|
747
|
+
}
|
|
748
|
+
const debounce = this.debounceTimers.get(hash);
|
|
749
|
+
if (debounce) {
|
|
750
|
+
clearTimeout(debounce);
|
|
751
|
+
this.debounceTimers.delete(hash);
|
|
752
|
+
}
|
|
753
|
+
this.cache.unregisterQuery(hash);
|
|
754
|
+
this.activeQueries.delete(hash);
|
|
755
|
+
this.subscriptions.delete(hash);
|
|
756
|
+
}
|
|
757
|
+
|
|
264
758
|
/**
|
|
265
759
|
* Get query state by id (for sync and devtools)
|
|
266
760
|
*/
|
|
@@ -275,11 +769,15 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
275
769
|
return Array.from(this.activeQueries.values());
|
|
276
770
|
}
|
|
277
771
|
|
|
772
|
+
getActiveQueryHashes(): QueryHash[] {
|
|
773
|
+
return Array.from(this.activeQueries.keys());
|
|
774
|
+
}
|
|
775
|
+
|
|
278
776
|
async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
|
|
279
777
|
const queryState = this.activeQueries.get(id);
|
|
280
778
|
if (!queryState) {
|
|
281
779
|
this.logger.warn(
|
|
282
|
-
{ id, Category: '
|
|
780
|
+
{ id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
|
|
283
781
|
'Query to update local array not found'
|
|
284
782
|
);
|
|
285
783
|
return;
|
|
@@ -295,7 +793,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
295
793
|
const queryState = this.getQueryByHash(hash);
|
|
296
794
|
if (!queryState) {
|
|
297
795
|
this.logger.warn(
|
|
298
|
-
{ hash, Category: '
|
|
796
|
+
{ hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
|
|
299
797
|
'Query to update remote array not found'
|
|
300
798
|
);
|
|
301
799
|
return;
|
|
@@ -307,6 +805,34 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
307
805
|
});
|
|
308
806
|
}
|
|
309
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Called after a query's initial sync completes.
|
|
810
|
+
* Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
|
|
811
|
+
*/
|
|
812
|
+
async notifyQuerySynced(queryHash: string): Promise<void> {
|
|
813
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
814
|
+
if (!queryState) return;
|
|
815
|
+
|
|
816
|
+
// Re-query local DB for latest data (windowed queries materialize from the
|
|
817
|
+
// list_ref window so they resolve even if the in-browser SSP never emits —
|
|
818
|
+
// it can't compute a high offset whose preceding rows aren't resident).
|
|
819
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
820
|
+
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
821
|
+
queryState.records = newRecords;
|
|
822
|
+
|
|
823
|
+
// Notify if data changed OR if this is the first sync (updateCount === 0)
|
|
824
|
+
// The latter handles "query truly has no results" so UI can stop loading
|
|
825
|
+
if (changed || queryState.updateCount === 0) {
|
|
826
|
+
queryState.updateCount++;
|
|
827
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
828
|
+
if (subscribers) {
|
|
829
|
+
for (const callback of subscribers) {
|
|
830
|
+
callback(queryState.records);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
310
836
|
// ==================== RUN JOBS ====================
|
|
311
837
|
|
|
312
838
|
async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
@@ -341,6 +867,14 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
341
867
|
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
342
868
|
};
|
|
343
869
|
|
|
870
|
+
if (options?.timeout != null) {
|
|
871
|
+
record.timeout = options.timeout;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (options?.delay != null) {
|
|
875
|
+
record.delay = options.delay;
|
|
876
|
+
}
|
|
877
|
+
|
|
344
878
|
if (options?.assignedTo) {
|
|
345
879
|
record.assigned_to = options.assignedTo;
|
|
346
880
|
}
|
|
@@ -363,7 +897,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
363
897
|
|
|
364
898
|
const rid = parseRecordIdString(id);
|
|
365
899
|
const params = parseParams(tableSchema.columns, data);
|
|
366
|
-
const mutationId = parseRecordIdString(`
|
|
900
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
367
901
|
|
|
368
902
|
const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
|
|
369
903
|
const prefixedParams = Object.fromEntries(
|
|
@@ -412,7 +946,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
412
946
|
callback([mutationEvent]);
|
|
413
947
|
}
|
|
414
948
|
|
|
415
|
-
this.logger.debug({ id, Category: '
|
|
949
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
|
|
416
950
|
|
|
417
951
|
return target;
|
|
418
952
|
}
|
|
@@ -434,7 +968,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
434
968
|
|
|
435
969
|
const rid = parseRecordIdString(id);
|
|
436
970
|
const params = parseParams(tableSchema.columns, data);
|
|
437
|
-
const mutationId = parseRecordIdString(`
|
|
971
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
972
|
+
|
|
973
|
+
// Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
|
|
974
|
+
// NOT through the record update pipeline. This keeps the record data clean.
|
|
438
975
|
|
|
439
976
|
// Capture current record state before mutation for rollback support
|
|
440
977
|
const [beforeRecord] = await withRetry(this.logger, () =>
|
|
@@ -443,7 +980,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
443
980
|
|
|
444
981
|
const query = surql.seal<{ target: T }>(
|
|
445
982
|
surql.tx([
|
|
446
|
-
surql.updateSet('id', [{ statement: '
|
|
983
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
447
984
|
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
448
985
|
surql.createMutation('update', 'mid', 'id', 'data'),
|
|
449
986
|
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
@@ -458,10 +995,19 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
458
995
|
})
|
|
459
996
|
);
|
|
460
997
|
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
998
|
+
// Build a partial record with only the fields the user actually changed
|
|
999
|
+
// This avoids overwriting rich relation objects (e.g. author: {id, name, ...})
|
|
1000
|
+
// with flat RecordIds from the UPDATE...MERGE result
|
|
1001
|
+
const updatedFields: Record<string, any> = { id: target.id };
|
|
1002
|
+
for (const key of Object.keys(data)) {
|
|
1003
|
+
if (key in target) {
|
|
1004
|
+
updatedFields[key] = (target as Record<string, any>)[key];
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if ('_00_rv' in (target as Record<string, any>)) {
|
|
1008
|
+
updatedFields._00_rv = (target as Record<string, any>)._00_rv;
|
|
1009
|
+
}
|
|
1010
|
+
this.replaceRecordInQueries(updatedFields);
|
|
465
1011
|
|
|
466
1012
|
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
467
1013
|
|
|
@@ -471,7 +1017,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
471
1017
|
table: table,
|
|
472
1018
|
op: 'UPDATE',
|
|
473
1019
|
record: parsedRecord,
|
|
474
|
-
version: target.
|
|
1020
|
+
version: target._00_rv as number,
|
|
475
1021
|
},
|
|
476
1022
|
true
|
|
477
1023
|
);
|
|
@@ -493,7 +1039,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
493
1039
|
callback([mutationEvent]);
|
|
494
1040
|
}
|
|
495
1041
|
|
|
496
|
-
this.logger.debug({ id, Category: '
|
|
1042
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
|
|
497
1043
|
|
|
498
1044
|
return target;
|
|
499
1045
|
}
|
|
@@ -509,14 +1055,53 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
509
1055
|
}
|
|
510
1056
|
|
|
511
1057
|
const rid = parseRecordIdString(id);
|
|
512
|
-
const mutationId = parseRecordIdString(`
|
|
1058
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1059
|
+
|
|
1060
|
+
// Fetch the record before deleting so DBSP can match it against query predicates
|
|
1061
|
+
const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
|
|
1062
|
+
'SELECT * FROM ONLY $id',
|
|
1063
|
+
{ id: rid }
|
|
1064
|
+
);
|
|
1065
|
+
const beforeRecord = beforeRecords ?? {};
|
|
513
1066
|
|
|
514
1067
|
const query = surql.seal<void>(
|
|
515
1068
|
surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
|
|
516
1069
|
);
|
|
517
1070
|
|
|
518
1071
|
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
519
|
-
|
|
1072
|
+
|
|
1073
|
+
// The local DELETE has now committed. Everything below must reflect that in
|
|
1074
|
+
// active live queries — so the deleted row disappears optimistically without
|
|
1075
|
+
// a reload — even if the optimistic SSP-view ingest below fails. Previously a
|
|
1076
|
+
// throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
|
|
1077
|
+
// commit, so the manual notify loop never ran and the row lingered on screen
|
|
1078
|
+
// until reload. Ingesting the delete into the in-browser SSP view is
|
|
1079
|
+
// best-effort: the manual re-materialize reads the local DB (which already
|
|
1080
|
+
// excludes the row), so the result is correct regardless.
|
|
1081
|
+
try {
|
|
1082
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
this.logger.error(
|
|
1085
|
+
{ err, id, Category: 'sp00ky-client::DataModule::delete' },
|
|
1086
|
+
'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
1091
|
+
// that reference this table. Each is isolated so one failing re-materialize
|
|
1092
|
+
// can't stop the others (or the sync emit below) from running.
|
|
1093
|
+
for (const [queryHash, queryState] of this.activeQueries) {
|
|
1094
|
+
if (queryState.config.tableName === tableName) {
|
|
1095
|
+
try {
|
|
1096
|
+
await this.notifyQuerySynced(queryHash);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
this.logger.error(
|
|
1099
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
|
|
1100
|
+
'notifyQuerySynced failed after delete'
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
520
1105
|
|
|
521
1106
|
// Emit mutation event
|
|
522
1107
|
const mutationEvent: DeleteEvent = {
|
|
@@ -529,7 +1114,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
529
1114
|
callback([mutationEvent]);
|
|
530
1115
|
}
|
|
531
1116
|
|
|
532
|
-
this.logger.debug({ id, Category: '
|
|
1117
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
|
|
533
1118
|
}
|
|
534
1119
|
|
|
535
1120
|
// ==================== ROLLBACK METHODS ====================
|
|
@@ -548,12 +1133,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
548
1133
|
this.removeRecordFromQueries(recordId);
|
|
549
1134
|
|
|
550
1135
|
this.logger.info(
|
|
551
|
-
{ id, tableName, Category: '
|
|
1136
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
552
1137
|
'Rolled back optimistic create'
|
|
553
1138
|
);
|
|
554
1139
|
} catch (err) {
|
|
555
1140
|
this.logger.error(
|
|
556
|
-
{ err, id, tableName, Category: '
|
|
1141
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
557
1142
|
'Failed to rollback create'
|
|
558
1143
|
);
|
|
559
1144
|
}
|
|
@@ -588,7 +1173,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
588
1173
|
table: tableName,
|
|
589
1174
|
op: 'UPDATE',
|
|
590
1175
|
record: parsedRecord,
|
|
591
|
-
version: (beforeRecord.
|
|
1176
|
+
version: (beforeRecord._00_rv as number) || 1,
|
|
592
1177
|
},
|
|
593
1178
|
true
|
|
594
1179
|
);
|
|
@@ -597,12 +1182,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
597
1182
|
await this.replaceRecordInQueries(beforeRecord);
|
|
598
1183
|
|
|
599
1184
|
this.logger.info(
|
|
600
|
-
{ id, tableName, Category: '
|
|
1185
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
601
1186
|
'Rolled back optimistic update'
|
|
602
1187
|
);
|
|
603
1188
|
} catch (err) {
|
|
604
1189
|
this.logger.error(
|
|
605
|
-
{ err, id, tableName, Category: '
|
|
1190
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
606
1191
|
'Failed to rollback update'
|
|
607
1192
|
);
|
|
608
1193
|
}
|
|
@@ -634,6 +1219,91 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
634
1219
|
|
|
635
1220
|
// ==================== PRIVATE HELPERS ====================
|
|
636
1221
|
|
|
1222
|
+
private async createAndRegisterQuery<T extends TableNames<S>>(
|
|
1223
|
+
hash: QueryHash,
|
|
1224
|
+
recordId: RecordId,
|
|
1225
|
+
surqlString: string,
|
|
1226
|
+
params: Record<string, any>,
|
|
1227
|
+
ttl: QueryTimeToLive,
|
|
1228
|
+
tableName: T
|
|
1229
|
+
): Promise<QueryHash> {
|
|
1230
|
+
const queryState = await this.createNewQuery<T>({
|
|
1231
|
+
recordId,
|
|
1232
|
+
surql: surqlString,
|
|
1233
|
+
params,
|
|
1234
|
+
ttl,
|
|
1235
|
+
tableName,
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const t0 = performance.now();
|
|
1239
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
1240
|
+
queryHash: hash,
|
|
1241
|
+
surql: surqlString,
|
|
1242
|
+
params,
|
|
1243
|
+
ttl: new Duration(ttl),
|
|
1244
|
+
lastActiveAt: new Date(),
|
|
1245
|
+
});
|
|
1246
|
+
const registrationTime = performance.now() - t0;
|
|
1247
|
+
|
|
1248
|
+
// Record the one-shot SSP registration timings (parse/plan/snapshot from the
|
|
1249
|
+
// WASM binding) + the register_view wall time for DevTools.
|
|
1250
|
+
queryState.registrationTimings = {
|
|
1251
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1252
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1253
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1254
|
+
wallMs: registrationTime,
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
await withRetry(this.logger, () =>
|
|
1258
|
+
this.local.query(
|
|
1259
|
+
surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
|
|
1260
|
+
{
|
|
1261
|
+
id: recordId,
|
|
1262
|
+
localArray,
|
|
1263
|
+
registrationTime,
|
|
1264
|
+
rowCount: localArray.length,
|
|
1265
|
+
}
|
|
1266
|
+
)
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
// Windowed (`START n`) queries skipped the raw initial load in
|
|
1270
|
+
// createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
|
|
1271
|
+
// initial rows now from the SSP's window id-set (`localArray`) via the same
|
|
1272
|
+
// window-materialization path the stream updates use — O(window), and the
|
|
1273
|
+
// ids are already the correct window — so the first paint isn't empty while
|
|
1274
|
+
// the remote `_00_list_ref` syncs in.
|
|
1275
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1276
|
+
if (windowMat && localArray.length > 0) {
|
|
1277
|
+
try {
|
|
1278
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1279
|
+
const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
1280
|
+
...params,
|
|
1281
|
+
__win: winIds,
|
|
1282
|
+
});
|
|
1283
|
+
queryState.records = seeded || [];
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
this.logger.warn(
|
|
1286
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
|
|
1287
|
+
'Failed to seed windowed initial records from localArray'
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
this.activeQueries.set(hash, queryState);
|
|
1293
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
1294
|
+
this.logger.debug(
|
|
1295
|
+
{
|
|
1296
|
+
hash,
|
|
1297
|
+
tableName,
|
|
1298
|
+
recordCount: queryState.records.length,
|
|
1299
|
+
Category: 'sp00ky-client::DataModule::query',
|
|
1300
|
+
},
|
|
1301
|
+
'Query registered'
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
return hash;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
637
1307
|
private async createNewQuery<T extends TableNames<S>>({
|
|
638
1308
|
recordId,
|
|
639
1309
|
surql: surqlString,
|
|
@@ -668,8 +1338,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
668
1338
|
localArray: [],
|
|
669
1339
|
remoteArray: [],
|
|
670
1340
|
lastActiveAt: new Date(),
|
|
1341
|
+
createdAt: new Date(),
|
|
671
1342
|
ttl,
|
|
672
1343
|
tableName,
|
|
1344
|
+
updateCount: 0,
|
|
1345
|
+
rowCount: 0,
|
|
1346
|
+
errorCount: 0,
|
|
673
1347
|
},
|
|
674
1348
|
})
|
|
675
1349
|
);
|
|
@@ -683,68 +1357,108 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
683
1357
|
};
|
|
684
1358
|
|
|
685
1359
|
let records: Record<string, any>[] = [];
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1360
|
+
// Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
|
|
1361
|
+
// `… LIMIT n START m` against the shared local store is O(m) — it sorts and
|
|
1362
|
+
// skips m rows on every window open — AND returns the wrong rows for sparse
|
|
1363
|
+
// windows (the reason `buildWindowMaterialization` exists). Those windows are
|
|
1364
|
+
// seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
|
|
1365
|
+
if (buildWindowMaterialization(surqlString) === null) {
|
|
1366
|
+
try {
|
|
1367
|
+
const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
|
|
1368
|
+
records = result || [];
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
this.logger.warn(
|
|
1371
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
1372
|
+
'Failed to load initial cached records'
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
694
1375
|
}
|
|
695
1376
|
|
|
1377
|
+
// Persisted counters survive a restart even though the rolling
|
|
1378
|
+
// sample window is rebuilt from scratch in memory.
|
|
1379
|
+
const persistedUpdateCount =
|
|
1380
|
+
typeof (configRecord as any)?.updateCount === 'number'
|
|
1381
|
+
? (configRecord as any).updateCount
|
|
1382
|
+
: 0;
|
|
1383
|
+
const persistedErrorCount =
|
|
1384
|
+
typeof (configRecord as any)?.errorCount === 'number'
|
|
1385
|
+
? (configRecord as any).errorCount
|
|
1386
|
+
: 0;
|
|
1387
|
+
|
|
696
1388
|
return {
|
|
697
1389
|
config,
|
|
698
1390
|
records,
|
|
699
1391
|
ttlTimer: null,
|
|
700
1392
|
ttlDurationMs: parseDuration(ttl),
|
|
701
|
-
updateCount:
|
|
1393
|
+
updateCount: persistedUpdateCount,
|
|
1394
|
+
materializationSamples: [],
|
|
1395
|
+
lastIngestLatencyMs: null,
|
|
1396
|
+
errorCount: persistedErrorCount,
|
|
1397
|
+
status: 'idle',
|
|
1398
|
+
phaseSamples: {},
|
|
1399
|
+
phaseLast: {},
|
|
1400
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
702
1401
|
};
|
|
703
1402
|
}
|
|
704
1403
|
|
|
705
1404
|
private async calculateHash(data: any): Promise<string> {
|
|
706
|
-
|
|
1405
|
+
// sessionId is part of the hash so the same logical query from two
|
|
1406
|
+
// sessions (e.g. two browser tabs of the same user) lands on different
|
|
1407
|
+
// `_00_query` rows and doesn't fight over a shared one.
|
|
1408
|
+
const content = JSON.stringify({ ...data, sessionId: this.sessionId });
|
|
707
1409
|
const msgBuffer = new TextEncoder().encode(content);
|
|
708
1410
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
709
1411
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
710
1412
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
711
1413
|
}
|
|
712
1414
|
|
|
713
|
-
private startTTLHeartbeat(queryState: QueryState): void {
|
|
1415
|
+
private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
|
|
714
1416
|
if (queryState.ttlTimer) return;
|
|
715
1417
|
|
|
716
1418
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
717
1419
|
|
|
718
1420
|
queryState.ttlTimer = setTimeout(() => {
|
|
719
|
-
|
|
1421
|
+
queryState.ttlTimer = null;
|
|
1422
|
+
// Only keep the remote query alive while something is actually watching
|
|
1423
|
+
// it. With the server now sweeping ALL expired views (not just in-circuit
|
|
1424
|
+
// ones), an un-refreshed query WOULD be swept after its TTL — so a live
|
|
1425
|
+
// subscriber must heartbeat. An abandoned query (no subscribers) is left
|
|
1426
|
+
// to expire and get swept; its local view was already torn down at the
|
|
1427
|
+
// last unsubscribe, so we just stop the timer here.
|
|
1428
|
+
const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
|
|
1429
|
+
if (subscriberCount === 0) {
|
|
1430
|
+
this.logger.debug(
|
|
1431
|
+
{ hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
|
|
1432
|
+
'TTL heartbeat: no subscribers, stopping'
|
|
1433
|
+
);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
this.onHeartbeat?.(hash);
|
|
720
1437
|
this.logger.debug(
|
|
721
1438
|
{
|
|
1439
|
+
hash,
|
|
722
1440
|
id: encodeRecordId(queryState.config.id),
|
|
723
|
-
Category: '
|
|
1441
|
+
Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
|
|
724
1442
|
},
|
|
725
|
-
'TTL heartbeat'
|
|
1443
|
+
'TTL heartbeat sent'
|
|
726
1444
|
);
|
|
727
|
-
this.startTTLHeartbeat(queryState);
|
|
1445
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
728
1446
|
}, heartbeatTime);
|
|
729
1447
|
}
|
|
730
1448
|
|
|
731
|
-
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
732
|
-
if (queryState.ttlTimer) {
|
|
733
|
-
clearTimeout(queryState.ttlTimer);
|
|
734
|
-
queryState.ttlTimer = null;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
1449
|
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
739
|
-
for (const queryState of this.activeQueries.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1450
|
+
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
1451
|
+
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
1452
|
+
if (index !== -1) {
|
|
1453
|
+
queryState.records[index] = { ...queryState.records[index], ...record };
|
|
1454
|
+
// Notify subscribers so UI updates immediately
|
|
1455
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
1456
|
+
if (subscribers) {
|
|
1457
|
+
for (const callback of subscribers) {
|
|
1458
|
+
callback(queryState.records);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
748
1462
|
}
|
|
749
1463
|
}
|
|
750
1464
|
}
|
|
@@ -764,7 +1478,7 @@ export function parseUpdateOptions(
|
|
|
764
1478
|
const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
|
|
765
1479
|
const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
|
|
766
1480
|
const key =
|
|
767
|
-
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).
|
|
1481
|
+
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
|
|
768
1482
|
|
|
769
1483
|
pushEventOptions = {
|
|
770
1484
|
debounced: {
|