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