@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 +9 -4
- package/dist/index.js +86 -17
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +32 -1
- package/package.json +3 -3
- package/src/client/router.ts +1 -1
- package/src/client/services.ts +36 -3
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +8 -3
- package/src/query/fetch.saga.ts +8 -2
- package/src/sync/live.saga.test.ts +93 -3
- package/src/sync/live.saga.ts +66 -8
- package/src/sync/tabs.saga.ts +2 -1
- package/src/types.ts +32 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
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
|
-
/**
|
|
1626
|
-
|
|
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.
|
|
1077
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
/**
|
|
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.
|
|
5039
|
-
*
|
|
5040
|
-
*
|
|
5041
|
-
*
|
|
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) => [
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)
|
|
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
|
},
|
package/dist/otel/index.d.ts
CHANGED
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 {
|
|
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.
|
|
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.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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",
|
package/src/client/router.ts
CHANGED
|
@@ -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':
|
package/src/client/services.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
},
|
package/src/kernel/events.ts
CHANGED
|
@@ -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
|
-
/**
|
|
15
|
-
|
|
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': {
|
package/src/query/fetch.saga.ts
CHANGED
|
@@ -60,8 +60,14 @@ export function* fetchRows(env: SagaEnv): Saga<void> {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
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
|
});
|
package/src/sync/live.saga.ts
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 [
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sync/tabs.saga.ts
CHANGED
|
@@ -63,7 +63,8 @@ export function* tabMessage(env: SagaEnv, raw: unknown): Saga<void> {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
case 'membership-dirty':
|
|
66
|
-
|
|
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
|
|