@spooky-sync/core 0.0.1-canary.245 → 0.0.1-canary.247

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
@@ -1,4 +1,4 @@
1
- import { A as ServerViewMeta, B as TimingPhase, C as QueryTimings, D as RecordVersionDiff, E as RecordVersionArray, F as StorageHealthStatus, G as SealedQuery, H as LocalStore, I as StoreType, K as EventDefinition, L as SyncHealth, M as Sp00kyQueryResult, N as Sp00kyQueryResultPromise, O as RegistrationTimings, P as StorageHealth, R as SyncHealthConfig, S as QueryTimeToLive, T as ReconnectConfig, U as DatabaseEventSystem, V as UpdateOptions, W as DatabaseEventTypes, _ as QueryConfigRecord, a as MATERIALIZATION_SAMPLE_WINDOW, b as QueryStatus, c as MutationEvent, d as PhaseStat, f as PinoTransmit, g as QueryConfig, h as QueryAuthorityCallback, i as Level, j as Sp00kyConfig, k as RunOptions, l as MutationEventType, m as PreloadRefresh, n as DebounceOptions, o as MembershipOutcome, p as PreloadOptions, q as EventSystem, r as EventSubscriptionOptions, s as MutationCallback, t as ConnectionState, u as PersistenceClient, v as QueryHash, w as QueryUpdateCallback, x as QueryStatusCallback, y as QueryState, z as SyncHealthStatus } from "./types.js";
1
+ import { A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, _ as QueryConfig, a as Level, b as QueryState, c as MutationCallback, d as PersistenceClient, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, s as MembershipOutcome, t as ConnectionState, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
2
2
  import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
3
3
  import * as surrealdb0 from "surrealdb";
4
4
  import { Duration, RecordId as RecordId$1, Surreal, SurrealEvents, SurrealTransaction } from "surrealdb";
@@ -1180,6 +1180,7 @@ type RuntimeEvent = {
1180
1180
  } | {
1181
1181
  type: 'LiveChange';
1182
1182
  hashes: QueryHash[];
1183
+ rows?: InlineRow[];
1183
1184
  } | {
1184
1185
  type: 'TabRole';
1185
1186
  role: 'solo' | 'leader' | 'follower';
@@ -1622,8 +1623,12 @@ interface Adapters {
1622
1623
  };
1623
1624
  remote: {
1624
1625
  queryResponses(sql: string, vars?: Record<string, unknown>): Promise<StatementResult[]>;
1625
- /** Subscribe to `LIVE SELECT * FROM <table>`; the callback receives the changed query hashes. */
1626
- live(table: string, onChange: (hashes: string[]) => void): Promise<string>;
1626
+ /**
1627
+ * Subscribe to `LIVE SELECT * FROM <table>`; the callback receives the
1628
+ * changed query hashes, plus the changed rows themselves when the
1629
+ * subscription was opened with the body joined on (`liveInlineBodies`).
1630
+ */
1631
+ live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
1627
1632
  kill(uuid: string): Promise<void>;
1628
1633
  };
1629
1634
  ssp: {
@@ -2355,4 +2360,4 @@ declare function textToHtml(text: string): string;
2355
2360
  */
2356
2361
 
2357
2362
  //#endregion
2358
- 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, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, ServerViewMeta, Sp00kyClient, Sp00kyClientDeps, 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 };
2363
+ 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, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, ServerViewMeta, Sp00kyClient, Sp00kyClientDeps, 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
@@ -1073,8 +1073,8 @@ function selfAllowlistedVariant(flag, userId) {
1073
1073
 
1074
1074
  //#endregion
1075
1075
  //#region src/modules/devtools/index.ts
1076
- const CORE_VERSION = "0.0.1-canary.245";
1077
- const WASM_VERSION = "0.0.1-canary.245";
1076
+ const CORE_VERSION = "0.0.1-canary.247";
1077
+ const WASM_VERSION = "0.0.1-canary.247";
1078
1078
  const SURREAL_VERSION = "3.0.3";
1079
1079
  var DevToolsService = class DevToolsService {
1080
1080
  eventsHistory = [];
@@ -4533,9 +4533,10 @@ function createInterpreter(adapters, host) {
4533
4533
  const pending = adapters.remote.queryResponses(effect.sql, effect.vars);
4534
4534
  return effect.timeoutMs ? withTimeout(pending, effect.timeoutMs, () => /* @__PURE__ */ new Error(`Remote request timed out after ${effect.timeoutMs}ms`)) : pending;
4535
4535
  }
4536
- case "remote.live": return adapters.remote.live(effect.table, (hashes) => host.dispatch({
4536
+ case "remote.live": return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({
4537
4537
  type: "LiveChange",
4538
- hashes
4538
+ hashes,
4539
+ rows
4539
4540
  }));
4540
4541
  case "remote.kill": return adapters.remote.kill(effect.uuid);
4541
4542
  case "ssp.register": {
@@ -4643,7 +4644,13 @@ function* fetchRows(env) {
4643
4644
  }
4644
4645
  }
4645
4646
  }
4646
- /** Write one chunk's bodies to the store and circuit; record their versions. */
4647
+ /**
4648
+ * Write one chunk's bodies to the store and circuit; record their versions.
4649
+ *
4650
+ * Shared with the LIVE path: a body that arrives on a notification is landed
4651
+ * through here so it is indistinguishable from a fetched one, which is what
4652
+ * makes `planFetch` stop asking for it.
4653
+ */
4647
4654
  function* landChunk(env, requested, rows, versions, state, epoch) {
4648
4655
  const bodies = [];
4649
4656
  const ingest = [];
@@ -5035,10 +5042,15 @@ function* pollTick(env) {
5035
5042
  //#endregion
5036
5043
  //#region src/sync/live.saga.ts
5037
5044
  /**
5038
- * LIVE on the session's `_00_list_ref` table. Events never carry data into
5039
- * state: every change only marks its query's membership dirty, and the
5040
- * batched re-read decides. Followers get the same dirt relayed by the
5041
- * leader.
5045
+ * LIVE on the session's `_00_list_ref` table. An event marks its query's
5046
+ * membership dirty and the batched re-read decides; followers get the same
5047
+ * dirt relayed by the leader.
5048
+ *
5049
+ * When the subscription was opened with the body joined on
5050
+ * (`liveInlineBodies`) the event ALSO carries the changed row. That is an
5051
+ * optimisation on top, never a source of truth: the row is landed exactly as a
5052
+ * fetched body would be, so a notification the server drops costs nothing but
5053
+ * the round trip it would have saved.
5042
5054
  */
5043
5055
  function* liveStart(env) {
5044
5056
  const state = yield fx.state.read((s) => s);
@@ -5085,10 +5097,18 @@ function* liveInvalidate() {
5085
5097
  }));
5086
5098
  }
5087
5099
  /** One or more edges of these queries changed on the server. */
5088
- function* liveChange(hashes) {
5089
- const [known, role] = yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole]);
5100
+ function* liveChange(env, hashes, rows) {
5101
+ const [known, role, streak] = yield fx.state.read((s) => [
5102
+ hashes.filter((h) => s.queries.has(h)),
5103
+ s.tabRole,
5104
+ s.sync.pollIdleStreak
5105
+ ]);
5090
5106
  if (known.length === 0) return;
5091
- yield fx.state.update(patchSync({ pollIdleStreak: 0 }));
5107
+ if (streak > 0) {
5108
+ yield fx.state.update(patchSync({ pollIdleStreak: 0 }));
5109
+ yield fx.timer.set("poll", env.pollBaseMs, { type: "PollTick" });
5110
+ }
5111
+ if (rows && rows.length > 0) yield* landInlineRows(env, rows);
5092
5112
  yield* markMembershipDirty(known);
5093
5113
  if (role === "leader") yield fx.emit({
5094
5114
  type: "tabs:broadcast",
@@ -5098,6 +5118,29 @@ function* liveChange(hashes) {
5098
5118
  }
5099
5119
  });
5100
5120
  }
5121
+ /**
5122
+ * Land bodies that rode in on the notification. Rows already held at this
5123
+ * version or newer are dropped: one edit bumps the edge in every view holding
5124
+ * the row, so the same body arrives once per view.
5125
+ */
5126
+ function* landInlineRows(env, rows) {
5127
+ const state = yield fx.state.read((s) => s);
5128
+ const fresh = /* @__PURE__ */ new Map();
5129
+ for (const row of rows) {
5130
+ if ((state.versions.get(row.id) ?? -1) >= row.version) continue;
5131
+ const held = fresh.get(row.id);
5132
+ if (!held || held.version < row.version) fresh.set(row.id, row);
5133
+ }
5134
+ if (fresh.size === 0) return;
5135
+ const picked = [...fresh.values()];
5136
+ const epoch = yield fx.local.epoch();
5137
+ if (!(yield* landChunk(env, picked.map((r) => r.id), picked.map((r) => r.record), new Map(picked.map((r) => [r.id, r.version])), state, epoch))) yield fx.emit({
5138
+ type: "log",
5139
+ level: "debug",
5140
+ message: "inline live body not landed; the fetch path will pull it",
5141
+ data: { ids: picked.map((r) => r.id) }
5142
+ });
5143
+ }
5101
5144
 
5102
5145
  //#endregion
5103
5146
  //#region src/sync/connection.saga.ts
@@ -5230,7 +5273,7 @@ function* tabMessage(env, raw) {
5230
5273
  yield fx.state.update(compose(setVersions(msg.records.filter((r) => r.op !== "DELETE").map((r) => [r.id, typeof r.record?._00_rv === "number" ? r.record._00_rv : 0])), deleteVersions(msg.records.filter((r) => r.op === "DELETE").map((r) => r.id)), ...[...new Set(msg.records.map((r) => r.table))].map((t) => markTableDirty(t))));
5231
5274
  return;
5232
5275
  case "membership-dirty":
5233
- if (Array.isArray(msg.hashes)) yield* liveChange(msg.hashes);
5276
+ if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
5234
5277
  return;
5235
5278
  case "outbox-changed":
5236
5279
  if (role !== "leader") return;
@@ -5566,7 +5609,7 @@ function route(env, event) {
5566
5609
  lane: serial("live")
5567
5610
  };
5568
5611
  case "LiveChange": return {
5569
- saga: liveChange(event.hashes),
5612
+ saga: liveChange(env, event.hashes, event.rows),
5570
5613
  lane: serial("live-change")
5571
5614
  };
5572
5615
  case "TabRole": return {
@@ -12473,7 +12516,7 @@ function createTabsCoordinator(config, s, host) {
12473
12516
  return new TabsCoordinator({
12474
12517
  tabId: s.tabId,
12475
12518
  fingerprint: computeTabsFingerprint({
12476
- coreVersion: "0.0.1-canary.245",
12519
+ coreVersion: "0.0.1-canary.247",
12477
12520
  schemaHash: hash53(config.schemaSurql),
12478
12521
  endpoint: config.database.endpoint ?? "",
12479
12522
  namespace: config.database.namespace,
@@ -12578,6 +12621,29 @@ function hashOfEdge(value) {
12578
12621
  const str = inId instanceof RecordId$1 ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
12579
12622
  return str.length > 0 ? str : null;
12580
12623
  }
12624
+ /**
12625
+ * The row an edge notification carries, when the subscription joined it on.
12626
+ *
12627
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
12628
+ * land, so this returns null and the caller falls back to fetching the body.
12629
+ * It also returns null for a row the session may not read (the join yields
12630
+ * `out: null`) and for an edge whose target has been deleted.
12631
+ */
12632
+ function rowOfEdge(value) {
12633
+ const edge = value;
12634
+ const out = edge?.out;
12635
+ if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId$1) return null;
12636
+ const rid = out.id;
12637
+ if (!(rid instanceof RecordId$1)) return null;
12638
+ const id = encodeRecordId(rid);
12639
+ const version = typeof edge?.version === "number" ? edge.version : null;
12640
+ if (version === null) return null;
12641
+ return {
12642
+ id,
12643
+ version,
12644
+ record: out
12645
+ };
12646
+ }
12581
12647
  /** Build the adapters the interpreter drives from the services. */
12582
12648
  function createAdapters(config, s, host, lateModules) {
12583
12649
  const timers = /* @__PURE__ */ new Map();
@@ -12670,11 +12736,14 @@ function createAdapters(config, s, host, lateModules) {
12670
12736
  remote: {
12671
12737
  queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
12672
12738
  live: async (table, onChange) => {
12673
- const [uuid] = await s.remote.query(`LIVE SELECT * FROM ${table}`);
12739
+ const join = config.liveInlineBodies ? " FETCH out" : "";
12740
+ const [uuid] = await s.remote.query(`LIVE SELECT * FROM ${table}${join}`);
12674
12741
  (await s.remote.getClient().liveOf(uuid)).subscribe((message) => {
12675
12742
  if (message.action === "KILLED") return;
12676
12743
  const hash = hashOfEdge(message.value);
12677
- if (hash) onChange([hash]);
12744
+ if (!hash) return;
12745
+ const row = message.action === "DELETE" ? null : rowOfEdge(message.value);
12746
+ onChange([hash], row ? [row] : void 0);
12678
12747
  });
12679
12748
  return String(uuid);
12680
12749
  },
@@ -1,4 +1,4 @@
1
- import { f as PinoTransmit } from "../types.js";
1
+ import { p as PinoTransmit } from "../types.js";
2
2
  import { Level } from "pino";
3
3
 
4
4
  //#region src/otel/index.d.ts
package/dist/types.d.ts CHANGED
@@ -598,6 +598,26 @@ interface Sp00kyConfig<S extends SchemaStructure> {
598
598
  * freshness path and no duplicate one-shot fetches are made.
599
599
  */
600
600
  instantHydrate?: boolean;
601
+ /**
602
+ * Carry the changed row's body on the LIVE notification instead of going
603
+ * back for it.
604
+ *
605
+ * The client holds exactly one live subscription, on its `_00_list_ref`
606
+ * table, and today it is a doorbell: the notification is reduced to the
607
+ * query hash and the row is then pulled with `SELECT * FROM $ids`. The edge
608
+ * is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
609
+ * the notification and the body fetch disappears.
610
+ *
611
+ * The push stays advisory: the body is landed exactly the way a fetched body
612
+ * is (store write, circuit ingest, version recorded), so a notification the
613
+ * server drops costs nothing but the old round trip. Membership is still
614
+ * read back; only the body fetch is saved.
615
+ *
616
+ * Default `false`. The cost is wire bytes: one edit bumps the edge in every
617
+ * view holding the row, and under `FETCH` each of those notifications
618
+ * carries its own full copy of the body.
619
+ */
620
+ liveInlineBodies?: boolean;
601
621
  /**
602
622
  * Enable realtime sync while signed out. When `true`, the client starts its
603
623
  * `_00_list_ref` poll (and a LIVE subscription) against the shared
@@ -769,6 +789,17 @@ interface StorageHealth {
769
789
  role?: 'leader' | 'follower' | 'solo';
770
790
  }
771
791
  type QueryHash = string;
792
+ /**
793
+ * One changed row delivered inline on a LIVE notification, when the
794
+ * subscription was opened with the body joined on (see
795
+ * {@link Sp00kyConfig.liveInlineBodies}). `version` is the edge's `version`,
796
+ * i.e. the same number the membership read would have reported for this row.
797
+ */
798
+ interface InlineRow {
799
+ id: string;
800
+ version: number;
801
+ record: Record<string, unknown>;
802
+ }
772
803
  type RecordVersionArray = Array<[string, number]>;
773
804
  /**
774
805
  * Represents the difference between two record version sets.
@@ -1057,4 +1088,4 @@ interface DebounceOptions {
1057
1088
  delay?: number;
1058
1089
  }
1059
1090
  //#endregion
1060
- export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F, SealedQuery as G, LocalStore as H, StoreType as I, EventDefinition as K, SyncHealth as L, Sp00kyQueryResult as M, Sp00kyQueryResultPromise as N, RegistrationTimings as O, StorageHealth as P, SyncHealthConfig as R, QueryTimeToLive as S, ReconnectConfig as T, DatabaseEventSystem as U, UpdateOptions as V, DatabaseEventTypes as W, QueryConfigRecord as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryStatus as b, MutationEvent as c, PhaseStat as d, PinoTransmit as f, QueryConfig as g, QueryAuthorityCallback as h, Level$1 as i, Sp00kyConfig as j, RunOptions as k, MutationEventType as l, PreloadRefresh as m, DebounceOptions as n, MembershipOutcome as o, PreloadOptions as p, EventSystem as q, EventSubscriptionOptions as r, MutationCallback as s, ConnectionState as t, PersistenceClient as u, QueryHash as v, QueryUpdateCallback as w, QueryStatusCallback as x, QueryState as y, SyncHealthStatus as z };
1091
+ export { RunOptions as A, SyncHealthStatus as B, QueryTimeToLive as C, RecordVersionArray as D, ReconnectConfig as E, StorageHealth as F, DatabaseEventTypes as G, UpdateOptions as H, StorageHealthStatus as I, EventSystem as J, SealedQuery as K, StoreType as L, Sp00kyConfig as M, Sp00kyQueryResult as N, RecordVersionDiff as O, Sp00kyQueryResultPromise as P, SyncHealth as R, QueryStatusCallback as S, QueryUpdateCallback as T, LocalStore as U, TimingPhase as V, DatabaseEventSystem as W, QueryConfig as _, Level$1 as a, QueryState as b, MutationCallback as c, PersistenceClient as d, PhaseStat as f, QueryAuthorityCallback as g, PreloadRefresh as h, InlineRow as i, ServerViewMeta as j, RegistrationTimings as k, MutationEvent as l, PreloadOptions as m, DebounceOptions as n, MATERIALIZATION_SAMPLE_WINDOW as o, PinoTransmit as p, EventDefinition as q, EventSubscriptionOptions as r, MembershipOutcome as s, ConnectionState as t, MutationEventType as u, QueryConfigRecord as v, QueryTimings as w, QueryStatus as x, QueryHash as y, SyncHealthConfig as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.245",
3
+ "version": "0.0.1-canary.247",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,8 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.245",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.245",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.247",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.247",
68
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
69
  "@surrealdb/wasm": "^3.0.3",
70
70
  "blurhash": "^2.0.5",
@@ -71,7 +71,7 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
71
71
  case 'LiveStart':
72
72
  return { saga: liveStart(env), lane: serial('live') };
73
73
  case 'LiveChange':
74
- return { saga: liveChange(event.hashes), lane: serial('live-change') };
74
+ return { saga: liveChange(env, event.hashes, event.rows), lane: serial('live-change') };
75
75
  case 'TabRole':
76
76
  return { saga: setRole(env, event.role), lane: serial('tabs') };
77
77
  case 'TabMessage':
@@ -1,6 +1,6 @@
1
1
  import { RecordId, type Uuid } from 'surrealdb';
2
2
  import type { SchemaStructure } from '@spooky-sync/query-builder';
3
- import type { Sp00kyConfig, PersistenceClient } from '../types';
3
+ import type { InlineRow, Sp00kyConfig, PersistenceClient } from '../types';
4
4
  import type { Logger } from '../services/logger/index';
5
5
  import { createLogger } from '../services/logger/index';
6
6
  import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
@@ -216,6 +216,28 @@ export function hashOfEdge(value: unknown): string | null {
216
216
  return str.length > 0 ? str : null;
217
217
  }
218
218
 
219
+ /**
220
+ * The row an edge notification carries, when the subscription joined it on.
221
+ *
222
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
223
+ * land, so this returns null and the caller falls back to fetching the body.
224
+ * It also returns null for a row the session may not read (the join yields
225
+ * `out: null`) and for an edge whose target has been deleted.
226
+ */
227
+ export function rowOfEdge(value: unknown): InlineRow | null {
228
+ const edge = value as { out?: unknown; version?: unknown } | null;
229
+ const out = edge?.out;
230
+ if (!out || typeof out !== 'object' || Array.isArray(out) || out instanceof RecordId) return null;
231
+ // Must be a real RecordId: the landing path keys off `row.id` being one, and
232
+ // would otherwise record the version for a body it never wrote.
233
+ const rid = (out as { id?: unknown }).id;
234
+ if (!(rid instanceof RecordId)) return null;
235
+ const id = encodeRecordId(rid);
236
+ const version = typeof edge?.version === 'number' ? edge.version : null;
237
+ if (version === null) return null;
238
+ return { id, version, record: out as Record<string, unknown> };
239
+ }
240
+
219
241
  /** Build the adapters the interpreter drives from the services. */
220
242
  export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
221
243
  const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -305,12 +327,23 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
305
327
  remote: {
306
328
  queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
307
329
  live: async (table, onChange) => {
308
- const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}`);
330
+ // `FETCH out` resolves the edge's target into the notification, so the
331
+ // body arrives with the doorbell instead of costing a second round
332
+ // trip. The clause is only ever added, never relied on: a server that
333
+ // ignores it, or a row the session cannot read, simply yields no
334
+ // inline row and the fetch path takes over.
335
+ const join = config.liveInlineBodies ? ' FETCH out' : '';
336
+ const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}${join}`);
309
337
  const live = await s.remote.getClient().liveOf(uuid);
310
338
  live.subscribe((message) => {
311
339
  if (message.action === 'KILLED') return;
312
340
  const hash = hashOfEdge(message.value);
313
- if (hash) onChange([hash]);
341
+ if (!hash) return;
342
+ // Not on DELETE: that edge is a row leaving the view, and for a row
343
+ // that was actually deleted the join yields nothing anyway. Landing
344
+ // a body there would only ever write back what is going away.
345
+ const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
346
+ onChange([hash], row ? [row] : undefined);
314
347
  });
315
348
  return String(uuid);
316
349
  },
@@ -1,4 +1,4 @@
1
- import type { ConnectionState, QueryHash, QueryStatus, SyncHealth } from '../types';
1
+ import type { ConnectionState, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
2
2
  import type { StreamUpdate } from '../services/stream-processor/index';
3
3
 
4
4
  /**
@@ -33,7 +33,7 @@ export type RuntimeEvent =
33
33
  | { type: 'ConnectionChanged'; state: ConnectionState }
34
34
  | { type: 'StreamUpdate'; update: StreamUpdate }
35
35
  | { type: 'LiveStart' }
36
- | { type: 'LiveChange'; hashes: QueryHash[] }
36
+ | { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[] }
37
37
  | { type: 'TabRole'; role: 'solo' | 'leader' | 'follower' }
38
38
  | { type: 'TabMessage'; message: unknown };
39
39
 
@@ -2,6 +2,7 @@ import { Duration } from 'surrealdb';
2
2
  import type { LocalStore } from '../services/database/cache-engine';
3
3
  import type { IngestRecord, StreamUpdate } from '../services/stream-processor/index';
4
4
  import type { ClientState } from '../state/client-state';
5
+ import type { InlineRow } from '../types';
5
6
  import { withTimeout } from '../utils/index';
6
7
  import type { Effect, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
7
8
  import type { OutEvent, RuntimeEvent } from './events';
@@ -11,8 +12,12 @@ export interface Adapters {
11
12
  local: Pick<LocalStore, 'query' | 'execute' | 'select' | 'getById' | 'upsert' | 'delete'> & { readonly epoch: number };
12
13
  remote: {
13
14
  queryResponses(sql: string, vars?: Record<string, unknown>): Promise<StatementResult[]>;
14
- /** Subscribe to `LIVE SELECT * FROM <table>`; the callback receives the changed query hashes. */
15
- live(table: string, onChange: (hashes: string[]) => void): Promise<string>;
15
+ /**
16
+ * Subscribe to `LIVE SELECT * FROM <table>`; the callback receives the
17
+ * changed query hashes, plus the changed rows themselves when the
18
+ * subscription was opened with the body joined on (`liveInlineBodies`).
19
+ */
20
+ live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
16
21
  kill(uuid: string): Promise<void>;
17
22
  };
18
23
  ssp: {
@@ -76,7 +81,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
76
81
  : pending;
77
82
  }
78
83
  case 'remote.live':
79
- return adapters.remote.live(effect.table, (hashes) => host.dispatch({ type: 'LiveChange', hashes }));
84
+ return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({ type: 'LiveChange', hashes, rows }));
80
85
  case 'remote.kill':
81
86
  return adapters.remote.kill(effect.uuid);
82
87
  case 'ssp.register': {
@@ -60,8 +60,14 @@ export function* fetchRows(env: SagaEnv): Saga<void> {
60
60
  }
61
61
  }
62
62
 
63
- /** Write one chunk's bodies to the store and circuit; record their versions. */
64
- function* landChunk(
63
+ /**
64
+ * Write one chunk's bodies to the store and circuit; record their versions.
65
+ *
66
+ * Shared with the LIVE path: a body that arrives on a notification is landed
67
+ * through here so it is indistinguishable from a fetched one, which is what
68
+ * makes `planFetch` stop asking for it.
69
+ */
70
+ export function* landChunk(
65
71
  env: SagaEnv,
66
72
  requested: string[],
67
73
  rows: Array<Record<string, unknown>>,
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import { runPure } from '../testing/run-pure';
3
4
  import { buildEntry, buildState } from '../testing/build';
4
5
  import * as R from '../state/reducers';
5
6
  import { defaultEnv } from '../query/env';
6
7
  import { liveChange, liveInvalidate, liveStart } from './live.saga';
8
+ import { rowOfEdge } from '../client/services';
7
9
 
8
10
  const env = defaultEnv({ tables: [] } as any);
9
11
 
@@ -56,14 +58,102 @@ describe('liveStart', () => {
56
58
  describe('liveChange', () => {
57
59
  it('marks known hashes dirty, resets the poll streak, relays as leader; ignores unknown hashes', async () => {
58
60
  const s = R.patchSync({ pollIdleStreak: 3 })(buildState([buildEntry({ def: { hash: 'a' } })]));
59
- const out = await runPure(liveChange(['a', 'zz']), { state: { ...s, tabRole: 'leader' } });
61
+ const out = await runPure(liveChange(env, ['a', 'zz']), { state: { ...s, tabRole: 'leader' } });
60
62
  expect([...out.state.membershipDirty]).toEqual(['a']);
61
63
  expect(out.state.sync.pollIdleStreak).toBe(0);
62
64
  expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
63
65
  expect(out.emitted).toEqual([{ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } }]);
64
- const solo = await runPure(liveChange(['a']), { state: s });
66
+ const solo = await runPure(liveChange(env, ['a']), { state: s });
65
67
  expect(solo.emitted).toEqual([]);
66
- const none = await runPure(liveChange(['zz']), { state: s });
68
+ const none = await runPure(liveChange(env, ['zz']), { state: s });
67
69
  expect(none.timers.size).toBe(0);
68
70
  });
71
+
72
+ it('pulls a backed-off poll back to the base cadence, and leaves an already-base one alone', async () => {
73
+ const entry = buildEntry({ def: { hash: 'a' } });
74
+ // Coasted up to the cap: the armed timer is the reason a change the poll
75
+ // has to catch could wait 5s, so the event has to re-arm it, not just zero
76
+ // the streak the next tick would read.
77
+ const backedOff = R.patchSync({ pollIdleStreak: 4 })(buildState([entry]));
78
+ const out = await runPure(liveChange(env, ['a']), { state: backedOff });
79
+ expect(out.timers.get('poll')).toEqual({ ms: env.pollBaseMs, event: { type: 'PollTick' } });
80
+
81
+ // Already at base: re-arming here would push the poll out by another
82
+ // `pollBaseMs` on every notification and starve it under sustained LIVE.
83
+ const atBase = R.patchSync({ pollIdleStreak: 0 })(buildState([entry]));
84
+ const quiet = await runPure(liveChange(env, ['a']), { state: atBase });
85
+ expect(quiet.timers.has('poll')).toBe(false);
86
+ expect(quiet.state.membershipDirty.has('a')).toBe(true);
87
+
88
+ // An unknown hash is not activity: nothing is touched.
89
+ const unknown = await runPure(liveChange(env, ['zz']), { state: backedOff });
90
+ expect(unknown.timers.has('poll')).toBe(false);
91
+ expect(unknown.state.sync.pollIdleStreak).toBe(4);
92
+ });
93
+ });
94
+
95
+ describe('rowOfEdge', () => {
96
+ const rid = new RecordId('game', 'g1');
97
+
98
+ it('reads the joined body off the notification', () => {
99
+ expect(rowOfEdge({ in: new RecordId('_00_query', 'a'), out: { id: rid, result: '1-0' }, version: 7 })).toEqual({
100
+ id: 'game:g1',
101
+ version: 7,
102
+ record: { id: rid, result: '1-0' },
103
+ });
104
+ });
105
+
106
+ it('yields nothing when there is no body to read', () => {
107
+ // No FETCH clause: `out` is still just the record id.
108
+ expect(rowOfEdge({ out: rid, version: 7 })).toBeNull();
109
+ // The session may not read the row, or its target is gone.
110
+ expect(rowOfEdge({ out: null, version: 7 })).toBeNull();
111
+ // A body with no version cannot be placed against what we already hold.
112
+ expect(rowOfEdge({ out: { id: rid }, version: undefined })).toBeNull();
113
+ // `landChunk` keys off a real RecordId; a string id would record the
114
+ // version for a body it never wrote.
115
+ expect(rowOfEdge({ out: { id: 'game:g1' }, version: 7 })).toBeNull();
116
+ expect(rowOfEdge(null)).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe('liveChange with the body joined on', () => {
121
+ const rid = new RecordId('game', 'g1');
122
+ const row = (version: number) => ({ id: 'game:g1', version, record: { id: rid, result: '1-0' } });
123
+ const handlers = { 'local.execute': () => undefined, 'ssp.ingest': () => undefined };
124
+
125
+ it('lands the pushed body so the fetch plan has nothing left to pull', async () => {
126
+ const s = buildState([buildEntry({ def: { hash: 'a' } })]);
127
+ const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
128
+
129
+ // Recorded at the edge's version: this is what makes `planFetch` skip it.
130
+ expect(out.state.versions.get('game:g1')).toBe(7);
131
+ // Written to the store and ingested into the circuit, exactly as a fetch would.
132
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
133
+ expect(out.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(1);
134
+ // Still a doorbell: membership is re-read regardless.
135
+ expect([...out.state.membershipDirty]).toEqual(['a']);
136
+ });
137
+
138
+ it('ignores a body we already hold at that version or newer', async () => {
139
+ const s = R.setVersions([['game:g1', 7]])(buildState([buildEntry({ def: { hash: 'a' } })]));
140
+ const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
141
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
142
+ expect([...out.state.membershipDirty]).toEqual(['a']);
143
+ });
144
+
145
+ it('writes one copy when the same row arrives for several views', async () => {
146
+ const s = buildState([buildEntry({ def: { hash: 'a' } })]);
147
+ const out = await runPure(liveChange(env, ['a'], [row(7), row(7), row(8)]), { state: s, handlers });
148
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
149
+ expect(out.state.versions.get('game:g1')).toBe(8);
150
+ });
151
+
152
+ it('behaves exactly as before when no body rode along', async () => {
153
+ const s = buildState([buildEntry({ def: { hash: 'a' } })]);
154
+ const out = await runPure(liveChange(env, ['a']), { state: s, handlers });
155
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
156
+ expect(out.state.versions.size).toBe(0);
157
+ expect([...out.state.membershipDirty]).toEqual(['a']);
158
+ });
69
159
  });
@@ -1,4 +1,4 @@
1
- import type { QueryHash } from '../types';
1
+ import type { InlineRow, QueryHash } from '../types';
2
2
  import type { Saga } from '../kernel/saga';
3
3
  import { fx } from '../kernel/effects';
4
4
  import type { ClientState } from '../state/client-state';
@@ -6,12 +6,18 @@ import * as R from '../state/reducers';
6
6
  import type { SagaEnv } from '../query/env';
7
7
  import { listRefTable } from '../query/env';
8
8
  import { markMembershipDirty } from '../query/membership.saga';
9
+ import { landChunk } from '../query/fetch.saga';
9
10
 
10
11
  /**
11
- * LIVE on the session's `_00_list_ref` table. Events never carry data into
12
- * state: every change only marks its query's membership dirty, and the
13
- * batched re-read decides. Followers get the same dirt relayed by the
14
- * leader.
12
+ * LIVE on the session's `_00_list_ref` table. An event marks its query's
13
+ * membership dirty and the batched re-read decides; followers get the same
14
+ * dirt relayed by the leader.
15
+ *
16
+ * When the subscription was opened with the body joined on
17
+ * (`liveInlineBodies`) the event ALSO carries the changed row. That is an
18
+ * optimisation on top, never a source of truth: the row is landed exactly as a
19
+ * fetched body would be, so a notification the server drops costs nothing but
20
+ * the round trip it would have saved.
15
21
  */
16
22
  export function* liveStart(env: SagaEnv): Saga<void> {
17
23
  const state = (yield fx.state.read((s) => s)) as ClientState;
@@ -40,10 +46,62 @@ export function* liveInvalidate(): Saga<void> {
40
46
  }
41
47
 
42
48
  /** One or more edges of these queries changed on the server. */
43
- export function* liveChange(hashes: QueryHash[]): Saga<void> {
44
- const [known, role] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole])) as [QueryHash[], ClientState['tabRole']];
49
+ export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
50
+ const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
51
+ QueryHash[],
52
+ ClientState['tabRole'],
53
+ number,
54
+ ];
45
55
  if (known.length === 0) return;
46
- yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
56
+ // Zeroing the streak only decides the delay the NEXT tick picks: `pollTick`
57
+ // arms its timer at the end of a tick, so a page that had coasted up to the
58
+ // 5s cap still waited out that armed timer. When the poll is the thing that
59
+ // catches a change (a notification the server dropped, a row LIVE never
60
+ // carried) that made the worst case 5s even though LIVE had just proved the
61
+ // page was active. Re-arm at the base cadence too, so the safety net is back
62
+ // under us immediately.
63
+ //
64
+ // Only when the poll had actually backed off. A `set` on the same key
65
+ // replaces the pending timer, so re-arming on every event while LIVE is
66
+ // delivering faster than `pollBaseMs` would push the poll out forever and
67
+ // starve the full-membership reconciliation it exists to provide. A streak
68
+ // of 0 means the cadence is already base and there is nothing to correct.
69
+ if (streak > 0) {
70
+ yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
71
+ yield fx.timer.set('poll', env.pollBaseMs, { type: 'PollTick' });
72
+ }
73
+ // Before marking membership dirty: recording the version here is what makes
74
+ // the re-read's `planFetch` find nothing left to pull for this row.
75
+ if (rows && rows.length > 0) yield* landInlineRows(env, rows);
47
76
  yield* markMembershipDirty(known);
48
77
  if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
49
78
  }
79
+
80
+ /**
81
+ * Land bodies that rode in on the notification. Rows already held at this
82
+ * version or newer are dropped: one edit bumps the edge in every view holding
83
+ * the row, so the same body arrives once per view.
84
+ */
85
+ function* landInlineRows(env: SagaEnv, rows: InlineRow[]): Saga<void> {
86
+ const state = (yield fx.state.read((s) => s)) as ClientState;
87
+ const fresh = new Map<string, InlineRow>();
88
+ for (const row of rows) {
89
+ if ((state.versions.get(row.id) ?? -1) >= row.version) continue;
90
+ const held = fresh.get(row.id);
91
+ if (!held || held.version < row.version) fresh.set(row.id, row);
92
+ }
93
+ if (fresh.size === 0) return;
94
+ const picked = [...fresh.values()];
95
+ const epoch = (yield fx.local.epoch()) as number;
96
+ const landed = yield* landChunk(
97
+ env,
98
+ picked.map((r) => r.id),
99
+ picked.map((r) => r.record),
100
+ new Map(picked.map((r) => [r.id, r.version] as const)),
101
+ state,
102
+ epoch
103
+ );
104
+ if (!landed) {
105
+ yield fx.emit({ type: 'log', level: 'debug', message: 'inline live body not landed; the fetch path will pull it', data: { ids: picked.map((r) => r.id) } });
106
+ }
107
+ }
@@ -63,7 +63,8 @@ export function* tabMessage(env: SagaEnv, raw: unknown): Saga<void> {
63
63
  return;
64
64
  }
65
65
  case 'membership-dirty':
66
- if (Array.isArray(msg.hashes)) yield* liveChange(msg.hashes);
66
+ // No inline rows here: the leader relays its own bodies as an `ingest`.
67
+ if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
67
68
  return;
68
69
  case 'outbox-changed':
69
70
  if (role !== 'leader') return;
package/src/types.ts CHANGED
@@ -292,6 +292,26 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
292
292
  * freshness path and no duplicate one-shot fetches are made.
293
293
  */
294
294
  instantHydrate?: boolean;
295
+ /**
296
+ * Carry the changed row's body on the LIVE notification instead of going
297
+ * back for it.
298
+ *
299
+ * The client holds exactly one live subscription, on its `_00_list_ref`
300
+ * table, and today it is a doorbell: the notification is reduced to the
301
+ * query hash and the row is then pulled with `SELECT * FROM $ids`. The edge
302
+ * is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
303
+ * the notification and the body fetch disappears.
304
+ *
305
+ * The push stays advisory: the body is landed exactly the way a fetched body
306
+ * is (store write, circuit ingest, version recorded), so a notification the
307
+ * server drops costs nothing but the old round trip. Membership is still
308
+ * read back; only the body fetch is saved.
309
+ *
310
+ * Default `false`. The cost is wire bytes: one edit bumps the edge in every
311
+ * view holding the row, and under `FETCH` each of those notifications
312
+ * carries its own full copy of the body.
313
+ */
314
+ liveInlineBodies?: boolean;
295
315
  /**
296
316
  * Enable realtime sync while signed out. When `true`, the client starts its
297
317
  * `_00_list_ref` poll (and a LIVE subscription) against the shared
@@ -469,6 +489,18 @@ export interface StorageHealth {
469
489
 
470
490
  export type QueryHash = string;
471
491
 
492
+ /**
493
+ * One changed row delivered inline on a LIVE notification, when the
494
+ * subscription was opened with the body joined on (see
495
+ * {@link Sp00kyConfig.liveInlineBodies}). `version` is the edge's `version`,
496
+ * i.e. the same number the membership read would have reported for this row.
497
+ */
498
+ export interface InlineRow {
499
+ id: string;
500
+ version: number;
501
+ record: Record<string, unknown>;
502
+ }
503
+
472
504
  // Flat array format: [[record-id, version], [record-id, version], ...]
473
505
  export type RecordVersionArray = Array<[string, number]>;
474
506