@spooky-sync/core 0.0.1-canary.245 → 0.0.1-canary.246
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 +77 -15
- 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 +71 -3
- package/src/sync/live.saga.ts +44 -6
- 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.246";
|
|
1077
|
+
const WASM_VERSION = "0.0.1-canary.246";
|
|
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,11 @@ function* liveInvalidate() {
|
|
|
5085
5097
|
}));
|
|
5086
5098
|
}
|
|
5087
5099
|
/** One or more edges of these queries changed on the server. */
|
|
5088
|
-
function* liveChange(hashes) {
|
|
5100
|
+
function* liveChange(env, hashes, rows) {
|
|
5089
5101
|
const [known, role] = yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole]);
|
|
5090
5102
|
if (known.length === 0) return;
|
|
5091
5103
|
yield fx.state.update(patchSync({ pollIdleStreak: 0 }));
|
|
5104
|
+
if (rows && rows.length > 0) yield* landInlineRows(env, rows);
|
|
5092
5105
|
yield* markMembershipDirty(known);
|
|
5093
5106
|
if (role === "leader") yield fx.emit({
|
|
5094
5107
|
type: "tabs:broadcast",
|
|
@@ -5098,6 +5111,29 @@ function* liveChange(hashes) {
|
|
|
5098
5111
|
}
|
|
5099
5112
|
});
|
|
5100
5113
|
}
|
|
5114
|
+
/**
|
|
5115
|
+
* Land bodies that rode in on the notification. Rows already held at this
|
|
5116
|
+
* version or newer are dropped: one edit bumps the edge in every view holding
|
|
5117
|
+
* the row, so the same body arrives once per view.
|
|
5118
|
+
*/
|
|
5119
|
+
function* landInlineRows(env, rows) {
|
|
5120
|
+
const state = yield fx.state.read((s) => s);
|
|
5121
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
5122
|
+
for (const row of rows) {
|
|
5123
|
+
if ((state.versions.get(row.id) ?? -1) >= row.version) continue;
|
|
5124
|
+
const held = fresh.get(row.id);
|
|
5125
|
+
if (!held || held.version < row.version) fresh.set(row.id, row);
|
|
5126
|
+
}
|
|
5127
|
+
if (fresh.size === 0) return;
|
|
5128
|
+
const picked = [...fresh.values()];
|
|
5129
|
+
const epoch = yield fx.local.epoch();
|
|
5130
|
+
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({
|
|
5131
|
+
type: "log",
|
|
5132
|
+
level: "debug",
|
|
5133
|
+
message: "inline live body not landed; the fetch path will pull it",
|
|
5134
|
+
data: { ids: picked.map((r) => r.id) }
|
|
5135
|
+
});
|
|
5136
|
+
}
|
|
5101
5137
|
|
|
5102
5138
|
//#endregion
|
|
5103
5139
|
//#region src/sync/connection.saga.ts
|
|
@@ -5230,7 +5266,7 @@ function* tabMessage(env, raw) {
|
|
|
5230
5266
|
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
5267
|
return;
|
|
5232
5268
|
case "membership-dirty":
|
|
5233
|
-
if (Array.isArray(msg.hashes)) yield* liveChange(msg.hashes);
|
|
5269
|
+
if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
|
|
5234
5270
|
return;
|
|
5235
5271
|
case "outbox-changed":
|
|
5236
5272
|
if (role !== "leader") return;
|
|
@@ -5566,7 +5602,7 @@ function route(env, event) {
|
|
|
5566
5602
|
lane: serial("live")
|
|
5567
5603
|
};
|
|
5568
5604
|
case "LiveChange": return {
|
|
5569
|
-
saga: liveChange(event.hashes),
|
|
5605
|
+
saga: liveChange(env, event.hashes, event.rows),
|
|
5570
5606
|
lane: serial("live-change")
|
|
5571
5607
|
};
|
|
5572
5608
|
case "TabRole": return {
|
|
@@ -12473,7 +12509,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
12473
12509
|
return new TabsCoordinator({
|
|
12474
12510
|
tabId: s.tabId,
|
|
12475
12511
|
fingerprint: computeTabsFingerprint({
|
|
12476
|
-
coreVersion: "0.0.1-canary.
|
|
12512
|
+
coreVersion: "0.0.1-canary.246",
|
|
12477
12513
|
schemaHash: hash53(config.schemaSurql),
|
|
12478
12514
|
endpoint: config.database.endpoint ?? "",
|
|
12479
12515
|
namespace: config.database.namespace,
|
|
@@ -12578,6 +12614,29 @@ function hashOfEdge(value) {
|
|
|
12578
12614
|
const str = inId instanceof RecordId$1 ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
|
|
12579
12615
|
return str.length > 0 ? str : null;
|
|
12580
12616
|
}
|
|
12617
|
+
/**
|
|
12618
|
+
* The row an edge notification carries, when the subscription joined it on.
|
|
12619
|
+
*
|
|
12620
|
+
* Without `FETCH out` the edge's `out` is a record id and there is nothing to
|
|
12621
|
+
* land, so this returns null and the caller falls back to fetching the body.
|
|
12622
|
+
* It also returns null for a row the session may not read (the join yields
|
|
12623
|
+
* `out: null`) and for an edge whose target has been deleted.
|
|
12624
|
+
*/
|
|
12625
|
+
function rowOfEdge(value) {
|
|
12626
|
+
const edge = value;
|
|
12627
|
+
const out = edge?.out;
|
|
12628
|
+
if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId$1) return null;
|
|
12629
|
+
const rid = out.id;
|
|
12630
|
+
if (!(rid instanceof RecordId$1)) return null;
|
|
12631
|
+
const id = encodeRecordId(rid);
|
|
12632
|
+
const version = typeof edge?.version === "number" ? edge.version : null;
|
|
12633
|
+
if (version === null) return null;
|
|
12634
|
+
return {
|
|
12635
|
+
id,
|
|
12636
|
+
version,
|
|
12637
|
+
record: out
|
|
12638
|
+
};
|
|
12639
|
+
}
|
|
12581
12640
|
/** Build the adapters the interpreter drives from the services. */
|
|
12582
12641
|
function createAdapters(config, s, host, lateModules) {
|
|
12583
12642
|
const timers = /* @__PURE__ */ new Map();
|
|
@@ -12670,11 +12729,14 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
12670
12729
|
remote: {
|
|
12671
12730
|
queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
|
|
12672
12731
|
live: async (table, onChange) => {
|
|
12673
|
-
const
|
|
12732
|
+
const join = config.liveInlineBodies ? " FETCH out" : "";
|
|
12733
|
+
const [uuid] = await s.remote.query(`LIVE SELECT * FROM ${table}${join}`);
|
|
12674
12734
|
(await s.remote.getClient().liveOf(uuid)).subscribe((message) => {
|
|
12675
12735
|
if (message.action === "KILLED") return;
|
|
12676
12736
|
const hash = hashOfEdge(message.value);
|
|
12677
|
-
if (hash)
|
|
12737
|
+
if (!hash) return;
|
|
12738
|
+
const row = message.action === "DELETE" ? null : rowOfEdge(message.value);
|
|
12739
|
+
onChange([hash], row ? [row] : void 0);
|
|
12678
12740
|
});
|
|
12679
12741
|
return String(uuid);
|
|
12680
12742
|
},
|
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.246",
|
|
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.246",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.246",
|
|
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,80 @@ 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
|
});
|
|
69
71
|
});
|
|
72
|
+
|
|
73
|
+
describe('rowOfEdge', () => {
|
|
74
|
+
const rid = new RecordId('game', 'g1');
|
|
75
|
+
|
|
76
|
+
it('reads the joined body off the notification', () => {
|
|
77
|
+
expect(rowOfEdge({ in: new RecordId('_00_query', 'a'), out: { id: rid, result: '1-0' }, version: 7 })).toEqual({
|
|
78
|
+
id: 'game:g1',
|
|
79
|
+
version: 7,
|
|
80
|
+
record: { id: rid, result: '1-0' },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('yields nothing when there is no body to read', () => {
|
|
85
|
+
// No FETCH clause: `out` is still just the record id.
|
|
86
|
+
expect(rowOfEdge({ out: rid, version: 7 })).toBeNull();
|
|
87
|
+
// The session may not read the row, or its target is gone.
|
|
88
|
+
expect(rowOfEdge({ out: null, version: 7 })).toBeNull();
|
|
89
|
+
// A body with no version cannot be placed against what we already hold.
|
|
90
|
+
expect(rowOfEdge({ out: { id: rid }, version: undefined })).toBeNull();
|
|
91
|
+
// `landChunk` keys off a real RecordId; a string id would record the
|
|
92
|
+
// version for a body it never wrote.
|
|
93
|
+
expect(rowOfEdge({ out: { id: 'game:g1' }, version: 7 })).toBeNull();
|
|
94
|
+
expect(rowOfEdge(null)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('liveChange with the body joined on', () => {
|
|
99
|
+
const rid = new RecordId('game', 'g1');
|
|
100
|
+
const row = (version: number) => ({ id: 'game:g1', version, record: { id: rid, result: '1-0' } });
|
|
101
|
+
const handlers = { 'local.execute': () => undefined, 'ssp.ingest': () => undefined };
|
|
102
|
+
|
|
103
|
+
it('lands the pushed body so the fetch plan has nothing left to pull', async () => {
|
|
104
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
105
|
+
const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
|
|
106
|
+
|
|
107
|
+
// Recorded at the edge's version: this is what makes `planFetch` skip it.
|
|
108
|
+
expect(out.state.versions.get('game:g1')).toBe(7);
|
|
109
|
+
// Written to the store and ingested into the circuit, exactly as a fetch would.
|
|
110
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
|
|
111
|
+
expect(out.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(1);
|
|
112
|
+
// Still a doorbell: membership is re-read regardless.
|
|
113
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('ignores a body we already hold at that version or newer', async () => {
|
|
117
|
+
const s = R.setVersions([['game:g1', 7]])(buildState([buildEntry({ def: { hash: 'a' } })]));
|
|
118
|
+
const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
|
|
119
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
|
|
120
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('writes one copy when the same row arrives for several views', async () => {
|
|
124
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
125
|
+
const out = await runPure(liveChange(env, ['a'], [row(7), row(7), row(8)]), { state: s, handlers });
|
|
126
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
|
|
127
|
+
expect(out.state.versions.get('game:g1')).toBe(8);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('behaves exactly as before when no body rode along', async () => {
|
|
131
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
132
|
+
const out = await runPure(liveChange(env, ['a']), { state: s, handlers });
|
|
133
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
|
|
134
|
+
expect(out.state.versions.size).toBe(0);
|
|
135
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
136
|
+
});
|
|
137
|
+
});
|
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,42 @@ 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> {
|
|
49
|
+
export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
|
|
44
50
|
const [known, role] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole])) as [QueryHash[], ClientState['tabRole']];
|
|
45
51
|
if (known.length === 0) return;
|
|
46
52
|
yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
|
|
53
|
+
// Before marking membership dirty: recording the version here is what makes
|
|
54
|
+
// the re-read's `planFetch` find nothing left to pull for this row.
|
|
55
|
+
if (rows && rows.length > 0) yield* landInlineRows(env, rows);
|
|
47
56
|
yield* markMembershipDirty(known);
|
|
48
57
|
if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
|
|
49
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Land bodies that rode in on the notification. Rows already held at this
|
|
62
|
+
* version or newer are dropped: one edit bumps the edge in every view holding
|
|
63
|
+
* the row, so the same body arrives once per view.
|
|
64
|
+
*/
|
|
65
|
+
function* landInlineRows(env: SagaEnv, rows: InlineRow[]): Saga<void> {
|
|
66
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
67
|
+
const fresh = new Map<string, InlineRow>();
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if ((state.versions.get(row.id) ?? -1) >= row.version) continue;
|
|
70
|
+
const held = fresh.get(row.id);
|
|
71
|
+
if (!held || held.version < row.version) fresh.set(row.id, row);
|
|
72
|
+
}
|
|
73
|
+
if (fresh.size === 0) return;
|
|
74
|
+
const picked = [...fresh.values()];
|
|
75
|
+
const epoch = (yield fx.local.epoch()) as number;
|
|
76
|
+
const landed = yield* landChunk(
|
|
77
|
+
env,
|
|
78
|
+
picked.map((r) => r.id),
|
|
79
|
+
picked.map((r) => r.record),
|
|
80
|
+
new Map(picked.map((r) => [r.id, r.version] as const)),
|
|
81
|
+
state,
|
|
82
|
+
epoch
|
|
83
|
+
);
|
|
84
|
+
if (!landed) {
|
|
85
|
+
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) } });
|
|
86
|
+
}
|
|
87
|
+
}
|
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
|
|