@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131
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 +1171 -372
- package/dist/index.js +5726 -1277
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- 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 +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1234 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- 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 +91 -0
- package/src/modules/ref-tables.ts +88 -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 +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- 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/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- 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 +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- 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.reset.test.ts +104 -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 +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- 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 +77 -1
- package/src/spooky.ts +0 -392
|
@@ -1,28 +1,34 @@
|
|
|
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
|
+
QueryPlan,
|
|
8
9
|
} from '@spooky-sync/query-builder';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
10
|
+
import type { LocalStore } from '../../services/database/index';
|
|
11
|
+
import { StaleEpochError } from '../../services/database/index';
|
|
12
|
+
import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
|
|
13
|
+
import type { Logger } from '../../services/logger/index';
|
|
14
|
+
import type { StreamUpdate } from '../../services/stream-processor/index';
|
|
15
|
+
import type {
|
|
15
16
|
QueryConfig,
|
|
16
17
|
QueryHash,
|
|
17
18
|
QueryState,
|
|
19
|
+
QueryStatus,
|
|
20
|
+
QueryStatusCallback,
|
|
18
21
|
QueryTimeToLive,
|
|
19
22
|
QueryUpdateCallback,
|
|
20
23
|
MutationCallback,
|
|
21
24
|
RecordVersionArray,
|
|
22
25
|
QueryConfigRecord,
|
|
23
26
|
UpdateOptions,
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
QueryTimings,
|
|
28
|
+
PhaseStat,
|
|
29
|
+
RegistrationTimings,
|
|
30
|
+
RunOptions} from '../../types';
|
|
31
|
+
import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
|
|
26
32
|
import {
|
|
27
33
|
parseRecordIdString,
|
|
28
34
|
extractIdPart,
|
|
@@ -31,11 +37,29 @@ import {
|
|
|
31
37
|
withRetry,
|
|
32
38
|
surql,
|
|
33
39
|
parseParams,
|
|
40
|
+
cleanRecord,
|
|
34
41
|
extractTablePart,
|
|
35
42
|
generateId,
|
|
36
43
|
} from '../../utils/index';
|
|
37
|
-
import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
38
|
-
import { PushEventOptions } from '../../events/index';
|
|
44
|
+
import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
45
|
+
import type { PushEventOptions } from '../../events/index';
|
|
46
|
+
import { buildWindowMaterialization, buildWindowMaterializationPlan } from './window-query';
|
|
47
|
+
|
|
48
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
49
|
+
function pushSample(samples: number[], ms: number): void {
|
|
50
|
+
samples.push(ms);
|
|
51
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
55
|
+
function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
56
|
+
if (samples.length === 0) {
|
|
57
|
+
return { lastMs, p50: null, p90: null, p99: null, count: 0 };
|
|
58
|
+
}
|
|
59
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
60
|
+
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
61
|
+
return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
|
|
62
|
+
}
|
|
39
63
|
|
|
40
64
|
/**
|
|
41
65
|
* DataModule - Unified query and mutation management
|
|
@@ -47,22 +71,105 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
47
71
|
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
48
72
|
private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
|
|
49
73
|
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
74
|
+
private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
|
|
50
75
|
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
51
76
|
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
77
|
+
// The update each debounce timer would process on its trailing edge. Kept in a
|
|
78
|
+
// map (not just the timer closure) so `flushPendingStreamUpdate` can process
|
|
79
|
+
// it early — the sync engine flushes before flipping a query to `idle`, so
|
|
80
|
+
// subscribers never observe idle status with stale (partial-window) rows.
|
|
81
|
+
private pendingStreamUpdates: Map<QueryHash, StreamUpdate> = new Map();
|
|
82
|
+
// Refcount of in-flight fetch cycles per query (registration + concurrent
|
|
83
|
+
// poll/LIVE sync rounds can overlap). Status flips to `fetching` on 0→1 and
|
|
84
|
+
// back to `idle` only on 1→0, so an inner cycle finishing can't emit a
|
|
85
|
+
// premature idle mid-registration.
|
|
86
|
+
private fetchDepth: Map<QueryHash, number> = new Map();
|
|
52
87
|
private logger: Logger;
|
|
88
|
+
/**
|
|
89
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
90
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
91
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
92
|
+
* constructed after DataModule.
|
|
93
|
+
*/
|
|
94
|
+
public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
97
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
98
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
99
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
100
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
101
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
102
|
+
*/
|
|
103
|
+
public onHeartbeat?: (hash: QueryHash) => void;
|
|
104
|
+
/**
|
|
105
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
106
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
107
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
108
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
109
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
110
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
111
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
112
|
+
*/
|
|
113
|
+
public onDeregister?: (hash: QueryHash) => void;
|
|
114
|
+
// Salt for query-id hashing. Set from SurrealDB's session::id() so two
|
|
115
|
+
// browser sessions registering the same logical query (same surql + params)
|
|
116
|
+
// don't collide on the same `_00_query` row — each session gets its own.
|
|
117
|
+
// Empty string until init(sessionId) is called.
|
|
118
|
+
private sessionId: string = '';
|
|
119
|
+
// Authenticated user record id (e.g. `"user:abc"`). Updated by
|
|
120
|
+
// `setCurrentUserId` from the auth subscription. null when
|
|
121
|
+
// unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
|
|
122
|
+
// poll and LIVE subscription target the same per-user
|
|
123
|
+
// `_00_list_ref_user_<id>` table the sync engine writes to.
|
|
124
|
+
private currentUserId: string | null = null;
|
|
53
125
|
|
|
54
126
|
constructor(
|
|
55
127
|
private cache: CacheModule,
|
|
56
|
-
private local:
|
|
128
|
+
private local: LocalStore,
|
|
57
129
|
private schema: S,
|
|
58
130
|
logger: Logger,
|
|
59
|
-
|
|
131
|
+
// Client-side SSP aggregation throttle: coalesces the in-browser
|
|
132
|
+
// StreamProcessor's per-record stream updates per query before notifying
|
|
133
|
+
// readers, so a burst of synced records repaints the UI once per window
|
|
134
|
+
// rather than row-by-row. 50ms keeps it responsive while still batching the
|
|
135
|
+
// initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
|
|
136
|
+
private streamDebounceTime: number = 50
|
|
60
137
|
) {
|
|
61
138
|
this.logger = logger.child({ service: 'DataModule' });
|
|
62
139
|
}
|
|
63
140
|
|
|
64
|
-
async init(): Promise<void> {
|
|
65
|
-
this.
|
|
141
|
+
async init(sessionId: string): Promise<void> {
|
|
142
|
+
this.sessionId = sessionId;
|
|
143
|
+
this.logger.info(
|
|
144
|
+
{ sessionId, Category: 'sp00ky-client::DataModule::init' },
|
|
145
|
+
'DataModule initialized'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
151
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
152
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
153
|
+
*/
|
|
154
|
+
setSessionId(sessionId: string): void {
|
|
155
|
+
this.sessionId = sessionId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
160
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
161
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
162
|
+
* SSP writes to.
|
|
163
|
+
*/
|
|
164
|
+
setCurrentUserId(userId: string | null): void {
|
|
165
|
+
this.currentUserId = userId;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
169
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
170
|
+
* same table name DataModule does. */
|
|
171
|
+
getCurrentUserId(): string | null {
|
|
172
|
+
return this.currentUserId;
|
|
66
173
|
}
|
|
67
174
|
|
|
68
175
|
// ==================== QUERY MANAGEMENT ====================
|
|
@@ -74,19 +181,22 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
74
181
|
tableName: T,
|
|
75
182
|
surqlString: string,
|
|
76
183
|
params: Record<string, any>,
|
|
77
|
-
ttl: QueryTimeToLive
|
|
184
|
+
ttl: QueryTimeToLive,
|
|
185
|
+
plan?: QueryPlan
|
|
78
186
|
): Promise<QueryHash> {
|
|
79
187
|
const hash = await this.calculateHash({ surql: surqlString, params });
|
|
80
188
|
this.logger.debug(
|
|
81
|
-
{ hash, Category: '
|
|
189
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
82
190
|
'Query Initialization: started'
|
|
83
191
|
);
|
|
84
192
|
|
|
85
|
-
|
|
193
|
+
// `_00_query` stays the single shared registration table in both
|
|
194
|
+
// ref-modes; the per-user split happens only on `_00_list_ref`.
|
|
195
|
+
const recordId = new RecordId('_00_query', hash);
|
|
86
196
|
|
|
87
197
|
if (this.activeQueries.has(hash)) {
|
|
88
198
|
this.logger.debug(
|
|
89
|
-
{ hash, Category: '
|
|
199
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
90
200
|
'Query Initialization: exists, returning'
|
|
91
201
|
);
|
|
92
202
|
return hash;
|
|
@@ -95,7 +205,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
95
205
|
// Another call is already creating this query — wait for it
|
|
96
206
|
if (this.pendingQueries.has(hash)) {
|
|
97
207
|
this.logger.debug(
|
|
98
|
-
{ hash, Category: '
|
|
208
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
99
209
|
'Query Initialization: pending, waiting for existing creation'
|
|
100
210
|
);
|
|
101
211
|
await this.pendingQueries.get(hash);
|
|
@@ -103,12 +213,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
103
213
|
}
|
|
104
214
|
|
|
105
215
|
this.logger.debug(
|
|
106
|
-
{ hash, Category: '
|
|
216
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
107
217
|
'Query Initialization: not found, creating new query'
|
|
108
218
|
);
|
|
109
219
|
|
|
110
220
|
// Create the query and track the pending promise
|
|
111
|
-
const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
|
|
221
|
+
const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName, plan);
|
|
112
222
|
this.pendingQueries.set(hash, promise);
|
|
113
223
|
try {
|
|
114
224
|
await promise;
|
|
@@ -147,11 +257,95 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
147
257
|
subs.delete(callback);
|
|
148
258
|
if (subs.size === 0) {
|
|
149
259
|
this.subscriptions.delete(queryHash);
|
|
260
|
+
// NOTE: intentionally do NOT tear down the query / free its in-browser
|
|
261
|
+
// SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
|
|
262
|
+
// already self-stops once subscribers hit 0, so an abandoned query
|
|
263
|
+
// stops being kept alive and the server's TTL sweep removes it. Freeing
|
|
264
|
+
// the local view on every last-unsubscribe caused re-registration churn
|
|
265
|
+
// on navigation (open A → leave → open A again re-registers), which is
|
|
266
|
+
// a flakiness risk for no real benefit — keep the local view resident.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
274
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
275
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
276
|
+
*/
|
|
277
|
+
subscribeStatus(
|
|
278
|
+
queryHash: string,
|
|
279
|
+
callback: QueryStatusCallback,
|
|
280
|
+
options: { immediate?: boolean } = {}
|
|
281
|
+
): () => void {
|
|
282
|
+
if (!this.statusSubscriptions.has(queryHash)) {
|
|
283
|
+
this.statusSubscriptions.set(queryHash, new Set());
|
|
284
|
+
}
|
|
285
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
286
|
+
|
|
287
|
+
if (options.immediate) {
|
|
288
|
+
callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return () => {
|
|
292
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
293
|
+
if (subs) {
|
|
294
|
+
subs.delete(callback);
|
|
295
|
+
if (subs.size === 0) {
|
|
296
|
+
this.statusSubscriptions.delete(queryHash);
|
|
150
297
|
}
|
|
151
298
|
}
|
|
152
299
|
};
|
|
153
300
|
}
|
|
154
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
304
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
305
|
+
* query is unknown.
|
|
306
|
+
*/
|
|
307
|
+
setQueryStatus(queryHash: string, status: QueryStatus): void {
|
|
308
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
309
|
+
if (!queryState || queryState.status === status) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
queryState.status = status;
|
|
313
|
+
|
|
314
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
315
|
+
|
|
316
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
317
|
+
if (subs) {
|
|
318
|
+
for (const callback of subs) {
|
|
319
|
+
callback(status);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Enter a fetch cycle for a query. Refcounted: registration and concurrent
|
|
326
|
+
* poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
|
|
327
|
+
* cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
|
|
328
|
+
* emits `idle`. Always pair with `endFetching` in a `finally`.
|
|
329
|
+
*/
|
|
330
|
+
beginFetching(queryHash: string): void {
|
|
331
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
332
|
+
this.fetchDepth.set(queryHash, depth + 1);
|
|
333
|
+
if (depth === 0) {
|
|
334
|
+
this.setQueryStatus(queryHash, 'fetching');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
|
|
339
|
+
endFetching(queryHash: string): void {
|
|
340
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
341
|
+
if (depth <= 1) {
|
|
342
|
+
this.fetchDepth.delete(queryHash);
|
|
343
|
+
this.setQueryStatus(queryHash, 'idle');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.fetchDepth.set(queryHash, depth - 1);
|
|
347
|
+
}
|
|
348
|
+
|
|
155
349
|
/**
|
|
156
350
|
* Subscribe to mutations (for sync)
|
|
157
351
|
*/
|
|
@@ -168,67 +362,208 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
168
362
|
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
169
363
|
const { queryHash, op } = update;
|
|
170
364
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
365
|
+
// DELETE propagates immediately — a removed row should disappear without
|
|
366
|
+
// waiting on a debounce.
|
|
367
|
+
//
|
|
368
|
+
// CREATE and UPDATE are coalesced per query on a trailing timer. A list's
|
|
369
|
+
// rows stream in from sync as many small `_00_list_ref` diffs, each its own
|
|
370
|
+
// `cache.saveBatch` → one stream update per chunk. `ingestMany` only
|
|
371
|
+
// coalesces records ingested synchronously together, so chunks spread over
|
|
372
|
+
// time each re-materialize and re-notify — a 50-row window can fire 30+
|
|
373
|
+
// updates as it fills. Each StreamUpdate carries the full materialized
|
|
374
|
+
// `localArray`, so the latest one already reflects every prior chunk: keep
|
|
375
|
+
// only it and fire once on the trailing edge, settling the query in a couple
|
|
376
|
+
// of notifications instead of one per chunk.
|
|
377
|
+
if (op === 'DELETE') {
|
|
378
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
379
|
+
if (existing) {
|
|
380
|
+
clearTimeout(existing);
|
|
381
|
+
this.debounceTimers.delete(queryHash);
|
|
177
382
|
}
|
|
383
|
+
// The DELETE update carries the full latest localArray, so the coalesced
|
|
384
|
+
// CREATE/UPDATE it supersedes is already reflected — drop it.
|
|
385
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
386
|
+
await this.processStreamUpdate(update);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
178
389
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
390
|
+
// Clear existing timer if any
|
|
391
|
+
if (this.debounceTimers.has(queryHash)) {
|
|
392
|
+
// oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
|
|
393
|
+
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
394
|
+
}
|
|
184
395
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
396
|
+
// Set new timer
|
|
397
|
+
this.pendingStreamUpdates.set(queryHash, update);
|
|
398
|
+
const timer = setTimeout(async () => {
|
|
399
|
+
this.debounceTimers.delete(queryHash);
|
|
400
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
188
401
|
await this.processStreamUpdate(update);
|
|
402
|
+
}, this.streamDebounceTime);
|
|
403
|
+
|
|
404
|
+
this.debounceTimers.set(queryHash, timer);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Process a query's pending (debounced) stream update NOW instead of on the
|
|
409
|
+
* trailing edge. Called by the sync engine before it flips a query back to
|
|
410
|
+
* `idle`, so the status change never races ahead of the rows it fetched.
|
|
411
|
+
* No-op when nothing is pending. The pending entry is removed before the
|
|
412
|
+
* await so a concurrently-firing timer can't process it twice.
|
|
413
|
+
*/
|
|
414
|
+
async flushPendingStreamUpdate(queryHash: string): Promise<void> {
|
|
415
|
+
const timer = this.debounceTimers.get(queryHash);
|
|
416
|
+
if (timer) {
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
this.debounceTimers.delete(queryHash);
|
|
419
|
+
}
|
|
420
|
+
const pending = this.pendingStreamUpdates.get(queryHash);
|
|
421
|
+
if (!pending) return;
|
|
422
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
423
|
+
await this.processStreamUpdate(pending);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Materialize a query's result rows from the local DB. For a windowed query
|
|
427
|
+
// (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
|
|
428
|
+
// `START m` against the shared local store would skip the window's own rows
|
|
429
|
+
// (sparse windowing) and return nothing. Instead we select the window's
|
|
430
|
+
// record id-set directly, preferring the server's `list_ref` (`remoteArray`,
|
|
431
|
+
// authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
|
|
432
|
+
// the original ORDER BY for stable order.
|
|
433
|
+
private async materializeRecords(
|
|
434
|
+
queryState: QueryState,
|
|
435
|
+
sspArray?: Array<[string, number]>
|
|
436
|
+
): Promise<Record<string, any>[]> {
|
|
437
|
+
const t0 = performance.now();
|
|
438
|
+
const plan = queryState.config.plan;
|
|
439
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
440
|
+
let records: Record<string, any>[];
|
|
441
|
+
if (windowMat) {
|
|
442
|
+
const win =
|
|
443
|
+
(queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
|
|
444
|
+
(sspArray?.length && sspArray) ||
|
|
445
|
+
queryState.config.localArray ||
|
|
446
|
+
[];
|
|
447
|
+
const winIds = win.map(([id]) => parseRecordIdString(id));
|
|
448
|
+
if (plan) {
|
|
449
|
+
// Engine-neutral window materialization: select exactly the id-set,
|
|
450
|
+
// keeping ORDER BY + relations. Works on any local engine.
|
|
451
|
+
const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
|
|
452
|
+
records = await this.local.select(winPlan, queryState.config.params);
|
|
453
|
+
} else {
|
|
454
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
455
|
+
...queryState.config.params,
|
|
456
|
+
__win: winIds,
|
|
457
|
+
});
|
|
458
|
+
records = rows || [];
|
|
459
|
+
}
|
|
460
|
+
} else if (plan) {
|
|
461
|
+
records = await this.local.select(plan, queryState.config.params);
|
|
462
|
+
} else {
|
|
463
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
464
|
+
queryState.config.surql,
|
|
465
|
+
queryState.config.params
|
|
466
|
+
);
|
|
467
|
+
records = rows || [];
|
|
189
468
|
}
|
|
469
|
+
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
470
|
+
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
471
|
+
return records;
|
|
190
472
|
}
|
|
191
473
|
|
|
192
474
|
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
193
|
-
const { queryHash, localArray } = update;
|
|
475
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
194
476
|
const queryState = this.activeQueries.get(queryHash);
|
|
195
477
|
if (!queryState) {
|
|
196
478
|
this.logger.warn(
|
|
197
|
-
{ queryHash, Category: '
|
|
479
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
198
480
|
'Received update for unknown query. Skipping...'
|
|
199
481
|
);
|
|
200
482
|
return;
|
|
201
483
|
}
|
|
202
484
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
485
|
+
// Update the rolling materialization-sample window before the work that
|
|
486
|
+
// could throw, so the percentiles still move when the downstream local
|
|
487
|
+
// query fails (the materialization step itself ran).
|
|
488
|
+
if (typeof materializationTimeMs === 'number') {
|
|
489
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
490
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
|
|
491
|
+
queryState.materializationSamples.shift();
|
|
492
|
+
}
|
|
493
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
494
|
+
}
|
|
495
|
+
// Record the SSP internal sub-phase timings (from the WASM binding) so
|
|
496
|
+
// DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
|
|
497
|
+
if (typeof update.storeApplyMs === 'number')
|
|
498
|
+
this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
|
|
499
|
+
if (typeof update.circuitStepMs === 'number')
|
|
500
|
+
this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
|
|
501
|
+
if (typeof update.transformMs === 'number')
|
|
502
|
+
this.recordPhase(queryState, 'sspTransform', update.transformMs);
|
|
503
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
504
|
+
|
|
505
|
+
// Fence against bucket switches: this update's `localArray` came from the
|
|
506
|
+
// pre-switch SSP circuit; applying it after a switch would show (and
|
|
507
|
+
// persist) the previous user's ids in the new bucket.
|
|
508
|
+
const epoch = this.local.epoch;
|
|
209
509
|
|
|
210
|
-
|
|
211
|
-
|
|
510
|
+
try {
|
|
511
|
+
// Materialize the query's rows. For a windowed (offset) query, re-running
|
|
512
|
+
// the original surql would re-apply `START n` against the shared local DB
|
|
513
|
+
// and skip the window's rows entirely; instead select the SSP's
|
|
514
|
+
// materialized window id-set (`localArray`) directly, re-applying the
|
|
515
|
+
// original ORDER BY for stable display order. Non-offset queries keep the
|
|
516
|
+
// normal re-query path.
|
|
517
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
518
|
+
if (epoch !== this.local.epoch) return;
|
|
212
519
|
queryState.config.localArray = localArray;
|
|
213
|
-
await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
214
|
-
id: queryState.config.id,
|
|
215
|
-
localArray,
|
|
216
|
-
});
|
|
217
520
|
|
|
218
|
-
// Skip notification if records haven't changed
|
|
219
521
|
const prevJson = JSON.stringify(queryState.records);
|
|
220
522
|
const newJson = JSON.stringify(newRecords);
|
|
221
523
|
queryState.records = newRecords;
|
|
222
|
-
|
|
524
|
+
const recordsChanged = prevJson !== newJson;
|
|
525
|
+
|
|
526
|
+
// updateCount counts user-visible updates (matches the prior semantic),
|
|
527
|
+
// while the materialization sample/percentiles already moved above for
|
|
528
|
+
// every observed engine step.
|
|
529
|
+
if (recordsChanged) {
|
|
530
|
+
queryState.updateCount++;
|
|
531
|
+
queryState.lastUpdatedAt = Date.now();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await this.local.query(
|
|
535
|
+
surql.seal(
|
|
536
|
+
surql.updateSet('id', [
|
|
537
|
+
'localArray',
|
|
538
|
+
'rowCount',
|
|
539
|
+
'updateCount',
|
|
540
|
+
'lastIngestLatency',
|
|
541
|
+
'materializationP55',
|
|
542
|
+
'materializationP90',
|
|
543
|
+
'materializationP99',
|
|
544
|
+
])
|
|
545
|
+
),
|
|
546
|
+
{
|
|
547
|
+
id: queryState.config.id,
|
|
548
|
+
localArray,
|
|
549
|
+
rowCount: localArray.length,
|
|
550
|
+
updateCount: queryState.updateCount,
|
|
551
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
552
|
+
materializationP55: percentiles.p55,
|
|
553
|
+
materializationP90: percentiles.p90,
|
|
554
|
+
materializationP99: percentiles.p99,
|
|
555
|
+
},
|
|
556
|
+
{ epoch }
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
if (!recordsChanged) {
|
|
223
560
|
this.logger.debug(
|
|
224
|
-
{ queryHash, Category: '
|
|
561
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
225
562
|
'Query records unchanged, skipping notification'
|
|
226
563
|
);
|
|
227
564
|
return;
|
|
228
565
|
}
|
|
229
566
|
|
|
230
|
-
queryState.updateCount++;
|
|
231
|
-
|
|
232
567
|
// Notify subscribers
|
|
233
568
|
const subscribers = this.subscriptions.get(queryHash);
|
|
234
569
|
if (subscribers) {
|
|
@@ -240,17 +575,108 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
240
575
|
this.logger.debug(
|
|
241
576
|
{
|
|
242
577
|
queryHash,
|
|
243
|
-
recordCount:
|
|
244
|
-
Category: '
|
|
578
|
+
recordCount: newRecords?.length,
|
|
579
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
245
580
|
},
|
|
246
581
|
'Query updated from stream'
|
|
247
582
|
);
|
|
248
583
|
} catch (err) {
|
|
584
|
+
if (err instanceof StaleEpochError) {
|
|
585
|
+
this.logger.debug(
|
|
586
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
587
|
+
'Dropped stream update from before a bucket switch'
|
|
588
|
+
);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
queryState.errorCount++;
|
|
249
592
|
this.logger.error(
|
|
250
|
-
{ err, queryHash, Category: '
|
|
593
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
251
594
|
'Failed to fetch records for stream update'
|
|
252
595
|
);
|
|
596
|
+
// Best-effort persist of the bumped errorCount; swallow secondary
|
|
597
|
+
// failures to avoid masking the original error in logs.
|
|
598
|
+
try {
|
|
599
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
|
|
600
|
+
id: queryState.config.id,
|
|
601
|
+
errorCount: queryState.errorCount,
|
|
602
|
+
});
|
|
603
|
+
} catch (persistErr) {
|
|
604
|
+
this.logger.warn(
|
|
605
|
+
{
|
|
606
|
+
err: persistErr,
|
|
607
|
+
queryHash,
|
|
608
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
609
|
+
},
|
|
610
|
+
'Failed to persist incremented errorCount'
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
618
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
619
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
620
|
+
*/
|
|
621
|
+
private computeMaterializationPercentiles(samples: number[]): {
|
|
622
|
+
p55: number | null;
|
|
623
|
+
p90: number | null;
|
|
624
|
+
p99: number | null;
|
|
625
|
+
} {
|
|
626
|
+
if (samples.length === 0) {
|
|
627
|
+
return { p55: null, p90: null, p99: null };
|
|
253
628
|
}
|
|
629
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
630
|
+
const pick = (q: number) => {
|
|
631
|
+
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
|
|
632
|
+
return sorted[idx]!;
|
|
633
|
+
};
|
|
634
|
+
return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
638
|
+
private recordPhase(qs: QueryState, phase: string, ms: number): void {
|
|
639
|
+
if (!Number.isFinite(ms)) return;
|
|
640
|
+
const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
|
|
641
|
+
pushSample(arr, ms);
|
|
642
|
+
qs.phaseLast[phase] = ms;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
646
|
+
recordRemoteFetch(hash: string, ms: number): void {
|
|
647
|
+
const qs = this.activeQueries.get(hash);
|
|
648
|
+
if (qs) this.recordPhase(qs, 'remoteFetch', ms);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
653
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
654
|
+
*/
|
|
655
|
+
recordFrontendTiming(hash: string, ms: number): void {
|
|
656
|
+
const qs = this.activeQueries.get(hash);
|
|
657
|
+
if (qs) this.recordPhase(qs, 'frontend', ms);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
662
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
663
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
664
|
+
*/
|
|
665
|
+
phaseTimings(q: QueryState): QueryTimings {
|
|
666
|
+
const stat = (phase: string) =>
|
|
667
|
+
phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
668
|
+
return {
|
|
669
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
670
|
+
sspStoreApply: stat('sspStoreApply'),
|
|
671
|
+
sspCircuitStep: stat('sspCircuitStep'),
|
|
672
|
+
sspTransform: stat('sspTransform'),
|
|
673
|
+
localFetch: stat('localFetch'),
|
|
674
|
+
remoteFetch: stat('remoteFetch'),
|
|
675
|
+
frontend: stat('frontend'),
|
|
676
|
+
registration: q.registrationTimings,
|
|
677
|
+
updateCount: q.updateCount,
|
|
678
|
+
errorCount: q.errorCount,
|
|
679
|
+
};
|
|
254
680
|
}
|
|
255
681
|
|
|
256
682
|
/**
|
|
@@ -260,6 +686,279 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
260
686
|
return this.activeQueries.get(hash);
|
|
261
687
|
}
|
|
262
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
691
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
692
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
693
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
694
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
695
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
696
|
+
*/
|
|
697
|
+
isCold(hash: string): boolean {
|
|
698
|
+
const qs = this.activeQueries.get(hash);
|
|
699
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
704
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
705
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
706
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
707
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
708
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
709
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
710
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
711
|
+
* `seen` dedupes within the batch.
|
|
712
|
+
*/
|
|
713
|
+
private collectEmbeddedChildren(
|
|
714
|
+
record: Record<string, any>,
|
|
715
|
+
batch: CacheRecord[],
|
|
716
|
+
seen: Set<string>
|
|
717
|
+
): void {
|
|
718
|
+
const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
|
|
719
|
+
!!v &&
|
|
720
|
+
typeof v === 'object' &&
|
|
721
|
+
!(v instanceof RecordId) &&
|
|
722
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
723
|
+
|
|
724
|
+
for (const value of Object.values(record)) {
|
|
725
|
+
const children = Array.isArray(value)
|
|
726
|
+
? value.filter(isEmbeddedRecord)
|
|
727
|
+
: isEmbeddedRecord(value)
|
|
728
|
+
? [value]
|
|
729
|
+
: [];
|
|
730
|
+
for (const child of children) {
|
|
731
|
+
const key = encodeRecordId(child.id);
|
|
732
|
+
if (seen.has(key)) continue;
|
|
733
|
+
seen.add(key);
|
|
734
|
+
// Recurse FIRST so nested grandchildren are captured before `cleanRecord`
|
|
735
|
+
// strips this child's alias fields.
|
|
736
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
737
|
+
const table = child.id.table.toString();
|
|
738
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
739
|
+
batch.push({
|
|
740
|
+
table,
|
|
741
|
+
op: 'CREATE',
|
|
742
|
+
// Flatten the child too: a nested comment carries its own embedded
|
|
743
|
+
// `author` object which the schemafull `record<user>` field would
|
|
744
|
+
// reject. (Its author is captured as a separate row by the recursion
|
|
745
|
+
// above.)
|
|
746
|
+
record: this.flattenRelationsForStorage(child, tableSchema),
|
|
747
|
+
version: (child._00_rv as number) || 1,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Prepare a subquery-bearing row (preload / hydration) for the schemafull
|
|
755
|
+
* local store: replace an embedded FORWARD-relation object (`author = { id, … }`)
|
|
756
|
+
* with its RecordId so a `record<…>` field coerces, and DROP reverse-subquery
|
|
757
|
+
* ARRAYS (`comments = [ … ]`) since their rows are cached separately as their
|
|
758
|
+
* own bodies. A flat record — as the live `SELECT * FROM $ids` sync returns,
|
|
759
|
+
* with relations already RecordIds — passes through unchanged.
|
|
760
|
+
*/
|
|
761
|
+
private flattenRelationsForStorage(
|
|
762
|
+
record: Record<string, any>,
|
|
763
|
+
tableSchema?: { columns: Record<string, any> }
|
|
764
|
+
): RecordWithId {
|
|
765
|
+
const isEmbeddedRecord = (v: unknown): boolean =>
|
|
766
|
+
!!v &&
|
|
767
|
+
typeof v === 'object' &&
|
|
768
|
+
!(v instanceof RecordId) &&
|
|
769
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
770
|
+
const flat: Record<string, any> = {};
|
|
771
|
+
for (const [k, v] of Object.entries(record)) {
|
|
772
|
+
if (isEmbeddedRecord(v)) {
|
|
773
|
+
flat[k] = (v as { id: unknown }).id;
|
|
774
|
+
} else if (Array.isArray(v) && v.some(isEmbeddedRecord)) {
|
|
775
|
+
continue;
|
|
776
|
+
} else {
|
|
777
|
+
flat[k] = v;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return (tableSchema ? cleanRecord(tableSchema.columns, flat) : flat) as RecordWithId;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
785
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
786
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
787
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
788
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
789
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
790
|
+
*/
|
|
791
|
+
async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
|
|
792
|
+
const queryState = this.activeQueries.get(hash);
|
|
793
|
+
if (!queryState) return;
|
|
794
|
+
queryState.hydrated = true; // run-once, even when the remote returns nothing
|
|
795
|
+
if (rows.length === 0) return;
|
|
796
|
+
|
|
797
|
+
const epoch = this.local.epoch;
|
|
798
|
+
const tableName = queryState.config.tableName;
|
|
799
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
800
|
+
// Bucket switched while we persisted: these rows were fetched under the
|
|
801
|
+
// previous auth context — don't let them prime the new bucket's query
|
|
802
|
+
// state; the rebind's re-registration refills it. (saveBatch's own epoch
|
|
803
|
+
// fence usually catches this, but the switch can land between it and here.)
|
|
804
|
+
if (epoch !== this.local.epoch) return;
|
|
805
|
+
|
|
806
|
+
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
807
|
+
// prefers it for windowed queries (correct window) and it feeds the version
|
|
808
|
+
// dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
|
|
809
|
+
queryState.config.remoteArray = rows.map(
|
|
810
|
+
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
814
|
+
const subscribers = this.subscriptions.get(hash);
|
|
815
|
+
if (subscribers) {
|
|
816
|
+
for (const cb of subscribers) cb(queryState.records);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Build the cache batch for a set of one-shot rows and persist it to the
|
|
822
|
+
* local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
|
|
823
|
+
* and extracts EMBEDDED related children (any nesting depth) as their own
|
|
824
|
+
* records — a `.related()` query returns its children embedded, and a later
|
|
825
|
+
* correlated re-materialization needs them present as standalone rows.
|
|
826
|
+
* Shared by `applyHydration` (live registration) and `persistSnapshot`
|
|
827
|
+
* (preload).
|
|
828
|
+
*/
|
|
829
|
+
private async buildAndSaveCacheBatch(
|
|
830
|
+
tableName: string,
|
|
831
|
+
rows: RecordWithId[]
|
|
832
|
+
): Promise<void> {
|
|
833
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
834
|
+
const batch: CacheRecord[] = rows.map((record) => ({
|
|
835
|
+
table: tableName,
|
|
836
|
+
op: 'CREATE' as const,
|
|
837
|
+
// Flatten embedded relations first: a preload/hydration row can carry a
|
|
838
|
+
// forward relation as a full nested OBJECT (`author = { id, … }`) and a
|
|
839
|
+
// reverse subquery as an array of objects (`comments = [ … ]`). The
|
|
840
|
+
// schemafull local field is `record<user>`, which rejects an object
|
|
841
|
+
// (`Couldn't coerce … found { id: …, username: … }`) and would throw the
|
|
842
|
+
// WHOLE batch. Store the parent with `author` as its RecordId and drop the
|
|
843
|
+
// subquery arrays — the children are cached as their own rows below.
|
|
844
|
+
record: this.flattenRelationsForStorage(record, tableSchema),
|
|
845
|
+
version: (record._00_rv as number) || 1,
|
|
846
|
+
}));
|
|
847
|
+
|
|
848
|
+
const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
|
|
849
|
+
for (const record of rows) {
|
|
850
|
+
this.collectEmbeddedChildren(record, batch, seen);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
await this.cache.saveBatch(batch);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Preload/prewarm: persist one-shot rows (and their embedded related children)
|
|
858
|
+
* into the local cache WITHOUT registering a query — no `activeQueries` entry,
|
|
859
|
+
* no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
|
|
860
|
+
* ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
|
|
861
|
+
* first paint from them instantly, then registers a live view to freshen.
|
|
862
|
+
*/
|
|
863
|
+
async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
|
|
864
|
+
if (rows.length === 0) return;
|
|
865
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Read the durable preload freshness marker for a query hash, or null if this
|
|
870
|
+
* query was never preloaded in the current bucket. Co-located with the cached
|
|
871
|
+
* rows (per-bucket `_00_preload` table) so a bucket switch that clears the
|
|
872
|
+
* data also clears the marker — a stale marker can't claim "warm" when the
|
|
873
|
+
* rows are gone. Any read error is treated as cold.
|
|
874
|
+
*/
|
|
875
|
+
async getPreloadMarker(
|
|
876
|
+
hash: string
|
|
877
|
+
): Promise<{ fetchedAt: number; rowCount: number } | null> {
|
|
878
|
+
try {
|
|
879
|
+
// Pass a real RecordId: a bare string id hits the SurrealDB engine's
|
|
880
|
+
// `FROM ONLY $__id` as a plain string, which "selects" the string itself
|
|
881
|
+
// (a truthy non-row) instead of the record — misread as a warm marker.
|
|
882
|
+
const row = await this.local.getById('_00_preload', new RecordId('_00_preload', hash));
|
|
883
|
+
if (!row || typeof row !== 'object') return null;
|
|
884
|
+
return {
|
|
885
|
+
fetchedAt: Number((row as any).fetchedAt) || 0,
|
|
886
|
+
rowCount: Number((row as any).rowCount) || 0,
|
|
887
|
+
};
|
|
888
|
+
} catch {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* True when this query's preload marker exists and is younger than
|
|
895
|
+
* `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
|
|
896
|
+
* recent `preload()` already persisted (the register lifecycle re-syncs them
|
|
897
|
+
* authoritatively). Missing or unreadable markers are treated as stale.
|
|
898
|
+
*/
|
|
899
|
+
async isPreloadFresh(hashKey: string, maxAgeMs: number): Promise<boolean> {
|
|
900
|
+
const marker = await this.getPreloadMarker(hashKey);
|
|
901
|
+
return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
905
|
+
async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
|
|
906
|
+
// RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
|
|
907
|
+
// and `UPSERT <string>` is an InternalError — the marker silently never
|
|
908
|
+
// landed (the write is awaited inside preload's best-effort catch).
|
|
909
|
+
await this.local.upsert(
|
|
910
|
+
'_00_preload',
|
|
911
|
+
new RecordId('_00_preload', hash),
|
|
912
|
+
{ fetchedAt: Date.now(), rowCount },
|
|
913
|
+
'replace'
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
918
|
+
hasSubscribers(hash: string): boolean {
|
|
919
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
924
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
925
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
926
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
927
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
928
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
929
|
+
*
|
|
930
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
931
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
932
|
+
*/
|
|
933
|
+
deregisterQuery(hash: string): void {
|
|
934
|
+
if (this.hasSubscribers(hash)) return;
|
|
935
|
+
if (!this.activeQueries.has(hash)) return;
|
|
936
|
+
this.onDeregister?.(hash);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
941
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
942
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
943
|
+
*/
|
|
944
|
+
finalizeDeregister(hash: string): void {
|
|
945
|
+
const qs = this.activeQueries.get(hash);
|
|
946
|
+
if (qs?.ttlTimer) {
|
|
947
|
+
clearTimeout(qs.ttlTimer);
|
|
948
|
+
qs.ttlTimer = null;
|
|
949
|
+
}
|
|
950
|
+
const debounce = this.debounceTimers.get(hash);
|
|
951
|
+
if (debounce) {
|
|
952
|
+
clearTimeout(debounce);
|
|
953
|
+
this.debounceTimers.delete(hash);
|
|
954
|
+
}
|
|
955
|
+
this.pendingStreamUpdates.delete(hash);
|
|
956
|
+
this.fetchDepth.delete(hash);
|
|
957
|
+
this.cache.unregisterQuery(hash);
|
|
958
|
+
this.activeQueries.delete(hash);
|
|
959
|
+
this.subscriptions.delete(hash);
|
|
960
|
+
}
|
|
961
|
+
|
|
263
962
|
/**
|
|
264
963
|
* Get query state by id (for sync and devtools)
|
|
265
964
|
*/
|
|
@@ -274,36 +973,163 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
274
973
|
return Array.from(this.activeQueries.values());
|
|
275
974
|
}
|
|
276
975
|
|
|
976
|
+
getActiveQueryHashes(): QueryHash[] {
|
|
977
|
+
return Array.from(this.activeQueries.keys());
|
|
978
|
+
}
|
|
979
|
+
|
|
277
980
|
async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
|
|
278
981
|
const queryState = this.activeQueries.get(id);
|
|
279
982
|
if (!queryState) {
|
|
280
983
|
this.logger.warn(
|
|
281
|
-
{ id, Category: '
|
|
984
|
+
{ id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
|
|
282
985
|
'Query to update local array not found'
|
|
283
986
|
);
|
|
284
987
|
return;
|
|
285
988
|
}
|
|
989
|
+
const epoch = this.local.epoch;
|
|
286
990
|
queryState.config.localArray = localArray;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
991
|
+
try {
|
|
992
|
+
await this.local.query(
|
|
993
|
+
surql.seal(surql.updateSet('id', ['localArray'])),
|
|
994
|
+
{
|
|
995
|
+
id: queryState.config.id,
|
|
996
|
+
localArray,
|
|
997
|
+
},
|
|
998
|
+
{ epoch }
|
|
999
|
+
);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
if (err instanceof StaleEpochError) return;
|
|
1002
|
+
throw err;
|
|
1003
|
+
}
|
|
291
1004
|
}
|
|
292
1005
|
|
|
293
1006
|
async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
|
|
294
1007
|
const queryState = this.getQueryByHash(hash);
|
|
295
1008
|
if (!queryState) {
|
|
296
1009
|
this.logger.warn(
|
|
297
|
-
{ hash, Category: '
|
|
1010
|
+
{ hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
|
|
298
1011
|
'Query to update remote array not found'
|
|
299
1012
|
);
|
|
300
1013
|
return;
|
|
301
1014
|
}
|
|
1015
|
+
const epoch = this.local.epoch;
|
|
302
1016
|
queryState.config.remoteArray = remoteArray;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
1017
|
+
try {
|
|
1018
|
+
await this.local.query(
|
|
1019
|
+
surql.seal(surql.updateSet('id', ['remoteArray'])),
|
|
1020
|
+
{
|
|
1021
|
+
id: queryState.config.id,
|
|
1022
|
+
remoteArray,
|
|
1023
|
+
},
|
|
1024
|
+
{ epoch }
|
|
1025
|
+
);
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
if (err instanceof StaleEpochError) return;
|
|
1028
|
+
throw err;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Cancel every armed timer ahead of a local-bucket switch: stream-update
|
|
1034
|
+
* debounce timers (their pending updates carry the OLD bucket's id-sets) and
|
|
1035
|
+
* per-query TTL heartbeats (they'd refresh the previous user's remote
|
|
1036
|
+
* `_00_query` rows under the new session). The rebind re-arms heartbeats.
|
|
1037
|
+
*/
|
|
1038
|
+
quiesce(): void {
|
|
1039
|
+
for (const timer of this.debounceTimers.values()) {
|
|
1040
|
+
clearTimeout(timer);
|
|
1041
|
+
}
|
|
1042
|
+
this.debounceTimers.clear();
|
|
1043
|
+
this.pendingStreamUpdates.clear();
|
|
1044
|
+
this.fetchDepth.clear();
|
|
1045
|
+
for (const queryState of this.activeQueries.values()) {
|
|
1046
|
+
if (queryState.ttlTimer) {
|
|
1047
|
+
clearTimeout(queryState.ttlTimer);
|
|
1048
|
+
queryState.ttlTimer = null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Re-home every active query in a freshly-opened bucket, KEEPING its hash —
|
|
1055
|
+
* `useQuery` subscriptions are keyed by hash and don't re-register on auth
|
|
1056
|
+
* changes, so the hooks must stay attached. Per query:
|
|
1057
|
+
* 1. reset the sync arrays + hydration flag and drop the previous user's
|
|
1058
|
+
* records, notifying subscribers with the new-bucket materialization
|
|
1059
|
+
* (usually empty) so their rows leave the UI immediately;
|
|
1060
|
+
* 2. recreate the `_00_query` row in the new bucket;
|
|
1061
|
+
* 3. re-register the SSP view on the (fresh, post-reset) processor — this
|
|
1062
|
+
* also rebinds the view to the NEW `$auth` context;
|
|
1063
|
+
* 4. restart the TTL heartbeat.
|
|
1064
|
+
* Returns the hashes so the caller can enqueue remote re-registration, which
|
|
1065
|
+
* refills records from the server via the normal register→sync→notify path.
|
|
1066
|
+
*/
|
|
1067
|
+
async rebindAfterBucketSwitch(): Promise<QueryHash[]> {
|
|
1068
|
+
const hashes: QueryHash[] = [];
|
|
1069
|
+
for (const [hash, queryState] of this.activeQueries.entries()) {
|
|
1070
|
+
const config = queryState.config;
|
|
1071
|
+
config.localArray = [];
|
|
1072
|
+
config.remoteArray = [];
|
|
1073
|
+
config.subqueryRemoteArray = undefined;
|
|
1074
|
+
queryState.hydrated = false;
|
|
1075
|
+
queryState.syncNotified = false;
|
|
1076
|
+
queryState.records = [];
|
|
1077
|
+
// Via setQueryStatus (not a bare assignment) so status observers see the
|
|
1078
|
+
// flip back to a loading state.
|
|
1079
|
+
this.setQueryStatus(hash, 'fetching');
|
|
1080
|
+
|
|
1081
|
+
try {
|
|
1082
|
+
await withRetry(this.logger, () =>
|
|
1083
|
+
this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
|
|
1084
|
+
id: config.id,
|
|
1085
|
+
data: {
|
|
1086
|
+
surql: config.surql,
|
|
1087
|
+
params: config.params,
|
|
1088
|
+
localArray: [],
|
|
1089
|
+
remoteArray: [],
|
|
1090
|
+
lastActiveAt: new Date(),
|
|
1091
|
+
createdAt: new Date(),
|
|
1092
|
+
ttl: config.ttl,
|
|
1093
|
+
tableName: config.tableName,
|
|
1094
|
+
updateCount: queryState.updateCount,
|
|
1095
|
+
rowCount: 0,
|
|
1096
|
+
errorCount: queryState.errorCount,
|
|
1097
|
+
},
|
|
1098
|
+
})
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
const { localArray } = this.cache.registerQuery({
|
|
1102
|
+
queryHash: hash,
|
|
1103
|
+
surql: config.surql,
|
|
1104
|
+
params: config.params,
|
|
1105
|
+
ttl: new Duration(config.ttl),
|
|
1106
|
+
lastActiveAt: new Date(),
|
|
1107
|
+
});
|
|
1108
|
+
config.localArray = localArray;
|
|
1109
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['localArray', 'rowCount'])), {
|
|
1110
|
+
id: config.id,
|
|
1111
|
+
localArray,
|
|
1112
|
+
rowCount: localArray.length,
|
|
1113
|
+
});
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
this.logger.error(
|
|
1116
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::rebindAfterBucketSwitch' },
|
|
1117
|
+
'Failed to rebind query after bucket switch; remote re-registration will retry'
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Notify AFTER the SSP re-registration so a subscriber that re-reads
|
|
1122
|
+
// synchronously sees consistent (empty) state.
|
|
1123
|
+
const subscribers = this.subscriptions.get(hash);
|
|
1124
|
+
if (subscribers) {
|
|
1125
|
+
for (const callback of subscribers) {
|
|
1126
|
+
callback(queryState.records);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
1130
|
+
hashes.push(hash);
|
|
1131
|
+
}
|
|
1132
|
+
return hashes;
|
|
307
1133
|
}
|
|
308
1134
|
|
|
309
1135
|
/**
|
|
@@ -313,20 +1139,29 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
313
1139
|
async notifyQuerySynced(queryHash: string): Promise<void> {
|
|
314
1140
|
const queryState = this.activeQueries.get(queryHash);
|
|
315
1141
|
if (!queryState) return;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
|
|
1142
|
+
const epoch = this.local.epoch;
|
|
1143
|
+
|
|
1144
|
+
// Re-query local DB for latest data (windowed queries materialize from the
|
|
1145
|
+
// list_ref window so they resolve even if the in-browser SSP never emits —
|
|
1146
|
+
// it can't compute a high offset whose preceding rows aren't resident).
|
|
1147
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
1148
|
+
// Bucket switched while we materialized: these rows mix old-bucket reads
|
|
1149
|
+
// with new-bucket state — drop them; the rebind/re-registration re-emits.
|
|
1150
|
+
if (epoch !== this.local.epoch) return;
|
|
323
1151
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
324
1152
|
queryState.records = newRecords;
|
|
325
1153
|
|
|
326
|
-
// Notify if data changed OR if this
|
|
327
|
-
// The latter handles "query truly has no
|
|
328
|
-
|
|
1154
|
+
// Notify if data changed OR if this registration lifetime hasn't emitted a
|
|
1155
|
+
// post-sync notification yet. The latter handles "query truly has no
|
|
1156
|
+
// results" so the UI can stop loading — gated on the in-memory
|
|
1157
|
+
// `syncNotified` flag rather than `updateCount === 0`, because updateCount
|
|
1158
|
+
// is PERSISTED across deregister/re-register: a re-registered empty window
|
|
1159
|
+
// (updateCount > 0, records unchanged) would otherwise never emit and its
|
|
1160
|
+
// subscribers would show a loading state forever.
|
|
1161
|
+
if (changed || !queryState.syncNotified) {
|
|
1162
|
+
queryState.syncNotified = true;
|
|
329
1163
|
queryState.updateCount++;
|
|
1164
|
+
queryState.lastUpdatedAt = Date.now();
|
|
330
1165
|
const subscribers = this.subscriptions.get(queryHash);
|
|
331
1166
|
if (subscribers) {
|
|
332
1167
|
for (const callback of subscribers) {
|
|
@@ -344,6 +1179,21 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
344
1179
|
data: RoutePayload<S, B, R>,
|
|
345
1180
|
options?: RunOptions
|
|
346
1181
|
): Promise<void> {
|
|
1182
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1183
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
1184
|
+
await this.create(recordId, record);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Build the outbox job record + resolve its table for a backend route. Shared
|
|
1189
|
+
* by `run` (one-shot) and `runRecurring` (durable schedule).
|
|
1190
|
+
*/
|
|
1191
|
+
private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1192
|
+
backend: B,
|
|
1193
|
+
path: R,
|
|
1194
|
+
data: RoutePayload<S, B, R>,
|
|
1195
|
+
options?: RunOptions
|
|
1196
|
+
): { tableName: string; record: Record<string, unknown> } {
|
|
347
1197
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
348
1198
|
if (!route) {
|
|
349
1199
|
throw new Error(`Route ${backend}.${path} not found`);
|
|
@@ -370,12 +1220,126 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
370
1220
|
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
371
1221
|
};
|
|
372
1222
|
|
|
1223
|
+
if (options?.timeout != null) {
|
|
1224
|
+
record.timeout = options.timeout;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (options?.delay != null) {
|
|
1228
|
+
record.delay = options.delay;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
373
1231
|
if (options?.assignedTo) {
|
|
374
1232
|
record.assigned_to = options.assignedTo;
|
|
375
1233
|
}
|
|
376
1234
|
|
|
377
|
-
|
|
378
|
-
|
|
1235
|
+
return { tableName, record };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Deterministic id for the single recurring-schedule row of a given
|
|
1240
|
+
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
1241
|
+
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
1242
|
+
*/
|
|
1243
|
+
private recurringJobId(tableName: string, assignedTo: string, path: string): string {
|
|
1244
|
+
const suffix = `${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1245
|
+
return `${tableName}:${suffix}`;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
1250
|
+
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
1251
|
+
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
1252
|
+
* this on every connect/re-login never forks a second schedule. The first run
|
|
1253
|
+
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
1254
|
+
*/
|
|
1255
|
+
async runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1256
|
+
backend: B,
|
|
1257
|
+
path: R,
|
|
1258
|
+
data: RoutePayload<S, B, R>,
|
|
1259
|
+
options: RunOptions & { interval: number; assignedTo: string }
|
|
1260
|
+
): Promise<void> {
|
|
1261
|
+
if (options?.interval == null) {
|
|
1262
|
+
throw new Error('runRecurring requires options.interval (ms)');
|
|
1263
|
+
}
|
|
1264
|
+
if (!options?.assignedTo) {
|
|
1265
|
+
throw new Error('runRecurring requires options.assignedTo');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1269
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
1270
|
+
const rid = parseRecordIdString(recordId);
|
|
1271
|
+
|
|
1272
|
+
// Single schedule per key: if the row already exists, do nothing.
|
|
1273
|
+
const [existing] = await withRetry(this.logger, () =>
|
|
1274
|
+
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1275
|
+
);
|
|
1276
|
+
if (existing) return;
|
|
1277
|
+
|
|
1278
|
+
record.recurring = true;
|
|
1279
|
+
record.interval = options.interval;
|
|
1280
|
+
record.next_run_at = new Date(); // run now, then re-arm to now + interval on completion
|
|
1281
|
+
try {
|
|
1282
|
+
await this.create(recordId, record);
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
// The local existence check can miss a row that exists on the server but
|
|
1285
|
+
// hasn't synced into this session yet (e.g. a re-login before catch-up).
|
|
1286
|
+
// The deterministic id makes that CREATE collide; treat it as "schedule
|
|
1287
|
+
// already exists" and keep runRecurring idempotent rather than throwing.
|
|
1288
|
+
this.logger.debug(
|
|
1289
|
+
{ id: recordId, err: (err as Error)?.message, Category: 'sp00ky-client::DataModule::runRecurring' },
|
|
1290
|
+
'runRecurring create skipped (schedule likely already exists)'
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
1297
|
+
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
1298
|
+
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
1299
|
+
* from that run's completion. No-op if no schedule exists (caller should have
|
|
1300
|
+
* created one via `runRecurring`).
|
|
1301
|
+
*/
|
|
1302
|
+
async pokeRecurring<B extends BackendNames<S>>(
|
|
1303
|
+
backend: B,
|
|
1304
|
+
path: BackendRoutes<S, B>,
|
|
1305
|
+
options: { assignedTo: string }
|
|
1306
|
+
): Promise<void> {
|
|
1307
|
+
if (!options?.assignedTo) {
|
|
1308
|
+
throw new Error('pokeRecurring requires options.assignedTo');
|
|
1309
|
+
}
|
|
1310
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1311
|
+
if (!tableName) {
|
|
1312
|
+
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1313
|
+
}
|
|
1314
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1315
|
+
const rid = parseRecordIdString(recordId);
|
|
1316
|
+
|
|
1317
|
+
const [existing] = await withRetry(this.logger, () =>
|
|
1318
|
+
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1319
|
+
);
|
|
1320
|
+
if (!existing) return;
|
|
1321
|
+
|
|
1322
|
+
await this.update(tableName, recordId, { next_run_at: new Date() });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
1327
|
+
* being dispatched server-side.
|
|
1328
|
+
*/
|
|
1329
|
+
async cancelRecurring<B extends BackendNames<S>>(
|
|
1330
|
+
backend: B,
|
|
1331
|
+
path: BackendRoutes<S, B>,
|
|
1332
|
+
options: { assignedTo: string }
|
|
1333
|
+
): Promise<void> {
|
|
1334
|
+
if (!options?.assignedTo) {
|
|
1335
|
+
throw new Error('cancelRecurring requires options.assignedTo');
|
|
1336
|
+
}
|
|
1337
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1338
|
+
if (!tableName) {
|
|
1339
|
+
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1340
|
+
}
|
|
1341
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1342
|
+
await this.delete(tableName, recordId);
|
|
379
1343
|
}
|
|
380
1344
|
|
|
381
1345
|
// ==================== MUTATION MANAGEMENT ====================
|
|
@@ -392,7 +1356,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
392
1356
|
|
|
393
1357
|
const rid = parseRecordIdString(id);
|
|
394
1358
|
const params = parseParams(tableSchema.columns, data);
|
|
395
|
-
const mutationId = parseRecordIdString(`
|
|
1359
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
396
1360
|
|
|
397
1361
|
const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
|
|
398
1362
|
const prefixedParams = Object.fromEntries(
|
|
@@ -441,7 +1405,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
441
1405
|
callback([mutationEvent]);
|
|
442
1406
|
}
|
|
443
1407
|
|
|
444
|
-
this.logger.debug({ id, Category: '
|
|
1408
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
|
|
445
1409
|
|
|
446
1410
|
return target;
|
|
447
1411
|
}
|
|
@@ -463,7 +1427,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
463
1427
|
|
|
464
1428
|
const rid = parseRecordIdString(id);
|
|
465
1429
|
const params = parseParams(tableSchema.columns, data);
|
|
466
|
-
const mutationId = parseRecordIdString(`
|
|
1430
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1431
|
+
|
|
1432
|
+
// Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
|
|
1433
|
+
// NOT through the record update pipeline. This keeps the record data clean.
|
|
467
1434
|
|
|
468
1435
|
// Capture current record state before mutation for rollback support
|
|
469
1436
|
const [beforeRecord] = await withRetry(this.logger, () =>
|
|
@@ -472,7 +1439,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
472
1439
|
|
|
473
1440
|
const query = surql.seal<{ target: T }>(
|
|
474
1441
|
surql.tx([
|
|
475
|
-
surql.updateSet('id', [{ statement: '
|
|
1442
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
476
1443
|
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
477
1444
|
surql.createMutation('update', 'mid', 'id', 'data'),
|
|
478
1445
|
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
@@ -496,8 +1463,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
496
1463
|
updatedFields[key] = (target as Record<string, any>)[key];
|
|
497
1464
|
}
|
|
498
1465
|
}
|
|
499
|
-
if ('
|
|
500
|
-
updatedFields.
|
|
1466
|
+
if ('_00_rv' in (target as Record<string, any>)) {
|
|
1467
|
+
updatedFields._00_rv = (target as Record<string, any>)._00_rv;
|
|
501
1468
|
}
|
|
502
1469
|
this.replaceRecordInQueries(updatedFields);
|
|
503
1470
|
|
|
@@ -509,7 +1476,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
509
1476
|
table: table,
|
|
510
1477
|
op: 'UPDATE',
|
|
511
1478
|
record: parsedRecord,
|
|
512
|
-
version: target.
|
|
1479
|
+
version: target._00_rv as number,
|
|
513
1480
|
},
|
|
514
1481
|
true
|
|
515
1482
|
);
|
|
@@ -531,7 +1498,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
531
1498
|
callback([mutationEvent]);
|
|
532
1499
|
}
|
|
533
1500
|
|
|
534
|
-
this.logger.debug({ id, Category: '
|
|
1501
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
|
|
535
1502
|
|
|
536
1503
|
return target;
|
|
537
1504
|
}
|
|
@@ -547,14 +1514,53 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
547
1514
|
}
|
|
548
1515
|
|
|
549
1516
|
const rid = parseRecordIdString(id);
|
|
550
|
-
const mutationId = parseRecordIdString(`
|
|
1517
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1518
|
+
|
|
1519
|
+
// Fetch the record before deleting so DBSP can match it against query predicates
|
|
1520
|
+
const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
|
|
1521
|
+
'SELECT * FROM ONLY $id',
|
|
1522
|
+
{ id: rid }
|
|
1523
|
+
);
|
|
1524
|
+
const beforeRecord = beforeRecords ?? {};
|
|
551
1525
|
|
|
552
1526
|
const query = surql.seal<void>(
|
|
553
1527
|
surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
|
|
554
1528
|
);
|
|
555
1529
|
|
|
556
1530
|
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
557
|
-
|
|
1531
|
+
|
|
1532
|
+
// The local DELETE has now committed. Everything below must reflect that in
|
|
1533
|
+
// active live queries — so the deleted row disappears optimistically without
|
|
1534
|
+
// a reload — even if the optimistic SSP-view ingest below fails. Previously a
|
|
1535
|
+
// throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
|
|
1536
|
+
// commit, so the manual notify loop never ran and the row lingered on screen
|
|
1537
|
+
// until reload. Ingesting the delete into the in-browser SSP view is
|
|
1538
|
+
// best-effort: the manual re-materialize reads the local DB (which already
|
|
1539
|
+
// excludes the row), so the result is correct regardless.
|
|
1540
|
+
try {
|
|
1541
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
this.logger.error(
|
|
1544
|
+
{ err, id, Category: 'sp00ky-client::DataModule::delete' },
|
|
1545
|
+
'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
1550
|
+
// that reference this table. Each is isolated so one failing re-materialize
|
|
1551
|
+
// can't stop the others (or the sync emit below) from running.
|
|
1552
|
+
for (const [queryHash, queryState] of this.activeQueries) {
|
|
1553
|
+
if (queryState.config.tableName === tableName) {
|
|
1554
|
+
try {
|
|
1555
|
+
await this.notifyQuerySynced(queryHash);
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
this.logger.error(
|
|
1558
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
|
|
1559
|
+
'notifyQuerySynced failed after delete'
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
558
1564
|
|
|
559
1565
|
// Emit mutation event
|
|
560
1566
|
const mutationEvent: DeleteEvent = {
|
|
@@ -567,7 +1573,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
567
1573
|
callback([mutationEvent]);
|
|
568
1574
|
}
|
|
569
1575
|
|
|
570
|
-
this.logger.debug({ id, Category: '
|
|
1576
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
|
|
571
1577
|
}
|
|
572
1578
|
|
|
573
1579
|
// ==================== ROLLBACK METHODS ====================
|
|
@@ -586,12 +1592,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
586
1592
|
this.removeRecordFromQueries(recordId);
|
|
587
1593
|
|
|
588
1594
|
this.logger.info(
|
|
589
|
-
{ id, tableName, Category: '
|
|
1595
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
590
1596
|
'Rolled back optimistic create'
|
|
591
1597
|
);
|
|
592
1598
|
} catch (err) {
|
|
593
1599
|
this.logger.error(
|
|
594
|
-
{ err, id, tableName, Category: '
|
|
1600
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
595
1601
|
'Failed to rollback create'
|
|
596
1602
|
);
|
|
597
1603
|
}
|
|
@@ -626,7 +1632,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
626
1632
|
table: tableName,
|
|
627
1633
|
op: 'UPDATE',
|
|
628
1634
|
record: parsedRecord,
|
|
629
|
-
version: (beforeRecord.
|
|
1635
|
+
version: (beforeRecord._00_rv as number) || 1,
|
|
630
1636
|
},
|
|
631
1637
|
true
|
|
632
1638
|
);
|
|
@@ -635,12 +1641,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
635
1641
|
await this.replaceRecordInQueries(beforeRecord);
|
|
636
1642
|
|
|
637
1643
|
this.logger.info(
|
|
638
|
-
{ id, tableName, Category: '
|
|
1644
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
639
1645
|
'Rolled back optimistic update'
|
|
640
1646
|
);
|
|
641
1647
|
} catch (err) {
|
|
642
1648
|
this.logger.error(
|
|
643
|
-
{ err, id, tableName, Category: '
|
|
1649
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
644
1650
|
'Failed to rollback update'
|
|
645
1651
|
);
|
|
646
1652
|
}
|
|
@@ -678,7 +1684,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
678
1684
|
surqlString: string,
|
|
679
1685
|
params: Record<string, any>,
|
|
680
1686
|
ttl: QueryTimeToLive,
|
|
681
|
-
tableName: T
|
|
1687
|
+
tableName: T,
|
|
1688
|
+
plan?: QueryPlan
|
|
682
1689
|
): Promise<QueryHash> {
|
|
683
1690
|
const queryState = await this.createNewQuery<T>({
|
|
684
1691
|
recordId,
|
|
@@ -686,31 +1693,76 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
686
1693
|
params,
|
|
687
1694
|
ttl,
|
|
688
1695
|
tableName,
|
|
1696
|
+
plan,
|
|
689
1697
|
});
|
|
690
1698
|
|
|
691
|
-
const
|
|
1699
|
+
const t0 = performance.now();
|
|
1700
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
692
1701
|
queryHash: hash,
|
|
693
1702
|
surql: surqlString,
|
|
694
1703
|
params,
|
|
695
1704
|
ttl: new Duration(ttl),
|
|
696
1705
|
lastActiveAt: new Date(),
|
|
697
1706
|
});
|
|
1707
|
+
const registrationTime = performance.now() - t0;
|
|
1708
|
+
|
|
1709
|
+
// Record the one-shot SSP registration timings (parse/plan/snapshot from the
|
|
1710
|
+
// WASM binding) + the register_view wall time for DevTools.
|
|
1711
|
+
queryState.registrationTimings = {
|
|
1712
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1713
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1714
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1715
|
+
wallMs: registrationTime,
|
|
1716
|
+
};
|
|
698
1717
|
|
|
699
1718
|
await withRetry(this.logger, () =>
|
|
700
|
-
this.local.query(
|
|
701
|
-
id
|
|
702
|
-
|
|
703
|
-
|
|
1719
|
+
this.local.query(
|
|
1720
|
+
surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
|
|
1721
|
+
{
|
|
1722
|
+
id: recordId,
|
|
1723
|
+
localArray,
|
|
1724
|
+
registrationTime,
|
|
1725
|
+
rowCount: localArray.length,
|
|
1726
|
+
}
|
|
1727
|
+
)
|
|
704
1728
|
);
|
|
705
1729
|
|
|
1730
|
+
// Windowed (`START n`) queries skipped the raw initial load in
|
|
1731
|
+
// createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
|
|
1732
|
+
// initial rows now from the SSP's window id-set (`localArray`) via the same
|
|
1733
|
+
// window-materialization path the stream updates use — O(window), and the
|
|
1734
|
+
// ids are already the correct window — so the first paint isn't empty while
|
|
1735
|
+
// the remote `_00_list_ref` syncs in.
|
|
1736
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1737
|
+
if (windowMat && localArray.length > 0) {
|
|
1738
|
+
try {
|
|
1739
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1740
|
+
if (plan) {
|
|
1741
|
+
const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
|
|
1742
|
+
queryState.records = await this.local.select(winPlan, params);
|
|
1743
|
+
} else {
|
|
1744
|
+
const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
1745
|
+
...params,
|
|
1746
|
+
__win: winIds,
|
|
1747
|
+
});
|
|
1748
|
+
queryState.records = seeded || [];
|
|
1749
|
+
}
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
this.logger.warn(
|
|
1752
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
|
|
1753
|
+
'Failed to seed windowed initial records from localArray'
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
706
1758
|
this.activeQueries.set(hash, queryState);
|
|
707
|
-
this.startTTLHeartbeat(queryState);
|
|
1759
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
708
1760
|
this.logger.debug(
|
|
709
1761
|
{
|
|
710
1762
|
hash,
|
|
711
1763
|
tableName,
|
|
712
1764
|
recordCount: queryState.records.length,
|
|
713
|
-
Category: '
|
|
1765
|
+
Category: 'sp00ky-client::DataModule::query',
|
|
714
1766
|
},
|
|
715
1767
|
'Query registered'
|
|
716
1768
|
);
|
|
@@ -724,12 +1776,14 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
724
1776
|
params,
|
|
725
1777
|
ttl,
|
|
726
1778
|
tableName,
|
|
1779
|
+
plan,
|
|
727
1780
|
}: {
|
|
728
1781
|
recordId: RecordId;
|
|
729
1782
|
surql: string;
|
|
730
1783
|
params: Record<string, any>;
|
|
731
1784
|
ttl: QueryTimeToLive;
|
|
732
1785
|
tableName: T;
|
|
1786
|
+
plan?: QueryPlan;
|
|
733
1787
|
}): Promise<QueryState> {
|
|
734
1788
|
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
735
1789
|
if (!tableSchema) {
|
|
@@ -752,8 +1806,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
752
1806
|
localArray: [],
|
|
753
1807
|
remoteArray: [],
|
|
754
1808
|
lastActiveAt: new Date(),
|
|
1809
|
+
createdAt: new Date(),
|
|
755
1810
|
ttl,
|
|
756
1811
|
tableName,
|
|
1812
|
+
updateCount: 0,
|
|
1813
|
+
rowCount: 0,
|
|
1814
|
+
errorCount: 0,
|
|
757
1815
|
},
|
|
758
1816
|
})
|
|
759
1817
|
);
|
|
@@ -763,62 +1821,111 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
763
1821
|
const config: QueryConfig = {
|
|
764
1822
|
...configRecord,
|
|
765
1823
|
id: recordId,
|
|
1824
|
+
// In-memory only — carries the engine-neutral plan so non-SurrealQL local
|
|
1825
|
+
// engines materialize via `select(plan)` instead of parsing `surql`.
|
|
1826
|
+
plan,
|
|
766
1827
|
params: parseParams(tableSchema.columns, configRecord.params),
|
|
767
1828
|
};
|
|
768
1829
|
|
|
769
1830
|
let records: Record<string, any>[] = [];
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1831
|
+
// Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
|
|
1832
|
+
// `… LIMIT n START m` against the shared local store is O(m) — it sorts and
|
|
1833
|
+
// skips m rows on every window open — AND returns the wrong rows for sparse
|
|
1834
|
+
// windows (the reason `buildWindowMaterialization` exists). Those windows are
|
|
1835
|
+
// seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
|
|
1836
|
+
if (buildWindowMaterialization(surqlString) === null) {
|
|
1837
|
+
try {
|
|
1838
|
+
// Prefer the engine-neutral plan (required for non-SurrealQL engines);
|
|
1839
|
+
// fall back to running the raw surql on the SurrealDB engine.
|
|
1840
|
+
const result = plan
|
|
1841
|
+
? await this.local.select(plan, params)
|
|
1842
|
+
: (await this.local.query<[Record<string, any>[]]>(surqlString, params))[0];
|
|
1843
|
+
records = result || [];
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
this.logger.warn(
|
|
1846
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
1847
|
+
'Failed to load initial cached records'
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
778
1850
|
}
|
|
779
1851
|
|
|
1852
|
+
// Persisted counters survive a restart even though the rolling
|
|
1853
|
+
// sample window is rebuilt from scratch in memory.
|
|
1854
|
+
const persistedUpdateCount =
|
|
1855
|
+
typeof (configRecord as any)?.updateCount === 'number'
|
|
1856
|
+
? (configRecord as any).updateCount
|
|
1857
|
+
: 0;
|
|
1858
|
+
const persistedErrorCount =
|
|
1859
|
+
typeof (configRecord as any)?.errorCount === 'number'
|
|
1860
|
+
? (configRecord as any).errorCount
|
|
1861
|
+
: 0;
|
|
1862
|
+
|
|
780
1863
|
return {
|
|
781
1864
|
config,
|
|
782
1865
|
records,
|
|
783
1866
|
ttlTimer: null,
|
|
784
1867
|
ttlDurationMs: parseDuration(ttl),
|
|
785
|
-
updateCount:
|
|
1868
|
+
updateCount: persistedUpdateCount,
|
|
1869
|
+
lastUpdatedAt: null,
|
|
1870
|
+
materializationSamples: [],
|
|
1871
|
+
lastIngestLatencyMs: null,
|
|
1872
|
+
errorCount: persistedErrorCount,
|
|
1873
|
+
// Born `fetching`, not `idle`: every cold registration is followed by a
|
|
1874
|
+
// `register` down-event whose lifecycle (Sp00kySync.registerQuery) resolves
|
|
1875
|
+
// the status to `idle` once the initial sync completed. Starting idle left
|
|
1876
|
+
// a gap where a fresh windowed query looked settled while still empty.
|
|
1877
|
+
status: 'fetching',
|
|
1878
|
+
phaseSamples: {},
|
|
1879
|
+
phaseLast: {},
|
|
1880
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
786
1881
|
};
|
|
787
1882
|
}
|
|
788
1883
|
|
|
789
1884
|
private async calculateHash(data: any): Promise<string> {
|
|
790
|
-
|
|
1885
|
+
// sessionId is part of the hash so the same logical query from two
|
|
1886
|
+
// sessions (e.g. two browser tabs of the same user) lands on different
|
|
1887
|
+
// `_00_query` rows and doesn't fight over a shared one.
|
|
1888
|
+
const content = JSON.stringify({ ...data, sessionId: this.sessionId });
|
|
791
1889
|
const msgBuffer = new TextEncoder().encode(content);
|
|
792
1890
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
793
1891
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
794
1892
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
795
1893
|
}
|
|
796
1894
|
|
|
797
|
-
private startTTLHeartbeat(queryState: QueryState): void {
|
|
1895
|
+
private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
|
|
798
1896
|
if (queryState.ttlTimer) return;
|
|
799
1897
|
|
|
800
1898
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
801
1899
|
|
|
802
1900
|
queryState.ttlTimer = setTimeout(() => {
|
|
803
|
-
|
|
1901
|
+
queryState.ttlTimer = null;
|
|
1902
|
+
// Only keep the remote query alive while something is actually watching
|
|
1903
|
+
// it. With the server now sweeping ALL expired views (not just in-circuit
|
|
1904
|
+
// ones), an un-refreshed query WOULD be swept after its TTL — so a live
|
|
1905
|
+
// subscriber must heartbeat. An abandoned query (no subscribers) is left
|
|
1906
|
+
// to expire and get swept; its local view was already torn down at the
|
|
1907
|
+
// last unsubscribe, so we just stop the timer here.
|
|
1908
|
+
const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
|
|
1909
|
+
if (subscriberCount === 0) {
|
|
1910
|
+
this.logger.debug(
|
|
1911
|
+
{ hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
|
|
1912
|
+
'TTL heartbeat: no subscribers, stopping'
|
|
1913
|
+
);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
this.onHeartbeat?.(hash);
|
|
804
1917
|
this.logger.debug(
|
|
805
1918
|
{
|
|
1919
|
+
hash,
|
|
806
1920
|
id: encodeRecordId(queryState.config.id),
|
|
807
|
-
Category: '
|
|
1921
|
+
Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
|
|
808
1922
|
},
|
|
809
|
-
'TTL heartbeat'
|
|
1923
|
+
'TTL heartbeat sent'
|
|
810
1924
|
);
|
|
811
|
-
this.startTTLHeartbeat(queryState);
|
|
1925
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
812
1926
|
}, heartbeatTime);
|
|
813
1927
|
}
|
|
814
1928
|
|
|
815
|
-
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
816
|
-
if (queryState.ttlTimer) {
|
|
817
|
-
clearTimeout(queryState.ttlTimer);
|
|
818
|
-
queryState.ttlTimer = null;
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
1929
|
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
823
1930
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
824
1931
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
@@ -851,7 +1958,7 @@ export function parseUpdateOptions(
|
|
|
851
1958
|
const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
|
|
852
1959
|
const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
|
|
853
1960
|
const key =
|
|
854
|
-
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).
|
|
1961
|
+
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
|
|
855
1962
|
|
|
856
1963
|
pushEventOptions = {
|
|
857
1964
|
debounced: {
|