@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70

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.js CHANGED
@@ -152,12 +152,21 @@ function generateId() {
152
152
  return Uuid.v4().toString().replace(/-/g, "");
153
153
  }
154
154
  /**
155
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
156
+ * releases expose it under `milliseconds` (current) or the older private
157
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
158
+ */
159
+ function durationMillis(duration) {
160
+ const d = duration;
161
+ return Number(d.milliseconds || d._milliseconds || 0);
162
+ }
163
+ /**
155
164
  * Parse duration string or Duration object to milliseconds
156
165
  */
157
166
  function parseDuration(duration) {
158
167
  if (duration instanceof Duration) {
159
- const ms = duration.milliseconds || duration._milliseconds;
160
- if (ms) return Number(ms);
168
+ const ms = durationMillis(duration);
169
+ if (ms) return ms;
161
170
  const str = duration.toString();
162
171
  if (str !== "[object Object]") return parseDuration(str);
163
172
  return 6e5;
@@ -209,8 +218,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
209
218
  throw lastError;
210
219
  }
211
220
 
221
+ //#endregion
222
+ //#region src/modules/data/window-query.ts
223
+ /**
224
+ * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
225
+ * materialized from an explicit record-id set — the window the SSP already
226
+ * computed — instead of re-running the original query (with its `START m`)
227
+ * against the shared local DB.
228
+ *
229
+ * Why: `DataModule.processStreamUpdate` materializes a query's rows by
230
+ * re-querying the local in-browser SurrealDB with the original surql. For an
231
+ * offset query that re-applies `START m` against the *shared* local store —
232
+ * which, with sparse windowing, may hold only this window's rows — so it skips
233
+ * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
234
+ * materialized view (`StreamUpdate.localArray`) is exactly this window's row
235
+ * ids, so we select those ids directly and re-apply the original `ORDER BY` for
236
+ * stable display order.
237
+ *
238
+ * Returns `null` for non-offset queries (`START` absent or 0) — the caller
239
+ * keeps the normal re-query path, so only the broken case changes behavior.
240
+ *
241
+ * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
242
+ * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
243
+ * projection are preserved verbatim (the scanner is paren/quote aware), with
244
+ * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
245
+ * shape — such windowed+subquery queries are rare and were already returning 0,
246
+ * so this is no regression.
247
+ */
248
+ function buildWindowMaterialization(surql, idsParam = "__win") {
249
+ const kw = scanTopLevelClauses(surql);
250
+ if (kw.startValue === null || kw.startValue <= 0) return null;
251
+ if (kw.fromIndex === null) return null;
252
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
253
+ let orderBy = "";
254
+ if (kw.orderByIndex !== null) {
255
+ const ends = [
256
+ kw.limitIndex,
257
+ kw.startIndex,
258
+ kw.semicolonIndex,
259
+ surql.length
260
+ ].filter((n) => n !== null && n > kw.orderByIndex);
261
+ const end = Math.min(...ends);
262
+ orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
263
+ }
264
+ return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
265
+ }
266
+ function scanTopLevelClauses(sql) {
267
+ const out = {
268
+ fromIndex: null,
269
+ orderByIndex: null,
270
+ limitIndex: null,
271
+ startIndex: null,
272
+ startValue: null,
273
+ semicolonIndex: null
274
+ };
275
+ let depth = 0;
276
+ let inStr = false;
277
+ for (let i = 0; i < sql.length; i++) {
278
+ const ch = sql[i];
279
+ if (inStr) {
280
+ if (ch === "\\") i++;
281
+ else if (ch === "'") inStr = false;
282
+ continue;
283
+ }
284
+ if (ch === "'") {
285
+ inStr = true;
286
+ continue;
287
+ }
288
+ if (ch === "(") {
289
+ depth++;
290
+ continue;
291
+ }
292
+ if (ch === ")") {
293
+ depth--;
294
+ continue;
295
+ }
296
+ if (depth !== 0) continue;
297
+ if (ch === ";" && out.semicolonIndex === null) {
298
+ out.semicolonIndex = i;
299
+ continue;
300
+ }
301
+ if (!isWordBoundary(sql, i)) continue;
302
+ if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
303
+ out.fromIndex = i;
304
+ continue;
305
+ }
306
+ if (out.fromIndex === null) continue;
307
+ if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
308
+ out.orderByIndex = i;
309
+ continue;
310
+ }
311
+ if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
312
+ out.limitIndex = i;
313
+ continue;
314
+ }
315
+ if (out.startIndex === null && matchKeyword(sql, i, "START")) {
316
+ out.startIndex = i;
317
+ out.startValue = readNumberAfter(sql, i + 5);
318
+ continue;
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+ function isWordBoundary(sql, i) {
324
+ if (i === 0) return true;
325
+ return !/[A-Za-z0-9_]/.test(sql[i - 1]);
326
+ }
327
+ function matchKeyword(sql, i, keyword) {
328
+ const parts = keyword.split(" ");
329
+ let pos = i;
330
+ for (let p = 0; p < parts.length; p++) {
331
+ const word = parts[p];
332
+ if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
333
+ pos += word.length;
334
+ if (p < parts.length - 1) {
335
+ const wsStart = pos;
336
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
337
+ if (pos === wsStart) return false;
338
+ }
339
+ }
340
+ return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
341
+ }
342
+ function readNumberAfter(sql, from) {
343
+ let pos = from;
344
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
345
+ const m = /^\d+/.exec(sql.slice(pos));
346
+ return m ? parseInt(m[0], 10) : null;
347
+ }
348
+
212
349
  //#endregion
213
350
  //#region src/modules/data/index.ts
351
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
352
+ function pushSample(samples, ms) {
353
+ samples.push(ms);
354
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
355
+ }
356
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
357
+ function phaseStatOf(samples, lastMs) {
358
+ if (samples.length === 0) return {
359
+ lastMs,
360
+ p50: null,
361
+ p90: null,
362
+ p99: null,
363
+ count: 0
364
+ };
365
+ const sorted = [...samples].sort((a, b) => a - b);
366
+ const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
367
+ return {
368
+ lastMs,
369
+ p50: pick(.5),
370
+ p90: pick(.9),
371
+ p99: pick(.99),
372
+ count: samples.length
373
+ };
374
+ }
214
375
  /**
215
376
  * DataModule - Unified query and mutation management
216
377
  *
@@ -221,9 +382,36 @@ var DataModule = class {
221
382
  activeQueries = /* @__PURE__ */ new Map();
222
383
  pendingQueries = /* @__PURE__ */ new Map();
223
384
  subscriptions = /* @__PURE__ */ new Map();
385
+ statusSubscriptions = /* @__PURE__ */ new Map();
224
386
  mutationCallbacks = /* @__PURE__ */ new Set();
225
387
  debounceTimers = /* @__PURE__ */ new Map();
226
388
  logger;
389
+ /**
390
+ * Optional observer notified whenever a query's fetch status changes.
391
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
392
+ * settable field (rather than a constructor arg) because DevTools is
393
+ * constructed after DataModule.
394
+ */
395
+ onQueryStatusChange;
396
+ /**
397
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
398
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
399
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
400
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
401
+ * field (not a constructor arg) because the sync engine is wired after
402
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
403
+ */
404
+ onHeartbeat;
405
+ /**
406
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
407
+ * viewport-windowed list cancelling an off-screen window) loses its last
408
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
409
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
410
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
411
+ * {@link finalizeDeregister} only after that remote delete, so a fast
412
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
413
+ */
414
+ onDeregister;
227
415
  sessionId = "";
228
416
  currentUserId = null;
229
417
  constructor(cache, local, schema, logger, streamDebounceTime = 100) {
@@ -323,6 +511,36 @@ var DataModule = class {
323
511
  };
324
512
  }
325
513
  /**
514
+ * Subscribe to a query's fetch-status changes (idle/fetching).
515
+ * With `{ immediate: true }` the callback fires synchronously with the
516
+ * current status (defaults to `idle` if the query isn't registered yet).
517
+ */
518
+ subscribeStatus(queryHash, callback, options = {}) {
519
+ if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
520
+ this.statusSubscriptions.get(queryHash)?.add(callback);
521
+ if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
522
+ return () => {
523
+ const subs = this.statusSubscriptions.get(queryHash);
524
+ if (subs) {
525
+ subs.delete(callback);
526
+ if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
527
+ }
528
+ };
529
+ }
530
+ /**
531
+ * Set a query's fetch status and notify status observers (DevTools +
532
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
533
+ * query is unknown.
534
+ */
535
+ setQueryStatus(queryHash, status) {
536
+ const queryState = this.activeQueries.get(queryHash);
537
+ if (!queryState || queryState.status === status) return;
538
+ queryState.status = status;
539
+ this.onQueryStatusChange?.(queryHash, status);
540
+ const subs = this.statusSubscriptions.get(queryHash);
541
+ if (subs) for (const callback of subs) callback(status);
542
+ }
543
+ /**
326
544
  * Subscribe to mutations (for sync)
327
545
  */
328
546
  onMutation(callback) {
@@ -336,14 +554,39 @@ var DataModule = class {
336
554
  */
337
555
  async onStreamUpdate(update) {
338
556
  const { queryHash, op } = update;
339
- if (op === "UPDATE") {
340
- if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
341
- const timer = setTimeout(async () => {
557
+ if (op === "DELETE") {
558
+ const existing = this.debounceTimers.get(queryHash);
559
+ if (existing) {
560
+ clearTimeout(existing);
342
561
  this.debounceTimers.delete(queryHash);
343
- await this.processStreamUpdate(update);
344
- }, this.streamDebounceTime);
345
- this.debounceTimers.set(queryHash, timer);
346
- } else await this.processStreamUpdate(update);
562
+ }
563
+ await this.processStreamUpdate(update);
564
+ return;
565
+ }
566
+ if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
567
+ const timer = setTimeout(async () => {
568
+ this.debounceTimers.delete(queryHash);
569
+ await this.processStreamUpdate(update);
570
+ }, this.streamDebounceTime);
571
+ this.debounceTimers.set(queryHash, timer);
572
+ }
573
+ async materializeRecords(queryState, sspArray) {
574
+ const t0 = performance.now();
575
+ const windowMat = buildWindowMaterialization(queryState.config.surql);
576
+ let records;
577
+ if (windowMat) {
578
+ const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
579
+ const [rows] = await this.local.query(windowMat.query, {
580
+ ...queryState.config.params,
581
+ __win: winIds
582
+ });
583
+ records = rows || [];
584
+ } else {
585
+ const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
586
+ records = rows || [];
587
+ }
588
+ this.recordPhase(queryState, "localFetch", performance.now() - t0);
589
+ return records;
347
590
  }
348
591
  async processStreamUpdate(update) {
349
592
  const { queryHash, localArray, materializationTimeMs } = update;
@@ -360,10 +603,12 @@ var DataModule = class {
360
603
  if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
361
604
  queryState.lastIngestLatencyMs = materializationTimeMs;
362
605
  }
606
+ if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
607
+ if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
608
+ if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
363
609
  const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
364
610
  try {
365
- const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
366
- const newRecords = records || [];
611
+ const newRecords = await this.materializeRecords(queryState, localArray);
367
612
  queryState.config.localArray = localArray;
368
613
  const prevJson = JSON.stringify(queryState.records);
369
614
  const newJson = JSON.stringify(newRecords);
@@ -399,7 +644,7 @@ var DataModule = class {
399
644
  if (subscribers) for (const callback of subscribers) callback(queryState.records);
400
645
  this.logger.debug({
401
646
  queryHash,
402
- recordCount: records?.length,
647
+ recordCount: newRecords?.length,
403
648
  Category: "sp00ky-client::DataModule::onStreamUpdate"
404
649
  }, "Query updated from stream");
405
650
  } catch (err) {
@@ -444,6 +689,45 @@ var DataModule = class {
444
689
  p99: pick(.99)
445
690
  };
446
691
  }
692
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
693
+ recordPhase(qs, phase, ms) {
694
+ if (!Number.isFinite(ms)) return;
695
+ pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
696
+ qs.phaseLast[phase] = ms;
697
+ }
698
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
699
+ recordRemoteFetch(hash, ms) {
700
+ const qs = this.activeQueries.get(hash);
701
+ if (qs) this.recordPhase(qs, "remoteFetch", ms);
702
+ }
703
+ /**
704
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
705
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
706
+ */
707
+ recordFrontendTiming(hash, ms) {
708
+ const qs = this.activeQueries.get(hash);
709
+ if (qs) this.recordPhase(qs, "frontend", ms);
710
+ }
711
+ /**
712
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
713
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
714
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
715
+ */
716
+ phaseTimings(q) {
717
+ const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
718
+ return {
719
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
720
+ sspStoreApply: stat("sspStoreApply"),
721
+ sspCircuitStep: stat("sspCircuitStep"),
722
+ sspTransform: stat("sspTransform"),
723
+ localFetch: stat("localFetch"),
724
+ remoteFetch: stat("remoteFetch"),
725
+ frontend: stat("frontend"),
726
+ registration: q.registrationTimings,
727
+ updateCount: q.updateCount,
728
+ errorCount: q.errorCount
729
+ };
730
+ }
447
731
  /**
448
732
  * Get query state (for sync and devtools)
449
733
  */
@@ -451,6 +735,84 @@ var DataModule = class {
451
735
  return this.activeQueries.get(hash);
452
736
  }
453
737
  /**
738
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
739
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
740
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
741
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
742
+ * but it still hasn't loaded its own full window from the server — so it should
743
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
744
+ */
745
+ isCold(hash) {
746
+ const qs = this.activeQueries.get(hash);
747
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
748
+ }
749
+ /**
750
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
751
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
752
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
753
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
754
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
755
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
756
+ */
757
+ async applyHydration(hash, rows) {
758
+ const queryState = this.activeQueries.get(hash);
759
+ if (!queryState) return;
760
+ queryState.hydrated = true;
761
+ if (rows.length === 0) return;
762
+ const tableName = queryState.config.tableName;
763
+ const batch = rows.map((record) => ({
764
+ table: tableName,
765
+ op: "CREATE",
766
+ record,
767
+ version: record._00_rv || 1
768
+ }));
769
+ await this.cache.saveBatch(batch);
770
+ queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
771
+ queryState.records = await this.materializeRecords(queryState);
772
+ const subscribers = this.subscriptions.get(hash);
773
+ if (subscribers) for (const cb of subscribers) cb(queryState.records);
774
+ }
775
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
776
+ hasSubscribers(hash) {
777
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
778
+ }
779
+ /**
780
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
781
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
782
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
783
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
784
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
785
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
786
+ *
787
+ * NOTE: most queries should NOT use this — the default keep-alive on
788
+ * unsubscribe avoids re-registration churn on navigation.
789
+ */
790
+ deregisterQuery(hash) {
791
+ if (this.hasSubscribers(hash)) return;
792
+ if (!this.activeQueries.has(hash)) return;
793
+ this.onDeregister?.(hash);
794
+ }
795
+ /**
796
+ * Final local teardown after the remote `_00_query` row was deleted: free the
797
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
798
+ * (`cleanupQuery`) guarantees no subscriber remains.
799
+ */
800
+ finalizeDeregister(hash) {
801
+ const qs = this.activeQueries.get(hash);
802
+ if (qs?.ttlTimer) {
803
+ clearTimeout(qs.ttlTimer);
804
+ qs.ttlTimer = null;
805
+ }
806
+ const debounce = this.debounceTimers.get(hash);
807
+ if (debounce) {
808
+ clearTimeout(debounce);
809
+ this.debounceTimers.delete(hash);
810
+ }
811
+ this.cache.unregisterQuery(hash);
812
+ this.activeQueries.delete(hash);
813
+ this.subscriptions.delete(hash);
814
+ }
815
+ /**
454
816
  * Get query state by id (for sync and devtools)
455
817
  */
456
818
  getQueryById(id) {
@@ -502,8 +864,7 @@ var DataModule = class {
502
864
  async notifyQuerySynced(queryHash) {
503
865
  const queryState = this.activeQueries.get(queryHash);
504
866
  if (!queryState) return;
505
- const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
506
- const newRecords = records || [];
867
+ const newRecords = await this.materializeRecords(queryState);
507
868
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
508
869
  queryState.records = newRecords;
509
870
  if (changed || queryState.updateCount === 0) {
@@ -645,8 +1006,24 @@ var DataModule = class {
645
1006
  id: rid,
646
1007
  mid: mutationId
647
1008
  }));
648
- await this.cache.delete(table, id, true, beforeRecord);
649
- for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) await this.notifyQuerySynced(queryHash);
1009
+ try {
1010
+ await this.cache.delete(table, id, true, beforeRecord);
1011
+ } catch (err) {
1012
+ this.logger.error({
1013
+ err,
1014
+ id,
1015
+ Category: "sp00ky-client::DataModule::delete"
1016
+ }, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
1017
+ }
1018
+ for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
1019
+ await this.notifyQuerySynced(queryHash);
1020
+ } catch (err) {
1021
+ this.logger.error({
1022
+ err,
1023
+ queryHash,
1024
+ Category: "sp00ky-client::DataModule::delete"
1025
+ }, "notifyQuerySynced failed after delete");
1026
+ }
650
1027
  const mutationEvent = {
651
1028
  type: "delete",
652
1029
  mutation_id: mutationId,
@@ -740,7 +1117,7 @@ var DataModule = class {
740
1117
  tableName
741
1118
  });
742
1119
  const t0 = performance.now();
743
- const { localArray } = this.cache.registerQuery({
1120
+ const { localArray, registrationTimings } = this.cache.registerQuery({
744
1121
  queryHash: hash,
745
1122
  surql: surqlString,
746
1123
  params,
@@ -748,6 +1125,12 @@ var DataModule = class {
748
1125
  lastActiveAt: /* @__PURE__ */ new Date()
749
1126
  });
750
1127
  const registrationTime = performance.now() - t0;
1128
+ queryState.registrationTimings = {
1129
+ parseMs: registrationTimings?.parseMs ?? null,
1130
+ planMs: registrationTimings?.planMs ?? null,
1131
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1132
+ wallMs: registrationTime
1133
+ };
751
1134
  await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
752
1135
  "localArray",
753
1136
  "registrationTime",
@@ -758,8 +1141,23 @@ var DataModule = class {
758
1141
  registrationTime,
759
1142
  rowCount: localArray.length
760
1143
  }));
1144
+ const windowMat = buildWindowMaterialization(surqlString);
1145
+ if (windowMat && localArray.length > 0) try {
1146
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1147
+ const [seeded] = await this.local.query(windowMat.query, {
1148
+ ...params,
1149
+ __win: winIds
1150
+ });
1151
+ queryState.records = seeded || [];
1152
+ } catch (err) {
1153
+ this.logger.warn({
1154
+ err,
1155
+ hash,
1156
+ Category: "sp00ky-client::DataModule::createAndRegisterQuery"
1157
+ }, "Failed to seed windowed initial records from localArray");
1158
+ }
761
1159
  this.activeQueries.set(hash, queryState);
762
- this.startTTLHeartbeat(queryState);
1160
+ this.startTTLHeartbeat(queryState, hash);
763
1161
  this.logger.debug({
764
1162
  hash,
765
1163
  tableName,
@@ -797,7 +1195,7 @@ var DataModule = class {
797
1195
  params: parseParams(tableSchema.columns, configRecord.params)
798
1196
  };
799
1197
  let records = [];
800
- try {
1198
+ if (buildWindowMaterialization(surqlString) === null) try {
801
1199
  const [result] = await this.local.query(surqlString, params);
802
1200
  records = result || [];
803
1201
  } catch (err) {
@@ -816,7 +1214,16 @@ var DataModule = class {
816
1214
  updateCount: persistedUpdateCount,
817
1215
  materializationSamples: [],
818
1216
  lastIngestLatencyMs: null,
819
- errorCount: persistedErrorCount
1217
+ errorCount: persistedErrorCount,
1218
+ status: "idle",
1219
+ phaseSamples: {},
1220
+ phaseLast: {},
1221
+ registrationTimings: {
1222
+ parseMs: null,
1223
+ planMs: null,
1224
+ snapshotMs: null,
1225
+ wallMs: null
1226
+ }
820
1227
  };
821
1228
  }
822
1229
  async calculateHash(data) {
@@ -828,23 +1235,27 @@ var DataModule = class {
828
1235
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
829
1236
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
830
1237
  }
831
- startTTLHeartbeat(queryState) {
1238
+ startTTLHeartbeat(queryState, hash) {
832
1239
  if (queryState.ttlTimer) return;
833
1240
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
834
1241
  queryState.ttlTimer = setTimeout(() => {
1242
+ queryState.ttlTimer = null;
1243
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
1244
+ this.logger.debug({
1245
+ hash,
1246
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1247
+ }, "TTL heartbeat: no subscribers, stopping");
1248
+ return;
1249
+ }
1250
+ this.onHeartbeat?.(hash);
835
1251
  this.logger.debug({
1252
+ hash,
836
1253
  id: encodeRecordId(queryState.config.id),
837
1254
  Category: "sp00ky-client::DataModule::startTTLHeartbeat"
838
- }, "TTL heartbeat");
839
- this.startTTLHeartbeat(queryState);
1255
+ }, "TTL heartbeat sent");
1256
+ this.startTTLHeartbeat(queryState, hash);
840
1257
  }, heartbeatTime);
841
1258
  }
842
- stopTTLHeartbeat(queryState) {
843
- if (queryState.ttlTimer) {
844
- clearTimeout(queryState.ttlTimer);
845
- queryState.ttlTimer = null;
846
- }
847
- }
848
1259
  async replaceRecordInQueries(record) {
849
1260
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
850
1261
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -1137,38 +1548,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
1137
1548
  }
1138
1549
  async connect() {
1139
1550
  const { namespace, database } = this.getConfig();
1551
+ const store = this.getConfig().store ?? "memory";
1552
+ const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
1140
1553
  this.logger.info({
1141
1554
  namespace,
1142
1555
  database,
1556
+ storeUrl,
1143
1557
  Category: "sp00ky-client::LocalDatabaseService::connect"
1144
1558
  }, "Connecting to local database");
1559
+ this.registerUnloadClose();
1145
1560
  try {
1146
- const storeUrl = (this.getConfig().store ?? "memory") === "memory" ? "mem://" : "indxdb://sp00ky";
1147
- this.logger.debug({
1148
- storeUrl,
1149
- Category: "sp00ky-client::LocalDatabaseService::connect"
1150
- }, "[LocalDatabaseService] Calling client.connect");
1151
- await this.client.connect(storeUrl, {});
1152
- this.logger.debug({
1153
- namespace,
1154
- database,
1155
- Category: "sp00ky-client::LocalDatabaseService::connect"
1156
- }, "[LocalDatabaseService] client.connect returned. Calling client.use");
1157
- await this.client.use({
1158
- namespace,
1159
- database
1160
- });
1161
- this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
1561
+ await this.openStore(storeUrl, namespace, database);
1162
1562
  this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
1563
+ return;
1163
1564
  } catch (err) {
1164
- this.logger.error({
1565
+ if (store === "memory" || !isLocalStoreOpenError(err)) {
1566
+ this.logger.error({
1567
+ err,
1568
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1569
+ }, "Failed to connect to local database");
1570
+ throw err;
1571
+ }
1572
+ this.logger.warn({
1165
1573
  err,
1166
1574
  Category: "sp00ky-client::LocalDatabaseService::connect"
1167
- }, "Failed to connect to local database");
1168
- throw err;
1575
+ }, "Local IndexedDB store failed to open; retrying before clearing");
1576
+ }
1577
+ for (let attempt = 1; attempt <= 2; attempt++) {
1578
+ try {
1579
+ await this.client.close();
1580
+ } catch {}
1581
+ await delay(150 * attempt);
1582
+ try {
1583
+ await this.openStore(storeUrl, namespace, database);
1584
+ this.logger.info({
1585
+ attempt,
1586
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1587
+ }, "Connected to local database on retry (cache preserved)");
1588
+ return;
1589
+ } catch (retryErr) {
1590
+ this.logger.warn({
1591
+ err: retryErr,
1592
+ attempt,
1593
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1594
+ }, "Local store retry failed");
1595
+ }
1596
+ }
1597
+ try {
1598
+ await this.client.close();
1599
+ } catch {}
1600
+ await dropLocalIndexedDbStores(this.logger);
1601
+ try {
1602
+ await this.openStore(storeUrl, namespace, database);
1603
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
1604
+ } catch (retryErr) {
1605
+ this.logger.error({
1606
+ err: retryErr,
1607
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1608
+ }, "Local store still failing after clear; falling back to in-memory");
1609
+ try {
1610
+ await this.client.close();
1611
+ } catch {}
1612
+ await this.openStore("mem://", namespace, database);
1613
+ this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
1169
1614
  }
1170
1615
  }
1616
+ unloadCloseRegistered = false;
1617
+ /**
1618
+ * Close the local DB on page unload so the SurrealDB-WASM worker releases its
1619
+ * IndexedDB connection cleanly. Without this, the previous page's connection
1620
+ * lingers; the next load's `client.connect` opens the store but the first
1621
+ * write transaction in `client.use` hits an "IndexedDB error" — which then
1622
+ * (mis)triggered the corrupt-store recovery and WIPED the cache on every
1623
+ * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
1624
+ * unload signal (fires on bfcache + normal navigation); `close()` is async but
1625
+ * the WASM worker initiates the IndexedDB connection teardown synchronously.
1626
+ */
1627
+ registerUnloadClose() {
1628
+ if (this.unloadCloseRegistered || typeof window === "undefined") return;
1629
+ this.unloadCloseRegistered = true;
1630
+ const close = () => {
1631
+ try {
1632
+ this.client.close();
1633
+ } catch {}
1634
+ };
1635
+ window.addEventListener("pagehide", close);
1636
+ window.addEventListener("beforeunload", close);
1637
+ }
1638
+ async openStore(storeUrl, namespace, database) {
1639
+ this.logger.debug({
1640
+ storeUrl,
1641
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1642
+ }, "[LocalDatabaseService] Calling client.connect");
1643
+ await this.client.connect(storeUrl, {});
1644
+ this.logger.debug({
1645
+ namespace,
1646
+ database,
1647
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1648
+ }, "[LocalDatabaseService] client.connect returned. Calling client.use");
1649
+ await this.client.use({
1650
+ namespace,
1651
+ database
1652
+ });
1653
+ this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
1654
+ }
1171
1655
  };
1656
+ function delay(ms) {
1657
+ return new Promise((resolve) => setTimeout(resolve, ms));
1658
+ }
1659
+ /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
1660
+ * store can't be opened (corrupt / version-incompatible / blocked). Exported
1661
+ * for unit testing the error-message match. */
1662
+ function isLocalStoreOpenError(err) {
1663
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
1664
+ return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
1665
+ }
1666
+ /** Best-effort delete of this client's IndexedDB store(s). The persistent local
1667
+ * DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
1668
+ * IndexedDB databases whose names include `sp00ky`. Resolves even on
1669
+ * error/blocked so startup can proceed. No-op outside a browser. */
1670
+ async function dropLocalIndexedDbStores(logger) {
1671
+ if (typeof indexedDB === "undefined") return;
1672
+ const remove = (name) => new Promise((resolve) => {
1673
+ try {
1674
+ const req = indexedDB.deleteDatabase(name);
1675
+ req.onsuccess = () => resolve();
1676
+ req.onerror = () => resolve();
1677
+ req.onblocked = () => resolve();
1678
+ } catch {
1679
+ resolve();
1680
+ }
1681
+ });
1682
+ try {
1683
+ let names = [];
1684
+ if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
1685
+ if (names.length === 0) names = ["sp00ky"];
1686
+ await Promise.all(names.map(remove));
1687
+ logger.info({
1688
+ names,
1689
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1690
+ }, "Cleared local IndexedDB store(s)");
1691
+ } catch (e) {
1692
+ logger.warn({
1693
+ err: e,
1694
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1695
+ }, "Failed to enumerate/clear IndexedDB; proceeding anyway");
1696
+ }
1697
+ }
1172
1698
 
1173
1699
  //#endregion
1174
1700
  //#region src/services/database/remote.ts
@@ -1727,36 +2253,54 @@ function resolveListRefPollInterval(opt) {
1727
2253
  return opt;
1728
2254
  }
1729
2255
  /**
1730
- * When the LIVE feed has delivered an event within this window, treat
1731
- * the LIVE subscription as healthy and back the poll off to
1732
- * {@link LIVE_HEALTHY_POLL_INTERVAL_MS}. As soon as LIVE quiets for
1733
- * longer than this, the poll snaps back to the aggressive default.
2256
+ * Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
2257
+ * events, no poll-detected list_ref changes) coasts up to this cadence;
2258
+ * the existing healthy-LIVE safety net runs at the same 5s, so this keeps
2259
+ * the worst-case catch-up latency for a missed cross-session change at the
2260
+ * cadence the codebase already treats as acceptable.
1734
2261
  */
1735
- const LIVE_HEALTHY_COOLDOWN_MS = 5e3;
2262
+ const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
1736
2263
  /**
1737
- * Poll interval while LIVE is delivering events. The aggressive
1738
- * default 500ms costs ~120 queries / minute / session — wasted work
1739
- * when LIVE is already covering us. 5s keeps the safety net in place
1740
- * at 1/10th the load.
2264
+ * Adaptive poll delay: stay at the responsive `baseIntervalMs` while
2265
+ * changes are arriving, and exponentially back off toward `maxIntervalMs`
2266
+ * while the `_00_list_ref` is quiet.
2267
+ *
2268
+ * `idleStreak` is the count of consecutive poll cycles that observed *no*
2269
+ * change. `Sp00kySync` resets it to 0 whenever a poll detects a real
2270
+ * remoteArray change OR a LIVE event lands, so any activity snaps the poll
2271
+ * straight back to `baseIntervalMs`. A streak of 0 (something just
2272
+ * happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
2273
+ *
2274
+ * This replaces {@link nextPollDelayMs}: the old helper slowed the poll
2275
+ * only while LIVE was *delivering*, but the cross-session LIVE-permission
2276
+ * gap means LIVE frequently never fires here, so it left a fully idle page
2277
+ * polling every `base` ms forever (the "continuous queries while idle"
2278
+ * symptom). Backing off on observed idleness instead covers the
2279
+ * LIVE-healthy case for free (LIVE applies the change → the next poll sees
2280
+ * nothing new → the streak grows → it backs off).
1741
2281
  */
1742
- const LIVE_HEALTHY_POLL_INTERVAL_MS = 5e3;
2282
+ function listRefPollDelayMs(args) {
2283
+ const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
2284
+ const cap = Math.max(baseIntervalMs, maxIntervalMs);
2285
+ if (idleStreak <= 0) return baseIntervalMs;
2286
+ const exponent = Math.min(idleStreak, 30);
2287
+ return Math.min(baseIntervalMs * 2 ** exponent, cap);
2288
+ }
1743
2289
  /**
1744
- * Pick the next poll delay based on whether LIVE has been healthy
1745
- * recently. If a LIVE event fired within `cooldownMs`, use the slow
1746
- * (`healthyIntervalMs`) cadence; otherwise the fast (`baseIntervalMs`)
1747
- * cadence. Pure so it's unit-testable; `Sp00kySync.startListRefPoll`
1748
- * calls it from a self-rescheduling timer.
1749
- *
1750
- * The healthy interval is clamped to at least `baseIntervalMs` so
1751
- * configuring an aggressive base (e.g. 100ms) never gets implicitly
1752
- * widened by this helper.
2290
+ * Order-insensitive equality for two `RecordVersionArray`s (each a list of
2291
+ * `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
2292
+ * BY`, so row order can differ between polls without anything having
2293
+ * actually changed comparing as an id→version map avoids false
2294
+ * "changed" verdicts that would defeat the idle backoff. Record ids are
2295
+ * unique within a query's list_ref, so a map is a faithful representation.
1753
2296
  */
1754
- function nextPollDelayMs(args) {
1755
- const { now, lastLiveEventAt, baseIntervalMs, cooldownMs = LIVE_HEALTHY_COOLDOWN_MS, healthyIntervalMs = LIVE_HEALTHY_POLL_INTERVAL_MS } = args;
1756
- if (lastLiveEventAt === null) return baseIntervalMs;
1757
- const sinceLive = now - lastLiveEventAt;
1758
- if (sinceLive < 0 || sinceLive >= cooldownMs) return baseIntervalMs;
1759
- return Math.max(healthyIntervalMs, baseIntervalMs);
2297
+ function recordVersionArraysEqual(a, b) {
2298
+ if (a === b) return true;
2299
+ if (a.length !== b.length) return false;
2300
+ const byId = /* @__PURE__ */ new Map();
2301
+ for (const [id, version] of a) byId.set(id, version);
2302
+ for (const [id, version] of b) if (byId.get(id) !== version) return false;
2303
+ return true;
1760
2304
  }
1761
2305
 
1762
2306
  //#endregion
@@ -1789,13 +2333,19 @@ var SyncEngine = class {
1789
2333
  removed,
1790
2334
  Category: "sp00ky-client::SyncEngine::syncRecords"
1791
2335
  }, "SyncEngine.syncRecords diff");
1792
- if (removed.length > 0) await this.handleRemovedRecords(removed);
2336
+ let stillRemoteIds = [];
2337
+ if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
1793
2338
  const toFetch = [...added, ...updated];
1794
2339
  const idsToFetch = toFetch.map((x) => x.id);
1795
- if (idsToFetch.length === 0) return;
2340
+ if (idsToFetch.length === 0) return {
2341
+ remoteFetchMs: 0,
2342
+ stillRemoteIds
2343
+ };
1796
2344
  const versionMap = /* @__PURE__ */ new Map();
1797
2345
  for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
2346
+ const remoteFetchStart = performance.now();
1798
2347
  const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
2348
+ const remoteFetchMs = performance.now() - remoteFetchStart;
1799
2349
  const cacheBatch = [];
1800
2350
  for (const record of remoteResults) {
1801
2351
  if (!record?.id) {
@@ -1831,6 +2381,10 @@ var SyncEngine = class {
1831
2381
  }
1832
2382
  if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
1833
2383
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
2384
+ return {
2385
+ remoteFetchMs,
2386
+ stillRemoteIds
2387
+ };
1834
2388
  }
1835
2389
  /**
1836
2390
  * Handle records that exist locally but not in remote array.
@@ -1851,30 +2405,19 @@ var SyncEngine = class {
1851
2405
  removed: removed.map((r) => r.toString()),
1852
2406
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1853
2407
  }, "Checking removed records");
1854
- const byTable = /* @__PURE__ */ new Map();
1855
- for (const r of removed) {
1856
- const list = byTable.get(r.table.name) ?? [];
1857
- list.push(r);
1858
- byTable.set(r.table.name, list);
1859
- }
1860
2408
  let existingRemoteIds;
1861
2409
  try {
1862
- existingRemoteIds = /* @__PURE__ */ new Set();
1863
- for (const [table, ids] of byTable) {
1864
- const [existing] = await this.remote.query("SELECT id FROM type::table($table) WHERE id IN $ids", {
1865
- table,
1866
- ids
1867
- });
1868
- for (const row of existing) existingRemoteIds.add(encodeRecordId(row.id));
1869
- }
2410
+ const [existing] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
2411
+ existingRemoteIds = new Set((existing ?? []).map((row) => encodeRecordId(row.id)));
1870
2412
  } catch (err) {
1871
2413
  this.logger.warn({
1872
2414
  err,
1873
2415
  removed: removed.map((r) => r.toString()),
1874
2416
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1875
2417
  }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
1876
- return;
2418
+ return [];
1877
2419
  }
2420
+ const stillRemoteIds = [];
1878
2421
  for (const recordId of removed) {
1879
2422
  const recordIdStr = encodeRecordId(recordId);
1880
2423
  if (!existingRemoteIds.has(recordIdStr)) {
@@ -1883,8 +2426,9 @@ var SyncEngine = class {
1883
2426
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1884
2427
  }, "Deleting confirmed removed record");
1885
2428
  await this.cache.delete(recordId.table.name, recordIdStr);
1886
- }
2429
+ } else stillRemoteIds.push(recordIdStr);
1887
2430
  }
2431
+ return stillRemoteIds;
1888
2432
  }
1889
2433
  };
1890
2434
 
@@ -2025,6 +2569,8 @@ var Sp00kySync = class {
2025
2569
  listRefPollTimer = null;
2026
2570
  listRefPollRunning = false;
2027
2571
  refSyncIntervalMs;
2572
+ listRefIdleStreak = 0;
2573
+ stillRemoteStreaks = /* @__PURE__ */ new Map();
2028
2574
  lastLiveEventAt = null;
2029
2575
  _liveRetryCount = 0;
2030
2576
  get liveRetryCount() {
@@ -2126,13 +2672,14 @@ var Sp00kySync = class {
2126
2672
  const schedule = (delayMs) => {
2127
2673
  this.listRefPollTimer = setTimeout(async () => {
2128
2674
  if (!this.listRefPollRunning) return;
2675
+ let changed = false;
2129
2676
  try {
2130
- await this.pollListRefForActiveQueries();
2677
+ changed = await this.pollListRefForActiveQueries();
2131
2678
  } finally {
2132
2679
  if (!this.listRefPollRunning) return;
2133
- schedule(nextPollDelayMs({
2134
- now: Date.now(),
2135
- lastLiveEventAt: this.lastLiveEventAt,
2680
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
2681
+ schedule(listRefPollDelayMs({
2682
+ idleStreak: this.listRefIdleStreak,
2136
2683
  baseIntervalMs: this.refSyncIntervalMs
2137
2684
  }));
2138
2685
  }
@@ -2147,11 +2694,17 @@ var Sp00kySync = class {
2147
2694
  this.listRefPollTimer = null;
2148
2695
  }
2149
2696
  }
2697
+ /**
2698
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
2699
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
2700
+ * to drive the adaptive idle backoff.
2701
+ */
2150
2702
  async pollListRefForActiveQueries() {
2151
2703
  const hashes = this.dataModule.getActiveQueryHashes();
2152
- if (hashes.length === 0) return;
2704
+ if (hashes.length === 0) return false;
2705
+ let anyChanged = false;
2153
2706
  for (const hash of hashes) try {
2154
- await this.refetchListRefForQuery(hash);
2707
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
2155
2708
  } catch (err) {
2156
2709
  this.logger.debug({
2157
2710
  err: err?.message ?? err,
@@ -2159,6 +2712,7 @@ var Sp00kySync = class {
2159
2712
  Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2160
2713
  }, "Per-query list_ref poll failed");
2161
2714
  }
2715
+ return anyChanged;
2162
2716
  }
2163
2717
  /**
2164
2718
  * Pull the upstream list_ref entries for `queryHash`, diff them
@@ -2170,12 +2724,16 @@ var Sp00kySync = class {
2170
2724
  */
2171
2725
  async refetchListRefForQuery(queryHash) {
2172
2726
  const queryState = this.dataModule.getQueryByHash(queryHash);
2173
- if (!queryState) return;
2727
+ if (!queryState) return false;
2174
2728
  const listRefTbl = this.listRefTable();
2175
2729
  const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2176
- if (!Array.isArray(items)) return;
2730
+ if (!Array.isArray(items)) return false;
2177
2731
  const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
2178
- await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
2732
+ const prevRemote = queryState.config.remoteArray ?? [];
2733
+ const freshIds = new Set(fresh.map(([id]) => id));
2734
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
2735
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
2736
+ if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
2179
2737
  try {
2180
2738
  await this.syncQuery(queryHash);
2181
2739
  } catch (err) {
@@ -2185,6 +2743,16 @@ var Sp00kySync = class {
2185
2743
  Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2186
2744
  }, "syncQuery failed during poll");
2187
2745
  }
2746
+ if (removedIds.length > 0) try {
2747
+ await this.dataModule.notifyQuerySynced(queryHash);
2748
+ } catch (err) {
2749
+ this.logger.info({
2750
+ err: err?.message ?? err,
2751
+ queryHash,
2752
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2753
+ }, "notifyQuerySynced failed during poll-removal re-render");
2754
+ }
2755
+ return changed;
2188
2756
  }
2189
2757
  /**
2190
2758
  * Resolve the current `_00_list_ref` table name for the active auth
@@ -2237,6 +2805,12 @@ var Sp00kySync = class {
2237
2805
  type: "register",
2238
2806
  payload: { hash }
2239
2807
  });
2808
+ if (this.currentUserId) this.restartRefLiveQuery().catch((err) => {
2809
+ this.logger.debug({
2810
+ err,
2811
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
2812
+ }, "LIVE restart after reconnect failed; relying on poll fallback");
2813
+ });
2240
2814
  });
2241
2815
  }
2242
2816
  async startRefLiveQueries() {
@@ -2253,6 +2827,7 @@ var Sp00kySync = class {
2253
2827
  Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
2254
2828
  }, "Live update received");
2255
2829
  if (message.action === "KILLED") return;
2830
+ if (message.value.parent != null) return;
2256
2831
  this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
2257
2832
  this.logger.error({
2258
2833
  err,
@@ -2263,14 +2838,7 @@ var Sp00kySync = class {
2263
2838
  }
2264
2839
  async handleRemoteListRefChange(action, queryId, recordId, version) {
2265
2840
  this.lastLiveEventAt = Date.now();
2266
- if (action === "DELETE") {
2267
- this.logger.debug({
2268
- queryId: queryId.toString(),
2269
- recordId: recordId.toString(),
2270
- Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
2271
- }, "Ignoring DELETE on list_ref — should not happen");
2272
- return;
2273
- }
2841
+ this.listRefIdleStreak = 0;
2274
2842
  const existing = this.dataModule.getQueryById(queryId);
2275
2843
  if (!existing) {
2276
2844
  this.logger.warn({
@@ -2289,7 +2857,8 @@ var Sp00kySync = class {
2289
2857
  Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
2290
2858
  }, "Live update is being processed");
2291
2859
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
2292
- await this.syncEngine.syncRecords(diff);
2860
+ const hash = extractIdPart(existing.config.id);
2861
+ await this.runSyncForQuery(hash, diff);
2293
2862
  }
2294
2863
  /**
2295
2864
  * Enqueues a 'down' event (from remote to local) for processing.
@@ -2396,7 +2965,70 @@ var Sp00kySync = class {
2396
2965
  }
2397
2966
  const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
2398
2967
  if (!diff) return;
2399
- return this.syncEngine.syncRecords(diff);
2968
+ return this.runSyncForQuery(hash, diff);
2969
+ }
2970
+ /**
2971
+ * Run a sync for a single query while reflecting its fetch status. Marks the
2972
+ * query `fetching` for the duration when the diff actually pulls records
2973
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
2974
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
2975
+ * means the single resulting UI update lands after this completes.
2976
+ */
2977
+ async runSyncForQuery(hash, diff) {
2978
+ if (diff.added.length > 0 || diff.updated.length > 0) {
2979
+ const pendingDeletes = await this.getPendingDeleteIds();
2980
+ if (pendingDeletes.size > 0) diff = {
2981
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
2982
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
2983
+ removed: diff.removed
2984
+ };
2985
+ }
2986
+ const fetching = diff.added.length + diff.updated.length > 0;
2987
+ if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
2988
+ try {
2989
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
2990
+ if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
2991
+ if (stillRemoteIds.length > 0) {
2992
+ const CONVERGE_AFTER = 3;
2993
+ const toConverge = [];
2994
+ for (const id of stillRemoteIds) {
2995
+ const key = `${hash}:${id}`;
2996
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
2997
+ if (n >= CONVERGE_AFTER) {
2998
+ this.stillRemoteStreaks.delete(key);
2999
+ toConverge.push(id);
3000
+ } else this.stillRemoteStreaks.set(key, n);
3001
+ }
3002
+ if (toConverge.length > 0) {
3003
+ const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
3004
+ if (local && local.length > 0) {
3005
+ const drop = new Set(toConverge);
3006
+ const next = local.filter(([id]) => !drop.has(id));
3007
+ if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
3008
+ }
3009
+ }
3010
+ }
3011
+ } finally {
3012
+ if (fetching) this.dataModule.setQueryStatus(hash, "idle");
3013
+ }
3014
+ }
3015
+ /**
3016
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
3017
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
3018
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
3019
+ * would otherwise resurrect a just-deleted record.
3020
+ */
3021
+ async getPendingDeleteIds() {
3022
+ try {
3023
+ const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
3024
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
3025
+ } catch (err) {
3026
+ this.logger.warn({
3027
+ err,
3028
+ Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
3029
+ }, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
3030
+ return /* @__PURE__ */ new Set();
3031
+ }
2400
3032
  }
2401
3033
  /**
2402
3034
  * Enqueues a list of mutations (up events) to be sent to the remote.
@@ -2465,14 +3097,17 @@ var Sp00kySync = class {
2465
3097
  }
2466
3098
  async cleanupQuery(queryHash) {
2467
3099
  const queryState = this.dataModule.getQueryByHash(queryHash);
2468
- if (!queryState) {
2469
- this.logger.warn({
2470
- queryHash,
2471
- Category: "sp00ky-client::Sp00kySync::cleanupQuery"
2472
- }, "Query to register not found");
2473
- throw new Error("Query to register not found");
2474
- }
3100
+ if (!queryState) return;
3101
+ if (this.dataModule.hasSubscribers(queryHash)) return;
2475
3102
  await this.remote.query(`DELETE $id`, { id: queryState.config.id });
3103
+ if (this.dataModule.hasSubscribers(queryHash)) {
3104
+ this.enqueueDownEvent({
3105
+ type: "register",
3106
+ payload: { hash: queryHash }
3107
+ });
3108
+ return;
3109
+ }
3110
+ this.dataModule.finalizeDeregister(queryHash);
2476
3111
  }
2477
3112
  };
2478
3113
 
@@ -2483,12 +3118,64 @@ function createAuthEventSystem() {
2483
3118
  return createEventSystem([AuthEventTypes.AuthStateChanged]);
2484
3119
  }
2485
3120
 
3121
+ //#endregion
3122
+ //#region src/modules/devtools/versions.ts
3123
+ const UNAVAILABLE = "unavailable";
3124
+ function emptyBackendVersions() {
3125
+ return {
3126
+ ssp: UNAVAILABLE,
3127
+ scheduler: UNAVAILABLE,
3128
+ surrealdb: UNAVAILABLE
3129
+ };
3130
+ }
3131
+ function emptyBackendInfo() {
3132
+ return {
3133
+ versions: emptyBackendVersions(),
3134
+ entities: []
3135
+ };
3136
+ }
3137
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
3138
+ function normalizeServerVersion(v) {
3139
+ return String(v).replace(/^surrealdb-/i, "").trim();
3140
+ }
3141
+ /**
3142
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
3143
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
3144
+ * how the result is unwrapped it may arrive as the array itself, a single
3145
+ * object, or `null`. Tolerant of all three.
3146
+ */
3147
+ function toEntityArray(raw) {
3148
+ if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
3149
+ if (raw && typeof raw === "object") return [raw];
3150
+ return [];
3151
+ }
3152
+ /**
3153
+ * Derive component versions + the full entity list from a `/info` entity array.
3154
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
3155
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
3156
+ */
3157
+ function parseBackendInfo(raw) {
3158
+ const entities = toEntityArray(raw);
3159
+ const versions = emptyBackendVersions();
3160
+ for (const entity of entities) {
3161
+ const version = entity.version ? String(entity.version) : void 0;
3162
+ if (entity.entity === "ssp" && version) versions.ssp = version;
3163
+ else if (entity.entity === "scheduler" && version) versions.scheduler = version;
3164
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
3165
+ }
3166
+ return {
3167
+ versions,
3168
+ entities
3169
+ };
3170
+ }
3171
+
2486
3172
  //#endregion
2487
3173
  //#region src/modules/devtools/index.ts
2488
3174
  var DevToolsService = class {
2489
3175
  eventsHistory = [];
2490
3176
  eventIdCounter = 0;
2491
- version = "1.0.0";
3177
+ version = "0.0.1-canary.70";
3178
+ backendInfo = emptyBackendInfo();
2492
3179
  constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
2493
3180
  this.databaseService = databaseService;
2494
3181
  this.remoteDatabaseService = remoteDatabaseService;
@@ -2500,8 +3187,27 @@ var DevToolsService = class {
2500
3187
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
2501
3188
  this.notifyDevTools();
2502
3189
  });
3190
+ this.refreshBackendVersions();
2503
3191
  this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
2504
3192
  }
3193
+ /**
3194
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
3195
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
3196
+ * Never throws: on failure the info stays empty/'unavailable'.
3197
+ */
3198
+ async refreshBackendVersions() {
3199
+ try {
3200
+ const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
3201
+ this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
3202
+ } catch (err) {
3203
+ this.logger.debug({
3204
+ err,
3205
+ Category: "sp00ky-client::DevToolsService::versions"
3206
+ }, "fn::spooky::info() unavailable; backend versions stay unavailable");
3207
+ this.backendInfo = emptyBackendInfo();
3208
+ }
3209
+ this.notifyDevTools();
3210
+ }
2505
3211
  getActiveQueries() {
2506
3212
  const result = /* @__PURE__ */ new Map();
2507
3213
  if (!this.dataManager) return result;
@@ -2510,6 +3216,8 @@ var DevToolsService = class {
2510
3216
  result.set(queryHash, {
2511
3217
  queryHash,
2512
3218
  status: "active",
3219
+ fetchStatus: q.status,
3220
+ isFetching: q.status === "fetching",
2513
3221
  createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
2514
3222
  lastUpdate: Date.now(),
2515
3223
  updateCount: q.updateCount,
@@ -2518,7 +3226,8 @@ var DevToolsService = class {
2518
3226
  dataSize: q.records?.length || 0,
2519
3227
  data: q.records,
2520
3228
  localArray: q.config.localArray,
2521
- remoteArray: q.config.remoteArray
3229
+ remoteArray: q.config.remoteArray,
3230
+ timings: this.dataManager.phaseTimings(q)
2522
3231
  });
2523
3232
  });
2524
3233
  return result;
@@ -2598,6 +3307,15 @@ var DevToolsService = class {
2598
3307
  userId: this.authService.currentUser?.id
2599
3308
  },
2600
3309
  version: this.version,
3310
+ versions: {
3311
+ frontend: {
3312
+ core: "0.0.1-canary.70",
3313
+ wasm: "0.0.1-canary.70",
3314
+ surrealdb: "3.0.3"
3315
+ },
3316
+ backend: this.backendInfo.versions,
3317
+ entities: this.backendInfo.entities
3318
+ },
2601
3319
  database: {
2602
3320
  tables: this.schema.tables.map((t) => t.name),
2603
3321
  tableData: {}
@@ -2640,6 +3358,7 @@ var DevToolsService = class {
2640
3358
  this.eventsHistory = [];
2641
3359
  this.notifyDevTools();
2642
3360
  },
3361
+ refreshVersions: () => this.refreshBackendVersions(),
2643
3362
  getTableData: async (tableName) => {
2644
3363
  try {
2645
3364
  const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
@@ -2893,6 +3612,8 @@ var StreamProcessorService = class {
2893
3612
  processor;
2894
3613
  isInitialized = false;
2895
3614
  receivers = [];
3615
+ batching = false;
3616
+ batchBuffer = /* @__PURE__ */ new Map();
2896
3617
  constructor(events, db, persistenceClient, logger) {
2897
3618
  this.events = events;
2898
3619
  this.db = db;
@@ -2907,9 +3628,75 @@ var StreamProcessorService = class {
2907
3628
  this.receivers.push(receiver);
2908
3629
  }
2909
3630
  notifyUpdates(updates) {
3631
+ if (this.batching) {
3632
+ for (const update of updates) {
3633
+ const prev = this.batchBuffer.get(update.queryHash);
3634
+ const sum = (a, b) => (a ?? 0) + (b ?? 0);
3635
+ this.batchBuffer.set(update.queryHash, {
3636
+ ...update,
3637
+ op: "CREATE",
3638
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
3639
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
3640
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
3641
+ transformMs: sum(prev?.transformMs, update.transformMs)
3642
+ });
3643
+ }
3644
+ return;
3645
+ }
3646
+ this.dispatchUpdates(updates);
3647
+ }
3648
+ dispatchUpdates(updates) {
2910
3649
  for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
2911
3650
  }
2912
3651
  /**
3652
+ * Ingest a batch of record changes as a single bulk operation, firing only
3653
+ * one coalesced `StreamUpdate` per affected query once every record has been
3654
+ * ingested (instead of one update per record). Use this whenever multiple
3655
+ * records land at once — e.g. sync fetching N missing rows — so a list query
3656
+ * re-runs and the UI re-renders once for the whole batch rather than
3657
+ * row-by-row.
3658
+ *
3659
+ * Internally opens a coalescing window, ingests each record, then flushes;
3660
+ * processor state is persisted once for the whole batch. No-op for an empty
3661
+ * batch.
3662
+ */
3663
+ ingestMany(records) {
3664
+ if (records.length === 0) return;
3665
+ this.beginCoalescing();
3666
+ try {
3667
+ for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
3668
+ } finally {
3669
+ this.flushCoalescing();
3670
+ }
3671
+ }
3672
+ /**
3673
+ * Open a coalescing window. While open, the per-record stream updates
3674
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
3675
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
3676
+ * `ingestMany` so the window always closes — otherwise the processor stays
3677
+ * stuck buffering forever.
3678
+ *
3679
+ * No-op if a window is already open (nested batches aren't expected here).
3680
+ */
3681
+ beginCoalescing() {
3682
+ if (this.batching) return;
3683
+ this.batching = true;
3684
+ this.batchBuffer.clear();
3685
+ }
3686
+ /**
3687
+ * Close the coalescing window and flush: dispatch one coalesced
3688
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
3689
+ * for the whole batch (instead of once per ingest).
3690
+ */
3691
+ flushCoalescing() {
3692
+ if (!this.batching) return;
3693
+ this.batching = false;
3694
+ const buffered = Array.from(this.batchBuffer.values());
3695
+ this.batchBuffer.clear();
3696
+ if (buffered.length > 0) this.dispatchUpdates(buffered);
3697
+ this.saveState();
3698
+ }
3699
+ /**
2913
3700
  * Initialize the WASM module and processor.
2914
3701
  * This must be called before using other methods.
2915
3702
  */
@@ -2950,6 +3737,23 @@ var StreamProcessorService = class {
2950
3737
  }, "Failed to load state");
2951
3738
  }
2952
3739
  }
3740
+ /**
3741
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
3742
+ * Must run after the processor exists and before any `register_view`, else
3743
+ * non-`_00_` tables are default-denied and registration fails.
3744
+ */
3745
+ setPermissions(permissions) {
3746
+ if (!this.processor) return;
3747
+ if (typeof this.processor.set_permissions !== "function") {
3748
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
3749
+ return;
3750
+ }
3751
+ this.processor.set_permissions(permissions);
3752
+ this.logger.info({
3753
+ tables: Object.keys(permissions).length,
3754
+ Category: "sp00ky-client::StreamProcessorService::setPermissions"
3755
+ }, "Seeded table permissions");
3756
+ }
2953
3757
  async saveState() {
2954
3758
  if (!this.processor) return;
2955
3759
  try {
@@ -3001,11 +3805,14 @@ var StreamProcessorService = class {
3001
3805
  queryHash: u.query_id,
3002
3806
  localArray: u.result_data,
3003
3807
  op,
3004
- materializationTimeMs
3808
+ materializationTimeMs,
3809
+ storeApplyMs: u.timing_store_apply_ms,
3810
+ circuitStepMs: u.timing_circuit_step_ms,
3811
+ transformMs: u.timing_transform_ms
3005
3812
  }));
3006
3813
  this.notifyUpdates(updates);
3007
3814
  }
3008
- this.saveState();
3815
+ if (!this.batching) this.saveState();
3009
3816
  return rawUpdates;
3010
3817
  } catch (e) {
3011
3818
  this.logger.error({
@@ -3047,7 +3854,12 @@ var StreamProcessorService = class {
3047
3854
  if (!initialUpdate) throw new Error("Failed to register query plan");
3048
3855
  const update = {
3049
3856
  queryHash: initialUpdate.query_id,
3050
- localArray: initialUpdate.result_data
3857
+ localArray: initialUpdate.result_data,
3858
+ registration: {
3859
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
3860
+ planMs: initialUpdate.timing_plan_ms ?? 0,
3861
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
3862
+ }
3051
3863
  };
3052
3864
  this.saveState();
3053
3865
  this.logger.debug({
@@ -3108,6 +3920,51 @@ var StreamProcessorService = class {
3108
3920
  }
3109
3921
  };
3110
3922
 
3923
+ //#endregion
3924
+ //#region src/services/stream-processor/permissions.ts
3925
+ /**
3926
+ * Extract per-table `select` permission predicates from a raw `.surql` schema.
3927
+ *
3928
+ * The in-browser SSP default-denies any non-`_00_` table that has no permission
3929
+ * predicate registered (see `permission_inject::build_predicate`). The client
3930
+ * already holds the full schema text (`config.schemaSurql`), so we parse each
3931
+ * `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
3932
+ * processor via `set_permissions` at boot — mirroring the native path that
3933
+ * seeds the same map from `INFO FOR DB`.
3934
+ *
3935
+ * Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
3936
+ * expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
3937
+ * `select` permission).
3938
+ */
3939
+ function extractSelectPermissions(schemaSurql) {
3940
+ const out = {};
3941
+ if (!schemaSurql) return out;
3942
+ const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
3943
+ const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
3944
+ let m;
3945
+ while ((m = tableStmt.exec(cleaned)) !== null) {
3946
+ const table = m[1];
3947
+ const body = m[2];
3948
+ out[table] = selectPredicateFromBody(body);
3949
+ }
3950
+ return out;
3951
+ }
3952
+ function selectPredicateFromBody(body) {
3953
+ const permIdx = body.search(/\bPERMISSIONS\b/i);
3954
+ if (permIdx === -1) return "false";
3955
+ const perms = body.slice(permIdx + 11).trim();
3956
+ if (/^FULL\b/i.test(perms)) return "true";
3957
+ if (/^NONE\b/i.test(perms)) return "false";
3958
+ const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
3959
+ for (const group of groups) {
3960
+ const where = group.search(/\bWHERE\b/i);
3961
+ if (where === -1) continue;
3962
+ const actions = group.slice(0, where).toLowerCase();
3963
+ if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
3964
+ }
3965
+ return "false";
3966
+ }
3967
+
3111
3968
  //#endregion
3112
3969
  //#region src/modules/cache/index.ts
3113
3970
  /**
@@ -3185,11 +4042,17 @@ var CacheModule = class {
3185
4042
  }, {});
3186
4043
  await this.local.execute(query, params);
3187
4044
  }
3188
- for (const record of populatedRecords) {
4045
+ const bulk = populatedRecords.map((record) => {
3189
4046
  const recordId = encodeRecordId(record.record.id);
3190
4047
  this.versionLookups[recordId] = record.version;
3191
- this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
3192
- }
4048
+ return {
4049
+ table: record.table,
4050
+ op: record.op,
4051
+ id: recordId,
4052
+ record: record.record
4053
+ };
4054
+ });
4055
+ this.streamProcessor.ingestMany(bulk);
3193
4056
  this.logger.debug({
3194
4057
  count: records.length,
3195
4058
  Category: "sp00ky-client::CacheModule::saveBatch"
@@ -3258,7 +4121,10 @@ var CacheModule = class {
3258
4121
  arrayLength: update.localArray?.length,
3259
4122
  Category: "sp00ky-client::CacheModule::registerQuery"
3260
4123
  }, "Query registered successfully");
3261
- return { localArray: update.localArray };
4124
+ return {
4125
+ localArray: update.localArray,
4126
+ registrationTimings: update.registration
4127
+ };
3262
4128
  } catch (err) {
3263
4129
  this.logger.error({
3264
4130
  err,
@@ -3729,7 +4595,13 @@ var CrdtManager = class {
3729
4595
  killTableSubscription(table) {
3730
4596
  const uuid = this.liveByTable.get(table);
3731
4597
  if (uuid) {
3732
- this.remote.query("KILL $uuid", { uuid }).catch(() => {});
4598
+ this.remote.query("KILL $uuid", { uuid }).catch((err) => {
4599
+ this.logger.debug({
4600
+ err,
4601
+ table,
4602
+ Category: "sp00ky-client::CrdtManager::killTableSubscription"
4603
+ }, "KILL of table LIVE failed (already closed?)");
4604
+ });
3733
4605
  this.liveByTable.delete(table);
3734
4606
  }
3735
4607
  }
@@ -3843,7 +4715,13 @@ var ResilientPersistenceClient = class {
3843
4715
  error: e,
3844
4716
  Category: "sp00ky-client::ResilientPersistenceClient::get"
3845
4717
  }, "Persistence read failed, dropping key");
3846
- await this.inner.remove(key).catch(() => {});
4718
+ await this.inner.remove(key).catch((removeErr) => {
4719
+ this.logger.debug({
4720
+ key,
4721
+ error: removeErr,
4722
+ Category: "sp00ky-client::ResilientPersistenceClient::get"
4723
+ }, "Failed to drop corrupt persistence key");
4724
+ });
3847
4725
  return null;
3848
4726
  }
3849
4727
  }
@@ -3956,6 +4834,27 @@ var Sp00kyClient = class {
3956
4834
  * Setup direct callbacks instead of event subscriptions
3957
4835
  */
3958
4836
  setupCallbacks() {
4837
+ this.dataModule.onQueryStatusChange = (queryHash, status) => {
4838
+ this.devTools.logEvent("QUERY_STATUS_CHANGED", {
4839
+ queryHash,
4840
+ status
4841
+ });
4842
+ };
4843
+ this.dataModule.onHeartbeat = (queryHash) => {
4844
+ this.sync.heartbeatQuery(queryHash).catch((err) => {
4845
+ this.logger.warn({
4846
+ err,
4847
+ queryHash,
4848
+ Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
4849
+ }, "TTL heartbeat failed");
4850
+ });
4851
+ };
4852
+ this.dataModule.onDeregister = (queryHash) => {
4853
+ this.sync.enqueueDownEvent({
4854
+ type: "cleanup",
4855
+ payload: { hash: queryHash }
4856
+ });
4857
+ };
3959
4858
  this.dataModule.onMutation((mutations) => {
3960
4859
  this.devTools.onMutation(mutations);
3961
4860
  if (mutations.length > 0) this.sync.enqueueMutation(mutations);
@@ -3996,6 +4895,7 @@ var Sp00kyClient = class {
3996
4895
  await this.remote.connect();
3997
4896
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
3998
4897
  await this.streamProcessor.init();
4898
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
3999
4899
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
4000
4900
  await this.auth.init();
4001
4901
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
@@ -4063,7 +4963,18 @@ var Sp00kyClient = class {
4063
4963
  async initQuery(table, q, ttl) {
4064
4964
  const tableSchema = this.config.schema.tables.find((t) => t.name === table);
4065
4965
  if (!tableSchema) throw new Error(`Table ${table} not found`);
4066
- const hash = await this.dataModule.query(table, q.selectQuery.query, parseParams(tableSchema.columns, q.selectQuery.vars ?? {}), ttl);
4966
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
4967
+ const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
4968
+ if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
4969
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
4970
+ await this.dataModule.applyHydration(hash, rows ?? []);
4971
+ } catch (err) {
4972
+ this.logger.warn({
4973
+ err,
4974
+ hash,
4975
+ Category: "sp00ky-client::Sp00kyClient::instantHydrate"
4976
+ }, "Instant hydrate failed; proceeding with registration");
4977
+ }
4067
4978
  await this.sync.enqueueDownEvent({
4068
4979
  type: "register",
4069
4980
  payload: { hash }
@@ -4077,6 +4988,32 @@ var Sp00kyClient = class {
4077
4988
  async subscribe(queryHash, callback, options) {
4078
4989
  return this.dataModule.subscribe(queryHash, callback, options);
4079
4990
  }
4991
+ /**
4992
+ * Opt-in eager teardown for a query whose last subscriber has gone away
4993
+ * (e.g. a viewport-windowed list cancelling an off-screen window). No-op
4994
+ * while any subscriber remains. Tears down the remote `_00_query` view +
4995
+ * local WASM view instead of waiting for the TTL sweep. Default behavior
4996
+ * (no call here) keeps the view resident for cheap re-subscription.
4997
+ */
4998
+ deregisterQuery(queryHash) {
4999
+ this.dataModule.deregisterQuery(queryHash);
5000
+ }
5001
+ /**
5002
+ * Subscribe to a query's fetch-status changes (idle/fetching). With
5003
+ * `{ immediate: true }` the callback fires synchronously with the current
5004
+ * status. Powers the `useQuery` hook's `isFetching()` accessor.
5005
+ */
5006
+ subscribeQueryStatus(queryHash, callback, options) {
5007
+ return this.dataModule.subscribeStatus(queryHash, callback, options);
5008
+ }
5009
+ /**
5010
+ * Report the frontend processing time (ms) a client framework spent applying
5011
+ * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
5012
+ * surface the "frontend" phase of the per-query timing breakdown.
5013
+ */
5014
+ reportFrontendTiming(queryHash, ms) {
5015
+ this.dataModule.recordFrontendTiming(queryHash, ms);
5016
+ }
4080
5017
  run(backend, path, payload, options) {
4081
5018
  return this.dataModule.run(backend, path, payload, options);
4082
5019
  }