@spooky-sync/core 0.0.1-canary.330 → 0.0.1-canary.332

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
@@ -2188,8 +2188,8 @@ function trimHistory(history, cap) {
2188
2188
 
2189
2189
  //#endregion
2190
2190
  //#region src/modules/devtools/index.ts
2191
- const CORE_VERSION = "0.0.1-canary.330";
2192
- const WASM_VERSION = "0.0.1-canary.330";
2191
+ const CORE_VERSION = "0.0.1-canary.332";
2192
+ const WASM_VERSION = "0.0.1-canary.332";
2193
2193
  const SURREAL_VERSION = "3.1.0";
2194
2194
  var DevToolsService = class DevToolsService {
2195
2195
  version = CORE_VERSION;
@@ -4168,6 +4168,35 @@ function decideMembershipOutcome(input) {
4168
4168
  return meta.rowCount === 0 && meta.state !== "materializing" ? "applied" : "ignored";
4169
4169
  }
4170
4170
  /**
4171
+ * Commit LIVE edge deltas onto a held id-set: each names a row that joined
4172
+ * (or moved to a newer version). Returns `held` itself when nothing moves.
4173
+ */
4174
+ function withDeltas(held, deltas) {
4175
+ const next = new Map(held);
4176
+ let moved = false;
4177
+ for (const { id, version } of deltas) {
4178
+ if ((next.get(id) ?? -1) >= version) continue;
4179
+ next.set(id, version);
4180
+ moved = true;
4181
+ }
4182
+ return moved ? Array.from(next.entries()) : held;
4183
+ }
4184
+ /**
4185
+ * A membership read answers for the moment the server ran it, which may be
4186
+ * before a LIVE delta that was committed while the read was in flight. Carry
4187
+ * what changed in state since the read was sent (`before` -> `now`) over onto
4188
+ * the read, so a late answer never takes back a row that just joined.
4189
+ */
4190
+ function rebaseRead(read, before, now) {
4191
+ if (before === now) return read;
4192
+ const was = new Map(before);
4193
+ const is = new Map(now);
4194
+ const out = new Map(read);
4195
+ for (const [id] of before) if (!is.has(id)) out.delete(id);
4196
+ for (const [id, version] of now) if (version > (was.get(id) ?? -1) && version > (out.get(id) ?? -1)) out.set(id, version);
4197
+ return Array.from(out.entries());
4198
+ }
4199
+ /**
4171
4200
  * A statement that did not answer (an ERR, or no result at all). Kept apart
4172
4201
  * from a `null` result, which is the server saying "no such row": reading a
4173
4202
  * failed statement as a missing row turned every timeout under load into a
@@ -4854,9 +4883,38 @@ const stmt$1 = (results, i) => {
4854
4883
  return r && r.status === "OK" ? r.result : FAILED;
4855
4884
  };
4856
4885
  /**
4886
+ * Commit LIVE edge deltas (rows joining a view, or moving to a newer
4887
+ * version) straight to membership, without re-reading the edges: the
4888
+ * notification already says what the read would. Returns the hashes it
4889
+ * settled; any other change still goes through {@link readMembership}.
4890
+ *
4891
+ * Only for a query whose membership is known and current (`live`, not
4892
+ * materializing, no view-lost recovery pending): before that the read decides
4893
+ * what the set is. Removals are never applied here, so a view whose edges
4894
+ * are being rewritten never loses (and garbage-collects) bodies mid-rewrite.
4895
+ * Nor for a query with a `LIMIT`: a row joining it pushes another out, and
4896
+ * rendering the joiner before the read removes the leaver would show one row
4897
+ * too many (the id-set render drops the `LIMIT`).
4898
+ */
4899
+ function* applyEdgeDeltas(deltas) {
4900
+ const settled = /* @__PURE__ */ new Set();
4901
+ const byHash = /* @__PURE__ */ new Map();
4902
+ for (const d of deltas) byHash.set(d.hash, [...byHash.get(d.hash) ?? [], d]);
4903
+ for (const [hash, list] of byHash) {
4904
+ const entry = yield fx.state.read((s) => s.queries.get(hash));
4905
+ if (!entry || entry.lifecycle.phase !== "live" || entry.serverState === "materializing" || entry.viewLostRetryAt !== null) continue;
4906
+ if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql)) continue;
4907
+ const next = withDeltas(entry.remoteArray, list);
4908
+ if (next !== entry.remoteArray) yield* applyMembership(hash, next);
4909
+ settled.add(hash);
4910
+ }
4911
+ return settled;
4912
+ }
4913
+ /**
4857
4914
  * Read the server membership of many queries in as few round trips as the
4858
- * row budget allows, then apply each changed set. This is the ONLY place a
4859
- * server id-set enters state: registration, the poll, LIVE dirt and view-lost
4915
+ * row budget allows, then apply each changed set. Every server id-set enters
4916
+ * state here, except the rows LIVE reports joining (see
4917
+ * {@link applyEdgeDeltas}): registration, the poll, LIVE dirt and view-lost
4860
4918
  * recovery all come through here.
4861
4919
  */
4862
4920
  function* readMembership(env, hashes, opts = {}) {
@@ -4919,9 +4977,10 @@ function* readMembership(env, hashes, opts = {}) {
4919
4977
  for (const [hash, snap] of snapshots) {
4920
4978
  const current = yield fx.state.read((s) => s.queries.get(hash));
4921
4979
  if (!current) continue;
4980
+ const primary = rebaseRead(snap.primary, byHash.get(hash).remoteArray, current.remoteArray);
4922
4981
  const metaChanged = current.serverState !== (snap.meta.present ? snap.meta.state : null);
4923
- if (!recordVersionArraysEqual(snap.primary, current.remoteArray) || metaChanged || current.lifecycle.phase === "cold" || current.lifecycle.phase === "cached") {
4924
- const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
4982
+ if (!recordVersionArraysEqual(primary, current.remoteArray) || metaChanged || current.lifecycle.phase === "cold" || current.lifecycle.phase === "cached") {
4983
+ const outcome = yield* applyMembership(hash, primary, snap.meta);
4925
4984
  if (outcome === "applied") changed = true;
4926
4985
  if (outcome === "ignored" && snap.meta.present && snap.meta.state === "materializing") stillMaterializing.push(hash);
4927
4986
  }
@@ -6158,10 +6217,11 @@ function createInterpreter(adapters, host) {
6158
6217
  const pending = adapters.remote.queryResponses(effect.sql, effect.vars);
6159
6218
  return effect.timeoutMs ? withTimeout(pending, effect.timeoutMs, () => /* @__PURE__ */ new Error(`Remote request timed out after ${effect.timeoutMs}ms`)) : pending;
6160
6219
  }
6161
- case "remote.live": return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({
6220
+ case "remote.live": return adapters.remote.live(effect.table, (hashes, rows, deltas) => host.dispatch({
6162
6221
  type: "LiveChange",
6163
6222
  hashes,
6164
- rows
6223
+ rows,
6224
+ deltas
6165
6225
  }));
6166
6226
  case "remote.kill": return adapters.remote.kill(effect.uuid);
6167
6227
  case "ssp.register": {
@@ -6711,9 +6771,10 @@ function* pollTick(env) {
6711
6771
  //#endregion
6712
6772
  //#region src/sync/live.saga.ts
6713
6773
  /**
6714
- * LIVE on the session's `_00_list_ref` table. An event marks its query's
6715
- * membership dirty and the batched re-read decides; followers get the same
6716
- * dirt relayed by the leader.
6774
+ * LIVE on the session's `_00_list_ref` table. A row joining a live view is
6775
+ * committed straight from its edge; any other event marks its query's
6776
+ * membership dirty and the batched re-read decides. Followers get the dirt
6777
+ * relayed by the leader.
6717
6778
  *
6718
6779
  * When the subscription was opened with the body joined on
6719
6780
  * (`liveInlineBodies`) the event ALSO carries the changed row. That is an
@@ -6765,8 +6826,12 @@ function* liveInvalidate() {
6765
6826
  liveTable: null
6766
6827
  }));
6767
6828
  }
6768
- /** One or more edges of these queries changed on the server. */
6769
- function* liveChange(env, hashes, rows) {
6829
+ /**
6830
+ * One or more edges of these queries changed on the server. A row joining a
6831
+ * live view (`deltas`) is committed at once; anything else marks the query
6832
+ * dirty for the batched re-read.
6833
+ */
6834
+ function* liveChange(env, hashes, rows, deltas) {
6770
6835
  const [known, role, streak] = yield fx.state.read((s) => [
6771
6836
  hashes.filter((h) => s.queries.has(h)),
6772
6837
  s.tabRole,
@@ -6778,7 +6843,9 @@ function* liveChange(env, hashes, rows) {
6778
6843
  yield fx.timer.set("poll", env.pollBaseMs, { type: "PollTick" });
6779
6844
  }
6780
6845
  if (rows && rows.length > 0) yield* landInlineRows(env, rows);
6781
- yield* markMembershipDirty(known);
6846
+ const settled = deltas && deltas.length > 0 ? yield* applyEdgeDeltas(deltas.filter((d) => known.includes(d.hash))) : /* @__PURE__ */ new Set();
6847
+ const dirty = known.filter((h) => !settled.has(h));
6848
+ if (dirty.length > 0) yield* markMembershipDirty(dirty);
6782
6849
  if (role === "leader") yield fx.emit({
6783
6850
  type: "tabs:broadcast",
6784
6851
  message: {
@@ -7326,7 +7393,7 @@ function route(env, event) {
7326
7393
  lane: serial("live")
7327
7394
  };
7328
7395
  case "LiveChange": return {
7329
- saga: liveChange(env, event.hashes, event.rows),
7396
+ saga: liveChange(env, event.hashes, event.rows, event.deltas),
7330
7397
  lane: serial("live-change")
7331
7398
  };
7332
7399
  case "TabRole": return {
@@ -8544,6 +8611,11 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8544
8611
  setAuthToken(token) {
8545
8612
  this.authToken = token;
8546
8613
  }
8614
+ /** The token the session is signed in with (or the configured one), for
8615
+ * calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
8616
+ getAuthToken() {
8617
+ return this.authToken ?? this.getConfig().token ?? null;
8618
+ }
8547
8619
  /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
8548
8620
  getReconnectConfig() {
8549
8621
  return this.reconnectConfig;
@@ -12620,8 +12692,6 @@ function loadLoro() {
12620
12692
  const LOCAL_TABLE = "_00_crdt_local";
12621
12693
  const LOG_SUFFIX = "__crdt_log";
12622
12694
  const PRESENCE_SUFFIX = "__crdt_presence";
12623
- /** Longest a burst of typing waits before its edits go out. */
12624
- const MAX_WAIT_MS = 2e3;
12625
12695
  const PERSIST_DEBOUNCE_MS = 400;
12626
12696
  const PRESENCE_THROTTLE_MS = 250;
12627
12697
  const CLAIM_RETRY_MS = 5e3;
@@ -12643,11 +12713,12 @@ var CrdtManager = class {
12643
12713
  claim = null;
12644
12714
  claimRetry = null;
12645
12715
  connectionUnsubscribes = [];
12646
- constructor(schema, local, remote, logger, debounceMs = 300) {
12716
+ constructor(schema, local, remote, logger, debounceMs = 50, sendIntervalMs = 200) {
12647
12717
  this.schema = schema;
12648
12718
  this.local = local;
12649
12719
  this.remote = remote;
12650
12720
  this.debounceMs = debounceMs;
12721
+ this.sendIntervalMs = sendIntervalMs;
12651
12722
  this.logger = logger.child({ service: "CrdtManager" });
12652
12723
  this.connectionUnsubscribes.push(this.remote.subscribeConnection("connected", () => {
12653
12724
  for (const s of this.sessions.values()) {
@@ -12768,9 +12839,11 @@ var CrdtManager = class {
12768
12839
  verifyTimer: null,
12769
12840
  disposers: [],
12770
12841
  flushTimer: null,
12771
- firstDirtyAt: 0,
12842
+ flushDueAt: 0,
12843
+ lastSentAt: 0,
12772
12844
  persistTimer: null,
12773
12845
  flushing: null,
12846
+ flushQueued: null,
12774
12847
  refetching: false,
12775
12848
  presenceTimer: null,
12776
12849
  presencePending: null,
@@ -12783,31 +12856,31 @@ var CrdtManager = class {
12783
12856
  if (local.snapshot || fresh) session.handle.markLoaded();
12784
12857
  this.sessions.set(key, session);
12785
12858
  this.ensureClaimed();
12786
- if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
12859
+ const canonical = !local.snapshot && !fresh ? this.refetchCanonical(session) : null;
12787
12860
  session.disposers.push(doc.subscribeLocalUpdates(() => {
12788
12861
  session.local.peers[this.peer] ??= { enqueued: 0 };
12789
12862
  this.scheduleFlush(session);
12790
12863
  this.schedulePersist(session);
12791
12864
  }));
12792
12865
  const ports = this.ports;
12793
- const log = await ports.watch(`${table}${LOG_SUFFIX}`, {
12866
+ const logWatch = ports.watch(`${table}${LOG_SUFFIX}`, {
12794
12867
  doc: recordId,
12795
12868
  field
12796
12869
  }, (rows) => {
12797
12870
  session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
12798
12871
  });
12872
+ const presenceWatch = cursor ? ports.watch(`${table}${PRESENCE_SUFFIX}`, {
12873
+ doc: recordId,
12874
+ field
12875
+ }, (rows) => this.ingestPresence(session, rows)) : null;
12876
+ const [log, presence] = await Promise.all([logWatch, presenceWatch]);
12799
12877
  session.disposers.push(log.stop);
12878
+ if (presence) session.disposers.push(presence.stop);
12800
12879
  log.settled.then(() => {
12801
12880
  session.logSettled = true;
12802
12881
  session.ingesting.then(() => this.checkLoaded(session));
12803
12882
  });
12804
- if (cursor) {
12805
- const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, {
12806
- doc: recordId,
12807
- field
12808
- }, (rows) => this.ingestPresence(session, rows));
12809
- session.disposers.push(presence.stop);
12810
- }
12883
+ if (canonical) await this.serverWait(canonical);
12811
12884
  if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
12812
12885
  await session.ingesting;
12813
12886
  this.scheduleFlush(session, 0);
@@ -12983,18 +13056,36 @@ var CrdtManager = class {
12983
13056
  fork?.free();
12984
13057
  }
12985
13058
  }
12986
- scheduleFlush(s, delay = this.debounceMs) {
13059
+ /**
13060
+ * Arm the send. Edits go out on a steady cadence while they keep coming:
13061
+ * the first one after a quiet spell waits `debounceMs` (one row for the
13062
+ * burst), later ones at least `sendIntervalMs` after the previous send. An
13063
+ * armed timer is never pushed back, so continuous typing cannot hold the
13064
+ * edits; it is only brought forward (`delay`, e.g. 0 on reconnect).
13065
+ */
13066
+ scheduleFlush(s, delay) {
12987
13067
  const now = Date.now();
12988
- if (!s.flushTimer) s.firstDirtyAt = now;
12989
- else clearTimeout(s.flushTimer);
12990
- const wait = Math.max(0, Math.min(delay, s.firstDirtyAt + MAX_WAIT_MS - now));
13068
+ const due = delay !== void 0 ? now + delay : Math.max(now + this.debounceMs, s.lastSentAt + this.sendIntervalMs);
13069
+ if (s.flushTimer) {
13070
+ if (s.flushDueAt <= due) return;
13071
+ clearTimeout(s.flushTimer);
13072
+ }
13073
+ s.flushDueAt = due;
12991
13074
  s.flushTimer = setTimeout(() => {
12992
13075
  s.flushTimer = null;
12993
13076
  this.flush(s);
12994
- }, wait);
12995
- }
12996
- async flush(s) {
12997
- if (s.flushing) return s.flushing;
13077
+ }, due - now);
13078
+ }
13079
+ /** Send what is unsent. A call while a send runs queues one more after it,
13080
+ * so edits made during that send are not left waiting for the next edit. */
13081
+ flush(s) {
13082
+ if (s.flushing) {
13083
+ s.flushQueued ??= s.flushing.then(() => {
13084
+ s.flushQueued = null;
13085
+ return this.flush(s);
13086
+ });
13087
+ return s.flushQueued;
13088
+ }
12998
13089
  s.flushing = this.sendOwnEdits(s).finally(() => {
12999
13090
  s.flushing = null;
13000
13091
  });
@@ -13012,6 +13103,7 @@ var CrdtManager = class {
13012
13103
  from: s.local.peers[peer]?.enqueued ?? 0
13013
13104
  })).filter((p) => p.end > p.from);
13014
13105
  if (due.length === 0 || !this.ports) return;
13106
+ s.lastSentAt = Date.now();
13015
13107
  if (!await this.ensureClaimed()) {
13016
13108
  this.retryLater(s);
13017
13109
  return;
@@ -13046,7 +13138,8 @@ var CrdtManager = class {
13046
13138
  }
13047
13139
  s.local.peers[peer] = { enqueued: end };
13048
13140
  }
13049
- await this.persist(s);
13141
+ if (this.sessions.get(s.key) === s) this.schedulePersist(s);
13142
+ else await this.persist(s);
13050
13143
  }
13051
13144
  retryLater(s) {
13052
13145
  if (this.sessions.get(s.key) === s) this.scheduleFlush(s, CLAIM_RETRY_MS);
@@ -15079,6 +15172,27 @@ function rowOfEdge(value) {
15079
15172
  record: out
15080
15173
  };
15081
15174
  }
15175
+ /**
15176
+ * The membership change a `CREATE` or `UPDATE` notification reports: the
15177
+ * edge's target joined the view (or its version moved). Null for anything a
15178
+ * re-read must settle instead: a `DELETE` (a row leaving, or every edge of a
15179
+ * view being rewritten), a subquery child (`parent` set), or an edge missing
15180
+ * its target or version. With `FETCH out` the target is the joined row, whose
15181
+ * `id` is the record id.
15182
+ */
15183
+ function deltaOfEdge(action, value) {
15184
+ if (action !== "CREATE" && action !== "UPDATE") return null;
15185
+ const hash = hashOfEdge(value);
15186
+ const edge = value;
15187
+ if (!hash || edge.parent != null || typeof edge.version !== "number") return null;
15188
+ const out = edge.out instanceof RecordId$1 ? edge.out : edge.out?.id;
15189
+ if (!(out instanceof RecordId$1)) return null;
15190
+ return {
15191
+ hash,
15192
+ id: encodeRecordId(out),
15193
+ version: edge.version
15194
+ };
15195
+ }
15082
15196
 
15083
15197
  //#endregion
15084
15198
  //#region src/utils/blurhash.ts
@@ -15150,6 +15264,122 @@ function bucketContentToBlob(content) {
15150
15264
  if (ArrayBuffer.isView(content)) return new Blob([content]);
15151
15265
  return null;
15152
15266
  }
15267
+ /** A handle's settings from the client config and the bucket's generated schema. */
15268
+ function bucketSettings(config, name, logger) {
15269
+ const backend = config.schema.buckets?.find((b) => b.name === name)?.backend;
15270
+ return {
15271
+ blurhash: config.blurhash,
15272
+ logger,
15273
+ backend,
15274
+ files: config.files
15275
+ };
15276
+ }
15277
+ /** `f"bucket:/path"` statements over the database connection. */
15278
+ function surrealIo(bucket, remote) {
15279
+ const file = (path) => `f"${bucket}:/${path}"`;
15280
+ return {
15281
+ async put(path, content) {
15282
+ await remote.query(`RETURN ${file(path)}.put($content);`, { content });
15283
+ },
15284
+ async get(path) {
15285
+ const [result] = await remote.query(`RETURN ${file(path)}.get();`);
15286
+ return result;
15287
+ },
15288
+ async delete(path) {
15289
+ await remote.query(`RETURN ${file(path)}.delete();`);
15290
+ },
15291
+ async exists(path) {
15292
+ const [result] = await remote.query(`RETURN ${file(path)}.exists();`);
15293
+ return result;
15294
+ },
15295
+ async head(path) {
15296
+ const [result] = await remote.query(`RETURN ${file(path)}.head();`);
15297
+ return result;
15298
+ },
15299
+ async copy(sourcePath, targetPath) {
15300
+ await remote.query(`RETURN ${file(sourcePath)}.copy($target);`, { target: targetPath });
15301
+ },
15302
+ async rename(sourcePath, targetPath) {
15303
+ await remote.query(`RETURN ${file(sourcePath)}.rename($target);`, { target: targetPath });
15304
+ },
15305
+ async list(prefix) {
15306
+ const [result] = await remote.query(`RETURN ${file(prefix)}.list();`);
15307
+ return result;
15308
+ }
15309
+ };
15310
+ }
15311
+ /**
15312
+ * `BACKEND "r2"` buckets: the SSP keeps the files (R2 on Cloudflare, a
15313
+ * directory under `spky dev`) and serves them at `/bucket/<name>/<key>`,
15314
+ * after asking SurrealDB, as the signed-in user, whether the bucket's
15315
+ * PERMISSIONS clause allows it. The request carries the session's token.
15316
+ */
15317
+ function sspIo(bucket, remote, endpoint) {
15318
+ const base = () => {
15319
+ if (!endpoint) throw new Error(`Bucket '${bucket}' is stored with the SSP (BACKEND "r2"): set \`files.endpoint\` in the client config to the SSP's URL.`);
15320
+ return endpoint.replace(/\/+$/, "");
15321
+ };
15322
+ const url = (path) => `${base()}/bucket/${encodeURIComponent(bucket)}/${path.split("/").filter(Boolean).map(encodeURIComponent).join("/")}`;
15323
+ const send = async (method, path, body, contentType) => {
15324
+ const headers = {};
15325
+ const token = remote.getAuthToken();
15326
+ if (token) headers.Authorization = `Bearer ${token}`;
15327
+ if (contentType) headers["Content-Type"] = contentType;
15328
+ const response = await fetch(url(path), {
15329
+ method,
15330
+ headers,
15331
+ body,
15332
+ credentials: "omit"
15333
+ });
15334
+ if (!response.ok && response.status !== 404) {
15335
+ let message = response.statusText;
15336
+ try {
15337
+ message = (await response.json()).error ?? message;
15338
+ } catch {}
15339
+ throw new Error(`Bucket '${bucket}' ${method} ${path} failed (${response.status}): ${message}`);
15340
+ }
15341
+ return response;
15342
+ };
15343
+ const op = async (path, body) => {
15344
+ const response = await send("POST", path, JSON.stringify(body), "application/json");
15345
+ if (response.status === 404) return null;
15346
+ return await response.json();
15347
+ };
15348
+ const missing = (path) => /* @__PURE__ */ new Error(`Bucket '${bucket}': no file at ${path}`);
15349
+ return {
15350
+ async put(path, content) {
15351
+ await send("PUT", path, content, (content instanceof Blob ? content.type : void 0) || "application/octet-stream");
15352
+ },
15353
+ async get(path) {
15354
+ const response = await send("GET", path);
15355
+ return response.status === 404 ? null : await response.blob();
15356
+ },
15357
+ async delete(path) {
15358
+ await send("DELETE", path);
15359
+ },
15360
+ async exists(path) {
15361
+ return await op(path, { op: "exists" }) ?? false;
15362
+ },
15363
+ async head(path) {
15364
+ return await op(path, { op: "head" });
15365
+ },
15366
+ async copy(sourcePath, targetPath) {
15367
+ if (await op(sourcePath, {
15368
+ op: "copy",
15369
+ target: targetPath
15370
+ }) === null) throw missing(sourcePath);
15371
+ },
15372
+ async rename(sourcePath, targetPath) {
15373
+ if (await op(sourcePath, {
15374
+ op: "rename",
15375
+ target: targetPath
15376
+ }) === null) throw missing(sourcePath);
15377
+ },
15378
+ async list() {
15379
+ throw new Error(`Bucket '${bucket}': listing is not available (BACKEND "r2")`);
15380
+ }
15381
+ };
15382
+ }
15153
15383
  /**
15154
15384
  * Paths known to have no blurhash sidecar, per tab. The blob cache has no
15155
15385
  * negative caching, so without this every mount of a hashless image would pay
@@ -15158,11 +15388,12 @@ function bucketContentToBlob(content) {
15158
15388
  */
15159
15389
  const missingBlurhash = /* @__PURE__ */ new Set();
15160
15390
  var BucketHandle = class {
15391
+ io;
15161
15392
  constructor(bucketName, remote, blobs, settings) {
15162
15393
  this.bucketName = bucketName;
15163
- this.remote = remote;
15164
15394
  this.blobs = blobs;
15165
15395
  this.settings = settings;
15396
+ this.io = settings?.backend === "r2" ? sspIo(bucketName, remote, settings.files?.endpoint) : surrealIo(bucketName, remote);
15166
15397
  }
15167
15398
  /** Effective blurhash setting: per-call option > client config > default ON. */
15168
15399
  resolveBlurhash(option) {
@@ -15173,7 +15404,7 @@ var BucketHandle = class {
15173
15404
  async put(path, content, options) {
15174
15405
  const encodeOptions = this.resolveBlurhash(options?.blurhash);
15175
15406
  const hashPromise = encodeOptions && isImagePath(path) ? encodeImageToBlurhash(content, encodeOptions) : Promise.resolve(null);
15176
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
15407
+ await this.io.put(path, content);
15177
15408
  await this.blobs?.invalidate({
15178
15409
  bucket: this.bucketName,
15179
15410
  path
@@ -15183,7 +15414,7 @@ var BucketHandle = class {
15183
15414
  hash = await hashPromise;
15184
15415
  if (hash) {
15185
15416
  const sidecar = blurhashSidecarPath(path);
15186
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, { content: hash });
15417
+ await this.io.put(sidecar, hash);
15187
15418
  await this.blobs?.invalidate({
15188
15419
  bucket: this.bucketName,
15189
15420
  path: sidecar
@@ -15231,8 +15462,7 @@ var BucketHandle = class {
15231
15462
  }
15232
15463
  }
15233
15464
  async get(path) {
15234
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
15235
- return result;
15465
+ return this.io.get(path);
15236
15466
  }
15237
15467
  /**
15238
15468
  * Read through the local blob cache: OPFS first, the bucket second. Unlike
@@ -15286,7 +15516,7 @@ var BucketHandle = class {
15286
15516
  })));
15287
15517
  }
15288
15518
  async delete(path) {
15289
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
15519
+ await this.io.delete(path);
15290
15520
  await this.blobs?.invalidate({
15291
15521
  bucket: this.bucketName,
15292
15522
  path
@@ -15294,7 +15524,7 @@ var BucketHandle = class {
15294
15524
  if (isImagePath(path)) {
15295
15525
  const sidecar = blurhashSidecarPath(path);
15296
15526
  try {
15297
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
15527
+ await this.io.delete(sidecar);
15298
15528
  await this.blobs?.invalidate({
15299
15529
  bucket: this.bucketName,
15300
15530
  path: sidecar
@@ -15304,23 +15534,19 @@ var BucketHandle = class {
15304
15534
  }
15305
15535
  }
15306
15536
  async exists(path) {
15307
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
15308
- return result;
15537
+ return this.io.exists(path);
15309
15538
  }
15310
15539
  async head(path) {
15311
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".head();`);
15312
- return result;
15540
+ return this.io.head(path);
15313
15541
  }
15314
15542
  async copy(sourcePath, targetPath) {
15315
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
15543
+ await this.io.copy(sourcePath, targetPath);
15316
15544
  }
15317
15545
  async rename(sourcePath, targetPath) {
15318
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
15546
+ await this.io.rename(sourcePath, targetPath);
15319
15547
  }
15320
15548
  async list(prefix) {
15321
- const p = prefix ?? "";
15322
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${p}".list();`);
15323
- return result;
15549
+ return this.io.list(prefix ?? "");
15324
15550
  }
15325
15551
  };
15326
15552
 
@@ -15405,10 +15631,7 @@ function createServices(config) {
15405
15631
  const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
15406
15632
  const remote = new RemoteDatabaseService(config.database, logger);
15407
15633
  const connectionSupervisor = new ConnectionSupervisor(remote, logger);
15408
- const rawBucket = (name) => new BucketHandle(name, remote, null, {
15409
- blurhash: config.blurhash,
15410
- logger
15411
- });
15634
+ const rawBucket = (name) => new BucketHandle(name, remote, null, bucketSettings(config, name, logger));
15412
15635
  const blobs = createBlobCache({
15413
15636
  local,
15414
15637
  namespace: ANON_USER_ID,
@@ -15427,7 +15650,7 @@ function createServices(config) {
15427
15650
  streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === "sqlite", config.circuitCheckpointMs);
15428
15651
  streamProcessor.configureProjection(config.circuitProjection ?? true);
15429
15652
  const migrator = new LocalMigrator(local, logger);
15430
- const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
15653
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 50, config.crdtSendIntervalMs ?? 200);
15431
15654
  const auth = new AuthService(config.schema, remote, persistence, logger);
15432
15655
  const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
15433
15656
  return {
@@ -15453,7 +15676,7 @@ function createTabsCoordinator(config, s, host) {
15453
15676
  return new TabsCoordinator({
15454
15677
  tabId: s.tabId,
15455
15678
  fingerprint: computeTabsFingerprint({
15456
- coreVersion: "0.0.1-canary.330",
15679
+ coreVersion: "0.0.1-canary.332",
15457
15680
  schemaHash: hash53(config.schemaSurql),
15458
15681
  endpoint: config.database.endpoint ?? "",
15459
15682
  namespace: config.database.namespace,
@@ -15673,7 +15896,8 @@ function createAdapters(config, s, host, lateModules) {
15673
15896
  const hash = hashOfEdge(message.value);
15674
15897
  if (!hash) return;
15675
15898
  const row = message.action === "DELETE" ? null : rowOfEdge(message.value);
15676
- onChange([hash], row ? [row] : void 0);
15899
+ const delta = deltaOfEdge(message.action, message.value);
15900
+ onChange([hash], row ? [row] : void 0, delta ? [delta] : void 0);
15677
15901
  });
15678
15902
  return String(uuid);
15679
15903
  },
@@ -16564,10 +16788,7 @@ var Sp00kyClient = class {
16564
16788
  return this.services.crdt.close(table, recordId, field);
16565
16789
  }
16566
16790
  bucket(name) {
16567
- return new BucketHandle(name, this.services.remote, this.services.blobs, {
16568
- blurhash: this.config.blurhash,
16569
- logger: this.services.logger
16570
- });
16791
+ return new BucketHandle(name, this.services.remote, this.services.blobs, bucketSettings(this.config, name, this.services.logger));
16571
16792
  }
16572
16793
  getBlobCacheStats() {
16573
16794
  return this.services.blobs.stats();
@@ -1,4 +1,4 @@
1
- import { p as PinoTransmit } from "../types.js";
1
+ import { m as PinoTransmit } from "../types.js";
2
2
  import { Level } from "pino";
3
3
 
4
4
  //#region src/otel/index.d.ts
package/dist/pure.d.ts CHANGED
@@ -139,6 +139,13 @@ interface InlineRow {
139
139
  version: number;
140
140
  record: Record<string, unknown>;
141
141
  }
142
+ /**
143
+ * A row that joined a query's view, or moved to a newer version, as one LIVE
144
+ * notification on `_00_list_ref` reports it: the edge's `out` and `version`.
145
+ * The client commits it to the query's membership straight away instead of
146
+ * re-reading the edges (see `sync/live.saga.ts`).
147
+ */
148
+
142
149
  type RecordVersionArray = Array<[string, number]>;
143
150
  /**
144
151
  * Represents the difference between two record version sets.
@@ -329,6 +336,15 @@ declare function hashOfEdge(value: unknown): string | null;
329
336
  * `out: null`) and for an edge whose target has been deleted.
330
337
  */
331
338
  declare function rowOfEdge(value: unknown): InlineRow | null;
339
+ /**
340
+ * The membership change a `CREATE` or `UPDATE` notification reports: the
341
+ * edge's target joined the view (or its version moved). Null for anything a
342
+ * re-read must settle instead: a `DELETE` (a row leaving, or every edge of a
343
+ * view being rewritten), a subquery child (`parent` set), or an edge missing
344
+ * its target or version. With `FETCH out` the target is the joined row, whose
345
+ * `id` is the record id.
346
+ */
347
+
332
348
  //#endregion
333
349
  //#region src/utils/parser.d.ts
334
350
  /**