@spooky-sync/core 0.0.1-canary.102 → 0.0.1-canary.104
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/dist/index.d.ts +180 -3
- package/dist/index.js +2212 -1620
- package/dist/types.d.ts +15 -4
- package/package.json +3 -3
- package/src/modules/cache/index.ts +35 -2
- package/src/modules/crdt/crdt-field.ts +9 -2
- package/src/modules/crdt/index.ts +7 -2
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.status.test.ts +143 -2
- package/src/modules/data/index.ts +229 -13
- package/src/modules/devtools/index.ts +8 -5
- package/src/modules/ref-tables.test.ts +26 -1
- package/src/modules/ref-tables.ts +19 -0
- package/src/modules/sync/queue/queue-down.ts +6 -0
- package/src/modules/sync/queue/queue-up.ts +14 -0
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +33 -4
- package/src/modules/sync/sync.ts +76 -5
- package/src/services/database/local-migrator.ts +6 -6
- package/src/services/database/local.test.ts +31 -0
- package/src/services/database/local.ts +298 -72
- package/src/services/stream-processor/index.ts +48 -2
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/sp00ky.auth-order.test.ts +27 -0
- package/src/sp00ky.ts +132 -2
- package/src/types.ts +15 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
|
|
2
|
-
import { QueryBuilder, RecordId as RecordId$1 } from "@spooky-sync/query-builder";
|
|
3
2
|
import { createWasmWorkerEngines } from "@surrealdb/wasm";
|
|
3
|
+
import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
|
|
4
4
|
import pino from "pino";
|
|
5
5
|
import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
|
|
6
6
|
import { LoroDoc } from "loro-crdt";
|
|
@@ -9,6 +9,241 @@ import { LoroDoc } from "loro-crdt";
|
|
|
9
9
|
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
10
10
|
const MATERIALIZATION_SAMPLE_WINDOW = 100;
|
|
11
11
|
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/services/database/database.ts
|
|
14
|
+
var AbstractDatabaseService = class {
|
|
15
|
+
client;
|
|
16
|
+
logger;
|
|
17
|
+
events;
|
|
18
|
+
constructor(client, logger, events) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.logger = logger.child({ service: "Database" });
|
|
21
|
+
this.events = events;
|
|
22
|
+
}
|
|
23
|
+
getClient() {
|
|
24
|
+
return this.client;
|
|
25
|
+
}
|
|
26
|
+
getEvents() {
|
|
27
|
+
return this.events;
|
|
28
|
+
}
|
|
29
|
+
tx() {
|
|
30
|
+
return this.client.beginTransaction();
|
|
31
|
+
}
|
|
32
|
+
queryQueue = Promise.resolve();
|
|
33
|
+
/**
|
|
34
|
+
* Execute a query with serialized execution to prevent WASM transaction issues.
|
|
35
|
+
*/
|
|
36
|
+
async query(query, vars) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
this.queryQueue = this.queryQueue.then(async () => {
|
|
39
|
+
const startTime = performance.now();
|
|
40
|
+
try {
|
|
41
|
+
this.logger.debug({
|
|
42
|
+
query,
|
|
43
|
+
vars,
|
|
44
|
+
Category: "sp00ky-client::Database::query"
|
|
45
|
+
}, "Executing query");
|
|
46
|
+
const result = await this.client.query(query, vars);
|
|
47
|
+
const duration = performance.now() - startTime;
|
|
48
|
+
this.events.emit(this.eventType, {
|
|
49
|
+
query,
|
|
50
|
+
vars,
|
|
51
|
+
duration,
|
|
52
|
+
success: true,
|
|
53
|
+
timestamp: Date.now()
|
|
54
|
+
});
|
|
55
|
+
resolve(result);
|
|
56
|
+
this.logger.trace({
|
|
57
|
+
query,
|
|
58
|
+
result,
|
|
59
|
+
Category: "sp00ky-client::Database::query"
|
|
60
|
+
}, "Query executed successfully");
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const duration = performance.now() - startTime;
|
|
63
|
+
this.events.emit(this.eventType, {
|
|
64
|
+
query,
|
|
65
|
+
vars,
|
|
66
|
+
duration,
|
|
67
|
+
success: false,
|
|
68
|
+
error: err instanceof Error ? err.message : String(err),
|
|
69
|
+
timestamp: Date.now()
|
|
70
|
+
});
|
|
71
|
+
this.logger.error({
|
|
72
|
+
query,
|
|
73
|
+
vars,
|
|
74
|
+
err,
|
|
75
|
+
Category: "sp00ky-client::Database::query"
|
|
76
|
+
}, "Query execution failed");
|
|
77
|
+
reject(err);
|
|
78
|
+
}
|
|
79
|
+
}).catch(() => {});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async execute(query, vars) {
|
|
83
|
+
const raw = await this.query(query.sql, vars);
|
|
84
|
+
return query.extract(raw);
|
|
85
|
+
}
|
|
86
|
+
async close() {
|
|
87
|
+
this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
|
|
88
|
+
await this.client.close();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/events/index.ts
|
|
94
|
+
/**
|
|
95
|
+
* A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
|
|
96
|
+
* @template E The EventTypeMap defining all supported events.
|
|
97
|
+
*/
|
|
98
|
+
var EventSystem = class {
|
|
99
|
+
subscriberId = 0;
|
|
100
|
+
isProcessing = false;
|
|
101
|
+
buffer;
|
|
102
|
+
subscribers;
|
|
103
|
+
subscribersTypeMap;
|
|
104
|
+
lastEvents;
|
|
105
|
+
debouncedEvents;
|
|
106
|
+
constructor(_eventTypes) {
|
|
107
|
+
this._eventTypes = _eventTypes;
|
|
108
|
+
this.buffer = [];
|
|
109
|
+
this.subscribers = this._eventTypes.reduce((acc, key) => {
|
|
110
|
+
return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
|
|
111
|
+
}, {});
|
|
112
|
+
this.lastEvents = {};
|
|
113
|
+
this.subscribersTypeMap = /* @__PURE__ */ new Map();
|
|
114
|
+
this.debouncedEvents = /* @__PURE__ */ new Map();
|
|
115
|
+
}
|
|
116
|
+
get eventTypes() {
|
|
117
|
+
return this._eventTypes;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Subscribes a handler to a specific event type.
|
|
121
|
+
* @param type The event type to subscribe to.
|
|
122
|
+
* @param handler The function to call when the event occurs.
|
|
123
|
+
* @param options Subscription options (once, immediately).
|
|
124
|
+
* @returns A subscription ID that can be used to unsubscribe.
|
|
125
|
+
*/
|
|
126
|
+
subscribe(type, handler, options) {
|
|
127
|
+
const id = this.subscriberId++;
|
|
128
|
+
this.subscribers[type].set(id, {
|
|
129
|
+
id,
|
|
130
|
+
handler,
|
|
131
|
+
once: options?.once ?? false
|
|
132
|
+
});
|
|
133
|
+
this.subscribersTypeMap.set(id, type);
|
|
134
|
+
if (options?.immediately) {
|
|
135
|
+
const lastEvent = this.lastEvents[type];
|
|
136
|
+
if (lastEvent) handler(lastEvent);
|
|
137
|
+
}
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Subscribes a handler to multiple event types.
|
|
142
|
+
* @param types An array of event types to subscribe to.
|
|
143
|
+
* @param handler The function to call when any of the events occur.
|
|
144
|
+
* @param options Subscription options.
|
|
145
|
+
* @returns An array of subscription IDs.
|
|
146
|
+
*/
|
|
147
|
+
subscribeMany(types, handler, options) {
|
|
148
|
+
return types.map((type) => this.subscribe(type, handler, options));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Unsubscribes a specific subscription by ID.
|
|
152
|
+
* @param id The subscription ID returned by subscribe().
|
|
153
|
+
* @returns True if the subscription was found and removed, false otherwise.
|
|
154
|
+
*/
|
|
155
|
+
unsubscribe(id) {
|
|
156
|
+
const type = this.subscribersTypeMap.get(id);
|
|
157
|
+
if (type) {
|
|
158
|
+
this.subscribers[type].delete(id);
|
|
159
|
+
this.subscribersTypeMap.delete(id);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Emits an event with the given type and payload.
|
|
166
|
+
* @param type The type of event to emit.
|
|
167
|
+
* @param payload The data associated with the event.
|
|
168
|
+
*/
|
|
169
|
+
emit(type, payload) {
|
|
170
|
+
const event = {
|
|
171
|
+
type,
|
|
172
|
+
payload
|
|
173
|
+
};
|
|
174
|
+
this.addEvent(event);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Adds a fully constructed event object to the system.
|
|
178
|
+
* Similar to emit, but takes the full event object directly.
|
|
179
|
+
* Supports debouncing if options are provided.
|
|
180
|
+
* @param event The event object.
|
|
181
|
+
* @param options Options for the event push (e.g., debouncing).
|
|
182
|
+
*/
|
|
183
|
+
addEvent(event, options) {
|
|
184
|
+
if (options?.debounced) {
|
|
185
|
+
this.handleDebouncedEvent(event, options.debounced.key, options.debounced.delay);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.buffer.push(event);
|
|
189
|
+
this.scheduleProcessing();
|
|
190
|
+
}
|
|
191
|
+
handleDebouncedEvent(event, key, delay) {
|
|
192
|
+
if (this.debouncedEvents.has(key)) clearTimeout(this.debouncedEvents.get(key)?.timer);
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
this.debouncedEvents.delete(key);
|
|
195
|
+
this.buffer.push(event);
|
|
196
|
+
this.scheduleProcessing();
|
|
197
|
+
}, delay);
|
|
198
|
+
this.debouncedEvents.set(key, {
|
|
199
|
+
timer,
|
|
200
|
+
resolve: () => {}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
scheduleProcessing() {
|
|
204
|
+
if (!this.isProcessing) queueMicrotask(() => this.processEvents());
|
|
205
|
+
}
|
|
206
|
+
async processEvents() {
|
|
207
|
+
if (this.isProcessing) return;
|
|
208
|
+
this.isProcessing = true;
|
|
209
|
+
try {
|
|
210
|
+
while (this.dequeue());
|
|
211
|
+
} finally {
|
|
212
|
+
this.isProcessing = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
dequeue() {
|
|
216
|
+
const event = this.buffer.shift();
|
|
217
|
+
if (!event) return false;
|
|
218
|
+
this.setLastEvent(event.type, event);
|
|
219
|
+
this.broadcastEvent(event.type, event);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
setLastEvent(type, event) {
|
|
223
|
+
this.lastEvents[type] = event;
|
|
224
|
+
}
|
|
225
|
+
broadcastEvent(type, event) {
|
|
226
|
+
const subscribers = this.subscribers[type].values();
|
|
227
|
+
for (const subscriber of subscribers) {
|
|
228
|
+
subscriber.handler(event);
|
|
229
|
+
if (subscriber.once) this.unsubscribe(subscriber.id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
function createEventSystem(eventTypes) {
|
|
234
|
+
return new EventSystem(eventTypes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/services/database/events/index.ts
|
|
239
|
+
const DatabaseEventTypes = {
|
|
240
|
+
LocalQuery: "DATABASE_LOCAL_QUERY",
|
|
241
|
+
RemoteQuery: "DATABASE_REMOTE_QUERY"
|
|
242
|
+
};
|
|
243
|
+
function createDatabaseEventSystem() {
|
|
244
|
+
return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
|
|
245
|
+
}
|
|
246
|
+
|
|
12
247
|
//#endregion
|
|
13
248
|
//#region src/utils/surql.ts
|
|
14
249
|
const surql = {
|
|
@@ -221,1718 +456,1852 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
221
456
|
}
|
|
222
457
|
|
|
223
458
|
//#endregion
|
|
224
|
-
//#region src/modules/
|
|
459
|
+
//#region src/modules/ref-tables.ts
|
|
225
460
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
232
|
-
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
233
|
-
* offset query that re-applies `START m` against the *shared* local store —
|
|
234
|
-
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
235
|
-
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
236
|
-
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
237
|
-
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
238
|
-
* stable display order.
|
|
239
|
-
*
|
|
240
|
-
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
241
|
-
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
242
|
-
*
|
|
243
|
-
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
244
|
-
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
245
|
-
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
246
|
-
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
247
|
-
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
248
|
-
* so this is no regression.
|
|
461
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
462
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
463
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
464
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
249
465
|
*/
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
function
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
};
|
|
277
|
-
let depth = 0;
|
|
278
|
-
let inStr = false;
|
|
279
|
-
for (let i = 0; i < sql.length; i++) {
|
|
280
|
-
const ch = sql[i];
|
|
281
|
-
if (inStr) {
|
|
282
|
-
if (ch === "\\") i++;
|
|
283
|
-
else if (ch === "'") inStr = false;
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
if (ch === "'") {
|
|
287
|
-
inStr = true;
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (ch === "(") {
|
|
291
|
-
depth++;
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
if (ch === ")") {
|
|
295
|
-
depth--;
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
if (depth !== 0) continue;
|
|
299
|
-
if (ch === ";" && out.semicolonIndex === null) {
|
|
300
|
-
out.semicolonIndex = i;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (!isWordBoundary(sql, i)) continue;
|
|
304
|
-
if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
|
|
305
|
-
out.fromIndex = i;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (out.fromIndex === null) continue;
|
|
309
|
-
if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
|
|
310
|
-
out.orderByIndex = i;
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
|
|
314
|
-
out.limitIndex = i;
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
if (out.startIndex === null && matchKeyword(sql, i, "START")) {
|
|
318
|
-
out.startIndex = i;
|
|
319
|
-
out.startValue = readNumberAfter(sql, i + 5);
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return out;
|
|
324
|
-
}
|
|
325
|
-
function isWordBoundary(sql, i) {
|
|
326
|
-
if (i === 0) return true;
|
|
327
|
-
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
466
|
+
const ANON_USER_ID = "anon";
|
|
467
|
+
/**
|
|
468
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
469
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
470
|
+
* box.
|
|
471
|
+
*/
|
|
472
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
473
|
+
/**
|
|
474
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
475
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
476
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
477
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
478
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
479
|
+
*
|
|
480
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
481
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
482
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
483
|
+
*/
|
|
484
|
+
function sanitizeUserId(userId) {
|
|
485
|
+
if (userId === null || userId === void 0) return null;
|
|
486
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
487
|
+
if (!asString) return null;
|
|
488
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
489
|
+
if (raw.length === 0) return null;
|
|
490
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
491
|
+
return raw;
|
|
328
492
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
495
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
496
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
497
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
498
|
+
*
|
|
499
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
500
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
501
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
502
|
+
*/
|
|
503
|
+
function bucketIdForUser(userId) {
|
|
504
|
+
if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
|
|
505
|
+
const uid = sanitizeUserId(userId);
|
|
506
|
+
if (uid) return uid;
|
|
507
|
+
return `u${cyrb53(String(userId)).toString(16)}`;
|
|
343
508
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
511
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
512
|
+
* single mode.
|
|
513
|
+
*/
|
|
514
|
+
function listRefTableFor(mode, userId) {
|
|
515
|
+
if (userId === ANON_USER_ID) return "_00_list_ref_anon";
|
|
516
|
+
if (mode === "single") return "_00_list_ref";
|
|
517
|
+
const uid = sanitizeUserId(userId);
|
|
518
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
349
519
|
}
|
|
350
520
|
|
|
351
521
|
//#endregion
|
|
352
|
-
//#region src/
|
|
353
|
-
/**
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
522
|
+
//#region src/services/database/local.ts
|
|
523
|
+
/** Thrown when a query carries an `epoch` from before a bucket switch. The
|
|
524
|
+
* caller's chain read from the previous user's store; its write must be
|
|
525
|
+
* dropped, not applied to the new bucket. */
|
|
526
|
+
var StaleEpochError = class extends Error {
|
|
527
|
+
constructor() {
|
|
528
|
+
super("Local store epoch changed (bucket switch); stale write dropped");
|
|
529
|
+
this.name = "StaleEpochError";
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
/** Store URL for a local bucket. One IndexedDB store per user (`anon` for
|
|
533
|
+
* signed-out) so cached rows never leak across accounts on a shared device. */
|
|
534
|
+
function bucketStoreUrl(bucketId) {
|
|
535
|
+
return `indxdb://${bucketStoreName(bucketId)}`;
|
|
357
536
|
}
|
|
358
|
-
/**
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
lastMs,
|
|
362
|
-
p50: null,
|
|
363
|
-
p90: null,
|
|
364
|
-
p99: null,
|
|
365
|
-
count: 0
|
|
366
|
-
};
|
|
367
|
-
const sorted = [...samples].sort((a, b) => a - b);
|
|
368
|
-
const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
369
|
-
return {
|
|
370
|
-
lastMs,
|
|
371
|
-
p50: pick(.5),
|
|
372
|
-
p90: pick(.9),
|
|
373
|
-
p99: pick(.99),
|
|
374
|
-
count: samples.length
|
|
375
|
-
};
|
|
537
|
+
/** The IndexedDB database name SurrealDB-WASM derives from the store URL. */
|
|
538
|
+
function bucketStoreName(bucketId) {
|
|
539
|
+
return `sp00ky-${bucketId}`;
|
|
376
540
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
541
|
+
function createLocalSurrealClient(logger) {
|
|
542
|
+
return new Surreal({
|
|
543
|
+
codecOptions: { valueDecodeVisitor(value) {
|
|
544
|
+
if (value instanceof RecordId) return encodeRecordId(value);
|
|
545
|
+
if (value instanceof DateTime) return value.toDate();
|
|
546
|
+
return value;
|
|
547
|
+
} },
|
|
548
|
+
engines: applyDiagnostics(createWasmWorkerEngines(), ({ key, type, phase, ...other }) => {
|
|
549
|
+
if (phase === "progress" || phase === "after") logger.trace({
|
|
550
|
+
...other,
|
|
551
|
+
key,
|
|
552
|
+
type,
|
|
553
|
+
phase,
|
|
554
|
+
service: "surrealdb:local",
|
|
555
|
+
Category: "sp00ky-client::LocalDatabaseService::diagnostics"
|
|
556
|
+
}, `Local SurrealDB diagnostics captured ${type}:${phase}`);
|
|
557
|
+
})
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
561
|
+
config;
|
|
562
|
+
eventType = DatabaseEventTypes.LocalQuery;
|
|
563
|
+
/** Bucket currently open. Set by `connect`/`switchStore`. */
|
|
564
|
+
bucketId = ANON_USER_ID;
|
|
398
565
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
566
|
+
* Monotonic store generation. Bumped on every `switchStore`. Async chains
|
|
567
|
+
* that read from the store, await something remote, and then write back
|
|
568
|
+
* (sync poll, SSP stream updates) capture this at chain start and drop
|
|
569
|
+
* their write when it no longer matches — a stale-epoch write would land
|
|
570
|
+
* another user's data in the new bucket.
|
|
405
571
|
*/
|
|
406
|
-
|
|
572
|
+
storeEpoch = 0;
|
|
573
|
+
/** Gate that `query()`/`execute()` await; closed for the switch window. */
|
|
574
|
+
gate = Promise.resolve();
|
|
575
|
+
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
576
|
+
pendingSwitchClient = null;
|
|
577
|
+
constructor(config, logger) {
|
|
578
|
+
const events = createDatabaseEventSystem();
|
|
579
|
+
super(createLocalSurrealClient(logger), logger, events);
|
|
580
|
+
this.config = config;
|
|
581
|
+
}
|
|
582
|
+
getConfig() {
|
|
583
|
+
return this.config;
|
|
584
|
+
}
|
|
585
|
+
get currentBucketId() {
|
|
586
|
+
return this.bucketId;
|
|
587
|
+
}
|
|
588
|
+
get epoch() {
|
|
589
|
+
return this.storeEpoch;
|
|
590
|
+
}
|
|
407
591
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
414
|
-
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
592
|
+
* Close the query gate for a bucket switch. Every `query()`/`execute()`
|
|
593
|
+
* issued after this waits until the returned release fn runs — so work
|
|
594
|
+
* triggered mid-switch (sibling auth subscribers registering queries)
|
|
595
|
+
* lands on the NEW bucket instead of racing the swap. The migrator uses
|
|
596
|
+
* `queryUngated()` to provision the new bucket while the gate is closed.
|
|
415
597
|
*/
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
this.schema = schema;
|
|
423
|
-
this.streamDebounceTime = streamDebounceTime;
|
|
424
|
-
this.logger = logger.child({ service: "DataModule" });
|
|
598
|
+
beginSwitch() {
|
|
599
|
+
let release;
|
|
600
|
+
this.gate = new Promise((resolve) => {
|
|
601
|
+
release = resolve;
|
|
602
|
+
});
|
|
603
|
+
return release;
|
|
425
604
|
}
|
|
426
|
-
async
|
|
427
|
-
this.
|
|
605
|
+
async query(query, vars, opts) {
|
|
606
|
+
await this.gate;
|
|
607
|
+
if (opts?.epoch !== void 0 && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
|
|
608
|
+
return super.query(query, vars);
|
|
609
|
+
}
|
|
610
|
+
async execute(query, vars, opts) {
|
|
611
|
+
const raw = await this.query(query.sql, vars, opts);
|
|
612
|
+
return query.extract(raw);
|
|
613
|
+
}
|
|
614
|
+
/** Gate-bypassing query — ONLY for the switch path itself (schema
|
|
615
|
+
* provisioning must run while the gate is closed, or it deadlocks). */
|
|
616
|
+
queryUngated(query, vars) {
|
|
617
|
+
return super.query(query, vars);
|
|
618
|
+
}
|
|
619
|
+
async connect(bucketId = ANON_USER_ID) {
|
|
620
|
+
const { namespace, database } = this.getConfig();
|
|
621
|
+
const store = this.getConfig().store ?? "memory";
|
|
622
|
+
this.bucketId = bucketId;
|
|
623
|
+
const storeUrl = store === "memory" ? "mem://" : bucketStoreUrl(bucketId);
|
|
428
624
|
this.logger.info({
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
625
|
+
namespace,
|
|
626
|
+
database,
|
|
627
|
+
storeUrl,
|
|
628
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
629
|
+
}, "Connecting to local database");
|
|
630
|
+
this.registerUnloadClose();
|
|
631
|
+
await this.openWithRecovery(this.client, storeUrl, namespace, database, bucketId, store);
|
|
432
632
|
}
|
|
433
633
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
634
|
+
* Switch the local store to another user's bucket. Opens the NEW bucket on a
|
|
635
|
+
* second client first (with the same 3-tier recovery), then atomically swaps
|
|
636
|
+
* `this.client` and closes the old one — a failed open never leaves the
|
|
637
|
+
* service on a dead client. Bumps the store epoch so in-flight old-bucket
|
|
638
|
+
* async chains can detect they're stale.
|
|
639
|
+
*
|
|
640
|
+
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
641
|
+
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
437
642
|
*/
|
|
438
|
-
|
|
439
|
-
this.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
return this.currentUserId;
|
|
455
|
-
}
|
|
456
|
-
/**
|
|
457
|
-
* Register a query and return its hash for subscriptions
|
|
458
|
-
*/
|
|
459
|
-
async query(tableName, surqlString, params, ttl) {
|
|
460
|
-
const hash = await this.calculateHash({
|
|
461
|
-
surql: surqlString,
|
|
462
|
-
params
|
|
463
|
-
});
|
|
464
|
-
this.logger.debug({
|
|
465
|
-
hash,
|
|
466
|
-
Category: "sp00ky-client::DataModule::query"
|
|
467
|
-
}, "Query Initialization: started");
|
|
468
|
-
const recordId = new RecordId("_00_query", hash);
|
|
469
|
-
if (this.activeQueries.has(hash)) {
|
|
470
|
-
this.logger.debug({
|
|
471
|
-
hash,
|
|
472
|
-
Category: "sp00ky-client::DataModule::query"
|
|
473
|
-
}, "Query Initialization: exists, returning");
|
|
474
|
-
return hash;
|
|
475
|
-
}
|
|
476
|
-
if (this.pendingQueries.has(hash)) {
|
|
477
|
-
this.logger.debug({
|
|
478
|
-
hash,
|
|
479
|
-
Category: "sp00ky-client::DataModule::query"
|
|
480
|
-
}, "Query Initialization: pending, waiting for existing creation");
|
|
481
|
-
await this.pendingQueries.get(hash);
|
|
482
|
-
return hash;
|
|
643
|
+
async switchStore(bucketId) {
|
|
644
|
+
if (bucketId === this.bucketId) return;
|
|
645
|
+
const { namespace, database } = this.getConfig();
|
|
646
|
+
const store = this.getConfig().store ?? "memory";
|
|
647
|
+
this.storeEpoch++;
|
|
648
|
+
if (store === "memory") {
|
|
649
|
+
try {
|
|
650
|
+
await this.client.close();
|
|
651
|
+
} catch {}
|
|
652
|
+
await this.openStore(this.client, "mem://", namespace, database);
|
|
653
|
+
this.bucketId = bucketId;
|
|
654
|
+
this.logger.info({
|
|
655
|
+
bucketId,
|
|
656
|
+
Category: "sp00ky-client::LocalDatabaseService::switchStore"
|
|
657
|
+
}, "Reset in-memory local store for bucket switch");
|
|
658
|
+
return;
|
|
483
659
|
}
|
|
484
|
-
this.logger
|
|
485
|
-
|
|
486
|
-
Category: "sp00ky-client::DataModule::query"
|
|
487
|
-
}, "Query Initialization: not found, creating new query");
|
|
488
|
-
const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
|
|
489
|
-
this.pendingQueries.set(hash, promise);
|
|
660
|
+
const next = createLocalSurrealClient(this.logger);
|
|
661
|
+
this.pendingSwitchClient = next;
|
|
490
662
|
try {
|
|
491
|
-
await
|
|
663
|
+
await this.openWithRecovery(next, bucketStoreUrl(bucketId), namespace, database, bucketId, store);
|
|
492
664
|
} finally {
|
|
493
|
-
this.
|
|
665
|
+
this.pendingSwitchClient = null;
|
|
494
666
|
}
|
|
495
|
-
|
|
667
|
+
const old = this.client;
|
|
668
|
+
this.client = next;
|
|
669
|
+
this.bucketId = bucketId;
|
|
670
|
+
try {
|
|
671
|
+
await old.close();
|
|
672
|
+
} catch {}
|
|
673
|
+
this.logger.info({
|
|
674
|
+
bucketId,
|
|
675
|
+
Category: "sp00ky-client::LocalDatabaseService::switchStore"
|
|
676
|
+
}, "Switched local store bucket");
|
|
496
677
|
}
|
|
497
678
|
/**
|
|
498
|
-
*
|
|
679
|
+
* Open `storeUrl` on `client` with tiered recovery:
|
|
680
|
+
* tier 1 retries the same store (transient idb-handle races — preserves the
|
|
681
|
+
* cache), tier 2 drops THIS bucket's IndexedDB store and reconnects fresh,
|
|
682
|
+
* tier 3 falls back to `mem://` for the session. Only ever drops the bucket
|
|
683
|
+
* being opened — other users' buckets hold their own caches AND un-pushed
|
|
684
|
+
* mutation outboxes, which must survive another bucket's corruption.
|
|
499
685
|
*/
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
686
|
+
async openWithRecovery(client, storeUrl, namespace, database, bucketId, store) {
|
|
687
|
+
try {
|
|
688
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
689
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
690
|
+
return;
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (store === "memory" || !isLocalStoreOpenError(err)) {
|
|
693
|
+
this.logger.error({
|
|
694
|
+
err,
|
|
695
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
696
|
+
}, "Failed to connect to local database");
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
699
|
+
this.logger.warn({
|
|
700
|
+
err,
|
|
701
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
702
|
+
}, "Local IndexedDB store failed to open; retrying before clearing");
|
|
506
703
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
704
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
705
|
+
try {
|
|
706
|
+
await client.close();
|
|
707
|
+
} catch {}
|
|
708
|
+
await delay(150 * attempt);
|
|
709
|
+
try {
|
|
710
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
711
|
+
this.logger.info({
|
|
712
|
+
attempt,
|
|
713
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
714
|
+
}, "Connected to local database on retry (cache preserved)");
|
|
715
|
+
return;
|
|
716
|
+
} catch (retryErr) {
|
|
717
|
+
this.logger.warn({
|
|
718
|
+
err: retryErr,
|
|
719
|
+
attempt,
|
|
720
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
721
|
+
}, "Local store retry failed");
|
|
512
722
|
}
|
|
513
|
-
}
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
await client.close();
|
|
726
|
+
} catch {}
|
|
727
|
+
await dropLocalIndexedDbStores(this.logger, bucketStoreName(bucketId));
|
|
728
|
+
try {
|
|
729
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
730
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
|
|
731
|
+
} catch (retryErr) {
|
|
732
|
+
this.logger.error({
|
|
733
|
+
err: retryErr,
|
|
734
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
735
|
+
}, "Local store still failing after clear; falling back to in-memory");
|
|
736
|
+
try {
|
|
737
|
+
await client.close();
|
|
738
|
+
} catch {}
|
|
739
|
+
await this.openStore(client, "mem://", namespace, database);
|
|
740
|
+
this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
|
|
741
|
+
}
|
|
514
742
|
}
|
|
743
|
+
unloadCloseRegistered = false;
|
|
515
744
|
/**
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
745
|
+
* Close the local DB on page unload so the SurrealDB-WASM worker releases its
|
|
746
|
+
* IndexedDB connection cleanly. Without this, the previous page's connection
|
|
747
|
+
* lingers; the next load's `client.connect` opens the store but the first
|
|
748
|
+
* write transaction in `client.use` hits an "IndexedDB error" — which then
|
|
749
|
+
* (mis)triggered the corrupt-store recovery and WIPED the cache on every
|
|
750
|
+
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
751
|
+
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
752
|
+
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
753
|
+
* Also closes a mid-switch incoming client so its fresh handle doesn't linger.
|
|
519
754
|
*/
|
|
520
|
-
|
|
521
|
-
if (
|
|
522
|
-
this.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
755
|
+
registerUnloadClose() {
|
|
756
|
+
if (this.unloadCloseRegistered || typeof window === "undefined") return;
|
|
757
|
+
this.unloadCloseRegistered = true;
|
|
758
|
+
const close = () => {
|
|
759
|
+
try {
|
|
760
|
+
this.client.close();
|
|
761
|
+
} catch {}
|
|
762
|
+
try {
|
|
763
|
+
this.pendingSwitchClient?.close();
|
|
764
|
+
} catch {}
|
|
530
765
|
};
|
|
766
|
+
window.addEventListener("pagehide", close);
|
|
767
|
+
window.addEventListener("beforeunload", close);
|
|
531
768
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
onMutation(callback) {
|
|
549
|
-
this.mutationCallbacks.add(callback);
|
|
550
|
-
return () => {
|
|
551
|
-
this.mutationCallbacks.delete(callback);
|
|
552
|
-
};
|
|
769
|
+
async openStore(client, storeUrl, namespace, database) {
|
|
770
|
+
this.logger.debug({
|
|
771
|
+
storeUrl,
|
|
772
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
773
|
+
}, "[LocalDatabaseService] Calling client.connect");
|
|
774
|
+
await client.connect(storeUrl, {});
|
|
775
|
+
this.logger.debug({
|
|
776
|
+
namespace,
|
|
777
|
+
database,
|
|
778
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
779
|
+
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
780
|
+
await client.use({
|
|
781
|
+
namespace,
|
|
782
|
+
database
|
|
783
|
+
});
|
|
784
|
+
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
553
785
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
786
|
+
};
|
|
787
|
+
function delay(ms) {
|
|
788
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
789
|
+
}
|
|
790
|
+
/** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
|
|
791
|
+
* store can't be opened (corrupt / version-incompatible / blocked). Exported
|
|
792
|
+
* for unit testing the error-message match. */
|
|
793
|
+
function isLocalStoreOpenError(err) {
|
|
794
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
795
|
+
return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
|
|
796
|
+
}
|
|
797
|
+
/** Best-effort delete of ONE bucket's IndexedDB store(s). SurrealDB-WASM backs
|
|
798
|
+
* `indxdb://<name>` with one or more IndexedDB databases whose names include
|
|
799
|
+
* `<name>`. Scoped to the given store name — never wipe by the bare `sp00ky`
|
|
800
|
+
* substring, that would take every user's bucket (and their un-pushed mutation
|
|
801
|
+
* outboxes) down with one corrupt store. Resolves even on error/blocked so
|
|
802
|
+
* startup can proceed. No-op outside a browser. Exported for unit tests. */
|
|
803
|
+
async function dropLocalIndexedDbStores(logger, storeName) {
|
|
804
|
+
if (typeof indexedDB === "undefined") return;
|
|
805
|
+
try {
|
|
806
|
+
let names = [];
|
|
807
|
+
if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && matchesBucketStore(n, storeName));
|
|
808
|
+
if (names.length === 0) names = [storeName];
|
|
809
|
+
await Promise.all(names.map(deleteIndexedDb));
|
|
810
|
+
logger.info({
|
|
811
|
+
names,
|
|
812
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
813
|
+
}, "Cleared local IndexedDB store(s)");
|
|
814
|
+
} catch (e) {
|
|
815
|
+
logger.warn({
|
|
816
|
+
err: e,
|
|
817
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
818
|
+
}, "Failed to enumerate/clear IndexedDB; proceeding anyway");
|
|
574
819
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
820
|
+
}
|
|
821
|
+
/** True when idb database `name` belongs to the bucket store `storeName` —
|
|
822
|
+
* exact match or a derived name (`<storeName>`, `<storeName>-*`, `*<storeName>*`
|
|
823
|
+
* with a non-alphanumeric boundary so `sp00ky-abc` never matches
|
|
824
|
+
* `sp00ky-abcdef`'s store). Exported for unit tests. */
|
|
825
|
+
function matchesBucketStore(name, storeName) {
|
|
826
|
+
const lower = name.toLowerCase();
|
|
827
|
+
const target = storeName.toLowerCase();
|
|
828
|
+
const idx = lower.indexOf(target);
|
|
829
|
+
if (idx === -1) return false;
|
|
830
|
+
const after = lower[idx + target.length];
|
|
831
|
+
return after === void 0 || !/[a-z0-9_]/.test(after);
|
|
832
|
+
}
|
|
833
|
+
function deleteIndexedDb(name) {
|
|
834
|
+
return new Promise((resolve) => {
|
|
835
|
+
try {
|
|
836
|
+
const req = indexedDB.deleteDatabase(name);
|
|
837
|
+
req.onsuccess = () => resolve();
|
|
838
|
+
req.onerror = () => resolve();
|
|
839
|
+
req.onblocked = () => resolve();
|
|
840
|
+
} catch {
|
|
841
|
+
resolve();
|
|
589
842
|
}
|
|
590
|
-
|
|
591
|
-
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/services/database/remote.ts
|
|
848
|
+
var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
849
|
+
config;
|
|
850
|
+
eventType = DatabaseEventTypes.RemoteQuery;
|
|
851
|
+
constructor(config, logger) {
|
|
852
|
+
const events = createDatabaseEventSystem();
|
|
853
|
+
super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
|
|
854
|
+
if (phase === "progress" || phase === "after") logger.trace({
|
|
855
|
+
...other,
|
|
856
|
+
key,
|
|
857
|
+
type,
|
|
858
|
+
phase,
|
|
859
|
+
service: "surrealdb:remote",
|
|
860
|
+
Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
|
|
861
|
+
}, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
|
|
862
|
+
}) }), logger, events);
|
|
863
|
+
this.config = config;
|
|
592
864
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
|
|
606
|
-
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
607
|
-
}
|
|
608
|
-
if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
|
|
609
|
-
if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
|
|
610
|
-
if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
|
|
611
|
-
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
612
|
-
try {
|
|
613
|
-
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
614
|
-
queryState.config.localArray = localArray;
|
|
615
|
-
const prevJson = JSON.stringify(queryState.records);
|
|
616
|
-
const newJson = JSON.stringify(newRecords);
|
|
617
|
-
queryState.records = newRecords;
|
|
618
|
-
const recordsChanged = prevJson !== newJson;
|
|
619
|
-
if (recordsChanged) queryState.updateCount++;
|
|
620
|
-
await this.local.query(surql.seal(surql.updateSet("id", [
|
|
621
|
-
"localArray",
|
|
622
|
-
"rowCount",
|
|
623
|
-
"updateCount",
|
|
624
|
-
"lastIngestLatency",
|
|
625
|
-
"materializationP55",
|
|
626
|
-
"materializationP90",
|
|
627
|
-
"materializationP99"
|
|
628
|
-
])), {
|
|
629
|
-
id: queryState.config.id,
|
|
630
|
-
localArray,
|
|
631
|
-
rowCount: localArray.length,
|
|
632
|
-
updateCount: queryState.updateCount,
|
|
633
|
-
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
634
|
-
materializationP55: percentiles.p55,
|
|
635
|
-
materializationP90: percentiles.p90,
|
|
636
|
-
materializationP99: percentiles.p99
|
|
637
|
-
});
|
|
638
|
-
if (!recordsChanged) {
|
|
639
|
-
this.logger.debug({
|
|
640
|
-
queryHash,
|
|
641
|
-
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
642
|
-
}, "Query records unchanged, skipping notification");
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const subscribers = this.subscriptions.get(queryHash);
|
|
646
|
-
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
647
|
-
this.logger.debug({
|
|
648
|
-
queryHash,
|
|
649
|
-
recordCount: newRecords?.length,
|
|
650
|
-
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
651
|
-
}, "Query updated from stream");
|
|
652
|
-
} catch (err) {
|
|
653
|
-
queryState.errorCount++;
|
|
654
|
-
this.logger.error({
|
|
655
|
-
err,
|
|
656
|
-
queryHash,
|
|
657
|
-
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
658
|
-
}, "Failed to fetch records for stream update");
|
|
865
|
+
getConfig() {
|
|
866
|
+
return this.config;
|
|
867
|
+
}
|
|
868
|
+
async connect() {
|
|
869
|
+
const { endpoint, token, namespace, database } = this.getConfig();
|
|
870
|
+
if (endpoint) {
|
|
871
|
+
this.logger.info({
|
|
872
|
+
endpoint,
|
|
873
|
+
namespace,
|
|
874
|
+
database,
|
|
875
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
876
|
+
}, "Connecting to remote database");
|
|
659
877
|
try {
|
|
660
|
-
await this.
|
|
661
|
-
|
|
662
|
-
|
|
878
|
+
await this.client.connect(endpoint);
|
|
879
|
+
await this.client.use({
|
|
880
|
+
namespace,
|
|
881
|
+
database
|
|
663
882
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
883
|
+
if (token) {
|
|
884
|
+
this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
|
|
885
|
+
await this.client.authenticate(token);
|
|
886
|
+
}
|
|
887
|
+
this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
|
|
888
|
+
} catch (err) {
|
|
889
|
+
this.logger.error({
|
|
890
|
+
err,
|
|
891
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
892
|
+
}, "Failed to connect to remote database");
|
|
893
|
+
throw err;
|
|
670
894
|
}
|
|
671
|
-
}
|
|
895
|
+
} else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
|
|
672
896
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
676
|
-
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
677
|
-
*/
|
|
678
|
-
computeMaterializationPercentiles(samples) {
|
|
679
|
-
if (samples.length === 0) return {
|
|
680
|
-
p55: null,
|
|
681
|
-
p90: null,
|
|
682
|
-
p99: null
|
|
683
|
-
};
|
|
684
|
-
const sorted = [...samples].sort((a, b) => a - b);
|
|
685
|
-
const pick = (q) => {
|
|
686
|
-
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
687
|
-
};
|
|
688
|
-
return {
|
|
689
|
-
p55: pick(.55),
|
|
690
|
-
p90: pick(.9),
|
|
691
|
-
p99: pick(.99)
|
|
692
|
-
};
|
|
897
|
+
async signin(params) {
|
|
898
|
+
return this.client.signin(params);
|
|
693
899
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (!Number.isFinite(ms)) return;
|
|
697
|
-
pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
|
|
698
|
-
qs.phaseLast[phase] = ms;
|
|
900
|
+
async signup(params) {
|
|
901
|
+
return this.client.signup(params);
|
|
699
902
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const qs = this.activeQueries.get(hash);
|
|
703
|
-
if (qs) this.recordPhase(qs, "remoteFetch", ms);
|
|
903
|
+
async authenticate(token) {
|
|
904
|
+
return this.client.authenticate(token);
|
|
704
905
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
708
|
-
*/
|
|
709
|
-
recordFrontendTiming(hash, ms) {
|
|
710
|
-
const qs = this.activeQueries.get(hash);
|
|
711
|
-
if (qs) this.recordPhase(qs, "frontend", ms);
|
|
906
|
+
async invalidate() {
|
|
907
|
+
return this.client.invalidate();
|
|
712
908
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region src/services/logger/index.ts
|
|
913
|
+
function createLogger(level = "info", transmit) {
|
|
914
|
+
const browserConfig = {
|
|
915
|
+
asObject: true,
|
|
916
|
+
write: (o) => {
|
|
917
|
+
console.log(JSON.stringify(o));
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
if (transmit) browserConfig.transmit = transmit;
|
|
921
|
+
return pino({
|
|
922
|
+
level,
|
|
923
|
+
browser: browserConfig
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/services/database/local-migrator.ts
|
|
929
|
+
const sha1 = async (str) => {
|
|
930
|
+
const enc = new TextEncoder();
|
|
931
|
+
const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
|
|
932
|
+
return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
933
|
+
};
|
|
934
|
+
var LocalMigrator = class {
|
|
935
|
+
logger;
|
|
936
|
+
constructor(localDb, logger) {
|
|
937
|
+
this.localDb = localDb;
|
|
938
|
+
this.logger = logger.child({ service: "LocalMigrator" });
|
|
939
|
+
logger?.child({ service: "LocalMigrator" }) ?? createLogger("info").child({ service: "LocalMigrator" });
|
|
732
940
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
941
|
+
async provision(schemaSurql) {
|
|
942
|
+
const hash = await sha1(schemaSurql);
|
|
943
|
+
const { database } = this.localDb.getConfig();
|
|
944
|
+
if (await this.isSchemaUpToDate(hash)) {
|
|
945
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
await this.recreateDatabase(database);
|
|
949
|
+
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
950
|
+
const statements = this.splitStatements(fullSchema);
|
|
951
|
+
for (let i = 0; i < statements.length; i++) {
|
|
952
|
+
const statement = statements[i];
|
|
953
|
+
const cleanStatement = statement.replace(/--.*/g, "").trim();
|
|
954
|
+
if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
|
|
955
|
+
this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
|
|
960
|
+
await this.localDb.queryUngated(statement);
|
|
961
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
|
|
962
|
+
} catch (e) {
|
|
963
|
+
this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
|
|
964
|
+
throw e;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
await this.createHashRecord(hash);
|
|
738
968
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
*/
|
|
747
|
-
isCold(hash) {
|
|
748
|
-
const qs = this.activeQueries.get(hash);
|
|
749
|
-
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
969
|
+
async isSchemaUpToDate(hash) {
|
|
970
|
+
try {
|
|
971
|
+
const [lastSchemaRecord] = await this.localDb.queryUngated(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
|
|
972
|
+
return lastSchemaRecord?.hash === hash;
|
|
973
|
+
} catch (_error) {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
750
976
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
977
|
+
async recreateDatabase(database) {
|
|
978
|
+
try {
|
|
979
|
+
await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
|
|
980
|
+
} catch (_e) {}
|
|
981
|
+
try {
|
|
982
|
+
await this.localDb.queryUngated(`
|
|
983
|
+
USE DB _00_temp;
|
|
984
|
+
REMOVE DATABASE ${database};
|
|
985
|
+
`);
|
|
986
|
+
} catch (_e) {}
|
|
987
|
+
await this.localDb.queryUngated(`
|
|
988
|
+
DEFINE DATABASE ${database};
|
|
989
|
+
USE DB ${database};
|
|
990
|
+
`);
|
|
991
|
+
}
|
|
992
|
+
splitStatements(schema) {
|
|
993
|
+
const statements = [];
|
|
994
|
+
let current = "";
|
|
995
|
+
let depth = 0;
|
|
996
|
+
let inQuote = false;
|
|
997
|
+
let quoteChar = "";
|
|
998
|
+
let inComment = false;
|
|
999
|
+
for (let i = 0; i < schema.length; i++) {
|
|
1000
|
+
const char = schema[i];
|
|
1001
|
+
const nextChar = schema[i + 1];
|
|
1002
|
+
if (inComment) {
|
|
1003
|
+
current += char;
|
|
1004
|
+
if (char === "\n") inComment = false;
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (!inQuote && char === "-" && nextChar === "-") {
|
|
1008
|
+
inComment = true;
|
|
1009
|
+
current += char;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (inQuote) {
|
|
1013
|
+
current += char;
|
|
1014
|
+
if (char === quoteChar && schema[i - 1] !== "\\") inQuote = false;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (char === "\"" || char === "'") {
|
|
1018
|
+
inQuote = true;
|
|
1019
|
+
quoteChar = char;
|
|
1020
|
+
current += char;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (char === "{") {
|
|
1024
|
+
depth++;
|
|
1025
|
+
current += char;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (char === "}") {
|
|
1029
|
+
depth--;
|
|
1030
|
+
current += char;
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (char === ";" && depth === 0) {
|
|
1034
|
+
if (current.trim().length > 0) statements.push(current.trim());
|
|
1035
|
+
current = "";
|
|
1036
|
+
continue;
|
|
779
1037
|
}
|
|
1038
|
+
current += char;
|
|
780
1039
|
}
|
|
1040
|
+
if (current.trim().length > 0) statements.push(current.trim());
|
|
1041
|
+
return statements;
|
|
781
1042
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
785
|
-
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
786
|
-
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
787
|
-
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
788
|
-
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
789
|
-
*/
|
|
790
|
-
async applyHydration(hash, rows) {
|
|
791
|
-
const queryState = this.activeQueries.get(hash);
|
|
792
|
-
if (!queryState) return;
|
|
793
|
-
queryState.hydrated = true;
|
|
794
|
-
if (rows.length === 0) return;
|
|
795
|
-
const tableName = queryState.config.tableName;
|
|
796
|
-
const batch = rows.map((record) => ({
|
|
797
|
-
table: tableName,
|
|
798
|
-
op: "CREATE",
|
|
799
|
-
record,
|
|
800
|
-
version: record._00_rv || 1
|
|
801
|
-
}));
|
|
802
|
-
const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
|
|
803
|
-
for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
|
|
804
|
-
await this.cache.saveBatch(batch);
|
|
805
|
-
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
806
|
-
queryState.records = await this.materializeRecords(queryState);
|
|
807
|
-
const subscribers = this.subscriptions.get(hash);
|
|
808
|
-
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
809
|
-
}
|
|
810
|
-
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
811
|
-
hasSubscribers(hash) {
|
|
812
|
-
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
1043
|
+
async createHashRecord(hash) {
|
|
1044
|
+
await this.localDb.queryUngated(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
|
|
813
1045
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/modules/data/window-query.ts
|
|
1050
|
+
/**
|
|
1051
|
+
* Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
|
|
1052
|
+
* materialized from an explicit record-id set — the window the SSP already
|
|
1053
|
+
* computed — instead of re-running the original query (with its `START m`)
|
|
1054
|
+
* against the shared local DB.
|
|
1055
|
+
*
|
|
1056
|
+
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
1057
|
+
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
1058
|
+
* offset query that re-applies `START m` against the *shared* local store —
|
|
1059
|
+
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
1060
|
+
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
1061
|
+
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
1062
|
+
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
1063
|
+
* stable display order.
|
|
1064
|
+
*
|
|
1065
|
+
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
1066
|
+
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
1067
|
+
*
|
|
1068
|
+
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
1069
|
+
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
1070
|
+
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
1071
|
+
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
1072
|
+
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
1073
|
+
* so this is no regression.
|
|
1074
|
+
*/
|
|
1075
|
+
function buildWindowMaterialization(surql, idsParam = "__win") {
|
|
1076
|
+
const kw = scanTopLevelClauses(surql);
|
|
1077
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
1078
|
+
if (kw.fromIndex === null) return null;
|
|
1079
|
+
const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
|
|
1080
|
+
let orderBy = "";
|
|
1081
|
+
if (kw.orderByIndex !== null) {
|
|
1082
|
+
const ends = [
|
|
1083
|
+
kw.limitIndex,
|
|
1084
|
+
kw.startIndex,
|
|
1085
|
+
kw.semicolonIndex,
|
|
1086
|
+
surql.length
|
|
1087
|
+
].filter((n) => n !== null && n > kw.orderByIndex);
|
|
1088
|
+
const end = Math.min(...ends);
|
|
1089
|
+
orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
|
|
829
1090
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1091
|
+
return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
|
|
1092
|
+
}
|
|
1093
|
+
function scanTopLevelClauses(sql) {
|
|
1094
|
+
const out = {
|
|
1095
|
+
fromIndex: null,
|
|
1096
|
+
orderByIndex: null,
|
|
1097
|
+
limitIndex: null,
|
|
1098
|
+
startIndex: null,
|
|
1099
|
+
startValue: null,
|
|
1100
|
+
semicolonIndex: null
|
|
1101
|
+
};
|
|
1102
|
+
let depth = 0;
|
|
1103
|
+
let inStr = false;
|
|
1104
|
+
for (let i = 0; i < sql.length; i++) {
|
|
1105
|
+
const ch = sql[i];
|
|
1106
|
+
if (inStr) {
|
|
1107
|
+
if (ch === "\\") i++;
|
|
1108
|
+
else if (ch === "'") inStr = false;
|
|
1109
|
+
continue;
|
|
840
1110
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
this.debounceTimers.delete(hash);
|
|
1111
|
+
if (ch === "'") {
|
|
1112
|
+
inStr = true;
|
|
1113
|
+
continue;
|
|
845
1114
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1115
|
+
if (ch === "(") {
|
|
1116
|
+
depth++;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (ch === ")") {
|
|
1120
|
+
depth--;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (depth !== 0) continue;
|
|
1124
|
+
if (ch === ";" && out.semicolonIndex === null) {
|
|
1125
|
+
out.semicolonIndex = i;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
1129
|
+
if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
|
|
1130
|
+
out.fromIndex = i;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (out.fromIndex === null) continue;
|
|
1134
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
|
|
1135
|
+
out.orderByIndex = i;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
|
|
1139
|
+
out.limitIndex = i;
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (out.startIndex === null && matchKeyword(sql, i, "START")) {
|
|
1143
|
+
out.startIndex = i;
|
|
1144
|
+
out.startValue = readNumberAfter(sql, i + 5);
|
|
1145
|
+
continue;
|
|
873
1146
|
}
|
|
874
|
-
queryState.config.localArray = localArray;
|
|
875
|
-
await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
|
|
876
|
-
id: queryState.config.id,
|
|
877
|
-
localArray
|
|
878
|
-
});
|
|
879
1147
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
function isWordBoundary(sql, i) {
|
|
1151
|
+
if (i === 0) return true;
|
|
1152
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
1153
|
+
}
|
|
1154
|
+
function matchKeyword(sql, i, keyword) {
|
|
1155
|
+
const parts = keyword.split(" ");
|
|
1156
|
+
let pos = i;
|
|
1157
|
+
for (let p = 0; p < parts.length; p++) {
|
|
1158
|
+
const word = parts[p];
|
|
1159
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
1160
|
+
pos += word.length;
|
|
1161
|
+
if (p < parts.length - 1) {
|
|
1162
|
+
const wsStart = pos;
|
|
1163
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
1164
|
+
if (pos === wsStart) return false;
|
|
888
1165
|
}
|
|
889
|
-
queryState.config.remoteArray = remoteArray;
|
|
890
|
-
await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
|
|
891
|
-
id: queryState.config.id,
|
|
892
|
-
remoteArray
|
|
893
|
-
});
|
|
894
1166
|
}
|
|
1167
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
1168
|
+
}
|
|
1169
|
+
function readNumberAfter(sql, from) {
|
|
1170
|
+
let pos = from;
|
|
1171
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
1172
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
1173
|
+
return m ? parseInt(m[0], 10) : null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/modules/data/index.ts
|
|
1178
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
1179
|
+
function pushSample(samples, ms) {
|
|
1180
|
+
samples.push(ms);
|
|
1181
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
1182
|
+
}
|
|
1183
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
1184
|
+
function phaseStatOf(samples, lastMs) {
|
|
1185
|
+
if (samples.length === 0) return {
|
|
1186
|
+
lastMs,
|
|
1187
|
+
p50: null,
|
|
1188
|
+
p90: null,
|
|
1189
|
+
p99: null,
|
|
1190
|
+
count: 0
|
|
1191
|
+
};
|
|
1192
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
1193
|
+
const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
1194
|
+
return {
|
|
1195
|
+
lastMs,
|
|
1196
|
+
p50: pick(.5),
|
|
1197
|
+
p90: pick(.9),
|
|
1198
|
+
p99: pick(.99),
|
|
1199
|
+
count: samples.length
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* DataModule - Unified query and mutation management
|
|
1204
|
+
*
|
|
1205
|
+
* Merges the functionality of QueryManager and MutationManager.
|
|
1206
|
+
* Uses CacheModule for all storage operations.
|
|
1207
|
+
*/
|
|
1208
|
+
var DataModule = class {
|
|
1209
|
+
activeQueries = /* @__PURE__ */ new Map();
|
|
1210
|
+
pendingQueries = /* @__PURE__ */ new Map();
|
|
1211
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1212
|
+
statusSubscriptions = /* @__PURE__ */ new Map();
|
|
1213
|
+
mutationCallbacks = /* @__PURE__ */ new Set();
|
|
1214
|
+
debounceTimers = /* @__PURE__ */ new Map();
|
|
1215
|
+
pendingStreamUpdates = /* @__PURE__ */ new Map();
|
|
1216
|
+
fetchDepth = /* @__PURE__ */ new Map();
|
|
1217
|
+
logger;
|
|
895
1218
|
/**
|
|
896
|
-
*
|
|
897
|
-
*
|
|
1219
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
1220
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
1221
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
1222
|
+
* constructed after DataModule.
|
|
898
1223
|
*/
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1224
|
+
onQueryStatusChange;
|
|
1225
|
+
/**
|
|
1226
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
1227
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
1228
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
1229
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
1230
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
1231
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
1232
|
+
*/
|
|
1233
|
+
onHeartbeat;
|
|
1234
|
+
/**
|
|
1235
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
1236
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
1237
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
1238
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
1239
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
1240
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
1241
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
1242
|
+
*/
|
|
1243
|
+
onDeregister;
|
|
1244
|
+
sessionId = "";
|
|
1245
|
+
currentUserId = null;
|
|
1246
|
+
constructor(cache, local, schema, logger, streamDebounceTime = 50) {
|
|
1247
|
+
this.cache = cache;
|
|
1248
|
+
this.local = local;
|
|
1249
|
+
this.schema = schema;
|
|
1250
|
+
this.streamDebounceTime = streamDebounceTime;
|
|
1251
|
+
this.logger = logger.child({ service: "DataModule" });
|
|
910
1252
|
}
|
|
911
|
-
async
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
for (const argName of Object.keys(route.args)) {
|
|
918
|
-
const arg = route.args[argName];
|
|
919
|
-
if (data[argName] === void 0 && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
|
|
920
|
-
payload[argName] = data[argName];
|
|
921
|
-
}
|
|
922
|
-
const record = {
|
|
923
|
-
path,
|
|
924
|
-
payload: JSON.stringify(payload),
|
|
925
|
-
max_retries: options?.max_retries ?? 3,
|
|
926
|
-
retry_strategy: options?.retry_strategy ?? "linear"
|
|
927
|
-
};
|
|
928
|
-
if (options?.timeout != null) record.timeout = options.timeout;
|
|
929
|
-
if (options?.delay != null) record.delay = options.delay;
|
|
930
|
-
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
931
|
-
const recordId = `${tableName}:${generateId()}`;
|
|
932
|
-
await this.create(recordId, record);
|
|
1253
|
+
async init(sessionId) {
|
|
1254
|
+
this.sessionId = sessionId;
|
|
1255
|
+
this.logger.info({
|
|
1256
|
+
sessionId,
|
|
1257
|
+
Category: "sp00ky-client::DataModule::init"
|
|
1258
|
+
}, "DataModule initialized");
|
|
933
1259
|
}
|
|
934
1260
|
/**
|
|
935
|
-
*
|
|
1261
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
1262
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
1263
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
936
1264
|
*/
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
940
|
-
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
941
|
-
const rid = parseRecordIdString(id);
|
|
942
|
-
const params = parseParams(tableSchema.columns, data);
|
|
943
|
-
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
944
|
-
const dataKeys = Object.keys(params).map((key) => ({
|
|
945
|
-
key,
|
|
946
|
-
variable: `data_${key}`
|
|
947
|
-
}));
|
|
948
|
-
const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, params[key]]));
|
|
949
|
-
const query = surql.seal(surql.tx([surql.createSet("id", dataKeys), surql.createMutation("create", "mid", "id", "data")]), { resultIndex: 0 });
|
|
950
|
-
const target = await withRetry(this.logger, () => this.local.execute(query, {
|
|
951
|
-
id: rid,
|
|
952
|
-
mid: mutationId,
|
|
953
|
-
...prefixedParams
|
|
954
|
-
}));
|
|
955
|
-
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
956
|
-
await this.cache.save({
|
|
957
|
-
table: tableName,
|
|
958
|
-
op: "CREATE",
|
|
959
|
-
record: parsedRecord,
|
|
960
|
-
version: 1
|
|
961
|
-
}, true);
|
|
962
|
-
const mutationEvent = {
|
|
963
|
-
type: "create",
|
|
964
|
-
mutation_id: mutationId,
|
|
965
|
-
record_id: rid,
|
|
966
|
-
data: params,
|
|
967
|
-
record: target,
|
|
968
|
-
tableName
|
|
969
|
-
};
|
|
970
|
-
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
971
|
-
this.logger.debug({
|
|
972
|
-
id,
|
|
973
|
-
Category: "sp00ky-client::DataModule::create"
|
|
974
|
-
}, "Record created");
|
|
975
|
-
return target;
|
|
1265
|
+
setSessionId(sessionId) {
|
|
1266
|
+
this.sessionId = sessionId;
|
|
976
1267
|
}
|
|
977
1268
|
/**
|
|
978
|
-
* Update
|
|
1269
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
1270
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
1271
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
1272
|
+
* SSP writes to.
|
|
979
1273
|
*/
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const query = surql.seal(surql.tx([
|
|
989
|
-
surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
|
|
990
|
-
surql.let("updated", surql.updateMerge("id", "data")),
|
|
991
|
-
surql.createMutation("update", "mid", "id", "data"),
|
|
992
|
-
surql.returnObject([{
|
|
993
|
-
key: "target",
|
|
994
|
-
variable: "updated"
|
|
995
|
-
}])
|
|
996
|
-
]));
|
|
997
|
-
const { target } = await withRetry(this.logger, () => this.local.execute(query, {
|
|
998
|
-
id: rid,
|
|
999
|
-
mid: mutationId,
|
|
1000
|
-
data: params
|
|
1001
|
-
}));
|
|
1002
|
-
const updatedFields = { id: target.id };
|
|
1003
|
-
for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
|
|
1004
|
-
if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
|
|
1005
|
-
this.replaceRecordInQueries(updatedFields);
|
|
1006
|
-
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
1007
|
-
await this.cache.save({
|
|
1008
|
-
table,
|
|
1009
|
-
op: "UPDATE",
|
|
1010
|
-
record: parsedRecord,
|
|
1011
|
-
version: target._00_rv
|
|
1012
|
-
}, true);
|
|
1013
|
-
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
1014
|
-
const mutationEvent = {
|
|
1015
|
-
type: "update",
|
|
1016
|
-
mutation_id: mutationId,
|
|
1017
|
-
record_id: rid,
|
|
1018
|
-
data: params,
|
|
1019
|
-
record: target,
|
|
1020
|
-
beforeRecord: beforeRecord || void 0,
|
|
1021
|
-
options: pushEventOptions
|
|
1022
|
-
};
|
|
1023
|
-
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
1024
|
-
this.logger.debug({
|
|
1025
|
-
id,
|
|
1026
|
-
Category: "sp00ky-client::DataModule::update"
|
|
1027
|
-
}, "Record updated");
|
|
1028
|
-
return target;
|
|
1274
|
+
setCurrentUserId(userId) {
|
|
1275
|
+
this.currentUserId = userId;
|
|
1276
|
+
}
|
|
1277
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
1278
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
1279
|
+
* same table name DataModule does. */
|
|
1280
|
+
getCurrentUserId() {
|
|
1281
|
+
return this.currentUserId;
|
|
1029
1282
|
}
|
|
1030
1283
|
/**
|
|
1031
|
-
*
|
|
1284
|
+
* Register a query and return its hash for subscriptions
|
|
1032
1285
|
*/
|
|
1033
|
-
async
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
err,
|
|
1050
|
-
id,
|
|
1051
|
-
Category: "sp00ky-client::DataModule::delete"
|
|
1052
|
-
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
1286
|
+
async query(tableName, surqlString, params, ttl) {
|
|
1287
|
+
const hash = await this.calculateHash({
|
|
1288
|
+
surql: surqlString,
|
|
1289
|
+
params
|
|
1290
|
+
});
|
|
1291
|
+
this.logger.debug({
|
|
1292
|
+
hash,
|
|
1293
|
+
Category: "sp00ky-client::DataModule::query"
|
|
1294
|
+
}, "Query Initialization: started");
|
|
1295
|
+
const recordId = new RecordId("_00_query", hash);
|
|
1296
|
+
if (this.activeQueries.has(hash)) {
|
|
1297
|
+
this.logger.debug({
|
|
1298
|
+
hash,
|
|
1299
|
+
Category: "sp00ky-client::DataModule::query"
|
|
1300
|
+
}, "Query Initialization: exists, returning");
|
|
1301
|
+
return hash;
|
|
1053
1302
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
}, "notifyQuerySynced failed after delete");
|
|
1303
|
+
if (this.pendingQueries.has(hash)) {
|
|
1304
|
+
this.logger.debug({
|
|
1305
|
+
hash,
|
|
1306
|
+
Category: "sp00ky-client::DataModule::query"
|
|
1307
|
+
}, "Query Initialization: pending, waiting for existing creation");
|
|
1308
|
+
await this.pendingQueries.get(hash);
|
|
1309
|
+
return hash;
|
|
1062
1310
|
}
|
|
1063
|
-
const mutationEvent = {
|
|
1064
|
-
type: "delete",
|
|
1065
|
-
mutation_id: mutationId,
|
|
1066
|
-
record_id: rid
|
|
1067
|
-
};
|
|
1068
|
-
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
1069
1311
|
this.logger.debug({
|
|
1070
|
-
|
|
1071
|
-
Category: "sp00ky-client::DataModule::
|
|
1072
|
-
}, "
|
|
1312
|
+
hash,
|
|
1313
|
+
Category: "sp00ky-client::DataModule::query"
|
|
1314
|
+
}, "Query Initialization: not found, creating new query");
|
|
1315
|
+
const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
|
|
1316
|
+
this.pendingQueries.set(hash, promise);
|
|
1317
|
+
try {
|
|
1318
|
+
await promise;
|
|
1319
|
+
} finally {
|
|
1320
|
+
this.pendingQueries.delete(hash);
|
|
1321
|
+
}
|
|
1322
|
+
return hash;
|
|
1073
1323
|
}
|
|
1074
1324
|
/**
|
|
1075
|
-
*
|
|
1325
|
+
* Subscribe to query updates
|
|
1076
1326
|
*/
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
this.logger.info({
|
|
1084
|
-
id,
|
|
1085
|
-
tableName,
|
|
1086
|
-
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
1087
|
-
}, "Rolled back optimistic create");
|
|
1088
|
-
} catch (err) {
|
|
1089
|
-
this.logger.error({
|
|
1090
|
-
err,
|
|
1091
|
-
id,
|
|
1092
|
-
tableName,
|
|
1093
|
-
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
1094
|
-
}, "Failed to rollback create");
|
|
1327
|
+
subscribe(queryHash, callback, options = {}) {
|
|
1328
|
+
if (!this.subscriptions.has(queryHash)) this.subscriptions.set(queryHash, /* @__PURE__ */ new Set());
|
|
1329
|
+
this.subscriptions.get(queryHash)?.add(callback);
|
|
1330
|
+
if (options.immediate) {
|
|
1331
|
+
const query = this.activeQueries.get(queryHash);
|
|
1332
|
+
if (query) callback(query.records);
|
|
1095
1333
|
}
|
|
1334
|
+
return () => {
|
|
1335
|
+
const subs = this.subscriptions.get(queryHash);
|
|
1336
|
+
if (subs) {
|
|
1337
|
+
subs.delete(callback);
|
|
1338
|
+
if (subs.size === 0) this.subscriptions.delete(queryHash);
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1096
1341
|
}
|
|
1097
1342
|
/**
|
|
1098
|
-
*
|
|
1343
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
1344
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
1345
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
1099
1346
|
*/
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1347
|
+
subscribeStatus(queryHash, callback, options = {}) {
|
|
1348
|
+
if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
|
|
1349
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
1350
|
+
if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
|
|
1351
|
+
return () => {
|
|
1352
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
1353
|
+
if (subs) {
|
|
1354
|
+
subs.delete(callback);
|
|
1355
|
+
if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
1361
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
1362
|
+
* query is unknown.
|
|
1363
|
+
*/
|
|
1364
|
+
setQueryStatus(queryHash, status) {
|
|
1365
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
1366
|
+
if (!queryState || queryState.status === status) return;
|
|
1367
|
+
queryState.status = status;
|
|
1368
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
1369
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
1370
|
+
if (subs) for (const callback of subs) callback(status);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Enter a fetch cycle for a query. Refcounted: registration and concurrent
|
|
1374
|
+
* poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
|
|
1375
|
+
* cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
|
|
1376
|
+
* emits `idle`. Always pair with `endFetching` in a `finally`.
|
|
1377
|
+
*/
|
|
1378
|
+
beginFetching(queryHash) {
|
|
1379
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
1380
|
+
this.fetchDepth.set(queryHash, depth + 1);
|
|
1381
|
+
if (depth === 0) this.setQueryStatus(queryHash, "fetching");
|
|
1382
|
+
}
|
|
1383
|
+
/** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
|
|
1384
|
+
endFetching(queryHash) {
|
|
1385
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
1386
|
+
if (depth <= 1) {
|
|
1387
|
+
this.fetchDepth.delete(queryHash);
|
|
1388
|
+
this.setQueryStatus(queryHash, "idle");
|
|
1389
|
+
return;
|
|
1129
1390
|
}
|
|
1391
|
+
this.fetchDepth.set(queryHash, depth - 1);
|
|
1130
1392
|
}
|
|
1131
1393
|
/**
|
|
1132
|
-
*
|
|
1394
|
+
* Subscribe to mutations (for sync)
|
|
1133
1395
|
*/
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1396
|
+
onMutation(callback) {
|
|
1397
|
+
this.mutationCallbacks.add(callback);
|
|
1398
|
+
return () => {
|
|
1399
|
+
this.mutationCallbacks.delete(callback);
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Handle stream updates from DBSP (via CacheModule)
|
|
1404
|
+
*/
|
|
1405
|
+
async onStreamUpdate(update) {
|
|
1406
|
+
const { queryHash, op } = update;
|
|
1407
|
+
if (op === "DELETE") {
|
|
1408
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
1409
|
+
if (existing) {
|
|
1410
|
+
clearTimeout(existing);
|
|
1411
|
+
this.debounceTimers.delete(queryHash);
|
|
1144
1412
|
}
|
|
1413
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
1414
|
+
await this.processStreamUpdate(update);
|
|
1415
|
+
return;
|
|
1145
1416
|
}
|
|
1417
|
+
if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
|
|
1418
|
+
this.pendingStreamUpdates.set(queryHash, update);
|
|
1419
|
+
const timer = setTimeout(async () => {
|
|
1420
|
+
this.debounceTimers.delete(queryHash);
|
|
1421
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
1422
|
+
await this.processStreamUpdate(update);
|
|
1423
|
+
}, this.streamDebounceTime);
|
|
1424
|
+
this.debounceTimers.set(queryHash, timer);
|
|
1146
1425
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1426
|
+
/**
|
|
1427
|
+
* Process a query's pending (debounced) stream update NOW instead of on the
|
|
1428
|
+
* trailing edge. Called by the sync engine before it flips a query back to
|
|
1429
|
+
* `idle`, so the status change never races ahead of the rows it fetched.
|
|
1430
|
+
* No-op when nothing is pending. The pending entry is removed before the
|
|
1431
|
+
* await so a concurrently-firing timer can't process it twice.
|
|
1432
|
+
*/
|
|
1433
|
+
async flushPendingStreamUpdate(queryHash) {
|
|
1434
|
+
const timer = this.debounceTimers.get(queryHash);
|
|
1435
|
+
if (timer) {
|
|
1436
|
+
clearTimeout(timer);
|
|
1437
|
+
this.debounceTimers.delete(queryHash);
|
|
1438
|
+
}
|
|
1439
|
+
const pending = this.pendingStreamUpdates.get(queryHash);
|
|
1440
|
+
if (!pending) return;
|
|
1441
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
1442
|
+
await this.processStreamUpdate(pending);
|
|
1443
|
+
}
|
|
1444
|
+
async materializeRecords(queryState, sspArray) {
|
|
1155
1445
|
const t0 = performance.now();
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
});
|
|
1163
|
-
const registrationTime = performance.now() - t0;
|
|
1164
|
-
queryState.registrationTimings = {
|
|
1165
|
-
parseMs: registrationTimings?.parseMs ?? null,
|
|
1166
|
-
planMs: registrationTimings?.planMs ?? null,
|
|
1167
|
-
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1168
|
-
wallMs: registrationTime
|
|
1169
|
-
};
|
|
1170
|
-
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
|
|
1171
|
-
"localArray",
|
|
1172
|
-
"registrationTime",
|
|
1173
|
-
"rowCount"
|
|
1174
|
-
])), {
|
|
1175
|
-
id: recordId,
|
|
1176
|
-
localArray,
|
|
1177
|
-
registrationTime,
|
|
1178
|
-
rowCount: localArray.length
|
|
1179
|
-
}));
|
|
1180
|
-
const windowMat = buildWindowMaterialization(surqlString);
|
|
1181
|
-
if (windowMat && localArray.length > 0) try {
|
|
1182
|
-
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1183
|
-
const [seeded] = await this.local.query(windowMat.query, {
|
|
1184
|
-
...params,
|
|
1446
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
1447
|
+
let records;
|
|
1448
|
+
if (windowMat) {
|
|
1449
|
+
const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
|
|
1450
|
+
const [rows] = await this.local.query(windowMat.query, {
|
|
1451
|
+
...queryState.config.params,
|
|
1185
1452
|
__win: winIds
|
|
1186
1453
|
});
|
|
1187
|
-
|
|
1188
|
-
}
|
|
1189
|
-
this.
|
|
1190
|
-
|
|
1191
|
-
hash,
|
|
1192
|
-
Category: "sp00ky-client::DataModule::createAndRegisterQuery"
|
|
1193
|
-
}, "Failed to seed windowed initial records from localArray");
|
|
1454
|
+
records = rows || [];
|
|
1455
|
+
} else {
|
|
1456
|
+
const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
|
|
1457
|
+
records = rows || [];
|
|
1194
1458
|
}
|
|
1195
|
-
this.
|
|
1196
|
-
|
|
1197
|
-
this.logger.debug({
|
|
1198
|
-
hash,
|
|
1199
|
-
tableName,
|
|
1200
|
-
recordCount: queryState.records.length,
|
|
1201
|
-
Category: "sp00ky-client::DataModule::query"
|
|
1202
|
-
}, "Query registered");
|
|
1203
|
-
return hash;
|
|
1459
|
+
this.recordPhase(queryState, "localFetch", performance.now() - t0);
|
|
1460
|
+
return records;
|
|
1204
1461
|
}
|
|
1205
|
-
async
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
if (!configRecord) {
|
|
1210
|
-
const [createdRecord] = await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
|
|
1211
|
-
id: recordId,
|
|
1212
|
-
data: {
|
|
1213
|
-
surql: surqlString,
|
|
1214
|
-
params,
|
|
1215
|
-
localArray: [],
|
|
1216
|
-
remoteArray: [],
|
|
1217
|
-
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
1218
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1219
|
-
ttl,
|
|
1220
|
-
tableName,
|
|
1221
|
-
updateCount: 0,
|
|
1222
|
-
rowCount: 0,
|
|
1223
|
-
errorCount: 0
|
|
1224
|
-
}
|
|
1225
|
-
}));
|
|
1226
|
-
configRecord = createdRecord;
|
|
1227
|
-
}
|
|
1228
|
-
const config = {
|
|
1229
|
-
...configRecord,
|
|
1230
|
-
id: recordId,
|
|
1231
|
-
params: parseParams(tableSchema.columns, configRecord.params)
|
|
1232
|
-
};
|
|
1233
|
-
let records = [];
|
|
1234
|
-
if (buildWindowMaterialization(surqlString) === null) try {
|
|
1235
|
-
const [result] = await this.local.query(surqlString, params);
|
|
1236
|
-
records = result || [];
|
|
1237
|
-
} catch (err) {
|
|
1462
|
+
async processStreamUpdate(update) {
|
|
1463
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
1464
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
1465
|
+
if (!queryState) {
|
|
1238
1466
|
this.logger.warn({
|
|
1239
|
-
|
|
1240
|
-
Category: "sp00ky-client::DataModule::
|
|
1241
|
-
}, "
|
|
1467
|
+
queryHash,
|
|
1468
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1469
|
+
}, "Received update for unknown query. Skipping...");
|
|
1470
|
+
return;
|
|
1242
1471
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1472
|
+
if (typeof materializationTimeMs === "number") {
|
|
1473
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
1474
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
|
|
1475
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
1476
|
+
}
|
|
1477
|
+
if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
|
|
1478
|
+
if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
|
|
1479
|
+
if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
|
|
1480
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
1481
|
+
const epoch = this.local.epoch;
|
|
1482
|
+
try {
|
|
1483
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
1484
|
+
if (epoch !== this.local.epoch) return;
|
|
1485
|
+
queryState.config.localArray = localArray;
|
|
1486
|
+
const prevJson = JSON.stringify(queryState.records);
|
|
1487
|
+
const newJson = JSON.stringify(newRecords);
|
|
1488
|
+
queryState.records = newRecords;
|
|
1489
|
+
const recordsChanged = prevJson !== newJson;
|
|
1490
|
+
if (recordsChanged) {
|
|
1491
|
+
queryState.updateCount++;
|
|
1492
|
+
queryState.lastUpdatedAt = Date.now();
|
|
1262
1493
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1494
|
+
await this.local.query(surql.seal(surql.updateSet("id", [
|
|
1495
|
+
"localArray",
|
|
1496
|
+
"rowCount",
|
|
1497
|
+
"updateCount",
|
|
1498
|
+
"lastIngestLatency",
|
|
1499
|
+
"materializationP55",
|
|
1500
|
+
"materializationP90",
|
|
1501
|
+
"materializationP99"
|
|
1502
|
+
])), {
|
|
1503
|
+
id: queryState.config.id,
|
|
1504
|
+
localArray,
|
|
1505
|
+
rowCount: localArray.length,
|
|
1506
|
+
updateCount: queryState.updateCount,
|
|
1507
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
1508
|
+
materializationP55: percentiles.p55,
|
|
1509
|
+
materializationP90: percentiles.p90,
|
|
1510
|
+
materializationP99: percentiles.p99
|
|
1511
|
+
}, { epoch });
|
|
1512
|
+
if (!recordsChanged) {
|
|
1280
1513
|
this.logger.debug({
|
|
1281
|
-
|
|
1282
|
-
Category: "sp00ky-client::DataModule::
|
|
1283
|
-
}, "
|
|
1514
|
+
queryHash,
|
|
1515
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1516
|
+
}, "Query records unchanged, skipping notification");
|
|
1284
1517
|
return;
|
|
1285
1518
|
}
|
|
1286
|
-
this.
|
|
1519
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
1520
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1287
1521
|
this.logger.debug({
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
Category: "sp00ky-client::DataModule::
|
|
1291
|
-
}, "
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1522
|
+
queryHash,
|
|
1523
|
+
recordCount: newRecords?.length,
|
|
1524
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1525
|
+
}, "Query updated from stream");
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
if (err instanceof StaleEpochError) {
|
|
1528
|
+
this.logger.debug({
|
|
1529
|
+
queryHash,
|
|
1530
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1531
|
+
}, "Dropped stream update from before a bucket switch");
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
queryState.errorCount++;
|
|
1535
|
+
this.logger.error({
|
|
1536
|
+
err,
|
|
1537
|
+
queryHash,
|
|
1538
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1539
|
+
}, "Failed to fetch records for stream update");
|
|
1540
|
+
try {
|
|
1541
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
|
|
1542
|
+
id: queryState.config.id,
|
|
1543
|
+
errorCount: queryState.errorCount
|
|
1544
|
+
});
|
|
1545
|
+
} catch (persistErr) {
|
|
1546
|
+
this.logger.warn({
|
|
1547
|
+
err: persistErr,
|
|
1548
|
+
queryHash,
|
|
1549
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
1550
|
+
}, "Failed to persist incremented errorCount");
|
|
1305
1551
|
}
|
|
1306
1552
|
}
|
|
1307
1553
|
}
|
|
1308
|
-
};
|
|
1309
|
-
/**
|
|
1310
|
-
* Parse update options to generate push event options
|
|
1311
|
-
*/
|
|
1312
|
-
function parseUpdateOptions(id, data, options) {
|
|
1313
|
-
let pushEventOptions = {};
|
|
1314
|
-
if (options?.debounced) pushEventOptions = { debounced: {
|
|
1315
|
-
delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
|
|
1316
|
-
key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
|
|
1317
|
-
} };
|
|
1318
|
-
return pushEventOptions;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
//#endregion
|
|
1322
|
-
//#region src/services/database/database.ts
|
|
1323
|
-
var AbstractDatabaseService = class {
|
|
1324
|
-
client;
|
|
1325
|
-
logger;
|
|
1326
|
-
events;
|
|
1327
|
-
constructor(client, logger, events) {
|
|
1328
|
-
this.client = client;
|
|
1329
|
-
this.logger = logger.child({ service: "Database" });
|
|
1330
|
-
this.events = events;
|
|
1331
|
-
}
|
|
1332
|
-
getClient() {
|
|
1333
|
-
return this.client;
|
|
1334
|
-
}
|
|
1335
|
-
getEvents() {
|
|
1336
|
-
return this.events;
|
|
1337
|
-
}
|
|
1338
|
-
tx() {
|
|
1339
|
-
return this.client.beginTransaction();
|
|
1340
|
-
}
|
|
1341
|
-
queryQueue = Promise.resolve();
|
|
1342
1554
|
/**
|
|
1343
|
-
*
|
|
1555
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
1556
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
1557
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
1344
1558
|
*/
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
duration,
|
|
1361
|
-
success: true,
|
|
1362
|
-
timestamp: Date.now()
|
|
1363
|
-
});
|
|
1364
|
-
resolve(result);
|
|
1365
|
-
this.logger.trace({
|
|
1366
|
-
query,
|
|
1367
|
-
result,
|
|
1368
|
-
Category: "sp00ky-client::Database::query"
|
|
1369
|
-
}, "Query executed successfully");
|
|
1370
|
-
} catch (err) {
|
|
1371
|
-
const duration = performance.now() - startTime;
|
|
1372
|
-
this.events.emit(this.eventType, {
|
|
1373
|
-
query,
|
|
1374
|
-
vars,
|
|
1375
|
-
duration,
|
|
1376
|
-
success: false,
|
|
1377
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1378
|
-
timestamp: Date.now()
|
|
1379
|
-
});
|
|
1380
|
-
this.logger.error({
|
|
1381
|
-
query,
|
|
1382
|
-
vars,
|
|
1383
|
-
err,
|
|
1384
|
-
Category: "sp00ky-client::Database::query"
|
|
1385
|
-
}, "Query execution failed");
|
|
1386
|
-
reject(err);
|
|
1387
|
-
}
|
|
1388
|
-
}).catch(() => {});
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
async execute(query, vars) {
|
|
1392
|
-
const raw = await this.query(query.sql, vars);
|
|
1393
|
-
return query.extract(raw);
|
|
1394
|
-
}
|
|
1395
|
-
async close() {
|
|
1396
|
-
this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
|
|
1397
|
-
await this.client.close();
|
|
1559
|
+
computeMaterializationPercentiles(samples) {
|
|
1560
|
+
if (samples.length === 0) return {
|
|
1561
|
+
p55: null,
|
|
1562
|
+
p90: null,
|
|
1563
|
+
p99: null
|
|
1564
|
+
};
|
|
1565
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
1566
|
+
const pick = (q) => {
|
|
1567
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
1568
|
+
};
|
|
1569
|
+
return {
|
|
1570
|
+
p55: pick(.55),
|
|
1571
|
+
p90: pick(.9),
|
|
1572
|
+
p99: pick(.99)
|
|
1573
|
+
};
|
|
1398
1574
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
* A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
|
|
1405
|
-
* @template E The EventTypeMap defining all supported events.
|
|
1406
|
-
*/
|
|
1407
|
-
var EventSystem = class {
|
|
1408
|
-
subscriberId = 0;
|
|
1409
|
-
isProcessing = false;
|
|
1410
|
-
buffer;
|
|
1411
|
-
subscribers;
|
|
1412
|
-
subscribersTypeMap;
|
|
1413
|
-
lastEvents;
|
|
1414
|
-
debouncedEvents;
|
|
1415
|
-
constructor(_eventTypes) {
|
|
1416
|
-
this._eventTypes = _eventTypes;
|
|
1417
|
-
this.buffer = [];
|
|
1418
|
-
this.subscribers = this._eventTypes.reduce((acc, key) => {
|
|
1419
|
-
return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
|
|
1420
|
-
}, {});
|
|
1421
|
-
this.lastEvents = {};
|
|
1422
|
-
this.subscribersTypeMap = /* @__PURE__ */ new Map();
|
|
1423
|
-
this.debouncedEvents = /* @__PURE__ */ new Map();
|
|
1575
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
1576
|
+
recordPhase(qs, phase, ms) {
|
|
1577
|
+
if (!Number.isFinite(ms)) return;
|
|
1578
|
+
pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
|
|
1579
|
+
qs.phaseLast[phase] = ms;
|
|
1424
1580
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1581
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
1582
|
+
recordRemoteFetch(hash, ms) {
|
|
1583
|
+
const qs = this.activeQueries.get(hash);
|
|
1584
|
+
if (qs) this.recordPhase(qs, "remoteFetch", ms);
|
|
1427
1585
|
}
|
|
1428
1586
|
/**
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
* @param handler The function to call when the event occurs.
|
|
1432
|
-
* @param options Subscription options (once, immediately).
|
|
1433
|
-
* @returns A subscription ID that can be used to unsubscribe.
|
|
1587
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
1588
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
1434
1589
|
*/
|
|
1435
|
-
|
|
1436
|
-
const
|
|
1437
|
-
this.
|
|
1438
|
-
id,
|
|
1439
|
-
handler,
|
|
1440
|
-
once: options?.once ?? false
|
|
1441
|
-
});
|
|
1442
|
-
this.subscribersTypeMap.set(id, type);
|
|
1443
|
-
if (options?.immediately) {
|
|
1444
|
-
const lastEvent = this.lastEvents[type];
|
|
1445
|
-
if (lastEvent) handler(lastEvent);
|
|
1446
|
-
}
|
|
1447
|
-
return id;
|
|
1590
|
+
recordFrontendTiming(hash, ms) {
|
|
1591
|
+
const qs = this.activeQueries.get(hash);
|
|
1592
|
+
if (qs) this.recordPhase(qs, "frontend", ms);
|
|
1448
1593
|
}
|
|
1449
1594
|
/**
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
* @param options Subscription options.
|
|
1454
|
-
* @returns An array of subscription IDs.
|
|
1595
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
1596
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
1597
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
1455
1598
|
*/
|
|
1456
|
-
|
|
1457
|
-
|
|
1599
|
+
phaseTimings(q) {
|
|
1600
|
+
const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
1601
|
+
return {
|
|
1602
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
1603
|
+
sspStoreApply: stat("sspStoreApply"),
|
|
1604
|
+
sspCircuitStep: stat("sspCircuitStep"),
|
|
1605
|
+
sspTransform: stat("sspTransform"),
|
|
1606
|
+
localFetch: stat("localFetch"),
|
|
1607
|
+
remoteFetch: stat("remoteFetch"),
|
|
1608
|
+
frontend: stat("frontend"),
|
|
1609
|
+
registration: q.registrationTimings,
|
|
1610
|
+
updateCount: q.updateCount,
|
|
1611
|
+
errorCount: q.errorCount
|
|
1612
|
+
};
|
|
1458
1613
|
}
|
|
1459
1614
|
/**
|
|
1460
|
-
*
|
|
1461
|
-
* @param id The subscription ID returned by subscribe().
|
|
1462
|
-
* @returns True if the subscription was found and removed, false otherwise.
|
|
1615
|
+
* Get query state (for sync and devtools)
|
|
1463
1616
|
*/
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
if (type) {
|
|
1467
|
-
this.subscribers[type].delete(id);
|
|
1468
|
-
this.subscribersTypeMap.delete(id);
|
|
1469
|
-
return true;
|
|
1470
|
-
}
|
|
1471
|
-
return false;
|
|
1617
|
+
getQueryByHash(hash) {
|
|
1618
|
+
return this.activeQueries.get(hash);
|
|
1472
1619
|
}
|
|
1473
1620
|
/**
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1621
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
1622
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
1623
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
1624
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
1625
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
1626
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
1477
1627
|
*/
|
|
1478
|
-
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
payload
|
|
1482
|
-
};
|
|
1483
|
-
this.addEvent(event);
|
|
1628
|
+
isCold(hash) {
|
|
1629
|
+
const qs = this.activeQueries.get(hash);
|
|
1630
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
1484
1631
|
}
|
|
1485
1632
|
/**
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1633
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
1634
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
1635
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
1636
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
1637
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
1638
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
1639
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
1640
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
1641
|
+
* `seen` dedupes within the batch.
|
|
1491
1642
|
*/
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1643
|
+
collectEmbeddedChildren(record, batch, seen) {
|
|
1644
|
+
const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
|
|
1645
|
+
for (const value of Object.values(record)) {
|
|
1646
|
+
const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
|
|
1647
|
+
for (const child of children) {
|
|
1648
|
+
const key = encodeRecordId(child.id);
|
|
1649
|
+
if (seen.has(key)) continue;
|
|
1650
|
+
seen.add(key);
|
|
1651
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
1652
|
+
const table = child.id.table.toString();
|
|
1653
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
1654
|
+
batch.push({
|
|
1655
|
+
table,
|
|
1656
|
+
op: "CREATE",
|
|
1657
|
+
record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
|
|
1658
|
+
version: child._00_rv || 1
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1496
1661
|
}
|
|
1497
|
-
this.buffer.push(event);
|
|
1498
|
-
this.scheduleProcessing();
|
|
1499
1662
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1663
|
+
/**
|
|
1664
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
1665
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
1666
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
1667
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
1668
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
1669
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
1670
|
+
*/
|
|
1671
|
+
async applyHydration(hash, rows) {
|
|
1672
|
+
const queryState = this.activeQueries.get(hash);
|
|
1673
|
+
if (!queryState) return;
|
|
1674
|
+
queryState.hydrated = true;
|
|
1675
|
+
if (rows.length === 0) return;
|
|
1676
|
+
const tableName = queryState.config.tableName;
|
|
1677
|
+
const batch = rows.map((record) => ({
|
|
1678
|
+
table: tableName,
|
|
1679
|
+
op: "CREATE",
|
|
1680
|
+
record,
|
|
1681
|
+
version: record._00_rv || 1
|
|
1682
|
+
}));
|
|
1683
|
+
const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
|
|
1684
|
+
for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
|
|
1685
|
+
await this.cache.saveBatch(batch);
|
|
1686
|
+
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
1687
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
1688
|
+
const subscribers = this.subscriptions.get(hash);
|
|
1689
|
+
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
1511
1690
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
async processEvents() {
|
|
1516
|
-
if (this.isProcessing) return;
|
|
1517
|
-
this.isProcessing = true;
|
|
1518
|
-
try {
|
|
1519
|
-
while (this.dequeue());
|
|
1520
|
-
} finally {
|
|
1521
|
-
this.isProcessing = false;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
dequeue() {
|
|
1525
|
-
const event = this.buffer.shift();
|
|
1526
|
-
if (!event) return false;
|
|
1527
|
-
this.setLastEvent(event.type, event);
|
|
1528
|
-
this.broadcastEvent(event.type, event);
|
|
1529
|
-
return true;
|
|
1691
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
1692
|
+
hasSubscribers(hash) {
|
|
1693
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
1530
1694
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1695
|
+
/**
|
|
1696
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
1697
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
1698
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
1699
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
1700
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
1701
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
1702
|
+
*
|
|
1703
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
1704
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
1705
|
+
*/
|
|
1706
|
+
deregisterQuery(hash) {
|
|
1707
|
+
if (this.hasSubscribers(hash)) return;
|
|
1708
|
+
if (!this.activeQueries.has(hash)) return;
|
|
1709
|
+
this.onDeregister?.(hash);
|
|
1533
1710
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1711
|
+
/**
|
|
1712
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
1713
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
1714
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
1715
|
+
*/
|
|
1716
|
+
finalizeDeregister(hash) {
|
|
1717
|
+
const qs = this.activeQueries.get(hash);
|
|
1718
|
+
if (qs?.ttlTimer) {
|
|
1719
|
+
clearTimeout(qs.ttlTimer);
|
|
1720
|
+
qs.ttlTimer = null;
|
|
1721
|
+
}
|
|
1722
|
+
const debounce = this.debounceTimers.get(hash);
|
|
1723
|
+
if (debounce) {
|
|
1724
|
+
clearTimeout(debounce);
|
|
1725
|
+
this.debounceTimers.delete(hash);
|
|
1539
1726
|
}
|
|
1727
|
+
this.pendingStreamUpdates.delete(hash);
|
|
1728
|
+
this.fetchDepth.delete(hash);
|
|
1729
|
+
this.cache.unregisterQuery(hash);
|
|
1730
|
+
this.activeQueries.delete(hash);
|
|
1731
|
+
this.subscriptions.delete(hash);
|
|
1540
1732
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
//#endregion
|
|
1547
|
-
//#region src/services/database/events/index.ts
|
|
1548
|
-
const DatabaseEventTypes = {
|
|
1549
|
-
LocalQuery: "DATABASE_LOCAL_QUERY",
|
|
1550
|
-
RemoteQuery: "DATABASE_REMOTE_QUERY"
|
|
1551
|
-
};
|
|
1552
|
-
function createDatabaseEventSystem() {
|
|
1553
|
-
return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
//#endregion
|
|
1557
|
-
//#region src/services/database/local.ts
|
|
1558
|
-
var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
1559
|
-
config;
|
|
1560
|
-
eventType = DatabaseEventTypes.LocalQuery;
|
|
1561
|
-
constructor(config, logger) {
|
|
1562
|
-
const events = createDatabaseEventSystem();
|
|
1563
|
-
super(new Surreal({
|
|
1564
|
-
codecOptions: { valueDecodeVisitor(value) {
|
|
1565
|
-
if (value instanceof RecordId) return encodeRecordId(value);
|
|
1566
|
-
if (value instanceof DateTime) return value.toDate();
|
|
1567
|
-
return value;
|
|
1568
|
-
} },
|
|
1569
|
-
engines: applyDiagnostics(createWasmWorkerEngines(), ({ key, type, phase, ...other }) => {
|
|
1570
|
-
if (phase === "progress" || phase === "after") logger.trace({
|
|
1571
|
-
...other,
|
|
1572
|
-
key,
|
|
1573
|
-
type,
|
|
1574
|
-
phase,
|
|
1575
|
-
service: "surrealdb:local",
|
|
1576
|
-
Category: "sp00ky-client::LocalDatabaseService::diagnostics"
|
|
1577
|
-
}, `Local SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1578
|
-
})
|
|
1579
|
-
}), logger, events);
|
|
1580
|
-
this.config = config;
|
|
1733
|
+
/**
|
|
1734
|
+
* Get query state by id (for sync and devtools)
|
|
1735
|
+
*/
|
|
1736
|
+
getQueryById(id) {
|
|
1737
|
+
return this.activeQueries.get(extractIdPart(id));
|
|
1581
1738
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1739
|
+
/**
|
|
1740
|
+
* Get all active queries (for devtools)
|
|
1741
|
+
*/
|
|
1742
|
+
getActiveQueries() {
|
|
1743
|
+
return Array.from(this.activeQueries.values());
|
|
1584
1744
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
this.
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
this.registerUnloadClose();
|
|
1596
|
-
try {
|
|
1597
|
-
await this.openStore(storeUrl, namespace, database);
|
|
1598
|
-
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
1745
|
+
getActiveQueryHashes() {
|
|
1746
|
+
return Array.from(this.activeQueries.keys());
|
|
1747
|
+
}
|
|
1748
|
+
async updateQueryLocalArray(id, localArray) {
|
|
1749
|
+
const queryState = this.activeQueries.get(id);
|
|
1750
|
+
if (!queryState) {
|
|
1751
|
+
this.logger.warn({
|
|
1752
|
+
id,
|
|
1753
|
+
Category: "sp00ky-client::DataModule::updateQueryLocalArray"
|
|
1754
|
+
}, "Query to update local array not found");
|
|
1599
1755
|
return;
|
|
1756
|
+
}
|
|
1757
|
+
const epoch = this.local.epoch;
|
|
1758
|
+
queryState.config.localArray = localArray;
|
|
1759
|
+
try {
|
|
1760
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
|
|
1761
|
+
id: queryState.config.id,
|
|
1762
|
+
localArray
|
|
1763
|
+
}, { epoch });
|
|
1600
1764
|
} catch (err) {
|
|
1601
|
-
if (
|
|
1602
|
-
|
|
1603
|
-
err,
|
|
1604
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1605
|
-
}, "Failed to connect to local database");
|
|
1606
|
-
throw err;
|
|
1607
|
-
}
|
|
1608
|
-
this.logger.warn({
|
|
1609
|
-
err,
|
|
1610
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1611
|
-
}, "Local IndexedDB store failed to open; retrying before clearing");
|
|
1765
|
+
if (err instanceof StaleEpochError) return;
|
|
1766
|
+
throw err;
|
|
1612
1767
|
}
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1623
|
-
}, "Connected to local database on retry (cache preserved)");
|
|
1624
|
-
return;
|
|
1625
|
-
} catch (retryErr) {
|
|
1626
|
-
this.logger.warn({
|
|
1627
|
-
err: retryErr,
|
|
1628
|
-
attempt,
|
|
1629
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1630
|
-
}, "Local store retry failed");
|
|
1631
|
-
}
|
|
1768
|
+
}
|
|
1769
|
+
async updateQueryRemoteArray(hash, remoteArray) {
|
|
1770
|
+
const queryState = this.getQueryByHash(hash);
|
|
1771
|
+
if (!queryState) {
|
|
1772
|
+
this.logger.warn({
|
|
1773
|
+
hash,
|
|
1774
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
1775
|
+
}, "Query to update remote array not found");
|
|
1776
|
+
return;
|
|
1632
1777
|
}
|
|
1778
|
+
const epoch = this.local.epoch;
|
|
1779
|
+
queryState.config.remoteArray = remoteArray;
|
|
1633
1780
|
try {
|
|
1634
|
-
await this.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
this.logger.error({
|
|
1642
|
-
err: retryErr,
|
|
1643
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1644
|
-
}, "Local store still failing after clear; falling back to in-memory");
|
|
1645
|
-
try {
|
|
1646
|
-
await this.client.close();
|
|
1647
|
-
} catch {}
|
|
1648
|
-
await this.openStore("mem://", namespace, database);
|
|
1649
|
-
this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
|
|
1781
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
|
|
1782
|
+
id: queryState.config.id,
|
|
1783
|
+
remoteArray
|
|
1784
|
+
}, { epoch });
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
if (err instanceof StaleEpochError) return;
|
|
1787
|
+
throw err;
|
|
1650
1788
|
}
|
|
1651
1789
|
}
|
|
1652
|
-
unloadCloseRegistered = false;
|
|
1653
1790
|
/**
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
* (mis)triggered the corrupt-store recovery and WIPED the cache on every
|
|
1659
|
-
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
1660
|
-
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
1661
|
-
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
1791
|
+
* Cancel every armed timer ahead of a local-bucket switch: stream-update
|
|
1792
|
+
* debounce timers (their pending updates carry the OLD bucket's id-sets) and
|
|
1793
|
+
* per-query TTL heartbeats (they'd refresh the previous user's remote
|
|
1794
|
+
* `_00_query` rows under the new session). The rebind re-arms heartbeats.
|
|
1662
1795
|
*/
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
this.
|
|
1666
|
-
|
|
1796
|
+
quiesce() {
|
|
1797
|
+
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
|
|
1798
|
+
this.debounceTimers.clear();
|
|
1799
|
+
this.pendingStreamUpdates.clear();
|
|
1800
|
+
this.fetchDepth.clear();
|
|
1801
|
+
for (const queryState of this.activeQueries.values()) if (queryState.ttlTimer) {
|
|
1802
|
+
clearTimeout(queryState.ttlTimer);
|
|
1803
|
+
queryState.ttlTimer = null;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Re-home every active query in a freshly-opened bucket, KEEPING its hash —
|
|
1808
|
+
* `useQuery` subscriptions are keyed by hash and don't re-register on auth
|
|
1809
|
+
* changes, so the hooks must stay attached. Per query:
|
|
1810
|
+
* 1. reset the sync arrays + hydration flag and drop the previous user's
|
|
1811
|
+
* records, notifying subscribers with the new-bucket materialization
|
|
1812
|
+
* (usually empty) so their rows leave the UI immediately;
|
|
1813
|
+
* 2. recreate the `_00_query` row in the new bucket;
|
|
1814
|
+
* 3. re-register the SSP view on the (fresh, post-reset) processor — this
|
|
1815
|
+
* also rebinds the view to the NEW `$auth` context;
|
|
1816
|
+
* 4. restart the TTL heartbeat.
|
|
1817
|
+
* Returns the hashes so the caller can enqueue remote re-registration, which
|
|
1818
|
+
* refills records from the server via the normal register→sync→notify path.
|
|
1819
|
+
*/
|
|
1820
|
+
async rebindAfterBucketSwitch() {
|
|
1821
|
+
const hashes = [];
|
|
1822
|
+
for (const [hash, queryState] of this.activeQueries.entries()) {
|
|
1823
|
+
const config = queryState.config;
|
|
1824
|
+
config.localArray = [];
|
|
1825
|
+
config.remoteArray = [];
|
|
1826
|
+
config.subqueryRemoteArray = void 0;
|
|
1827
|
+
queryState.hydrated = false;
|
|
1828
|
+
queryState.syncNotified = false;
|
|
1829
|
+
queryState.records = [];
|
|
1830
|
+
this.setQueryStatus(hash, "fetching");
|
|
1667
1831
|
try {
|
|
1668
|
-
this.
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1832
|
+
await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
|
|
1833
|
+
id: config.id,
|
|
1834
|
+
data: {
|
|
1835
|
+
surql: config.surql,
|
|
1836
|
+
params: config.params,
|
|
1837
|
+
localArray: [],
|
|
1838
|
+
remoteArray: [],
|
|
1839
|
+
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
1840
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1841
|
+
ttl: config.ttl,
|
|
1842
|
+
tableName: config.tableName,
|
|
1843
|
+
updateCount: queryState.updateCount,
|
|
1844
|
+
rowCount: 0,
|
|
1845
|
+
errorCount: queryState.errorCount
|
|
1846
|
+
}
|
|
1847
|
+
}));
|
|
1848
|
+
const { localArray } = this.cache.registerQuery({
|
|
1849
|
+
queryHash: hash,
|
|
1850
|
+
surql: config.surql,
|
|
1851
|
+
params: config.params,
|
|
1852
|
+
ttl: new Duration(config.ttl),
|
|
1853
|
+
lastActiveAt: /* @__PURE__ */ new Date()
|
|
1854
|
+
});
|
|
1855
|
+
config.localArray = localArray;
|
|
1856
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["localArray", "rowCount"])), {
|
|
1857
|
+
id: config.id,
|
|
1858
|
+
localArray,
|
|
1859
|
+
rowCount: localArray.length
|
|
1860
|
+
});
|
|
1861
|
+
} catch (err) {
|
|
1862
|
+
this.logger.error({
|
|
1863
|
+
err,
|
|
1864
|
+
hash,
|
|
1865
|
+
Category: "sp00ky-client::DataModule::rebindAfterBucketSwitch"
|
|
1866
|
+
}, "Failed to rebind query after bucket switch; remote re-registration will retry");
|
|
1867
|
+
}
|
|
1868
|
+
const subscribers = this.subscriptions.get(hash);
|
|
1869
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1870
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
1871
|
+
hashes.push(hash);
|
|
1872
|
+
}
|
|
1873
|
+
return hashes;
|
|
1673
1874
|
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1875
|
+
/**
|
|
1876
|
+
* Called after a query's initial sync completes.
|
|
1877
|
+
* Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
|
|
1878
|
+
*/
|
|
1879
|
+
async notifyQuerySynced(queryHash) {
|
|
1880
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
1881
|
+
if (!queryState) return;
|
|
1882
|
+
const epoch = this.local.epoch;
|
|
1883
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
1884
|
+
if (epoch !== this.local.epoch) return;
|
|
1885
|
+
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
1886
|
+
queryState.records = newRecords;
|
|
1887
|
+
if (changed || !queryState.syncNotified) {
|
|
1888
|
+
queryState.syncNotified = true;
|
|
1889
|
+
queryState.updateCount++;
|
|
1890
|
+
queryState.lastUpdatedAt = Date.now();
|
|
1891
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
1892
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1893
|
+
}
|
|
1690
1894
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
}
|
|
1702
|
-
/** Best-effort delete of this client's IndexedDB store(s). The persistent local
|
|
1703
|
-
* DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
|
|
1704
|
-
* IndexedDB databases whose names include `sp00ky`. Resolves even on
|
|
1705
|
-
* error/blocked so startup can proceed. No-op outside a browser. */
|
|
1706
|
-
async function dropLocalIndexedDbStores(logger) {
|
|
1707
|
-
if (typeof indexedDB === "undefined") return;
|
|
1708
|
-
const remove = (name) => new Promise((resolve) => {
|
|
1709
|
-
try {
|
|
1710
|
-
const req = indexedDB.deleteDatabase(name);
|
|
1711
|
-
req.onsuccess = () => resolve();
|
|
1712
|
-
req.onerror = () => resolve();
|
|
1713
|
-
req.onblocked = () => resolve();
|
|
1714
|
-
} catch {
|
|
1715
|
-
resolve();
|
|
1895
|
+
async run(backend, path, data, options) {
|
|
1896
|
+
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
1897
|
+
if (!route) throw new Error(`Route ${backend}.${path} not found`);
|
|
1898
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1899
|
+
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1900
|
+
const payload = {};
|
|
1901
|
+
for (const argName of Object.keys(route.args)) {
|
|
1902
|
+
const arg = route.args[argName];
|
|
1903
|
+
if (data[argName] === void 0 && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
|
|
1904
|
+
payload[argName] = data[argName];
|
|
1716
1905
|
}
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
logger.warn({
|
|
1729
|
-
err: e,
|
|
1730
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1731
|
-
}, "Failed to enumerate/clear IndexedDB; proceeding anyway");
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
//#endregion
|
|
1736
|
-
//#region src/services/database/remote.ts
|
|
1737
|
-
var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
1738
|
-
config;
|
|
1739
|
-
eventType = DatabaseEventTypes.RemoteQuery;
|
|
1740
|
-
constructor(config, logger) {
|
|
1741
|
-
const events = createDatabaseEventSystem();
|
|
1742
|
-
super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
|
|
1743
|
-
if (phase === "progress" || phase === "after") logger.trace({
|
|
1744
|
-
...other,
|
|
1745
|
-
key,
|
|
1746
|
-
type,
|
|
1747
|
-
phase,
|
|
1748
|
-
service: "surrealdb:remote",
|
|
1749
|
-
Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
|
|
1750
|
-
}, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1751
|
-
}) }), logger, events);
|
|
1752
|
-
this.config = config;
|
|
1753
|
-
}
|
|
1754
|
-
getConfig() {
|
|
1755
|
-
return this.config;
|
|
1756
|
-
}
|
|
1757
|
-
async connect() {
|
|
1758
|
-
const { endpoint, token, namespace, database } = this.getConfig();
|
|
1759
|
-
if (endpoint) {
|
|
1760
|
-
this.logger.info({
|
|
1761
|
-
endpoint,
|
|
1762
|
-
namespace,
|
|
1763
|
-
database,
|
|
1764
|
-
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1765
|
-
}, "Connecting to remote database");
|
|
1766
|
-
try {
|
|
1767
|
-
await this.client.connect(endpoint);
|
|
1768
|
-
await this.client.use({
|
|
1769
|
-
namespace,
|
|
1770
|
-
database
|
|
1771
|
-
});
|
|
1772
|
-
if (token) {
|
|
1773
|
-
this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
|
|
1774
|
-
await this.client.authenticate(token);
|
|
1775
|
-
}
|
|
1776
|
-
this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
|
|
1777
|
-
} catch (err) {
|
|
1778
|
-
this.logger.error({
|
|
1779
|
-
err,
|
|
1780
|
-
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1781
|
-
}, "Failed to connect to remote database");
|
|
1782
|
-
throw err;
|
|
1783
|
-
}
|
|
1784
|
-
} else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
|
|
1785
|
-
}
|
|
1786
|
-
async signin(params) {
|
|
1787
|
-
return this.client.signin(params);
|
|
1906
|
+
const record = {
|
|
1907
|
+
path,
|
|
1908
|
+
payload: JSON.stringify(payload),
|
|
1909
|
+
max_retries: options?.max_retries ?? 3,
|
|
1910
|
+
retry_strategy: options?.retry_strategy ?? "linear"
|
|
1911
|
+
};
|
|
1912
|
+
if (options?.timeout != null) record.timeout = options.timeout;
|
|
1913
|
+
if (options?.delay != null) record.delay = options.delay;
|
|
1914
|
+
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
1915
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
1916
|
+
await this.create(recordId, record);
|
|
1788
1917
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1918
|
+
/**
|
|
1919
|
+
* Create a new record
|
|
1920
|
+
*/
|
|
1921
|
+
async create(id, data) {
|
|
1922
|
+
const tableName = extractTablePart(id);
|
|
1923
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
1924
|
+
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
1925
|
+
const rid = parseRecordIdString(id);
|
|
1926
|
+
const params = parseParams(tableSchema.columns, data);
|
|
1927
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1928
|
+
const dataKeys = Object.keys(params).map((key) => ({
|
|
1929
|
+
key,
|
|
1930
|
+
variable: `data_${key}`
|
|
1931
|
+
}));
|
|
1932
|
+
const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, params[key]]));
|
|
1933
|
+
const query = surql.seal(surql.tx([surql.createSet("id", dataKeys), surql.createMutation("create", "mid", "id", "data")]), { resultIndex: 0 });
|
|
1934
|
+
const target = await withRetry(this.logger, () => this.local.execute(query, {
|
|
1935
|
+
id: rid,
|
|
1936
|
+
mid: mutationId,
|
|
1937
|
+
...prefixedParams
|
|
1938
|
+
}));
|
|
1939
|
+
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
1940
|
+
await this.cache.save({
|
|
1941
|
+
table: tableName,
|
|
1942
|
+
op: "CREATE",
|
|
1943
|
+
record: parsedRecord,
|
|
1944
|
+
version: 1
|
|
1945
|
+
}, true);
|
|
1946
|
+
const mutationEvent = {
|
|
1947
|
+
type: "create",
|
|
1948
|
+
mutation_id: mutationId,
|
|
1949
|
+
record_id: rid,
|
|
1950
|
+
data: params,
|
|
1951
|
+
record: target,
|
|
1952
|
+
tableName
|
|
1953
|
+
};
|
|
1954
|
+
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
1955
|
+
this.logger.debug({
|
|
1956
|
+
id,
|
|
1957
|
+
Category: "sp00ky-client::DataModule::create"
|
|
1958
|
+
}, "Record created");
|
|
1959
|
+
return target;
|
|
1791
1960
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
1961
|
+
/**
|
|
1962
|
+
* Update an existing record
|
|
1963
|
+
*/
|
|
1964
|
+
async update(table, id, data, options) {
|
|
1965
|
+
const tableName = extractTablePart(id);
|
|
1966
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
1967
|
+
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
1968
|
+
const rid = parseRecordIdString(id);
|
|
1969
|
+
const params = parseParams(tableSchema.columns, data);
|
|
1970
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1971
|
+
const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
|
|
1972
|
+
const query = surql.seal(surql.tx([
|
|
1973
|
+
surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
|
|
1974
|
+
surql.let("updated", surql.updateMerge("id", "data")),
|
|
1975
|
+
surql.createMutation("update", "mid", "id", "data"),
|
|
1976
|
+
surql.returnObject([{
|
|
1977
|
+
key: "target",
|
|
1978
|
+
variable: "updated"
|
|
1979
|
+
}])
|
|
1980
|
+
]));
|
|
1981
|
+
const { target } = await withRetry(this.logger, () => this.local.execute(query, {
|
|
1982
|
+
id: rid,
|
|
1983
|
+
mid: mutationId,
|
|
1984
|
+
data: params
|
|
1985
|
+
}));
|
|
1986
|
+
const updatedFields = { id: target.id };
|
|
1987
|
+
for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
|
|
1988
|
+
if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
|
|
1989
|
+
this.replaceRecordInQueries(updatedFields);
|
|
1990
|
+
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
1991
|
+
await this.cache.save({
|
|
1992
|
+
table,
|
|
1993
|
+
op: "UPDATE",
|
|
1994
|
+
record: parsedRecord,
|
|
1995
|
+
version: target._00_rv
|
|
1996
|
+
}, true);
|
|
1997
|
+
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
1998
|
+
const mutationEvent = {
|
|
1999
|
+
type: "update",
|
|
2000
|
+
mutation_id: mutationId,
|
|
2001
|
+
record_id: rid,
|
|
2002
|
+
data: params,
|
|
2003
|
+
record: target,
|
|
2004
|
+
beforeRecord: beforeRecord || void 0,
|
|
2005
|
+
options: pushEventOptions
|
|
2006
|
+
};
|
|
2007
|
+
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
2008
|
+
this.logger.debug({
|
|
2009
|
+
id,
|
|
2010
|
+
Category: "sp00ky-client::DataModule::update"
|
|
2011
|
+
}, "Record updated");
|
|
2012
|
+
return target;
|
|
1794
2013
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
2014
|
+
/**
|
|
2015
|
+
* Delete a record
|
|
2016
|
+
*/
|
|
2017
|
+
async delete(table, id) {
|
|
2018
|
+
const tableName = extractTablePart(id);
|
|
2019
|
+
if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
|
|
2020
|
+
const rid = parseRecordIdString(id);
|
|
2021
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
2022
|
+
const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
|
|
2023
|
+
const beforeRecord = beforeRecords ?? {};
|
|
2024
|
+
const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
|
|
2025
|
+
await withRetry(this.logger, () => this.local.execute(query, {
|
|
2026
|
+
id: rid,
|
|
2027
|
+
mid: mutationId
|
|
2028
|
+
}));
|
|
2029
|
+
try {
|
|
2030
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
this.logger.error({
|
|
2033
|
+
err,
|
|
2034
|
+
id,
|
|
2035
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
2036
|
+
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
2037
|
+
}
|
|
2038
|
+
for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
|
|
2039
|
+
await this.notifyQuerySynced(queryHash);
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
this.logger.error({
|
|
2042
|
+
err,
|
|
2043
|
+
queryHash,
|
|
2044
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
2045
|
+
}, "notifyQuerySynced failed after delete");
|
|
2046
|
+
}
|
|
2047
|
+
const mutationEvent = {
|
|
2048
|
+
type: "delete",
|
|
2049
|
+
mutation_id: mutationId,
|
|
2050
|
+
record_id: rid
|
|
2051
|
+
};
|
|
2052
|
+
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
2053
|
+
this.logger.debug({
|
|
2054
|
+
id,
|
|
2055
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
2056
|
+
}, "Record deleted");
|
|
1797
2057
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2058
|
+
/**
|
|
2059
|
+
* Rollback a failed optimistic create by deleting the record locally
|
|
2060
|
+
*/
|
|
2061
|
+
async rollbackCreate(recordId, tableName) {
|
|
2062
|
+
const id = encodeRecordId(recordId);
|
|
2063
|
+
try {
|
|
2064
|
+
await withRetry(this.logger, () => this.local.query("DELETE $id", { id: recordId }));
|
|
2065
|
+
await this.cache.delete(tableName, id, true);
|
|
2066
|
+
this.removeRecordFromQueries(recordId);
|
|
2067
|
+
this.logger.info({
|
|
2068
|
+
id,
|
|
2069
|
+
tableName,
|
|
2070
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
2071
|
+
}, "Rolled back optimistic create");
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
this.logger.error({
|
|
2074
|
+
err,
|
|
2075
|
+
id,
|
|
2076
|
+
tableName,
|
|
2077
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
2078
|
+
}, "Failed to rollback create");
|
|
1807
2079
|
}
|
|
1808
|
-
};
|
|
1809
|
-
if (transmit) browserConfig.transmit = transmit;
|
|
1810
|
-
return pino({
|
|
1811
|
-
level,
|
|
1812
|
-
browser: browserConfig
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
//#endregion
|
|
1817
|
-
//#region src/services/database/local-migrator.ts
|
|
1818
|
-
const sha1 = async (str) => {
|
|
1819
|
-
const enc = new TextEncoder();
|
|
1820
|
-
const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
|
|
1821
|
-
return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
1822
|
-
};
|
|
1823
|
-
var LocalMigrator = class {
|
|
1824
|
-
logger;
|
|
1825
|
-
constructor(localDb, logger) {
|
|
1826
|
-
this.localDb = localDb;
|
|
1827
|
-
this.logger = logger.child({ service: "LocalMigrator" });
|
|
1828
|
-
logger?.child({ service: "LocalMigrator" }) ?? createLogger("info").child({ service: "LocalMigrator" });
|
|
1829
2080
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2081
|
+
/**
|
|
2082
|
+
* Rollback a failed optimistic update by restoring the previous record state
|
|
2083
|
+
*/
|
|
2084
|
+
async rollbackUpdate(recordId, tableName, beforeRecord) {
|
|
2085
|
+
const id = encodeRecordId(recordId);
|
|
2086
|
+
try {
|
|
2087
|
+
const { id: _recordId, ...content } = beforeRecord;
|
|
2088
|
+
await withRetry(this.logger, () => this.local.query(surql.seal(surql.upsert("id", "content")), {
|
|
2089
|
+
id: recordId,
|
|
2090
|
+
content
|
|
2091
|
+
}));
|
|
2092
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
2093
|
+
const parsedRecord = tableSchema ? parseParams(tableSchema.columns, beforeRecord) : beforeRecord;
|
|
2094
|
+
await this.cache.save({
|
|
2095
|
+
table: tableName,
|
|
2096
|
+
op: "UPDATE",
|
|
2097
|
+
record: parsedRecord,
|
|
2098
|
+
version: beforeRecord._00_rv || 1
|
|
2099
|
+
}, true);
|
|
2100
|
+
await this.replaceRecordInQueries(beforeRecord);
|
|
2101
|
+
this.logger.info({
|
|
2102
|
+
id,
|
|
2103
|
+
tableName,
|
|
2104
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
2105
|
+
}, "Rolled back optimistic update");
|
|
2106
|
+
} catch (err) {
|
|
2107
|
+
this.logger.error({
|
|
2108
|
+
err,
|
|
2109
|
+
id,
|
|
2110
|
+
tableName,
|
|
2111
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
2112
|
+
}, "Failed to rollback update");
|
|
1836
2113
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
} catch (e) {
|
|
1852
|
-
this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
|
|
1853
|
-
throw e;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Remove a record from all active query states and notify subscribers
|
|
2117
|
+
*/
|
|
2118
|
+
removeRecordFromQueries(recordId) {
|
|
2119
|
+
const encodedId = encodeRecordId(recordId);
|
|
2120
|
+
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
2121
|
+
const index = queryState.records.findIndex((r) => {
|
|
2122
|
+
return (r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id)) === encodedId;
|
|
2123
|
+
});
|
|
2124
|
+
if (index !== -1) {
|
|
2125
|
+
queryState.records.splice(index, 1);
|
|
2126
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
2127
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1854
2128
|
}
|
|
1855
2129
|
}
|
|
1856
|
-
await this.createHashRecord(hash);
|
|
1857
2130
|
}
|
|
1858
|
-
async
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2131
|
+
async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
|
|
2132
|
+
const queryState = await this.createNewQuery({
|
|
2133
|
+
recordId,
|
|
2134
|
+
surql: surqlString,
|
|
2135
|
+
params,
|
|
2136
|
+
ttl,
|
|
2137
|
+
tableName
|
|
2138
|
+
});
|
|
2139
|
+
const t0 = performance.now();
|
|
2140
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
2141
|
+
queryHash: hash,
|
|
2142
|
+
surql: surqlString,
|
|
2143
|
+
params,
|
|
2144
|
+
ttl: new Duration(ttl),
|
|
2145
|
+
lastActiveAt: /* @__PURE__ */ new Date()
|
|
2146
|
+
});
|
|
2147
|
+
const registrationTime = performance.now() - t0;
|
|
2148
|
+
queryState.registrationTimings = {
|
|
2149
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
2150
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
2151
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
2152
|
+
wallMs: registrationTime
|
|
2153
|
+
};
|
|
2154
|
+
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
|
|
2155
|
+
"localArray",
|
|
2156
|
+
"registrationTime",
|
|
2157
|
+
"rowCount"
|
|
2158
|
+
])), {
|
|
2159
|
+
id: recordId,
|
|
2160
|
+
localArray,
|
|
2161
|
+
registrationTime,
|
|
2162
|
+
rowCount: localArray.length
|
|
2163
|
+
}));
|
|
2164
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
2165
|
+
if (windowMat && localArray.length > 0) try {
|
|
2166
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
2167
|
+
const [seeded] = await this.local.query(windowMat.query, {
|
|
2168
|
+
...params,
|
|
2169
|
+
__win: winIds
|
|
2170
|
+
});
|
|
2171
|
+
queryState.records = seeded || [];
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
this.logger.warn({
|
|
2174
|
+
err,
|
|
2175
|
+
hash,
|
|
2176
|
+
Category: "sp00ky-client::DataModule::createAndRegisterQuery"
|
|
2177
|
+
}, "Failed to seed windowed initial records from localArray");
|
|
2178
|
+
}
|
|
2179
|
+
this.activeQueries.set(hash, queryState);
|
|
2180
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
2181
|
+
this.logger.debug({
|
|
2182
|
+
hash,
|
|
2183
|
+
tableName,
|
|
2184
|
+
recordCount: queryState.records.length,
|
|
2185
|
+
Category: "sp00ky-client::DataModule::query"
|
|
2186
|
+
}, "Query registered");
|
|
2187
|
+
return hash;
|
|
2188
|
+
}
|
|
2189
|
+
async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
|
|
2190
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
2191
|
+
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
2192
|
+
let [configRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: recordId }));
|
|
2193
|
+
if (!configRecord) {
|
|
2194
|
+
const [createdRecord] = await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
|
|
2195
|
+
id: recordId,
|
|
2196
|
+
data: {
|
|
2197
|
+
surql: surqlString,
|
|
2198
|
+
params,
|
|
2199
|
+
localArray: [],
|
|
2200
|
+
remoteArray: [],
|
|
2201
|
+
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
2202
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
2203
|
+
ttl,
|
|
2204
|
+
tableName,
|
|
2205
|
+
updateCount: 0,
|
|
2206
|
+
rowCount: 0,
|
|
2207
|
+
errorCount: 0
|
|
2208
|
+
}
|
|
2209
|
+
}));
|
|
2210
|
+
configRecord = createdRecord;
|
|
2211
|
+
}
|
|
2212
|
+
const config = {
|
|
2213
|
+
...configRecord,
|
|
2214
|
+
id: recordId,
|
|
2215
|
+
params: parseParams(tableSchema.columns, configRecord.params)
|
|
2216
|
+
};
|
|
2217
|
+
let records = [];
|
|
2218
|
+
if (buildWindowMaterialization(surqlString) === null) try {
|
|
2219
|
+
const [result] = await this.local.query(surqlString, params);
|
|
2220
|
+
records = result || [];
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
this.logger.warn({
|
|
2223
|
+
err,
|
|
2224
|
+
Category: "sp00ky-client::DataModule::createNewQuery"
|
|
2225
|
+
}, "Failed to load initial cached records");
|
|
1864
2226
|
}
|
|
2227
|
+
const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
|
|
2228
|
+
const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
|
|
2229
|
+
return {
|
|
2230
|
+
config,
|
|
2231
|
+
records,
|
|
2232
|
+
ttlTimer: null,
|
|
2233
|
+
ttlDurationMs: parseDuration(ttl),
|
|
2234
|
+
updateCount: persistedUpdateCount,
|
|
2235
|
+
lastUpdatedAt: null,
|
|
2236
|
+
materializationSamples: [],
|
|
2237
|
+
lastIngestLatencyMs: null,
|
|
2238
|
+
errorCount: persistedErrorCount,
|
|
2239
|
+
status: "fetching",
|
|
2240
|
+
phaseSamples: {},
|
|
2241
|
+
phaseLast: {},
|
|
2242
|
+
registrationTimings: {
|
|
2243
|
+
parseMs: null,
|
|
2244
|
+
planMs: null,
|
|
2245
|
+
snapshotMs: null,
|
|
2246
|
+
wallMs: null
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
1865
2249
|
}
|
|
1866
|
-
async
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
`);
|
|
1875
|
-
} catch (_e) {}
|
|
1876
|
-
await this.localDb.query(`
|
|
1877
|
-
DEFINE DATABASE ${database};
|
|
1878
|
-
USE DB ${database};
|
|
1879
|
-
`);
|
|
2250
|
+
async calculateHash(data) {
|
|
2251
|
+
const content = JSON.stringify({
|
|
2252
|
+
...data,
|
|
2253
|
+
sessionId: this.sessionId
|
|
2254
|
+
});
|
|
2255
|
+
const msgBuffer = new TextEncoder().encode(content);
|
|
2256
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
2257
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1880
2258
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
current += char;
|
|
1893
|
-
if (char === "\n") inComment = false;
|
|
1894
|
-
continue;
|
|
1895
|
-
}
|
|
1896
|
-
if (!inQuote && char === "-" && nextChar === "-") {
|
|
1897
|
-
inComment = true;
|
|
1898
|
-
current += char;
|
|
1899
|
-
continue;
|
|
1900
|
-
}
|
|
1901
|
-
if (inQuote) {
|
|
1902
|
-
current += char;
|
|
1903
|
-
if (char === quoteChar && schema[i - 1] !== "\\") inQuote = false;
|
|
1904
|
-
continue;
|
|
1905
|
-
}
|
|
1906
|
-
if (char === "\"" || char === "'") {
|
|
1907
|
-
inQuote = true;
|
|
1908
|
-
quoteChar = char;
|
|
1909
|
-
current += char;
|
|
1910
|
-
continue;
|
|
1911
|
-
}
|
|
1912
|
-
if (char === "{") {
|
|
1913
|
-
depth++;
|
|
1914
|
-
current += char;
|
|
1915
|
-
continue;
|
|
1916
|
-
}
|
|
1917
|
-
if (char === "}") {
|
|
1918
|
-
depth--;
|
|
1919
|
-
current += char;
|
|
1920
|
-
continue;
|
|
2259
|
+
startTTLHeartbeat(queryState, hash) {
|
|
2260
|
+
if (queryState.ttlTimer) return;
|
|
2261
|
+
const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
|
|
2262
|
+
queryState.ttlTimer = setTimeout(() => {
|
|
2263
|
+
queryState.ttlTimer = null;
|
|
2264
|
+
if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
|
|
2265
|
+
this.logger.debug({
|
|
2266
|
+
hash,
|
|
2267
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
2268
|
+
}, "TTL heartbeat: no subscribers, stopping");
|
|
2269
|
+
return;
|
|
1921
2270
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2271
|
+
this.onHeartbeat?.(hash);
|
|
2272
|
+
this.logger.debug({
|
|
2273
|
+
hash,
|
|
2274
|
+
id: encodeRecordId(queryState.config.id),
|
|
2275
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
2276
|
+
}, "TTL heartbeat sent");
|
|
2277
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
2278
|
+
}, heartbeatTime);
|
|
2279
|
+
}
|
|
2280
|
+
async replaceRecordInQueries(record) {
|
|
2281
|
+
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
2282
|
+
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
2283
|
+
if (index !== -1) {
|
|
2284
|
+
queryState.records[index] = {
|
|
2285
|
+
...queryState.records[index],
|
|
2286
|
+
...record
|
|
2287
|
+
};
|
|
2288
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
2289
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1926
2290
|
}
|
|
1927
|
-
current += char;
|
|
1928
2291
|
}
|
|
1929
|
-
if (current.trim().length > 0) statements.push(current.trim());
|
|
1930
|
-
return statements;
|
|
1931
|
-
}
|
|
1932
|
-
async createHashRecord(hash) {
|
|
1933
|
-
await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
|
|
1934
2292
|
}
|
|
1935
2293
|
};
|
|
2294
|
+
/**
|
|
2295
|
+
* Parse update options to generate push event options
|
|
2296
|
+
*/
|
|
2297
|
+
function parseUpdateOptions(id, data, options) {
|
|
2298
|
+
let pushEventOptions = {};
|
|
2299
|
+
if (options?.debounced) pushEventOptions = { debounced: {
|
|
2300
|
+
delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
|
|
2301
|
+
key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
|
|
2302
|
+
} };
|
|
2303
|
+
return pushEventOptions;
|
|
2304
|
+
}
|
|
1936
2305
|
|
|
1937
2306
|
//#endregion
|
|
1938
2307
|
//#region src/modules/sync/events/index.ts
|
|
@@ -2014,6 +2383,17 @@ var UpQueue = class {
|
|
|
2014
2383
|
firstBeforeRecord
|
|
2015
2384
|
});
|
|
2016
2385
|
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Cancel all pending debounce timers WITHOUT enqueueing their events. Used
|
|
2388
|
+
* on local-bucket switches: the mutation's `_00_pending_mutations` row was
|
|
2389
|
+
* already persisted at mutation time, so dropping the in-memory push only
|
|
2390
|
+
* defers it to that bucket's next `loadFromDatabase` — it must NOT be pushed
|
|
2391
|
+
* now, the remote session already belongs to the next user.
|
|
2392
|
+
*/
|
|
2393
|
+
clearDebounceTimers() {
|
|
2394
|
+
for (const { timer } of this.debouncedMutations.values()) clearTimeout(timer);
|
|
2395
|
+
this.debouncedMutations.clear();
|
|
2396
|
+
}
|
|
2017
2397
|
async next(fn, onRollback) {
|
|
2018
2398
|
const event = this.queue.shift();
|
|
2019
2399
|
if (event) {
|
|
@@ -2145,6 +2525,11 @@ var DownQueue = class {
|
|
|
2145
2525
|
payload: { queueSize: this.queue.length }
|
|
2146
2526
|
});
|
|
2147
2527
|
}
|
|
2528
|
+
/** Drop all queued events (bucket switch: old-bucket register/sync work is
|
|
2529
|
+
* re-derived after the switch; replaying it would target dead query rows). */
|
|
2530
|
+
clear() {
|
|
2531
|
+
this.queue = [];
|
|
2532
|
+
}
|
|
2148
2533
|
async next(fn) {
|
|
2149
2534
|
const event = this.queue.shift();
|
|
2150
2535
|
if (event) try {
|
|
@@ -2494,6 +2879,8 @@ var SyncEngine = class {
|
|
|
2494
2879
|
var SyncScheduler = class {
|
|
2495
2880
|
isSyncingUp = false;
|
|
2496
2881
|
isSyncingDown = false;
|
|
2882
|
+
paused = false;
|
|
2883
|
+
pauseWaiters = [];
|
|
2497
2884
|
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
|
|
2498
2885
|
this.upQueue = upQueue;
|
|
2499
2886
|
this.downQueue = downQueue;
|
|
@@ -2521,14 +2908,36 @@ var SyncScheduler = class {
|
|
|
2521
2908
|
this.downQueue.push(event);
|
|
2522
2909
|
}
|
|
2523
2910
|
/**
|
|
2911
|
+
* Suspend syncing for a local-bucket switch. Refuses new rounds and resolves
|
|
2912
|
+
* once any in-flight round has finished — the pause point is BETWEEN queue
|
|
2913
|
+
* items, never between an item's remote push and its outbox-row delete, so a
|
|
2914
|
+
* processed mutation's `DELETE _00_pending_mutations` always lands in the
|
|
2915
|
+
* store it was read from.
|
|
2916
|
+
*/
|
|
2917
|
+
pause() {
|
|
2918
|
+
this.paused = true;
|
|
2919
|
+
if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
|
|
2920
|
+
return new Promise((resolve) => this.pauseWaiters.push(resolve));
|
|
2921
|
+
}
|
|
2922
|
+
resume() {
|
|
2923
|
+
this.paused = false;
|
|
2924
|
+
this.syncUp();
|
|
2925
|
+
}
|
|
2926
|
+
maybeResolvePause() {
|
|
2927
|
+
if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
|
|
2928
|
+
const waiters = this.pauseWaiters;
|
|
2929
|
+
this.pauseWaiters = [];
|
|
2930
|
+
for (const resolve of waiters) resolve();
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2524
2933
|
* Process upload queue
|
|
2525
2934
|
*/
|
|
2526
2935
|
async syncUp() {
|
|
2527
|
-
if (this.isSyncingUp) return;
|
|
2936
|
+
if (this.isSyncingUp || this.paused) return;
|
|
2528
2937
|
this.isSyncingUp = true;
|
|
2529
2938
|
let processedAny = false;
|
|
2530
2939
|
try {
|
|
2531
|
-
while (this.upQueue.size > 0) {
|
|
2940
|
+
while (this.upQueue.size > 0 && !this.paused) {
|
|
2532
2941
|
await this.upQueue.next(this.onProcessUp, this.onRollback);
|
|
2533
2942
|
processedAny = true;
|
|
2534
2943
|
}
|
|
@@ -2541,6 +2950,7 @@ var SyncScheduler = class {
|
|
|
2541
2950
|
}, "syncUp halted on a queue error; item re-queued, will retry on next trigger");
|
|
2542
2951
|
} finally {
|
|
2543
2952
|
this.isSyncingUp = false;
|
|
2953
|
+
this.maybeResolvePause();
|
|
2544
2954
|
this.syncDown();
|
|
2545
2955
|
}
|
|
2546
2956
|
}
|
|
@@ -2548,12 +2958,12 @@ var SyncScheduler = class {
|
|
|
2548
2958
|
* Process download queue
|
|
2549
2959
|
*/
|
|
2550
2960
|
async syncDown() {
|
|
2551
|
-
if (this.isSyncingDown) return;
|
|
2961
|
+
if (this.isSyncingDown || this.paused) return;
|
|
2552
2962
|
if (this.upQueue.size > 0) return;
|
|
2553
2963
|
this.isSyncingDown = true;
|
|
2554
2964
|
let processedAny = false;
|
|
2555
2965
|
try {
|
|
2556
|
-
while (this.downQueue.size > 0) {
|
|
2966
|
+
while (this.downQueue.size > 0 && !this.paused) {
|
|
2557
2967
|
if (this.upQueue.size > 0) break;
|
|
2558
2968
|
await this.downQueue.next(this.onProcessDown);
|
|
2559
2969
|
processedAny = true;
|
|
@@ -2567,6 +2977,7 @@ var SyncScheduler = class {
|
|
|
2567
2977
|
}, "syncDown halted on a queue error; item re-queued, will retry on next trigger");
|
|
2568
2978
|
} finally {
|
|
2569
2979
|
this.isSyncingDown = false;
|
|
2980
|
+
this.maybeResolvePause();
|
|
2570
2981
|
}
|
|
2571
2982
|
}
|
|
2572
2983
|
get isSyncing() {
|
|
@@ -2574,53 +2985,6 @@ var SyncScheduler = class {
|
|
|
2574
2985
|
}
|
|
2575
2986
|
};
|
|
2576
2987
|
|
|
2577
|
-
//#endregion
|
|
2578
|
-
//#region src/modules/ref-tables.ts
|
|
2579
|
-
/**
|
|
2580
|
-
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
2581
|
-
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
2582
|
-
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
2583
|
-
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
2584
|
-
*/
|
|
2585
|
-
const ANON_USER_ID = "anon";
|
|
2586
|
-
/**
|
|
2587
|
-
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
2588
|
-
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
2589
|
-
* box.
|
|
2590
|
-
*/
|
|
2591
|
-
const DEFAULT_REF_MODE = "dedicated";
|
|
2592
|
-
/**
|
|
2593
|
-
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
2594
|
-
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
2595
|
-
* the id is missing the `user:` prefix or contains characters that
|
|
2596
|
-
* aren't valid in a SurrealDB table identifier — the server-side
|
|
2597
|
-
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
2598
|
-
*
|
|
2599
|
-
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
2600
|
-
* objects (which only stringify cleanly via `.toString()`), since
|
|
2601
|
-
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
2602
|
-
*/
|
|
2603
|
-
function sanitizeUserId(userId) {
|
|
2604
|
-
if (userId === null || userId === void 0) return null;
|
|
2605
|
-
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
2606
|
-
if (!asString) return null;
|
|
2607
|
-
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
2608
|
-
if (raw.length === 0) return null;
|
|
2609
|
-
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
2610
|
-
return raw;
|
|
2611
|
-
}
|
|
2612
|
-
/**
|
|
2613
|
-
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
2614
|
-
* back to the global `_00_list_ref` when sanitization fails or in
|
|
2615
|
-
* single mode.
|
|
2616
|
-
*/
|
|
2617
|
-
function listRefTableFor(mode, userId) {
|
|
2618
|
-
if (userId === ANON_USER_ID) return "_00_list_ref_anon";
|
|
2619
|
-
if (mode === "single") return "_00_list_ref";
|
|
2620
|
-
const uid = sanitizeUserId(userId);
|
|
2621
|
-
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
2988
|
//#endregion
|
|
2625
2989
|
//#region src/modules/sync/sync.ts
|
|
2626
2990
|
/**
|
|
@@ -2651,6 +3015,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2651
3015
|
liveQueryUnsubscribe = null;
|
|
2652
3016
|
listRefPollTimer = null;
|
|
2653
3017
|
listRefPollRunning = false;
|
|
3018
|
+
listRefPollInFlight = null;
|
|
2654
3019
|
refSyncIntervalMs;
|
|
2655
3020
|
listRefIdleStreak = 0;
|
|
2656
3021
|
stillRemoteStreaks = /* @__PURE__ */ new Map();
|
|
@@ -2831,6 +3196,39 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2831
3196
|
}
|
|
2832
3197
|
}
|
|
2833
3198
|
/**
|
|
3199
|
+
* Quiesce all sync activity ahead of a local-bucket switch. After this
|
|
3200
|
+
* resolves, nothing in the sync module writes to the local store: the poll
|
|
3201
|
+
* loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
|
|
3202
|
+
* timers are cancelled (their outbox rows are already persisted), and the
|
|
3203
|
+
* scheduler has drained its in-flight queue item — including that item's
|
|
3204
|
+
* outbox-row delete, which must land in the OLD bucket. Queued down-events
|
|
3205
|
+
* are dropped (they reference old-bucket query rows; the post-switch rebind
|
|
3206
|
+
* re-enqueues registrations). The old user's un-pushed outbox is deliberately
|
|
3207
|
+
* NOT drained: the remote session already belongs to the next user.
|
|
3208
|
+
*/
|
|
3209
|
+
async prepareBucketSwitch() {
|
|
3210
|
+
this.stopSelfHeal();
|
|
3211
|
+
this.stopListRefPoll();
|
|
3212
|
+
if (this.listRefPollInFlight) await this.listRefPollInFlight;
|
|
3213
|
+
await this.killRefLiveQuery();
|
|
3214
|
+
this.upQueue.clearDebounceTimers();
|
|
3215
|
+
await this.scheduler.pause();
|
|
3216
|
+
this.downQueue.clear();
|
|
3217
|
+
this.stillRemoteStreaks.clear();
|
|
3218
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::prepareBucketSwitch" }, "Sync quiesced for bucket switch");
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Resume syncing against the freshly-opened bucket: reload the mutation
|
|
3222
|
+
* outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
|
|
3223
|
+
* offline work) and restart the scheduler. LIVE + the list_ref poll restart
|
|
3224
|
+
* via the `setCurrentUserId` call that follows in the auth listener.
|
|
3225
|
+
*/
|
|
3226
|
+
async completeBucketSwitch() {
|
|
3227
|
+
await this.upQueue.loadFromDatabase();
|
|
3228
|
+
this.scheduler.resume();
|
|
3229
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::completeBucketSwitch" }, "Sync resumed after bucket switch");
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
2834
3232
|
* Push the authenticated user's record id from the parent client's
|
|
2835
3233
|
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
2836
3234
|
* any) and re-registers it under the new user's dedicated table so
|
|
@@ -2897,9 +3295,14 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2897
3295
|
this.listRefPollTimer = setTimeout(async () => {
|
|
2898
3296
|
if (!this.listRefPollRunning) return;
|
|
2899
3297
|
let changed = false;
|
|
2900
|
-
|
|
3298
|
+
const tick = (async () => {
|
|
2901
3299
|
changed = await this.pollListRefForActiveQueries();
|
|
3300
|
+
})();
|
|
3301
|
+
this.listRefPollInFlight = tick.catch(() => {});
|
|
3302
|
+
try {
|
|
3303
|
+
await tick;
|
|
2902
3304
|
} finally {
|
|
3305
|
+
this.listRefPollInFlight = null;
|
|
2903
3306
|
if (!this.listRefPollRunning) return;
|
|
2904
3307
|
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
2905
3308
|
schedule(listRefPollDelayMs({
|
|
@@ -3285,7 +3688,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
3285
3688
|
};
|
|
3286
3689
|
}
|
|
3287
3690
|
const fetching = diff.added.length + diff.updated.length > 0;
|
|
3288
|
-
if (fetching) this.dataModule.
|
|
3691
|
+
if (fetching) this.dataModule.beginFetching(hash);
|
|
3289
3692
|
try {
|
|
3290
3693
|
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
3291
3694
|
if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
@@ -3310,7 +3713,18 @@ var Sp00kySync = class Sp00kySync {
|
|
|
3310
3713
|
}
|
|
3311
3714
|
}
|
|
3312
3715
|
} finally {
|
|
3313
|
-
if (fetching)
|
|
3716
|
+
if (fetching) {
|
|
3717
|
+
try {
|
|
3718
|
+
await this.dataModule.flushPendingStreamUpdate(hash);
|
|
3719
|
+
} catch (err) {
|
|
3720
|
+
this.logger.warn({
|
|
3721
|
+
err,
|
|
3722
|
+
hash,
|
|
3723
|
+
Category: "sp00ky-client::Sp00kySync::runSyncForQuery"
|
|
3724
|
+
}, "Failed to flush pending stream update before idle");
|
|
3725
|
+
}
|
|
3726
|
+
this.dataModule.endFetching(hash);
|
|
3727
|
+
}
|
|
3314
3728
|
}
|
|
3315
3729
|
}
|
|
3316
3730
|
/**
|
|
@@ -3339,6 +3753,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
3339
3753
|
this.scheduler.enqueueMutation(mutations);
|
|
3340
3754
|
}
|
|
3341
3755
|
async registerQuery(queryHash) {
|
|
3756
|
+
this.dataModule.beginFetching(queryHash);
|
|
3342
3757
|
try {
|
|
3343
3758
|
this.logger.debug({
|
|
3344
3759
|
queryHash,
|
|
@@ -3346,6 +3761,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
3346
3761
|
}, "Register Query state");
|
|
3347
3762
|
await this.createRemoteQuery(queryHash);
|
|
3348
3763
|
await this.syncQuery(queryHash);
|
|
3764
|
+
await this.dataModule.flushPendingStreamUpdate(queryHash);
|
|
3349
3765
|
await this.dataModule.notifyQuerySynced(queryHash);
|
|
3350
3766
|
} catch (e) {
|
|
3351
3767
|
this.logger.error({
|
|
@@ -3353,6 +3769,8 @@ var Sp00kySync = class Sp00kySync {
|
|
|
3353
3769
|
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
3354
3770
|
}, "registerQuery error");
|
|
3355
3771
|
throw e;
|
|
3772
|
+
} finally {
|
|
3773
|
+
this.dataModule.endFetching(queryHash);
|
|
3356
3774
|
}
|
|
3357
3775
|
}
|
|
3358
3776
|
async createRemoteQuery(queryHash) {
|
|
@@ -3515,8 +3933,8 @@ function parseBackendInfo(raw) {
|
|
|
3515
3933
|
|
|
3516
3934
|
//#endregion
|
|
3517
3935
|
//#region src/modules/devtools/index.ts
|
|
3518
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
3519
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
3936
|
+
const CORE_VERSION = "0.0.1-canary.104";
|
|
3937
|
+
const WASM_VERSION = "0.0.1-canary.104";
|
|
3520
3938
|
const SURREAL_VERSION = "3.0.3";
|
|
3521
3939
|
var DevToolsService = class {
|
|
3522
3940
|
eventsHistory = [];
|
|
@@ -3572,13 +3990,14 @@ var DevToolsService = class {
|
|
|
3572
3990
|
if (!this.dataManager) return result;
|
|
3573
3991
|
this.dataManager.getActiveQueries().forEach((q) => {
|
|
3574
3992
|
const queryHash = this.hashString(encodeRecordId(q.config.id));
|
|
3993
|
+
const createdAt = q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime();
|
|
3575
3994
|
result.set(queryHash, {
|
|
3576
3995
|
queryHash,
|
|
3577
3996
|
status: "active",
|
|
3578
3997
|
fetchStatus: q.status,
|
|
3579
3998
|
isFetching: q.status === "fetching",
|
|
3580
|
-
createdAt
|
|
3581
|
-
lastUpdate:
|
|
3999
|
+
createdAt,
|
|
4000
|
+
lastUpdate: q.lastUpdatedAt ?? createdAt,
|
|
3582
4001
|
updateCount: q.updateCount,
|
|
3583
4002
|
ttl: q.config.ttl,
|
|
3584
4003
|
query: q.config.surql,
|
|
@@ -4047,6 +4466,8 @@ var StreamProcessorService = class {
|
|
|
4047
4466
|
authId: "",
|
|
4048
4467
|
access: ""
|
|
4049
4468
|
};
|
|
4469
|
+
stateKeySuffix = "";
|
|
4470
|
+
stateGeneration = 0;
|
|
4050
4471
|
constructor(events, db, persistenceClient, logger) {
|
|
4051
4472
|
this.events = events;
|
|
4052
4473
|
this.db = db;
|
|
@@ -4150,10 +4571,34 @@ var StreamProcessorService = class {
|
|
|
4150
4571
|
throw e;
|
|
4151
4572
|
}
|
|
4152
4573
|
}
|
|
4574
|
+
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
4575
|
+
setStateKeySuffix(bucketId) {
|
|
4576
|
+
this.stateKeySuffix = bucketId;
|
|
4577
|
+
}
|
|
4578
|
+
stateKey() {
|
|
4579
|
+
return this.stateKeySuffix ? `_00_stream_processor_state:${this.stateKeySuffix}` : "_00_stream_processor_state";
|
|
4580
|
+
}
|
|
4581
|
+
/**
|
|
4582
|
+
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
4583
|
+
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
4584
|
+
* views registered with the previous `$auth` context, so neither may survive.
|
|
4585
|
+
* Deliberately does NOT `loadState()` — a persisted snapshot references views
|
|
4586
|
+
* under a dead sessionId salt; the DataModule rebind re-registers every live
|
|
4587
|
+
* view against this fresh processor. Caller must re-seed `setPermissions`
|
|
4588
|
+
* afterwards (a fresh circuit default-denies every table).
|
|
4589
|
+
*/
|
|
4590
|
+
async reset() {
|
|
4591
|
+
if (!this.isInitialized) return;
|
|
4592
|
+
this.stateGeneration++;
|
|
4593
|
+
this.batching = false;
|
|
4594
|
+
this.batchBuffer.clear();
|
|
4595
|
+
this.processor = new Sp00kyProcessor();
|
|
4596
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
|
|
4597
|
+
}
|
|
4153
4598
|
async loadState() {
|
|
4154
4599
|
if (!this.processor) return;
|
|
4155
4600
|
try {
|
|
4156
|
-
const result = await this.persistenceClient.get(
|
|
4601
|
+
const result = await this.persistenceClient.get(this.stateKey());
|
|
4157
4602
|
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
|
|
4158
4603
|
const state = result[0][0].state;
|
|
4159
4604
|
this.logger.info({
|
|
@@ -4210,11 +4655,13 @@ var StreamProcessorService = class {
|
|
|
4210
4655
|
}
|
|
4211
4656
|
async saveState() {
|
|
4212
4657
|
if (!this.processor) return;
|
|
4658
|
+
const generation = this.stateGeneration;
|
|
4213
4659
|
try {
|
|
4214
4660
|
if (typeof this.processor.save_state === "function") {
|
|
4215
4661
|
const state = this.processor.save_state();
|
|
4662
|
+
if (generation !== this.stateGeneration) return;
|
|
4216
4663
|
if (state) {
|
|
4217
|
-
await this.persistenceClient.set(
|
|
4664
|
+
await this.persistenceClient.set(this.stateKey(), state);
|
|
4218
4665
|
this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
|
|
4219
4666
|
}
|
|
4220
4667
|
}
|
|
@@ -4457,6 +4904,11 @@ var CacheModule = class {
|
|
|
4457
4904
|
lookup(recordId) {
|
|
4458
4905
|
return this.versionLookups[recordId] ?? 0;
|
|
4459
4906
|
}
|
|
4907
|
+
/** Drop the version cache on a bucket switch — a stale version would make
|
|
4908
|
+
* the sync diff skip fetching a body the new bucket legitimately needs. */
|
|
4909
|
+
clearVersionLookups() {
|
|
4910
|
+
this.versionLookups = {};
|
|
4911
|
+
}
|
|
4460
4912
|
/**
|
|
4461
4913
|
* Save a single record to local DB and ingest into DBSP
|
|
4462
4914
|
* Used by mutations (create/update)
|
|
@@ -4471,6 +4923,7 @@ var CacheModule = class {
|
|
|
4471
4923
|
*/
|
|
4472
4924
|
async saveBatch(records, skipDbInsert = false) {
|
|
4473
4925
|
if (records.length === 0) return;
|
|
4926
|
+
const epoch = this.local.epoch;
|
|
4474
4927
|
this.logger.debug({
|
|
4475
4928
|
count: records.length,
|
|
4476
4929
|
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
@@ -4498,8 +4951,9 @@ var CacheModule = class {
|
|
|
4498
4951
|
[`content${i}`]: content
|
|
4499
4952
|
};
|
|
4500
4953
|
}, {});
|
|
4501
|
-
await this.local.execute(query, params);
|
|
4954
|
+
await this.local.execute(query, params, { epoch });
|
|
4502
4955
|
}
|
|
4956
|
+
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
4503
4957
|
const bulk = populatedRecords.map((record) => {
|
|
4504
4958
|
const recordId = encodeRecordId(record.record.id);
|
|
4505
4959
|
this.versionLookups[recordId] = record.version;
|
|
@@ -4516,6 +4970,13 @@ var CacheModule = class {
|
|
|
4516
4970
|
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
4517
4971
|
}, "Batch saved successfully");
|
|
4518
4972
|
} catch (err) {
|
|
4973
|
+
if (err instanceof StaleEpochError) {
|
|
4974
|
+
this.logger.debug({
|
|
4975
|
+
count: records.length,
|
|
4976
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
4977
|
+
}, "Dropped batch from before a bucket switch");
|
|
4978
|
+
return;
|
|
4979
|
+
}
|
|
4519
4980
|
this.logger.error({
|
|
4520
4981
|
err,
|
|
4521
4982
|
count: records.length,
|
|
@@ -4533,8 +4994,10 @@ var CacheModule = class {
|
|
|
4533
4994
|
id,
|
|
4534
4995
|
Category: "sp00ky-client::CacheModule::delete"
|
|
4535
4996
|
}, "Deleting record");
|
|
4997
|
+
const epoch = this.local.epoch;
|
|
4536
4998
|
try {
|
|
4537
|
-
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
|
|
4999
|
+
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) }, { epoch });
|
|
5000
|
+
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
4538
5001
|
delete this.versionLookups[id];
|
|
4539
5002
|
this.streamProcessor.ingest(table, "DELETE", id, recordData);
|
|
4540
5003
|
this.logger.debug({
|
|
@@ -4543,6 +5006,14 @@ var CacheModule = class {
|
|
|
4543
5006
|
Category: "sp00ky-client::CacheModule::delete"
|
|
4544
5007
|
}, "Record deleted successfully");
|
|
4545
5008
|
} catch (err) {
|
|
5009
|
+
if (err instanceof StaleEpochError) {
|
|
5010
|
+
this.logger.debug({
|
|
5011
|
+
table,
|
|
5012
|
+
id,
|
|
5013
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
5014
|
+
}, "Dropped delete from before a bucket switch");
|
|
5015
|
+
return;
|
|
5016
|
+
}
|
|
4546
5017
|
this.logger.error({
|
|
4547
5018
|
err,
|
|
4548
5019
|
table,
|
|
@@ -4707,7 +5178,14 @@ var CrdtField = class {
|
|
|
4707
5178
|
this.scheduleRemotePush();
|
|
4708
5179
|
});
|
|
4709
5180
|
}
|
|
4710
|
-
|
|
5181
|
+
/**
|
|
5182
|
+
* Stop syncing this field. Flushes one final remote push by default so the
|
|
5183
|
+
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
5184
|
+
* the remote session already belongs to the NEXT user, and pushing this
|
|
5185
|
+
* (previous user's) snapshot under it would clobber the record remotely.
|
|
5186
|
+
*/
|
|
5187
|
+
stopSync(options = {}) {
|
|
5188
|
+
const flush = options.flush ?? true;
|
|
4711
5189
|
if (this.unsubscribe) {
|
|
4712
5190
|
this.unsubscribe();
|
|
4713
5191
|
this.unsubscribe = null;
|
|
@@ -4716,7 +5194,7 @@ var CrdtField = class {
|
|
|
4716
5194
|
clearTimeout(this.pushTimer);
|
|
4717
5195
|
this.pushTimer = null;
|
|
4718
5196
|
}
|
|
4719
|
-
if (this.remote && this.recordId) this.pushToRemote();
|
|
5197
|
+
if (flush && this.remote && this.recordId) this.pushToRemote();
|
|
4720
5198
|
}
|
|
4721
5199
|
importRemote(state) {
|
|
4722
5200
|
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
@@ -4937,8 +5415,13 @@ var CrdtManager = class {
|
|
|
4937
5415
|
Category: "sp00ky-client::CrdtManager::close"
|
|
4938
5416
|
}, "CrdtField closed");
|
|
4939
5417
|
}
|
|
4940
|
-
|
|
4941
|
-
|
|
5418
|
+
/**
|
|
5419
|
+
* Close every open field + table LIVE. Fields flush a final remote push by
|
|
5420
|
+
* default; pass `{ flush: false }` on a bucket switch, where that flush
|
|
5421
|
+
* would push the previous user's snapshot under the next user's session.
|
|
5422
|
+
*/
|
|
5423
|
+
closeAll(options = {}) {
|
|
5424
|
+
for (const [_, field] of this.fields) field.stopSync(options);
|
|
4942
5425
|
this.fields.clear();
|
|
4943
5426
|
for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
|
|
4944
5427
|
}
|
|
@@ -5375,6 +5858,28 @@ var BucketHandle = class {
|
|
|
5375
5858
|
return result;
|
|
5376
5859
|
}
|
|
5377
5860
|
};
|
|
5861
|
+
/**
|
|
5862
|
+
* Boot hint for which local bucket to open before auth resolves. Written to
|
|
5863
|
+
* PLAIN localStorage (never the configured persistenceClient): the surrealdb
|
|
5864
|
+
* persistence client stores its keys INSIDE a bucket, and the whole point of
|
|
5865
|
+
* the hint is to pick the bucket before any bucket is open. A warm reload of a
|
|
5866
|
+
* signed-in user thus opens their own bucket immediately — zero switches.
|
|
5867
|
+
* Losing the hint is fail-closed: boot lands on the anon bucket and the auth
|
|
5868
|
+
* callback switches to the user's bucket (cache + outbox intact).
|
|
5869
|
+
*/
|
|
5870
|
+
const LAST_BUCKET_KEY = "sp00ky:last_bucket";
|
|
5871
|
+
function readBootBucketHint() {
|
|
5872
|
+
try {
|
|
5873
|
+
return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
5874
|
+
} catch {
|
|
5875
|
+
return null;
|
|
5876
|
+
}
|
|
5877
|
+
}
|
|
5878
|
+
function writeBootBucketHint(bucketId) {
|
|
5879
|
+
try {
|
|
5880
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
5881
|
+
} catch {}
|
|
5882
|
+
}
|
|
5378
5883
|
var Sp00kyClient = class {
|
|
5379
5884
|
local;
|
|
5380
5885
|
remote;
|
|
@@ -5518,12 +6023,17 @@ var Sp00kyClient = class {
|
|
|
5518
6023
|
async init() {
|
|
5519
6024
|
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
|
|
5520
6025
|
try {
|
|
5521
|
-
|
|
5522
|
-
this.
|
|
6026
|
+
const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
|
|
6027
|
+
await this.local.connect(bootBucket);
|
|
6028
|
+
this.logger.debug({
|
|
6029
|
+
bootBucket,
|
|
6030
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
6031
|
+
}, "Local database connected");
|
|
5523
6032
|
await this.migrator.provision(this.config.schemaSurql);
|
|
5524
6033
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
5525
6034
|
await this.remote.connect();
|
|
5526
6035
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
6036
|
+
this.streamProcessor.setStateKeySuffix(bootBucket);
|
|
5527
6037
|
await this.streamProcessor.init();
|
|
5528
6038
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
5529
6039
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
@@ -5539,6 +6049,8 @@ var Sp00kyClient = class {
|
|
|
5539
6049
|
this.auth.subscribe(async (userId) => {
|
|
5540
6050
|
this.dataModule.setCurrentUserId(userId);
|
|
5541
6051
|
this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
|
|
6052
|
+
writeBootBucketHint(bucketIdForUser(userId));
|
|
6053
|
+
await this.ensureLocalBucket(userId);
|
|
5542
6054
|
const next = await this.fetchSessionId();
|
|
5543
6055
|
this.dataModule.setSessionId(next);
|
|
5544
6056
|
this.crdtManager.setSessionId(next);
|
|
@@ -5564,6 +6076,86 @@ var Sp00kyClient = class {
|
|
|
5564
6076
|
throw e;
|
|
5565
6077
|
}
|
|
5566
6078
|
}
|
|
6079
|
+
bucketSwitchChain = Promise.resolve();
|
|
6080
|
+
pendingBucketTarget = null;
|
|
6081
|
+
/**
|
|
6082
|
+
* Ensure the local store is this user's bucket, switching if needed. Called
|
|
6083
|
+
* from the auth listener on every auth flip; concurrent calls are chained
|
|
6084
|
+
* and superseded intermediates are skipped (latest target wins).
|
|
6085
|
+
*/
|
|
6086
|
+
ensureLocalBucket(userId) {
|
|
6087
|
+
const target = bucketIdForUser(userId);
|
|
6088
|
+
this.pendingBucketTarget = target;
|
|
6089
|
+
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
6090
|
+
if (this.pendingBucketTarget !== target) return;
|
|
6091
|
+
if (this.local.currentBucketId === target) return;
|
|
6092
|
+
await this.doSwitchBucket(target);
|
|
6093
|
+
});
|
|
6094
|
+
const result = this.bucketSwitchChain;
|
|
6095
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
|
|
6096
|
+
return result;
|
|
6097
|
+
}
|
|
6098
|
+
/**
|
|
6099
|
+
* The bucket-switch choreography: drain → swap → rebind.
|
|
6100
|
+
*
|
|
6101
|
+
* Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
|
|
6102
|
+
* outbox delete lands in the OLD bucket, debounce timers cancelled),
|
|
6103
|
+
* DataModule timers cleared, CRDT fields closed WITHOUT their final flush
|
|
6104
|
+
* (the remote session already belongs to the next user).
|
|
6105
|
+
*
|
|
6106
|
+
* Swap: gate closes so any local query issued mid-switch (sibling auth
|
|
6107
|
+
* subscribers, FeatureFlagModule) waits and then runs against the NEW
|
|
6108
|
+
* bucket; store swaps open-new-before-close-old; schema provisions
|
|
6109
|
+
* (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
|
|
6110
|
+
* sessionId-salted hashes with stale arrays — record bodies stay warm);
|
|
6111
|
+
* SSP resets to a fresh circuit with re-seeded permissions.
|
|
6112
|
+
*
|
|
6113
|
+
* Rebind: auth token re-persisted (the surrealdb persistence client wrote it
|
|
6114
|
+
* into the OLD bucket's `_00_kv` before this listener ran), active queries
|
|
6115
|
+
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
6116
|
+
* outbox, and every query re-registered remotely to refill from the server.
|
|
6117
|
+
*/
|
|
6118
|
+
async doSwitchBucket(target) {
|
|
6119
|
+
this.logger.info({
|
|
6120
|
+
target,
|
|
6121
|
+
from: this.local.currentBucketId,
|
|
6122
|
+
Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
|
|
6123
|
+
}, "Switching local bucket");
|
|
6124
|
+
await this.sync.prepareBucketSwitch();
|
|
6125
|
+
this.dataModule.quiesce();
|
|
6126
|
+
this.crdtManager.closeAll({ flush: false });
|
|
6127
|
+
const reopen = this.local.beginSwitch();
|
|
6128
|
+
try {
|
|
6129
|
+
await this.local.switchStore(target);
|
|
6130
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
6131
|
+
await this.local.queryUngated("DELETE _00_query;");
|
|
6132
|
+
this.streamProcessor.setStateKeySuffix(target);
|
|
6133
|
+
await this.streamProcessor.reset();
|
|
6134
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
6135
|
+
this.cache.clearVersionLookups();
|
|
6136
|
+
} finally {
|
|
6137
|
+
reopen();
|
|
6138
|
+
}
|
|
6139
|
+
if (this.auth.token) try {
|
|
6140
|
+
await this.persistenceClient.set("sp00ky_auth_token", this.auth.token);
|
|
6141
|
+
} catch (e) {
|
|
6142
|
+
this.logger.warn({
|
|
6143
|
+
error: e,
|
|
6144
|
+
Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
|
|
6145
|
+
}, "Failed to re-persist auth token into the new bucket");
|
|
6146
|
+
}
|
|
6147
|
+
const hashes = await this.dataModule.rebindAfterBucketSwitch();
|
|
6148
|
+
await this.sync.completeBucketSwitch();
|
|
6149
|
+
for (const hash of hashes) this.sync.enqueueDownEvent({
|
|
6150
|
+
type: "register",
|
|
6151
|
+
payload: { hash }
|
|
6152
|
+
});
|
|
6153
|
+
this.logger.info({
|
|
6154
|
+
target,
|
|
6155
|
+
queries: hashes.length,
|
|
6156
|
+
Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
|
|
6157
|
+
}, "Local bucket switch complete");
|
|
6158
|
+
}
|
|
5567
6159
|
async close() {
|
|
5568
6160
|
await this.featureFlags.closeAll();
|
|
5569
6161
|
this.crdtManager.closeAll();
|