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