@spooky-sync/core 0.0.1-canary.208 → 0.0.1-canary.209

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 CHANGED
@@ -12,12 +12,18 @@ declare abstract class AbstractDatabaseService {
12
12
  protected logger: Logger$1;
13
13
  protected events: DatabaseEventSystem;
14
14
  /**
15
- * Per-query deadline in ms; `0` disables. Only the remote service sets this
16
- * (see `RemoteDatabaseService`) a local query can be legitimately slow and
17
- * has its own retry ladders, and there is no half-open-socket failure mode
18
- * for an in-process engine.
15
+ * Per-query deadline in ms; `0` disables. The remote service sets it from
16
+ * `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
17
+ * `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
18
+ * legitimately slow, but it must never be endless - every query waits on the
19
+ * previous link of {@link query}'s chain, and one that never settled wedged
20
+ * every later local op behind it.
19
21
  */
20
22
  protected queryTimeoutMs: number;
23
+ /** The error a deadline expiry rejects with; the local service substitutes
24
+ * its typed `LocalOpTimeoutError`. "timed out" in the message is
25
+ * load-bearing either way: `classifySyncError` keys off it. */
26
+ protected timeoutError(_query: string): Error;
21
27
  protected abstract eventType: typeof DatabaseEventTypes.LocalQuery | typeof DatabaseEventTypes.RemoteQuery;
22
28
  constructor(client: Surreal$1, logger: Logger$1, events: DatabaseEventSystem);
23
29
  abstract connect(): Promise<void>;
@@ -256,6 +262,14 @@ interface StreamUpdate {
256
262
  queryHash: string;
257
263
  localArray: RecordVersionArray;
258
264
  op?: 'CREATE' | 'UPDATE' | 'DELETE';
265
+ /**
266
+ * Client-internal: not from the circuit. A membership-only change that
267
+ * needed no fetch is re-materialized through this same path so it cannot
268
+ * race a real update (DataModule.scheduleRematerialize). Carries the last
269
+ * known `localArray`; consumers that describe an INGEST (persist, metrics,
270
+ * devtools events) skip it.
271
+ */
272
+ synthetic?: boolean;
259
273
  /**
260
274
  * End-to-end ingest latency for the WASM call that produced this update,
261
275
  * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
@@ -734,6 +748,22 @@ declare class DataModule<S extends SchemaStructure> {
734
748
  * Handle stream updates from DBSP (via CacheModule)
735
749
  */
736
750
  onStreamUpdate(update: StreamUpdate): Promise<void>;
751
+ /** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
752
+ private queueStreamUpdate;
753
+ /**
754
+ * Re-materialize + notify a query whose MEMBERSHIP changed without any row
755
+ * needing to be fetched, i.e. without the SSP stream update that normally
756
+ * carries the notify. That is every row this client wrote itself: the local
757
+ * CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
758
+ * sync engine rightly fetches nothing - and then nobody told the subscribers
759
+ * that `remoteArray` now holds the id. The row appeared on reload only.
760
+ *
761
+ * Routed through the same per-query debounce as a real stream update, so it
762
+ * cannot race one: a pending real update already materializes against the
763
+ * current `remoteArray` and wins. The synthetic update re-uses the circuit's
764
+ * last `localArray` and skips the persist/metrics that describe an ingest.
765
+ */
766
+ scheduleRematerialize(queryHash: string): void;
737
767
  /**
738
768
  * Process a query's pending (debounced) stream update NOW instead of on the
739
769
  * trailing edge. Called by the sync engine before it flips a query back to
@@ -794,6 +824,8 @@ declare class DataModule<S extends SchemaStructure> {
794
824
  private pendingIdsAt;
795
825
  private pendingIdsInflight;
796
826
  private static readonly PENDING_IDS_TTL_MS;
827
+ private pendingIdsGen;
828
+ private static readonly PENDING_IDS_MAX_REREADS;
797
829
  /** Drop the cached outbox ids. Cheap; call it on anything that could change
798
830
  * `_00_pending_mutations`. */
799
831
  private invalidatePendingIds;
@@ -957,7 +989,8 @@ declare class DataModule<S extends SchemaStructure> {
957
989
  deletes: Set<string>;
958
990
  }>;
959
991
  /** The uncached read. Also the reload path after an invalidation, so the ids
960
- * still survive a reload exactly as before. */
992
+ * still survive a reload exactly as before. `gen` is the generation the read
993
+ * was issued under; the result is cached only if it is still current. */
961
994
  private readPendingRecordIds;
962
995
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
963
996
  hasSubscribers(hash: string): boolean;
@@ -2640,6 +2673,36 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2640
2673
  /** True when `a` is a valid version strictly greater than valid version `b`. */
2641
2674
  declare function semverGt(a: unknown, b: unknown): boolean;
2642
2675
  //#endregion
2676
+ //#region src/services/database/errors.d.ts
2677
+ /**
2678
+ * A local-store operation that did not answer within its deadline.
2679
+ *
2680
+ * The local write path (`db.create` / `db.update` / `db.delete`, every local
2681
+ * query behind them) used to have no deadline anywhere: the SQLite worker
2682
+ * transport parks a call until the worker replies, the surrealdb engine's
2683
+ * query chain waits on the previous link, and `withRetry` retries without a
2684
+ * clock. One op that never settled (a worker starved behind a long select, a
2685
+ * lock verification awaiting `navigator.locks.query()` forever) left the
2686
+ * caller's promise pending for the tab's lifetime - a chat composer that never
2687
+ * re-enabled, a call that never got past "Connecting".
2688
+ *
2689
+ * The message says "timed out" on purpose: `classifySyncError` keys off it and
2690
+ * treats the failure as transient (re-queue), never as an application error
2691
+ * that rolls the mutation back. `retryable: false` keeps `withRetry` from
2692
+ * spinning on it: the op is still running in the engine, retrying queues a
2693
+ * second copy behind it.
2694
+ */
2695
+ declare class LocalOpTimeoutError extends Error {
2696
+ readonly name = "LocalOpTimeoutError";
2697
+ readonly retryable = false;
2698
+ readonly op: string;
2699
+ readonly timeoutMs: number;
2700
+ constructor(op: string, timeoutMs: number);
2701
+ }
2702
+ /** Default deadline for one local-store operation. Generous: a cold 4k-row
2703
+ * select on a throttled tab is seconds, not tens of seconds. */
2704
+ declare const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30000;
2705
+ //#endregion
2643
2706
  //#region src/utils/index.d.ts
2644
2707
  declare function fileToUint8Array(file: File | Blob): Promise<Uint8Array>;
2645
2708
  /**
@@ -2652,4 +2715,4 @@ declare function textToHtml(text: string): string;
2652
2715
  */
2653
2716
 
2654
2717
  //#endregion
2655
- export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
2718
+ export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
package/dist/index.js CHANGED
@@ -229,6 +229,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
229
229
  return await operation();
230
230
  } catch (err) {
231
231
  lastError = err;
232
+ if (err?.retryable === false) throw err;
232
233
  if (err?.message?.includes("Can not open transaction") || err?.message?.includes("transaction") || err?.message?.includes("Database is busy")) {
233
234
  const msg = err instanceof Error ? err.message : String(err);
234
235
  logger.warn({
@@ -264,7 +265,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
264
265
  function withTimeout(promise, timeoutMs, message) {
265
266
  if (!(timeoutMs > 0)) return promise;
266
267
  return new Promise((resolve, reject) => {
267
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
268
+ const timer = setTimeout(() => reject(typeof message === "function" ? message() : new Error(message)), timeoutMs);
268
269
  promise.then((value) => {
269
270
  clearTimeout(timer);
270
271
  resolve(value);
@@ -282,12 +283,20 @@ var AbstractDatabaseService = class {
282
283
  logger;
283
284
  events;
284
285
  /**
285
- * Per-query deadline in ms; `0` disables. Only the remote service sets this
286
- * (see `RemoteDatabaseService`) a local query can be legitimately slow and
287
- * has its own retry ladders, and there is no half-open-socket failure mode
288
- * for an in-process engine.
286
+ * Per-query deadline in ms; `0` disables. The remote service sets it from
287
+ * `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
288
+ * `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
289
+ * legitimately slow, but it must never be endless - every query waits on the
290
+ * previous link of {@link query}'s chain, and one that never settled wedged
291
+ * every later local op behind it.
289
292
  */
290
293
  queryTimeoutMs = 0;
294
+ /** The error a deadline expiry rejects with; the local service substitutes
295
+ * its typed `LocalOpTimeoutError`. "timed out" in the message is
296
+ * load-bearing either way: `classifySyncError` keys off it. */
297
+ timeoutError(_query) {
298
+ return /* @__PURE__ */ new Error(`Remote query timed out after ${this.queryTimeoutMs}ms`);
299
+ }
291
300
  constructor(client, logger, events) {
292
301
  this.client = client;
293
302
  this.logger = logger.child({ service: "Database" });
@@ -321,7 +330,7 @@ var AbstractDatabaseService = class {
321
330
  vars,
322
331
  Category: "sp00ky-client::Database::query"
323
332
  }, "Executing query");
324
- const result = await withTimeout(this.client.query(query, vars), this.queryTimeoutMs, `Remote query timed out after ${this.queryTimeoutMs}ms`);
333
+ const result = await withTimeout(this.client.query(query, vars), this.queryTimeoutMs, () => this.timeoutError(query));
325
334
  const duration = performance.now() - startTime;
326
335
  this.events.emit(this.eventType, {
327
336
  query,
@@ -367,6 +376,41 @@ var AbstractDatabaseService = class {
367
376
  }
368
377
  };
369
378
 
379
+ //#endregion
380
+ //#region src/services/database/errors.ts
381
+ /**
382
+ * A local-store operation that did not answer within its deadline.
383
+ *
384
+ * The local write path (`db.create` / `db.update` / `db.delete`, every local
385
+ * query behind them) used to have no deadline anywhere: the SQLite worker
386
+ * transport parks a call until the worker replies, the surrealdb engine's
387
+ * query chain waits on the previous link, and `withRetry` retries without a
388
+ * clock. One op that never settled (a worker starved behind a long select, a
389
+ * lock verification awaiting `navigator.locks.query()` forever) left the
390
+ * caller's promise pending for the tab's lifetime - a chat composer that never
391
+ * re-enabled, a call that never got past "Connecting".
392
+ *
393
+ * The message says "timed out" on purpose: `classifySyncError` keys off it and
394
+ * treats the failure as transient (re-queue), never as an application error
395
+ * that rolls the mutation back. `retryable: false` keeps `withRetry` from
396
+ * spinning on it: the op is still running in the engine, retrying queues a
397
+ * second copy behind it.
398
+ */
399
+ var LocalOpTimeoutError = class extends Error {
400
+ name = "LocalOpTimeoutError";
401
+ retryable = false;
402
+ op;
403
+ timeoutMs;
404
+ constructor(op, timeoutMs) {
405
+ super(`Local database operation timed out after ${timeoutMs}ms (${op})`);
406
+ this.op = op;
407
+ this.timeoutMs = timeoutMs;
408
+ }
409
+ };
410
+ /** Default deadline for one local-store operation. Generous: a cold 4k-row
411
+ * select on a throttled tab is seconds, not tens of seconds. */
412
+ const DEFAULT_LOCAL_OP_TIMEOUT_MS = 3e4;
413
+
370
414
  //#endregion
371
415
  //#region src/events/index.ts
372
416
  /**
@@ -663,6 +707,10 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
663
707
  const events = createDatabaseEventSystem();
664
708
  super(createBareSurrealClient(), logger, events);
665
709
  this.config = config;
710
+ this.queryTimeoutMs = Math.max(0, config.localOpTimeoutMs ?? DEFAULT_LOCAL_OP_TIMEOUT_MS);
711
+ }
712
+ timeoutError(query) {
713
+ return new LocalOpTimeoutError(query.slice(0, 80), this.queryTimeoutMs);
666
714
  }
667
715
  getConfig() {
668
716
  return this.config;
@@ -2076,7 +2124,7 @@ var WorkerSqliteTransport = class extends BaseTransport {
2076
2124
  err: this.makeError(msg),
2077
2125
  Category: "sp00ky-client::SqliteCacheEngine::worker"
2078
2126
  }, "Worker error");
2079
- this.failAll(msg);
2127
+ this.close(msg);
2080
2128
  };
2081
2129
  this.worker.onerror = (e) => crash(e.message || "onerror");
2082
2130
  this.worker.onmessageerror = () => crash("messageerror");
@@ -2229,6 +2277,8 @@ var SqliteCacheEngine = class {
2229
2277
  workerSelectConfigured;
2230
2278
  events = createDatabaseEventSystem();
2231
2279
  bucketId = "anon";
2280
+ /** Deadline for one worker round trip; see `localOpTimeoutMs`. */
2281
+ localOpTimeoutMs;
2232
2282
  /** Durability of the local store, set on every open. A plain Set of callbacks
2233
2283
  * rather than a `DatabaseEventSystem` event: this changes at most once per
2234
2284
  * open, and the typed event map is about query traffic. */
@@ -2250,6 +2300,7 @@ var SqliteCacheEngine = class {
2250
2300
  constructor(config, logger, opts = {}) {
2251
2301
  this.config = config;
2252
2302
  this.logger = logger;
2303
+ this.localOpTimeoutMs = Math.max(0, config.localOpTimeoutMs ?? DEFAULT_LOCAL_OP_TIMEOUT_MS);
2253
2304
  this.useOpfs = opts.useOpfs ?? true;
2254
2305
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
2255
2306
  this.workerSelectConfigured = this.workerSelect;
@@ -2390,7 +2441,8 @@ var SqliteCacheEngine = class {
2390
2441
  this.inFlightCalls.delete(tracked);
2391
2442
  settle();
2392
2443
  };
2393
- return this.transport.call(type, payload).then((v) => {
2444
+ const timeoutMs = this.localOpTimeoutMs;
2445
+ return withTimeout(this.transport.call(type, payload), timeoutMs, () => new LocalOpTimeoutError(type, timeoutMs)).then((v) => {
2394
2446
  finish();
2395
2447
  s.inFlight--;
2396
2448
  const wt = v?.wt;
@@ -2402,6 +2454,14 @@ var SqliteCacheEngine = class {
2402
2454
  }, (e) => {
2403
2455
  finish();
2404
2456
  s.inFlight--;
2457
+ if (e instanceof LocalOpTimeoutError) {
2458
+ s.timeouts = (s.timeouts ?? 0) + 1;
2459
+ this.logger.error({
2460
+ type,
2461
+ timeoutMs,
2462
+ Category: "sp00ky-client::SqliteCacheEngine::rawCall"
2463
+ }, "Local store op did not answer within its deadline; rejecting the caller");
2464
+ }
2405
2465
  throw e;
2406
2466
  });
2407
2467
  }
@@ -3565,6 +3625,11 @@ var DataModule = class DataModule {
3565
3625
  await this.processStreamUpdate(update);
3566
3626
  return;
3567
3627
  }
3628
+ this.queueStreamUpdate(update);
3629
+ }
3630
+ /** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
3631
+ queueStreamUpdate(update) {
3632
+ const { queryHash } = update;
3568
3633
  if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
3569
3634
  this.pendingStreamUpdates.set(queryHash, update);
3570
3635
  const timer = setTimeout(async () => {
@@ -3575,6 +3640,30 @@ var DataModule = class DataModule {
3575
3640
  this.debounceTimers.set(queryHash, timer);
3576
3641
  }
3577
3642
  /**
3643
+ * Re-materialize + notify a query whose MEMBERSHIP changed without any row
3644
+ * needing to be fetched, i.e. without the SSP stream update that normally
3645
+ * carries the notify. That is every row this client wrote itself: the local
3646
+ * CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
3647
+ * sync engine rightly fetches nothing - and then nobody told the subscribers
3648
+ * that `remoteArray` now holds the id. The row appeared on reload only.
3649
+ *
3650
+ * Routed through the same per-query debounce as a real stream update, so it
3651
+ * cannot race one: a pending real update already materializes against the
3652
+ * current `remoteArray` and wins. The synthetic update re-uses the circuit's
3653
+ * last `localArray` and skips the persist/metrics that describe an ingest.
3654
+ */
3655
+ scheduleRematerialize(queryHash) {
3656
+ if (this.debounceTimers.has(queryHash)) return;
3657
+ const queryState = this.activeQueries.get(queryHash);
3658
+ if (!queryState) return;
3659
+ this.queueStreamUpdate({
3660
+ queryHash,
3661
+ localArray: queryState.config.localArray ?? [],
3662
+ op: "UPDATE",
3663
+ synthetic: true
3664
+ });
3665
+ }
3666
+ /**
3578
3667
  * Process a query's pending (debounced) stream update NOW instead of on the
3579
3668
  * trailing edge. Called by the sync engine before it flips a query back to
3580
3669
  * `idle`, so the status change never races ahead of the rows it fetched.
@@ -3679,10 +3768,14 @@ var DataModule = class DataModule {
3679
3768
  pendingIdsAt = 0;
3680
3769
  pendingIdsInflight = null;
3681
3770
  static PENDING_IDS_TTL_MS = 250;
3771
+ pendingIdsGen = 0;
3772
+ static PENDING_IDS_MAX_REREADS = 3;
3682
3773
  /** Drop the cached outbox ids. Cheap; call it on anything that could change
3683
3774
  * `_00_pending_mutations`. */
3684
3775
  invalidatePendingIds() {
3685
3776
  this.pendingIds = null;
3777
+ this.pendingIdsGen++;
3778
+ this.pendingIdsInflight = null;
3686
3779
  }
3687
3780
  /**
3688
3781
  * Grace period for a settled write. Long enough to cover an SSP round trip
@@ -3768,7 +3861,7 @@ var DataModule = class DataModule {
3768
3861
  try {
3769
3862
  const newRecords = await this.materializeRecords(queryState, localArray);
3770
3863
  if (epoch !== this.local.epoch) return;
3771
- queryState.config.localArray = localArray;
3864
+ if (!update.synthetic) queryState.config.localArray = localArray;
3772
3865
  const prevJson = JSON.stringify(queryState.records);
3773
3866
  const newJson = JSON.stringify(newRecords);
3774
3867
  queryState.records = newRecords;
@@ -3777,7 +3870,7 @@ var DataModule = class DataModule {
3777
3870
  queryState.updateCount++;
3778
3871
  queryState.lastUpdatedAt = Date.now();
3779
3872
  }
3780
- await this.local.query(surql.seal(surql.updateSet("id", [
3873
+ if (!update.synthetic) await this.local.query(surql.seal(surql.updateSet("id", [
3781
3874
  "localArray",
3782
3875
  "rowCount",
3783
3876
  "updateCount",
@@ -4113,18 +4206,27 @@ var DataModule = class DataModule {
4113
4206
  writes: new Set(cached.writes),
4114
4207
  deletes: new Set(cached.deletes)
4115
4208
  };
4116
- if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
4117
- this.pendingIdsInflight = null;
4118
- });
4119
- const fresh = await this.pendingIdsInflight;
4209
+ let fresh = null;
4210
+ for (let attempt = 0; attempt < DataModule.PENDING_IDS_MAX_REREADS; attempt++) {
4211
+ const gen = this.pendingIdsGen;
4212
+ if (!this.pendingIdsInflight) {
4213
+ const read = this.readPendingRecordIds(gen).finally(() => {
4214
+ if (this.pendingIdsInflight === read) this.pendingIdsInflight = null;
4215
+ });
4216
+ this.pendingIdsInflight = read;
4217
+ }
4218
+ fresh = await this.pendingIdsInflight;
4219
+ if (gen === this.pendingIdsGen) break;
4220
+ }
4120
4221
  return {
4121
4222
  writes: new Set(fresh.writes),
4122
4223
  deletes: new Set(fresh.deletes)
4123
4224
  };
4124
4225
  }
4125
4226
  /** The uncached read. Also the reload path after an invalidation, so the ids
4126
- * still survive a reload exactly as before. */
4127
- async readPendingRecordIds() {
4227
+ * still survive a reload exactly as before. `gen` is the generation the read
4228
+ * was issued under; the result is cached only if it is still current. */
4229
+ async readPendingRecordIds(gen) {
4128
4230
  const writes = /* @__PURE__ */ new Set();
4129
4231
  const deletes = /* @__PURE__ */ new Set();
4130
4232
  try {
@@ -4145,11 +4247,13 @@ var DataModule = class DataModule {
4145
4247
  deletes
4146
4248
  };
4147
4249
  }
4148
- this.pendingIds = {
4149
- writes,
4150
- deletes
4151
- };
4152
- this.pendingIdsAt = Date.now();
4250
+ if (gen === this.pendingIdsGen) {
4251
+ this.pendingIds = {
4252
+ writes,
4253
+ deletes
4254
+ };
4255
+ this.pendingIdsAt = Date.now();
4256
+ }
4153
4257
  return {
4154
4258
  writes,
4155
4259
  deletes
@@ -4454,13 +4558,22 @@ var DataModule = class DataModule {
4454
4558
  data: params,
4455
4559
  ...prefixedParams
4456
4560
  }));
4561
+ this.invalidatePendingIds();
4457
4562
  const parsedRecord = parseParams(tableSchema.columns, target);
4458
- await this.cache.save({
4459
- table: tableName,
4460
- op: "CREATE",
4461
- record: parsedRecord,
4462
- version: 1
4463
- }, true);
4563
+ try {
4564
+ await this.cache.save({
4565
+ table: tableName,
4566
+ op: "CREATE",
4567
+ record: parsedRecord,
4568
+ version: 1
4569
+ }, true);
4570
+ } catch (err) {
4571
+ this.logger.error({
4572
+ err,
4573
+ id,
4574
+ Category: "sp00ky-client::DataModule::create"
4575
+ }, "SSP create-ingest failed; the row is written and queued, but views only see it once membership arrives");
4576
+ }
4464
4577
  const mutationEvent = {
4465
4578
  type: "create",
4466
4579
  mutation_id: mutationId,
@@ -4469,7 +4582,6 @@ var DataModule = class DataModule {
4469
4582
  record: target,
4470
4583
  tableName
4471
4584
  };
4472
- this.invalidatePendingIds();
4473
4585
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4474
4586
  this.logger.debug({
4475
4587
  id,
@@ -4506,13 +4618,22 @@ var DataModule = class DataModule {
4506
4618
  for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
4507
4619
  if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
4508
4620
  this.replaceRecordInQueries(updatedFields);
4621
+ this.invalidatePendingIds();
4509
4622
  const parsedRecord = parseParams(tableSchema.columns, target);
4510
- await this.cache.save({
4511
- table,
4512
- op: "UPDATE",
4513
- record: parsedRecord,
4514
- version: target._00_rv
4515
- }, true);
4623
+ try {
4624
+ await this.cache.save({
4625
+ table,
4626
+ op: "UPDATE",
4627
+ record: parsedRecord,
4628
+ version: target._00_rv
4629
+ }, true);
4630
+ } catch (err) {
4631
+ this.logger.error({
4632
+ err,
4633
+ id,
4634
+ Category: "sp00ky-client::DataModule::update"
4635
+ }, "SSP update-ingest failed; the row is written and queued");
4636
+ }
4516
4637
  const pushEventOptions = parseUpdateOptions(id, data, options);
4517
4638
  const mutationEvent = {
4518
4639
  type: "update",
@@ -4523,7 +4644,6 @@ var DataModule = class DataModule {
4523
4644
  beforeRecord: beforeRecord || void 0,
4524
4645
  options: pushEventOptions
4525
4646
  };
4526
- this.invalidatePendingIds();
4527
4647
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4528
4648
  this.logger.debug({
4529
4649
  id,
@@ -4546,6 +4666,7 @@ var DataModule = class DataModule {
4546
4666
  id: rid,
4547
4667
  mid: mutationId
4548
4668
  }));
4669
+ this.invalidatePendingIds();
4549
4670
  try {
4550
4671
  await this.cache.delete(table, id, true, beforeRecord);
4551
4672
  } catch (err) {
@@ -4561,7 +4682,6 @@ var DataModule = class DataModule {
4561
4682
  mutation_id: mutationId,
4562
4683
  record_id: rid
4563
4684
  };
4564
- this.invalidatePendingIds();
4565
4685
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4566
4686
  this.logger.debug({
4567
4687
  id,
@@ -6505,6 +6625,7 @@ var Sp00kySync = class Sp00kySync {
6505
6625
  Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
6506
6626
  }, "syncQuery failed during poll");
6507
6627
  }
6628
+ if (changed) this.dataModule.scheduleRematerialize(queryHash);
6508
6629
  await this.syncSubqueryChildren(queryHash).catch((err) => {
6509
6630
  this.logger.info({
6510
6631
  err: err?.message ?? err,
@@ -6701,6 +6822,7 @@ var Sp00kySync = class Sp00kySync {
6701
6822
  }
6702
6823
  await this.runSyncForQuery(hash, diff);
6703
6824
  if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) await this.dataModule.notifyQuerySynced(hash);
6825
+ else if (diff.added.length === 0 && diff.updated.length === 0) this.dataModule.scheduleRematerialize(hash);
6704
6826
  }
6705
6827
  /**
6706
6828
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
@@ -7438,8 +7560,8 @@ function selfAllowlistedVariant(flag, userId) {
7438
7560
 
7439
7561
  //#endregion
7440
7562
  //#region src/modules/devtools/index.ts
7441
- const CORE_VERSION = "0.0.1-canary.208";
7442
- const WASM_VERSION = "0.0.1-canary.208";
7563
+ const CORE_VERSION = "0.0.1-canary.209";
7564
+ const WASM_VERSION = "0.0.1-canary.209";
7443
7565
  const SURREAL_VERSION = "3.0.3";
7444
7566
  var DevToolsService = class DevToolsService {
7445
7567
  eventsHistory = [];
@@ -7450,6 +7572,10 @@ var DevToolsService = class DevToolsService {
7450
7572
  static NOTIFY_MIN_INTERVAL_MS = 250;
7451
7573
  notifyTimer = null;
7452
7574
  lastNotifyAt = 0;
7575
+ /** How many ids a pushed state carries per view; the rest is on demand. */
7576
+ static STATE_IDS_CAP = 200;
7577
+ /** devtools numeric hash -> the query's `_00_query` id, for on-demand rows. */
7578
+ hashToQuery = /* @__PURE__ */ new Map();
7453
7579
  /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
7454
7580
  * feature was REQUESTED (so an inactive/degraded tab still reports why). */
7455
7581
  tabsInfoProvider = null;
@@ -7490,6 +7616,7 @@ var DevToolsService = class DevToolsService {
7490
7616
  const type = e.data?.type;
7491
7617
  if (type === "SP00KY_DEVTOOLS_CONNECT") {
7492
7618
  this.enabled = true;
7619
+ this.refreshLocalTables();
7493
7620
  this.notifyDevTools();
7494
7621
  } else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
7495
7622
  });
@@ -7525,6 +7652,9 @@ var DevToolsService = class DevToolsService {
7525
7652
  this.dataManager.getActiveQueries().forEach((q) => {
7526
7653
  const queryHash = this.hashString(encodeRecordId(q.config.id));
7527
7654
  const createdAt = q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime();
7655
+ this.hashToQuery.set(queryHash, q.config.id);
7656
+ const localArray = q.config.localArray ?? [];
7657
+ const remoteArray = q.config.remoteArray ?? [];
7528
7658
  result.set(queryHash, {
7529
7659
  queryHash,
7530
7660
  status: "active",
@@ -7537,9 +7667,11 @@ var DevToolsService = class DevToolsService {
7537
7667
  query: q.config.surql,
7538
7668
  variables: q.config.params || {},
7539
7669
  dataSize: q.records?.length || 0,
7540
- data: q.records,
7541
- localArray: q.config.localArray,
7542
- remoteArray: q.config.remoteArray,
7670
+ localCount: localArray.length,
7671
+ remoteCount: remoteArray.length,
7672
+ localIds: localArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
7673
+ remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
7674
+ idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
7543
7675
  membershipKnown: q.config.membershipKnown === true,
7544
7676
  remoteSeen: q.config.remoteSeen === true,
7545
7677
  emptyReads: q.config.emptyReads ?? 0,
@@ -7569,23 +7701,32 @@ var DevToolsService = class DevToolsService {
7569
7701
  const queryHash = this.hashString(payload.queryId.toString());
7570
7702
  this.addEvent("QUERY_UPDATED", {
7571
7703
  queryHash,
7572
- data: payload.records
7704
+ recordCount: Array.isArray(payload.records) ? payload.records.length : 0
7573
7705
  });
7574
7706
  this.notifyDevTools();
7575
7707
  }
7576
7708
  onStreamUpdate(update) {
7709
+ if (update.synthetic) return;
7577
7710
  this.logger.debug({
7578
- update,
7711
+ queryHash: update.queryHash,
7579
7712
  Category: "sp00ky-client::DevToolsService::onStreamUpdate"
7580
7713
  }, "StreamUpdate");
7581
- this.addEvent("STREAM_UPDATE", { updates: [update] });
7714
+ this.addEvent("STREAM_UPDATE", {
7715
+ queryHash: update.queryHash,
7716
+ op: update.op,
7717
+ localCount: update.localArray?.length ?? 0,
7718
+ materializationTimeMs: update.materializationTimeMs,
7719
+ storeApplyMs: update.storeApplyMs,
7720
+ circuitStepMs: update.circuitStepMs,
7721
+ transformMs: update.transformMs
7722
+ });
7582
7723
  this.notifyDevTools();
7583
7724
  }
7584
7725
  onMutation(payload) {
7585
7726
  payload.forEach((p) => {
7586
7727
  this.addEvent("MUTATION_REQUEST_EXECUTION", { mutation: {
7587
- type: "create",
7588
- data: "data" in p ? p.data : void 0,
7728
+ type: p.type ?? "create",
7729
+ fields: "data" in p && p.data && typeof p.data === "object" ? Object.keys(p.data) : [],
7589
7730
  selector: encodeRecordId(p.record_id)
7590
7731
  } });
7591
7732
  });
@@ -7630,7 +7771,7 @@ var DevToolsService = class DevToolsService {
7630
7771
  */
7631
7772
  refreshLocalTables() {
7632
7773
  if (this.localTablesFetching) return;
7633
- if (Date.now() - this.localTablesAt < 3e3) return;
7774
+ if (Date.now() - this.localTablesAt < 3e4) return;
7634
7775
  this.localTablesFetching = true;
7635
7776
  this.databaseService.query("INFO FOR DB").then((res) => {
7636
7777
  const info = this.unwrapInfo(res);
@@ -7645,8 +7786,8 @@ var DevToolsService = class DevToolsService {
7645
7786
  this.localTablesFetching = false;
7646
7787
  });
7647
7788
  }
7648
- getState() {
7649
- this.refreshLocalTables();
7789
+ getState(opts = {}) {
7790
+ if (opts.refreshTables) this.refreshLocalTables();
7650
7791
  return this.serializeForDevTools({
7651
7792
  eventsHistory: [...this.eventsHistory],
7652
7793
  activeQueries: Object.fromEntries(this.getActiveQueries()),
@@ -7760,14 +7901,11 @@ var DevToolsService = class DevToolsService {
7760
7901
  if (typeof window === "undefined") return;
7761
7902
  if (this.notifyTimer !== null) return;
7762
7903
  const waited = Date.now() - this.lastNotifyAt;
7763
- if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
7764
- this.flushNotify();
7765
- return;
7766
- }
7904
+ const delay = Math.max(0, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
7767
7905
  this.notifyTimer = setTimeout(() => {
7768
7906
  this.notifyTimer = null;
7769
7907
  if (this.enabled) this.flushNotify();
7770
- }, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
7908
+ }, delay);
7771
7909
  }
7772
7910
  flushNotify() {
7773
7911
  this.lastNotifyAt = Date.now();
@@ -7812,7 +7950,18 @@ var DevToolsService = class DevToolsService {
7812
7950
  if (typeof window !== "undefined") {
7813
7951
  window.__00__ = {
7814
7952
  version: this.version,
7815
- getState: () => this.getState(),
7953
+ getState: () => this.getState({ refreshTables: true }),
7954
+ getQueryRows: (queryHash) => {
7955
+ const id = this.hashToQuery.get(Number(queryHash));
7956
+ const q = id !== void 0 ? this.dataManager?.getQueryById(id) : void 0;
7957
+ if (!q) return null;
7958
+ return this.serializeForDevTools({
7959
+ queryHash: Number(queryHash),
7960
+ data: q.records,
7961
+ localArray: q.config.localArray,
7962
+ remoteArray: q.config.remoteArray
7963
+ });
7964
+ },
7816
7965
  getFlags: () => this.flagsAdmin.getFlags(),
7817
7966
  setFlagEnabled: (key, enabled) => this.flagsAdmin.setFlagEnabled(key, enabled),
7818
7967
  setFlagUserVariant: (key, variant, remove, userId) => this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
@@ -12423,7 +12572,7 @@ var Sp00kyClient = class {
12423
12572
  return new TabsCoordinator({
12424
12573
  tabId,
12425
12574
  fingerprint: computeTabsFingerprint({
12426
- coreVersion: "0.0.1-canary.208",
12575
+ coreVersion: "0.0.1-canary.209",
12427
12576
  schemaHash: hash53(this.config.schemaSurql),
12428
12577
  endpoint: this.config.database.endpoint ?? "",
12429
12578
  namespace: this.config.database.namespace,
@@ -13109,4 +13258,4 @@ var Sp00kyClient = class {
13109
13258
  };
13110
13259
 
13111
13260
  //#endregion
13112
- export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
13261
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };