@spooky-sync/core 0.0.1-canary.203 → 0.0.1-canary.204

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
@@ -696,6 +696,13 @@ declare class DataModule<S extends SchemaStructure> {
696
696
  private resolveMembership;
697
697
  private readonly settledWrites;
698
698
  private readonly settledDeletes;
699
+ private pendingIds;
700
+ private pendingIdsAt;
701
+ private pendingIdsInflight;
702
+ private static readonly PENDING_IDS_TTL_MS;
703
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
704
+ * `_00_pending_mutations`. */
705
+ private invalidatePendingIds;
699
706
  /**
700
707
  * Grace period for a settled write. Long enough to cover an SSP round trip
701
708
  * that is running slowly (seconds, not milliseconds, when the edge path is
@@ -841,6 +848,9 @@ declare class DataModule<S extends SchemaStructure> {
841
848
  writes: Set<string>;
842
849
  deletes: Set<string>;
843
850
  }>;
851
+ /** The uncached read. Also the reload path after an invalidation, so the ids
852
+ * still survive a reload exactly as before. */
853
+ private readPendingRecordIds;
844
854
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
845
855
  hasSubscribers(hash: string): boolean;
846
856
  /**
@@ -1079,6 +1089,15 @@ interface Sp00kySyncOptions {
1079
1089
  * up-queue for the session. Defaults to 30000; `0` disables the timeout.
1080
1090
  */
1081
1091
  pushTimeoutMs?: number;
1092
+ /**
1093
+ * Max time a single down event (`register`/`sync`/`cleanup`) may take before
1094
+ * it is treated as a network failure and retried. The mirror of
1095
+ * {@link pushTimeoutMs} for the read side, which had no such guard: a
1096
+ * `fn::query::register` that never settled held its slot in the down drain,
1097
+ * and every later registration behind it, for the rest of the session.
1098
+ * Defaults to 30000; `0` disables the timeout.
1099
+ */
1100
+ downTimeoutMs?: number;
1082
1101
  /**
1083
1102
  * Transport supervisor. Sync reads its state to report `connection` in
1084
1103
  * {@link SyncHealth} so a UI can show "reconnecting…" the instant the socket
@@ -1148,6 +1167,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
1148
1167
  private readonly degradeAfterFailures;
1149
1168
  /** Per-push RPC deadline; see {@link withPushTimeout}. */
1150
1169
  private readonly pushTimeoutMs;
1170
+ private readonly downTimeoutMs;
1151
1171
  private consecutiveSyncFailures;
1152
1172
  private syncHealthStatus;
1153
1173
  private lastSyncErrorKind;
@@ -1378,6 +1398,8 @@ declare class Sp00kySync<S extends SchemaStructure> {
1378
1398
  private handleMutationSettled;
1379
1399
  private handleRollback;
1380
1400
  private processDownEvent;
1401
+ private withDownTimeout;
1402
+ private runDownEvent;
1381
1403
  /**
1382
1404
  * Synchronizes a specific query by hash.
1383
1405
  * Compares local and remote version arrays and fetches differences.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, o as reviveRow, r as project, s as serializeRow, t as PROMOTION_OPEN_OPTIONS } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, c as serializeRow, i as projectedDataSql, l as resolveRelations, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as stableKey } from "./sqlite-open.js";
2
2
  import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
3
3
  import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
@@ -2658,16 +2658,15 @@ var SqliteCacheEngine = class {
2658
2658
  }
2659
2659
  await this.ensureTable(plan.table);
2660
2660
  const bind = [];
2661
- let sql = `SELECT data FROM "${plan.table}"`;
2661
+ let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
2662
2662
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
2663
2663
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
2664
2664
  else sql += ` ORDER BY id`;
2665
2665
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
2666
2666
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
2667
2667
  const rows = await this.execRows(sql, bind);
2668
- const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
2669
- await resolveRelations(projected, plan.relations, this);
2670
- return projected;
2668
+ await resolveRelations(rows, plan.relations, this);
2669
+ return rows;
2671
2670
  }
2672
2671
  async fetchRelation(req) {
2673
2672
  getStats().relationFetches++;
@@ -2686,14 +2685,18 @@ var SqliteCacheEngine = class {
2686
2685
  if (ids.length === 0) return [];
2687
2686
  await this.ensureTable(table);
2688
2687
  const keys = ids.map(stableKey);
2689
- let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
2688
+ const placeholders = keys.map(() => "?").join(", ");
2689
+ const bind = [];
2690
+ const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
2691
+ bind.push(...keys);
2692
+ let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
2690
2693
  if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
2691
- let rows = await this.execRows(sql, keys);
2694
+ let rows = await this.execRows(sql, bind);
2692
2695
  if (!opts?.orderBy || opts.orderBy.length === 0) {
2693
2696
  const pos = new Map(keys.map((k, i) => [k, i]));
2694
2697
  rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
2695
2698
  }
2696
- return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
2699
+ return rows;
2697
2700
  }
2698
2701
  async getById(table, id) {
2699
2702
  await this.ensureTable(table);
@@ -3586,6 +3589,15 @@ var DataModule = class DataModule {
3586
3589
  }
3587
3590
  settledWrites = /* @__PURE__ */ new Map();
3588
3591
  settledDeletes = /* @__PURE__ */ new Map();
3592
+ pendingIds = null;
3593
+ pendingIdsAt = 0;
3594
+ pendingIdsInflight = null;
3595
+ static PENDING_IDS_TTL_MS = 250;
3596
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
3597
+ * `_00_pending_mutations`. */
3598
+ invalidatePendingIds() {
3599
+ this.pendingIds = null;
3600
+ }
3589
3601
  /**
3590
3602
  * Grace period for a settled write. Long enough to cover an SSP round trip
3591
3603
  * that is running slowly (seconds, not milliseconds, when the edge path is
@@ -3604,6 +3616,7 @@ var DataModule = class DataModule {
3604
3616
  * disappear immediately, which is what makes this safe.
3605
3617
  */
3606
3618
  noteWriteSettled(recordId, mutationType) {
3619
+ this.invalidatePendingIds();
3607
3620
  const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
3608
3621
  if (mutationType === "delete") this.settledDeletes.set(recordId, until);
3609
3622
  else this.settledWrites.set(recordId, until);
@@ -3989,6 +4002,24 @@ var DataModule = class DataModule {
3989
4002
  * briefly hide an optimistic write but never resurrects a deleted row.
3990
4003
  */
3991
4004
  async getPendingRecordIds() {
4005
+ const now = Date.now();
4006
+ const cached = this.pendingIds;
4007
+ if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) return {
4008
+ writes: new Set(cached.writes),
4009
+ deletes: new Set(cached.deletes)
4010
+ };
4011
+ if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
4012
+ this.pendingIdsInflight = null;
4013
+ });
4014
+ const fresh = await this.pendingIdsInflight;
4015
+ return {
4016
+ writes: new Set(fresh.writes),
4017
+ deletes: new Set(fresh.deletes)
4018
+ };
4019
+ }
4020
+ /** The uncached read. Also the reload path after an invalidation, so the ids
4021
+ * still survive a reload exactly as before. */
4022
+ async readPendingRecordIds() {
3992
4023
  const writes = /* @__PURE__ */ new Set();
3993
4024
  const deletes = /* @__PURE__ */ new Set();
3994
4025
  try {
@@ -4004,7 +4035,16 @@ var DataModule = class DataModule {
4004
4035
  err,
4005
4036
  Category: "sp00ky-client::DataModule::getPendingRecordIds"
4006
4037
  }, "Failed to read pending mutations; optimistic writes may be briefly hidden");
4038
+ return {
4039
+ writes,
4040
+ deletes
4041
+ };
4007
4042
  }
4043
+ this.pendingIds = {
4044
+ writes,
4045
+ deletes
4046
+ };
4047
+ this.pendingIdsAt = Date.now();
4008
4048
  return {
4009
4049
  writes,
4010
4050
  deletes
@@ -4321,6 +4361,7 @@ var DataModule = class DataModule {
4321
4361
  record: target,
4322
4362
  tableName
4323
4363
  };
4364
+ this.invalidatePendingIds();
4324
4365
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4325
4366
  this.logger.debug({
4326
4367
  id,
@@ -4374,6 +4415,7 @@ var DataModule = class DataModule {
4374
4415
  beforeRecord: beforeRecord || void 0,
4375
4416
  options: pushEventOptions
4376
4417
  };
4418
+ this.invalidatePendingIds();
4377
4419
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4378
4420
  this.logger.debug({
4379
4421
  id,
@@ -4419,6 +4461,7 @@ var DataModule = class DataModule {
4419
4461
  mutation_id: mutationId,
4420
4462
  record_id: rid
4421
4463
  };
4464
+ this.invalidatePendingIds();
4422
4465
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4423
4466
  this.logger.debug({
4424
4467
  id,
@@ -5071,9 +5114,43 @@ var DownQueue = class {
5071
5114
  }
5072
5115
  async next(fn) {
5073
5116
  const event = this.queue.shift();
5074
- if (event) try {
5117
+ if (!event) return;
5118
+ const error = await this.run(event, fn);
5119
+ if (error !== void 0) throw error;
5120
+ }
5121
+ /**
5122
+ * The next event whose hash is NOT already being processed, or `undefined`
5123
+ * when every remaining event is blocked on a busy hash.
5124
+ *
5125
+ * Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
5126
+ * `register` for the same query — but the queue enforced it globally, so one
5127
+ * registration RPC at a time was the ceiling for the whole client. An event
5128
+ * for a busy hash keeps its place here (it is skipped, not reordered), so
5129
+ * per-hash ordering is preserved exactly while independent hashes proceed
5130
+ * concurrently.
5131
+ */
5132
+ takeNext(busy) {
5133
+ for (let i = 0; i < this.queue.length; i++) {
5134
+ const event = this.queue[i];
5135
+ if (busy.has(event.payload.hash)) continue;
5136
+ this.queue.splice(i, 1);
5137
+ return event;
5138
+ }
5139
+ }
5140
+ /**
5141
+ * Process one event, applying the re-head / rotate failure policy.
5142
+ *
5143
+ * NEVER rejects: it RETURNS the error instead (`undefined` on success). A
5144
+ * concurrent drain has other work in flight when one event fails, and a
5145
+ * rejection would either take that work down with it or have to be caught at
5146
+ * every call site. `next` reinstates the throwing contract for the serial
5147
+ * callers that still want it.
5148
+ */
5149
+ async run(event, fn) {
5150
+ try {
5075
5151
  await fn(event);
5076
5152
  this.failures.delete(event);
5153
+ return;
5077
5154
  } catch (error) {
5078
5155
  const attempts = (this.failures.get(event) ?? 0) + 1;
5079
5156
  this.failures.set(event, attempts);
@@ -5087,7 +5164,7 @@ var DownQueue = class {
5087
5164
  rotated: starvingOthers,
5088
5165
  Category: "sp00ky-client::DownQueue::next"
5089
5166
  }, "Failed to process query");
5090
- throw error;
5167
+ return error;
5091
5168
  }
5092
5169
  }
5093
5170
  };
@@ -5452,6 +5529,17 @@ var SyncEngine = class {
5452
5529
  */
5453
5530
  /** Backoff for re-draining a queue that halted on an error. */
5454
5531
  const RETRY_BASE_MS = 500;
5532
+ /**
5533
+ * How many down events may be in flight at once.
5534
+ *
5535
+ * The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
5536
+ * time for the WHOLE client. Measured in production that drained at roughly one
5537
+ * event per 8.8s, so a list that registers a query per scrolled-to window took
5538
+ * minutes to fill — the rows were already cached, only the registration lagged.
5539
+ * Ordering is a per-hash requirement, not a global one (see `takeNext`), so
5540
+ * independent hashes can go in parallel. Bounded to stay polite to the SSP.
5541
+ */
5542
+ const MAX_CONCURRENT_DOWN = 4;
5455
5543
  const RETRY_MAX_MS = 15e3;
5456
5544
  var SyncScheduler = class {
5457
5545
  isSyncingUp = false;
@@ -5593,14 +5681,35 @@ var SyncScheduler = class {
5593
5681
  }
5594
5682
  this.isSyncingDown = true;
5595
5683
  let processedAny = false;
5684
+ const busy = /* @__PURE__ */ new Set();
5685
+ const inFlight = /* @__PURE__ */ new Set();
5686
+ let failure;
5596
5687
  try {
5597
- while (this.downQueue.size > 0 && !this.paused) {
5598
- if (this.upQueue.size > 0) break;
5599
- await this.downQueue.next(this.onProcessDown);
5600
- processedAny = true;
5688
+ for (;;) {
5689
+ if (this.paused) break;
5690
+ const yieldToUp = this.upQueue.size > 0;
5691
+ while (failure === void 0 && !yieldToUp && inFlight.size < MAX_CONCURRENT_DOWN) {
5692
+ const event = this.downQueue.takeNext(busy);
5693
+ if (!event) break;
5694
+ const hash = event.payload.hash;
5695
+ busy.add(hash);
5696
+ processedAny = true;
5697
+ const task = this.downQueue.run(event, this.onProcessDown).then((error) => {
5698
+ if (error !== void 0 && failure === void 0) failure = error;
5699
+ }).finally(() => {
5700
+ busy.delete(hash);
5701
+ inFlight.delete(task);
5702
+ });
5703
+ inFlight.add(task);
5704
+ }
5705
+ if (inFlight.size === 0) break;
5706
+ await Promise.race(inFlight);
5707
+ if (yieldToUp && inFlight.size === 0) break;
5601
5708
  }
5709
+ if (failure !== void 0) throw failure;
5602
5710
  if (processedAny) this.onSyncOutcome?.(true);
5603
5711
  this.downRetryAttempt = 0;
5712
+ if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
5604
5713
  } catch (error) {
5605
5714
  this.onSyncOutcome?.(false, error);
5606
5715
  this.scheduleDownRetry();
@@ -5691,6 +5800,7 @@ var Sp00kySync = class Sp00kySync {
5691
5800
  degradeAfterFailures;
5692
5801
  /** Per-push RPC deadline; see {@link withPushTimeout}. */
5693
5802
  pushTimeoutMs;
5803
+ downTimeoutMs;
5694
5804
  consecutiveSyncFailures = 0;
5695
5805
  syncHealthStatus = "healthy";
5696
5806
  lastSyncErrorKind;
@@ -5862,6 +5972,7 @@ var Sp00kySync = class Sp00kySync {
5862
5972
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
5863
5973
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
5864
5974
  this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
5975
+ this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 3e4);
5865
5976
  this.connectionSupervisor = options?.connectionSupervisor;
5866
5977
  }
5867
5978
  /**
@@ -6544,6 +6655,12 @@ var Sp00kySync = class Sp00kySync {
6544
6655
  event,
6545
6656
  Category: "sp00ky-client::Sp00kySync::processDownEvent"
6546
6657
  }, "Processing down event");
6658
+ return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
6659
+ }
6660
+ withDownTimeout(promise, label) {
6661
+ return withTimeout(promise, this.downTimeoutMs, `Down event timed out after ${this.downTimeoutMs}ms (${label})`);
6662
+ }
6663
+ async runDownEvent(event) {
6547
6664
  switch (event.type) {
6548
6665
  case "register": return this.registerQuery(event.payload.hash);
6549
6666
  case "sync": return this.syncQuery(event.payload.hash);
@@ -7094,8 +7211,8 @@ function selfAllowlistedVariant(flag, userId) {
7094
7211
 
7095
7212
  //#endregion
7096
7213
  //#region src/modules/devtools/index.ts
7097
- const CORE_VERSION = "0.0.1-canary.203";
7098
- const WASM_VERSION = "0.0.1-canary.203";
7214
+ const CORE_VERSION = "0.0.1-canary.204";
7215
+ const WASM_VERSION = "0.0.1-canary.204";
7099
7216
  const SURREAL_VERSION = "3.0.3";
7100
7217
  var DevToolsService = class DevToolsService {
7101
7218
  eventsHistory = [];
@@ -11718,6 +11835,7 @@ var Sp00kyClient = class {
11718
11835
  anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
11719
11836
  degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
11720
11837
  pushTimeoutMs: this.config.pushTimeoutMs,
11838
+ downTimeoutMs: this.config.downTimeoutMs,
11721
11839
  connectionSupervisor: this.connectionSupervisor
11722
11840
  });
11723
11841
  this.featureFlags = new FeatureFlagModule({
@@ -11772,7 +11890,7 @@ var Sp00kyClient = class {
11772
11890
  return new TabsCoordinator({
11773
11891
  tabId,
11774
11892
  fingerprint: computeTabsFingerprint({
11775
- coreVersion: "0.0.1-canary.203",
11893
+ coreVersion: "0.0.1-canary.204",
11776
11894
  schemaHash: hash53(this.config.schemaSurql),
11777
11895
  endpoint: this.config.database.endpoint ?? "",
11778
11896
  namespace: this.config.database.namespace,
@@ -170,6 +170,33 @@ function reviveRow(json) {
170
170
  return v;
171
171
  });
172
172
  }
173
+ /**
174
+ * SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
175
+ * `id`), so SQLite never returns — and neither side ever parses — the fields the
176
+ * caller did not ask for. On a game list that is every row's `pgn`, which was
177
+ * being read, parsed and thrown away 50 rows at a time for eight rendered fields.
178
+ *
179
+ * Byte-identical to running {@link project} over the fully-parsed row, which is
180
+ * what both paths did before, and verified so against real SQLite:
181
+ * `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
182
+ * rather than becoming an explicit null — the difference `json_object`/
183
+ * `json_extract` would have introduced. A stored null, nested objects and
184
+ * arrays, and `{__u8}` blob tags all round-trip unchanged, because
185
+ * `json_group_object` understands `json_each`'s `value` column as JSON rather
186
+ * than as text. `COALESCE` covers a row sharing none of the requested keys,
187
+ * where the subquery yields NULL and `reviveRow` would throw.
188
+ *
189
+ * Deliberately unaliased, so the emitted statement keeps the exact shape the
190
+ * callers already produce (`FROM "t" WHERE id IN (…)`).
191
+ *
192
+ * Binds one parameter per key, pushed onto `bind` — these land in the SELECT
193
+ * clause, so they must be bound BEFORE any WHERE parameters.
194
+ */
195
+ function projectedDataSql(fields, bind) {
196
+ const keys = ["id", ...fields];
197
+ for (const k of keys) bind.push(k);
198
+ return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
199
+ }
173
200
  function project(row, fields) {
174
201
  const out = {};
175
202
  for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
@@ -273,4 +300,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
273
300
  }
274
301
 
275
302
  //#endregion
276
- export { renderWhereSql as a, resolveRelations as c, renderOrderSql as i, stableKey as l, openDb as n, reviveRow as o, project as r, serializeRow as s, PROMOTION_OPEN_OPTIONS as t };
303
+ export { renderOrderSql as a, serializeRow as c, projectedDataSql as i, resolveRelations as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, stableKey as u };
@@ -1,4 +1,4 @@
1
- import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, n as openDb, o as reviveRow, r as project } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, i as projectedDataSql, l as resolveRelations, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as stableKey } from "./sqlite-open.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
 
4
4
  //#region src/services/database/sqlite-select.ts
@@ -16,14 +16,18 @@ function selectByIds(db, table, ids, opts) {
16
16
  if (ids.length === 0) return [];
17
17
  ensureTable(db, table);
18
18
  const keys = ids.map(stableKey);
19
- let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
19
+ const placeholders = keys.map(() => "?").join(", ");
20
+ const bind = [];
21
+ const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
22
+ bind.push(...keys);
23
+ let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
20
24
  if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
21
- let rows = execRows(db, sql, keys);
25
+ let rows = execRows(db, sql, bind);
22
26
  if (!opts?.orderBy || opts.orderBy.length === 0) {
23
27
  const pos = new Map(keys.map((k, i) => [k, i]));
24
28
  rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
25
29
  }
26
- return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
30
+ return rows;
27
31
  }
28
32
  /** Mirrors the engine's `fetchRelation` SQL exactly. */
29
33
  function fetchRelation(db, req) {
@@ -57,17 +61,16 @@ async function executeSelect(plan, params, db) {
57
61
  }
58
62
  ensureTable(db, plan.table);
59
63
  const bind = [];
60
- let sql = `SELECT data FROM "${plan.table}"`;
64
+ let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
61
65
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
62
66
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
63
67
  else sql += ` ORDER BY id`;
64
68
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
65
69
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
66
70
  const rows = execRows(db, sql, bind);
67
- const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
68
- await resolveRelations(projected, plan.relations, fetcher);
71
+ await resolveRelations(rows, plan.relations, fetcher);
69
72
  return {
70
- rows: projected,
73
+ rows,
71
74
  relationFetches: counter.n
72
75
  };
73
76
  }
package/dist/types.d.ts CHANGED
@@ -654,6 +654,12 @@ interface Sp00kyConfig<S extends SchemaStructure> {
654
654
  * `0` disables. Defaults to `30_000`.
655
655
  */
656
656
  pushTimeoutMs?: number;
657
+ /**
658
+ * Max time a single down event (`register`/`sync`/`cleanup`) may take before
659
+ * it is retried. Mirror of {@link pushTimeoutMs} for the read side.
660
+ * Defaults to 30000; `0` disables the timeout.
661
+ */
662
+ downTimeoutMs?: number;
657
663
  }
658
664
  /** Tunables for sync-health reporting. See {@link Sp00kyConfig.syncHealth}. */
659
665
  interface SyncHealthConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.203",
3
+ "version": "0.0.1-canary.204",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.203",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.203",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.204",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.204",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+
5
+ /**
6
+ * `getPendingRecordIds` used to run a full `SELECT ... FROM
7
+ * _00_pending_mutations` on EVERY materialization of EVERY query — a round trip
8
+ * down the local engine's single-flight op queue, paid tens of times per ingest
9
+ * against an outbox that is usually empty. It is now cached, invalidated when
10
+ * the outbox actually changes, with a short TTL as a backstop.
11
+ */
12
+
13
+ function makeLogger(): any {
14
+ const noop = () => {};
15
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
16
+ logger.child = () => logger;
17
+ return logger;
18
+ }
19
+
20
+ /** DataModule over a local store that counts outbox reads and can be told what
21
+ * to return. */
22
+ function makeModule(rows: { recordId: RecordId<string>; mutationType: string }[] = []) {
23
+ const state = { rows, reads: 0 };
24
+ const local = {
25
+ query: vi.fn(async () => {
26
+ state.reads++;
27
+ return [state.rows];
28
+ }),
29
+ };
30
+ const dm = new DataModule(
31
+ {} as any,
32
+ local as any,
33
+ { tables: [] } as any,
34
+ makeLogger(),
35
+ 100
36
+ );
37
+ return { dm, state };
38
+ }
39
+
40
+ const pending = (id: string, mutationType = 'update') => ({
41
+ recordId: new RecordId('game', id),
42
+ mutationType,
43
+ });
44
+
45
+ describe('DataModule pending-id cache', () => {
46
+ beforeEach(() => vi.useFakeTimers());
47
+ afterEach(() => vi.useRealTimers());
48
+
49
+ it('reads the outbox once for repeated calls', async () => {
50
+ const { dm, state } = makeModule([pending('a')]);
51
+ await dm.getPendingRecordIds();
52
+ await dm.getPendingRecordIds();
53
+ await dm.getPendingRecordIds();
54
+ expect(state.reads).toBe(1);
55
+ });
56
+
57
+ it('collapses a burst of CONCURRENT calls into one read', async () => {
58
+ // The real shape: one ingest fans out to many queries materializing at once.
59
+ const { dm, state } = makeModule([pending('a')]);
60
+ await Promise.all([
61
+ dm.getPendingRecordIds(),
62
+ dm.getPendingRecordIds(),
63
+ dm.getPendingRecordIds(),
64
+ dm.getPendingRecordIds(),
65
+ ]);
66
+ expect(state.reads).toBe(1);
67
+ });
68
+
69
+ it('hands out COPIES, so a caller mutating the sets cannot poison the cache', async () => {
70
+ // buildRenderIds merges the settled-write ids into what it gets back.
71
+ const { dm } = makeModule([pending('a')]);
72
+ const first = await dm.getPendingRecordIds();
73
+ first.writes.add('game:injected');
74
+ const second = await dm.getPendingRecordIds();
75
+ expect(second.writes.has('game:injected')).toBe(false);
76
+ expect([...second.writes]).toEqual(['game:a']);
77
+ });
78
+
79
+ it('re-reads after a mutation settles (its outbox row is gone)', async () => {
80
+ const { dm, state } = makeModule([pending('a')]);
81
+ await dm.getPendingRecordIds();
82
+ expect(state.reads).toBe(1);
83
+
84
+ state.rows = [];
85
+ dm.noteWriteSettled('game:a', 'update');
86
+ const after = await dm.getPendingRecordIds();
87
+
88
+ expect(state.reads).toBe(2);
89
+ expect(after.writes.size).toBe(0);
90
+ });
91
+
92
+ it('re-reads once the TTL backstop lapses', async () => {
93
+ // Covers any path that removes an outbox row without telling us: staleness
94
+ // is bounded to a tick rather than lasting the session.
95
+ const { dm, state } = makeModule([pending('a')]);
96
+ await dm.getPendingRecordIds();
97
+ expect(state.reads).toBe(1);
98
+
99
+ vi.setSystemTime(Date.now() + 1_000);
100
+ await dm.getPendingRecordIds();
101
+ expect(state.reads).toBe(2);
102
+ });
103
+
104
+ it('does NOT cache a failed read', async () => {
105
+ // Empty sets are this call's fallback, not a claim that the outbox is empty.
106
+ const { dm, state } = makeModule([pending('a')]);
107
+ (dm as any).local.query = vi.fn(async () => {
108
+ state.reads++;
109
+ throw new Error('engine down');
110
+ });
111
+ const failed = await dm.getPendingRecordIds();
112
+ expect(failed.writes.size).toBe(0);
113
+ expect(state.reads).toBe(1);
114
+
115
+ await dm.getPendingRecordIds();
116
+ expect(state.reads).toBe(2);
117
+ });
118
+
119
+ it('splits writes from deletes', async () => {
120
+ const { dm } = makeModule([pending('a'), pending('b', 'delete')]);
121
+ const { writes, deletes } = await dm.getPendingRecordIds();
122
+ expect([...writes]).toEqual(['game:a']);
123
+ expect([...deletes]).toEqual(['game:b']);
124
+ });
125
+ });