@muhkoo/connect 0.7.0-alpha.4 → 0.8.0-alpha.0
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/browser/index.js +1992 -54
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +898 -4
- package/dist/server/index.js +1992 -54
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +8 -0
- package/dist/workers/index.js.map +1 -1
- package/package.json +2 -1
package/dist/browser/index.js
CHANGED
|
@@ -1807,16 +1807,15 @@ class DoubleRatchetManager {
|
|
|
1807
1807
|
}
|
|
1808
1808
|
async registerZK(clientId, secret, salt, ecdsaPub) {
|
|
1809
1809
|
await this._authenticator.initializeZK();
|
|
1810
|
-
console.log(`Registering ZK for ${clientId}...`);
|
|
1811
|
-
console.log('ECDSA Public Key:', ecdsaPub);
|
|
1812
1810
|
const ecdsaJwk = await crypto.subtle.exportKey('jwk', ecdsaPub);
|
|
1813
1811
|
const ecdsaHex = toHex(fromBase64Url(ecdsaJwk.x)).slice(0, 64);
|
|
1814
1812
|
const ecdsaPubField = new Field(BigInt('0x' + ecdsaHex));
|
|
1815
1813
|
const ecdsaPubHash = await Poseidon.hash([ecdsaPubField]);
|
|
1816
1814
|
const commitment = await Poseidon.hash([secret, salt, ecdsaPubHash]);
|
|
1817
|
-
console.log(`Commitment for ${clientId}:`, { commitment, salt });
|
|
1818
1815
|
this.registeredUsers.set(clientId, { commitment, salt });
|
|
1819
|
-
|
|
1816
|
+
// NB: never log `secret`, `salt`, or `commitment` — `salt` is a private
|
|
1817
|
+
// witness for the ZK circuit and `commitment` is the user's stable id.
|
|
1818
|
+
appLogger.debug(`Registered ZK identity for ${clientId}`);
|
|
1820
1819
|
}
|
|
1821
1820
|
async performHandshake(senderId, recipientId, zkProof, publicInput, clientPublicKey, // ECDH pub for session
|
|
1822
1821
|
clientAuthPublicKey, // ECDSA pub for auth
|
|
@@ -2083,6 +2082,14 @@ var EventCoreEvents;
|
|
|
2083
2082
|
EventCoreEvents["MESSAGE"] = "message";
|
|
2084
2083
|
EventCoreEvents["GET_HISTORY"] = "get_history";
|
|
2085
2084
|
EventCoreEvents["RECEIVED_HISTORY"] = "received_history";
|
|
2085
|
+
// Offline layer — connectivity + background sync lifecycle. Emitted by the
|
|
2086
|
+
// ConnectivityManager / SyncEngine; apps subscribe to drive a status UI.
|
|
2087
|
+
EventCoreEvents["ONLINE"] = "online";
|
|
2088
|
+
EventCoreEvents["OFFLINE"] = "offline";
|
|
2089
|
+
EventCoreEvents["SYNCING"] = "syncing";
|
|
2090
|
+
EventCoreEvents["SYNC_PROGRESS"] = "sync_progress";
|
|
2091
|
+
EventCoreEvents["SYNC_COMPLETE"] = "sync_complete";
|
|
2092
|
+
EventCoreEvents["SYNC_ERROR"] = "sync_error";
|
|
2086
2093
|
})(EventCoreEvents || (EventCoreEvents = {}));
|
|
2087
2094
|
/**
|
|
2088
2095
|
* @public
|
|
@@ -3805,6 +3812,8 @@ class ShardClient {
|
|
|
3805
3812
|
baseUrl;
|
|
3806
3813
|
pathPrefix;
|
|
3807
3814
|
fetchFn;
|
|
3815
|
+
cache;
|
|
3816
|
+
deferUpload;
|
|
3808
3817
|
constructor(opts) {
|
|
3809
3818
|
if (!opts?.baseUrl)
|
|
3810
3819
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -3820,6 +3829,8 @@ class ShardClient {
|
|
|
3820
3829
|
throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
3821
3830
|
}
|
|
3822
3831
|
this.fetchFn = f.bind(globalThis);
|
|
3832
|
+
this.cache = opts.cache;
|
|
3833
|
+
this.deferUpload = opts.deferUpload;
|
|
3823
3834
|
}
|
|
3824
3835
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
3825
3836
|
urlForHash(hash) {
|
|
@@ -3833,12 +3844,29 @@ class ShardClient {
|
|
|
3833
3844
|
*/
|
|
3834
3845
|
async putShard(hash, bytes) {
|
|
3835
3846
|
const url = this.urlForHash(hash);
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3847
|
+
// Write the cache first so a just-uploaded file reads back even if the
|
|
3848
|
+
// network PUT then fails (offline). The bytes are content-addressed, so
|
|
3849
|
+
// this is safe to keep regardless of upload outcome.
|
|
3850
|
+
if (this.cache)
|
|
3851
|
+
await this.cache.put(hash, bytes);
|
|
3852
|
+
let response;
|
|
3853
|
+
try {
|
|
3854
|
+
response = await this.fetchFn(url, {
|
|
3855
|
+
method: "PUT",
|
|
3856
|
+
headers: { "content-type": "application/octet-stream" },
|
|
3857
|
+
// BufferSource is what fetch's body expects; a Uint8Array satisfies it.
|
|
3858
|
+
body: bytes,
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
catch (err) {
|
|
3862
|
+
// Network unreachable (offline). If a defer hook is wired, queue the
|
|
3863
|
+
// upload for replay and report success — the bytes are cached.
|
|
3864
|
+
if (this.deferUpload) {
|
|
3865
|
+
await this.deferUpload(hash);
|
|
3866
|
+
return { dedup: false };
|
|
3867
|
+
}
|
|
3868
|
+
throw err;
|
|
3869
|
+
}
|
|
3842
3870
|
if (!response.ok) {
|
|
3843
3871
|
const text = await safeText(response);
|
|
3844
3872
|
throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
@@ -3858,8 +3886,25 @@ class ShardClient {
|
|
|
3858
3886
|
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
3859
3887
|
*/
|
|
3860
3888
|
async getShard(hash) {
|
|
3889
|
+
// Cache-first: a hit is content-verified by construction (key == hash).
|
|
3890
|
+
if (this.cache) {
|
|
3891
|
+
const cached = await this.cache.get(hash);
|
|
3892
|
+
if (cached)
|
|
3893
|
+
return cached;
|
|
3894
|
+
}
|
|
3861
3895
|
const url = this.urlForHash(hash);
|
|
3862
|
-
|
|
3896
|
+
let response;
|
|
3897
|
+
try {
|
|
3898
|
+
response = await this.fetchFn(url, { method: "GET" });
|
|
3899
|
+
}
|
|
3900
|
+
catch (err) {
|
|
3901
|
+
// Offline with no cache hit: report the shard as missing so
|
|
3902
|
+
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
3903
|
+
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
3904
|
+
if (this.cache)
|
|
3905
|
+
return null;
|
|
3906
|
+
throw err;
|
|
3907
|
+
}
|
|
3863
3908
|
if (response.status === 404)
|
|
3864
3909
|
return null;
|
|
3865
3910
|
if (!response.ok) {
|
|
@@ -3867,7 +3912,10 @@ class ShardClient {
|
|
|
3867
3912
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
3868
3913
|
}
|
|
3869
3914
|
const buf = await response.arrayBuffer();
|
|
3870
|
-
|
|
3915
|
+
const bytes = new Uint8Array(buf);
|
|
3916
|
+
if (this.cache)
|
|
3917
|
+
void this.cache.put(hash, bytes);
|
|
3918
|
+
return bytes;
|
|
3871
3919
|
}
|
|
3872
3920
|
/**
|
|
3873
3921
|
* Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
|
|
@@ -4892,7 +4940,7 @@ function base64UrlDecode(s) {
|
|
|
4892
4940
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
4893
4941
|
* `package.json` `version`.
|
|
4894
4942
|
*/
|
|
4895
|
-
const VERSION = "0.
|
|
4943
|
+
const VERSION = "0.8.0-alpha.0";
|
|
4896
4944
|
|
|
4897
4945
|
/**
|
|
4898
4946
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -4928,6 +4976,8 @@ class HttpError extends Error {
|
|
|
4928
4976
|
}
|
|
4929
4977
|
class HttpClient {
|
|
4930
4978
|
baseUrl;
|
|
4979
|
+
/** Origin of {@link baseUrl} — credentials are only attached same-origin. */
|
|
4980
|
+
baseOrigin;
|
|
4931
4981
|
apiKey;
|
|
4932
4982
|
getSessionToken;
|
|
4933
4983
|
onUnauthorized;
|
|
@@ -4936,6 +4986,12 @@ class HttpClient {
|
|
|
4936
4986
|
if (!opts?.baseUrl)
|
|
4937
4987
|
throw new Error("HttpClient: `baseUrl` is required");
|
|
4938
4988
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4989
|
+
try {
|
|
4990
|
+
this.baseOrigin = new URL(this.baseUrl).origin;
|
|
4991
|
+
}
|
|
4992
|
+
catch {
|
|
4993
|
+
this.baseOrigin = "";
|
|
4994
|
+
}
|
|
4939
4995
|
this.apiKey = opts.apiKey ?? null;
|
|
4940
4996
|
this.getSessionToken = opts.getSessionToken ?? (() => null);
|
|
4941
4997
|
this.onUnauthorized = opts.onUnauthorized ?? null;
|
|
@@ -4952,13 +5008,34 @@ class HttpClient {
|
|
|
4952
5008
|
*/
|
|
4953
5009
|
fetch = (input, init) => {
|
|
4954
5010
|
const headers = new Headers(init?.headers);
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
5011
|
+
// Only attach credentials when the request stays on the accelerator's
|
|
5012
|
+
// origin. An absolute URL pointing elsewhere (e.g. a caller-supplied or
|
|
5013
|
+
// server-reflected path that escapes `baseUrl`) must NOT receive the app
|
|
5014
|
+
// key or session token, or it becomes a token-exfiltration vector.
|
|
5015
|
+
if (this.isSameOrigin(input)) {
|
|
5016
|
+
if (this.apiKey)
|
|
5017
|
+
headers.set("X-Muhkoo-Key", this.apiKey);
|
|
5018
|
+
const token = this.getSessionToken();
|
|
5019
|
+
if (token)
|
|
5020
|
+
headers.set("X-Muhkoo-Session", token);
|
|
5021
|
+
}
|
|
4960
5022
|
return this.fetchFn(input, { ...init, headers });
|
|
4961
5023
|
};
|
|
5024
|
+
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
5025
|
+
isSameOrigin(input) {
|
|
5026
|
+
try {
|
|
5027
|
+
const urlStr = typeof input === "string" ? input
|
|
5028
|
+
: input instanceof URL ? input.href
|
|
5029
|
+
: input.url;
|
|
5030
|
+
// Relative URLs are resolved against baseUrl downstream → same origin.
|
|
5031
|
+
if (!/^https?:\/\//i.test(urlStr))
|
|
5032
|
+
return true;
|
|
5033
|
+
return new URL(urlStr).origin === this.baseOrigin;
|
|
5034
|
+
}
|
|
5035
|
+
catch {
|
|
5036
|
+
return true;
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
4962
5039
|
// -------------------------------------------------------------------------
|
|
4963
5040
|
// JSON convenience helpers (path is relative to baseUrl)
|
|
4964
5041
|
// -------------------------------------------------------------------------
|
|
@@ -6233,30 +6310,104 @@ class KvNamespace {
|
|
|
6233
6310
|
cipherIdentity = null;
|
|
6234
6311
|
constructor(deps) {
|
|
6235
6312
|
this.deps = deps;
|
|
6313
|
+
// Replay queued kv writes on reconnect by re-issuing the original request.
|
|
6314
|
+
if (deps.offline?.enabled) ;
|
|
6315
|
+
}
|
|
6316
|
+
/** Replay one queued kv mutation (called by the sync engine). */
|
|
6317
|
+
async replay(entry) {
|
|
6318
|
+
const a = entry.args;
|
|
6319
|
+
if (entry.method === "set") {
|
|
6320
|
+
await this.deps.http.post(this.kvPath(a.collection, a.id), { value: a.stored, hlc: a.hlc });
|
|
6321
|
+
}
|
|
6322
|
+
else {
|
|
6323
|
+
await this.deps.http.del(this.kvPath(a.collection, a.id), { hlc: a.hlc });
|
|
6324
|
+
}
|
|
6236
6325
|
}
|
|
6237
6326
|
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
6238
6327
|
async set(collection, id, value, opts) {
|
|
6239
6328
|
const encrypt = opts?.encrypt !== false;
|
|
6240
6329
|
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
6241
|
-
|
|
6330
|
+
const offline = this.deps.offline;
|
|
6331
|
+
if (!offline?.enabled) {
|
|
6332
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
const fullKey = `${collection}/${id}`;
|
|
6336
|
+
const hlc = await offline.nextHlc();
|
|
6337
|
+
await offline.writeLocal(this.commitment(), fullKey, stored, hlc);
|
|
6338
|
+
try {
|
|
6339
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored, hlc });
|
|
6340
|
+
}
|
|
6341
|
+
catch (err) {
|
|
6342
|
+
if (err instanceof HttpError)
|
|
6343
|
+
throw err; // server reachable + refused
|
|
6344
|
+
await offline.enqueue("set", { collection, id, stored, hlc }, hlc, offline.newClientId());
|
|
6345
|
+
}
|
|
6242
6346
|
}
|
|
6243
6347
|
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
6244
6348
|
async get(collection, id) {
|
|
6245
|
-
const
|
|
6246
|
-
|
|
6349
|
+
const offline = this.deps.offline;
|
|
6350
|
+
if (!offline?.enabled) {
|
|
6351
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
6352
|
+
return this.decode(res.value);
|
|
6353
|
+
}
|
|
6354
|
+
const fullKey = `${collection}/${id}`;
|
|
6355
|
+
try {
|
|
6356
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
6357
|
+
// Refresh the cache from the authoritative read (merge by HLC).
|
|
6358
|
+
await offline.mergeRemote(this.commitment(), fullKey, res.value ?? null, res.value == null ? "delete" : "set", res.hlc);
|
|
6359
|
+
return this.decode(res.value);
|
|
6360
|
+
}
|
|
6361
|
+
catch (err) {
|
|
6362
|
+
if (err instanceof HttpError)
|
|
6363
|
+
throw err; // a real server error, not offline
|
|
6364
|
+
const entry = await offline.read(this.commitment(), fullKey);
|
|
6365
|
+
if (!entry || entry.deleted)
|
|
6366
|
+
return null;
|
|
6367
|
+
return this.decode(entry.value);
|
|
6368
|
+
}
|
|
6247
6369
|
}
|
|
6248
6370
|
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
6249
6371
|
async delete(collection, id) {
|
|
6250
|
-
const
|
|
6251
|
-
|
|
6372
|
+
const offline = this.deps.offline;
|
|
6373
|
+
if (!offline?.enabled) {
|
|
6374
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
6375
|
+
return Boolean(res.existed);
|
|
6376
|
+
}
|
|
6377
|
+
const fullKey = `${collection}/${id}`;
|
|
6378
|
+
const hlc = await offline.nextHlc();
|
|
6379
|
+
await offline.deleteLocal(this.commitment(), fullKey, hlc);
|
|
6380
|
+
try {
|
|
6381
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), { hlc });
|
|
6382
|
+
return Boolean(res.existed);
|
|
6383
|
+
}
|
|
6384
|
+
catch (err) {
|
|
6385
|
+
if (err instanceof HttpError)
|
|
6386
|
+
throw err;
|
|
6387
|
+
await offline.enqueue("delete", { collection, id, hlc }, hlc, offline.newClientId());
|
|
6388
|
+
return true; // optimistic
|
|
6389
|
+
}
|
|
6252
6390
|
}
|
|
6253
6391
|
/** List the ids present in `collection`. */
|
|
6254
6392
|
async list(collection) {
|
|
6255
|
-
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
6256
6393
|
const prefix = `${collection}/`;
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6394
|
+
const offline = this.deps.offline;
|
|
6395
|
+
const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
|
|
6396
|
+
if (!offline?.enabled) {
|
|
6397
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
6398
|
+
return fromKeys(res.keys ?? []);
|
|
6399
|
+
}
|
|
6400
|
+
try {
|
|
6401
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
6402
|
+
// Union with cached live keys so un-synced offline creates still show.
|
|
6403
|
+
const cached = await offline.liveKeys(this.commitment());
|
|
6404
|
+
return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
|
|
6405
|
+
}
|
|
6406
|
+
catch (err) {
|
|
6407
|
+
if (err instanceof HttpError)
|
|
6408
|
+
throw err;
|
|
6409
|
+
return fromKeys(await offline.liveKeys(this.commitment()));
|
|
6410
|
+
}
|
|
6260
6411
|
}
|
|
6261
6412
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
6262
6413
|
async query(_collection, _filter) {
|
|
@@ -6315,6 +6466,10 @@ class KvNamespace {
|
|
|
6315
6466
|
return;
|
|
6316
6467
|
const collection = frame.key.slice(0, slash);
|
|
6317
6468
|
const id = frame.key.slice(slash + 1);
|
|
6469
|
+
// Keep the offline cache warm from the realtime feed (merge by HLC).
|
|
6470
|
+
if (this.deps.offline?.enabled) {
|
|
6471
|
+
await this.deps.offline.mergeRemote(this.commitment(), frame.key, frame.op === "set" ? frame.value ?? null : null, frame.op, frame.hlc);
|
|
6472
|
+
}
|
|
6318
6473
|
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
6319
6474
|
handler({ collection, id, type: frame.op, data });
|
|
6320
6475
|
}
|
|
@@ -6382,41 +6537,109 @@ class KvNamespace {
|
|
|
6382
6537
|
class DbTable {
|
|
6383
6538
|
http;
|
|
6384
6539
|
name;
|
|
6385
|
-
|
|
6540
|
+
offline;
|
|
6541
|
+
constructor(http, name, offline) {
|
|
6386
6542
|
this.http = http;
|
|
6387
6543
|
this.name = name;
|
|
6544
|
+
this.offline = offline;
|
|
6388
6545
|
}
|
|
6389
6546
|
base() {
|
|
6390
6547
|
return `/api/db/${encodeURIComponent(this.name)}`;
|
|
6391
6548
|
}
|
|
6549
|
+
rowUrl(id) {
|
|
6550
|
+
return `${this.base()}/${encodeURIComponent(String(id))}`;
|
|
6551
|
+
}
|
|
6392
6552
|
/** Insert a row. Returns the created row and its primary-key value. */
|
|
6393
6553
|
async insert(values) {
|
|
6394
|
-
|
|
6554
|
+
if (!this.offline?.enabled)
|
|
6555
|
+
return this.http.post(this.base(), { values });
|
|
6556
|
+
const hlc = await this.offline.nextHlc();
|
|
6557
|
+
const _hlc = this.offline.columnStamps(values, hlc);
|
|
6558
|
+
try {
|
|
6559
|
+
const res = await this.http.post(this.base(), { values, _hlc });
|
|
6560
|
+
await this.offline.writeRow(this.name, String(res.id), res.row);
|
|
6561
|
+
return res;
|
|
6562
|
+
}
|
|
6563
|
+
catch (err) {
|
|
6564
|
+
if (err instanceof HttpError)
|
|
6565
|
+
throw err;
|
|
6566
|
+
// Offline: queue for replay. No server pk yet — return a provisional
|
|
6567
|
+
// client id so the caller has a stable handle until sync.
|
|
6568
|
+
const clientId = this.offline.newClientId();
|
|
6569
|
+
await this.offline.enqueue("insert", { table: this.name, values, _hlc }, hlc, clientId);
|
|
6570
|
+
return { row: values, id: clientId };
|
|
6571
|
+
}
|
|
6395
6572
|
}
|
|
6396
6573
|
/** Fetch a row by primary key, or `null` if it doesn't exist. */
|
|
6397
6574
|
async get(id) {
|
|
6398
6575
|
try {
|
|
6399
|
-
const res = await this.http.get(
|
|
6576
|
+
const res = await this.http.get(this.rowUrl(id));
|
|
6577
|
+
if (this.offline?.enabled)
|
|
6578
|
+
await this.offline.writeRow(this.name, String(id), res.row);
|
|
6400
6579
|
return res.row;
|
|
6401
6580
|
}
|
|
6402
6581
|
catch (e) {
|
|
6403
6582
|
if (e?.status === 404)
|
|
6404
6583
|
return null;
|
|
6584
|
+
if (this.offline?.enabled && !(e instanceof HttpError)) {
|
|
6585
|
+
return (await this.offline.readRow(this.name, String(id)));
|
|
6586
|
+
}
|
|
6405
6587
|
throw e;
|
|
6406
6588
|
}
|
|
6407
6589
|
}
|
|
6408
6590
|
/** Query rows with filters, ordering, and keyset pagination. */
|
|
6409
6591
|
async query(query = {}) {
|
|
6410
|
-
|
|
6592
|
+
if (!this.offline?.enabled)
|
|
6593
|
+
return this.http.post(`${this.base()}/query`, query);
|
|
6594
|
+
try {
|
|
6595
|
+
const res = await this.http.post(`${this.base()}/query`, query);
|
|
6596
|
+
await this.offline.writeQuery(this.name, query, res);
|
|
6597
|
+
return res;
|
|
6598
|
+
}
|
|
6599
|
+
catch (e) {
|
|
6600
|
+
if (e instanceof HttpError)
|
|
6601
|
+
throw e;
|
|
6602
|
+
const cached = await this.offline.readQuery(this.name, query);
|
|
6603
|
+
return cached ?? { rows: [], nextCursor: null };
|
|
6604
|
+
}
|
|
6411
6605
|
}
|
|
6412
6606
|
/** Update a row by primary key. Returns the updated row. */
|
|
6413
6607
|
async update(id, values) {
|
|
6414
|
-
|
|
6608
|
+
if (!this.offline?.enabled)
|
|
6609
|
+
return this.http.patch(this.rowUrl(id), { values });
|
|
6610
|
+
const hlc = await this.offline.nextHlc();
|
|
6611
|
+
const _hlc = this.offline.columnStamps(values, hlc);
|
|
6612
|
+
await this.offline.patchRow(this.name, String(id), values); // optimistic
|
|
6613
|
+
try {
|
|
6614
|
+
const res = await this.http.patch(this.rowUrl(id), { values, _hlc });
|
|
6615
|
+
await this.offline.writeRow(this.name, String(id), res.row);
|
|
6616
|
+
return res;
|
|
6617
|
+
}
|
|
6618
|
+
catch (err) {
|
|
6619
|
+
if (err instanceof HttpError)
|
|
6620
|
+
throw err;
|
|
6621
|
+
await this.offline.enqueue("update", { table: this.name, id, values, _hlc }, hlc, this.offline.newClientId());
|
|
6622
|
+
return { row: (await this.offline.readRow(this.name, String(id))) };
|
|
6623
|
+
}
|
|
6415
6624
|
}
|
|
6416
6625
|
/** Delete a row by primary key. Returns the number of rows removed (0 or 1). */
|
|
6417
6626
|
async delete(id) {
|
|
6418
|
-
|
|
6419
|
-
|
|
6627
|
+
if (!this.offline?.enabled) {
|
|
6628
|
+
const res = await this.http.del(this.rowUrl(id));
|
|
6629
|
+
return res.deleted ?? 0;
|
|
6630
|
+
}
|
|
6631
|
+
const hlc = await this.offline.nextHlc();
|
|
6632
|
+
await this.offline.deleteRow(this.name, String(id)); // optimistic
|
|
6633
|
+
try {
|
|
6634
|
+
const res = await this.http.del(this.rowUrl(id), { _hlc: hlc });
|
|
6635
|
+
return res.deleted ?? 0;
|
|
6636
|
+
}
|
|
6637
|
+
catch (err) {
|
|
6638
|
+
if (err instanceof HttpError)
|
|
6639
|
+
throw err;
|
|
6640
|
+
await this.offline.enqueue("delete", { table: this.name, id, hlc }, hlc, this.offline.newClientId());
|
|
6641
|
+
return 1; // optimistic
|
|
6642
|
+
}
|
|
6420
6643
|
}
|
|
6421
6644
|
}
|
|
6422
6645
|
class DbNamespace {
|
|
@@ -6432,7 +6655,21 @@ class DbNamespace {
|
|
|
6432
6655
|
* const todos = client.db.table<Todo>('todos');
|
|
6433
6656
|
*/
|
|
6434
6657
|
table(name) {
|
|
6435
|
-
return new DbTable(this.deps.http, name);
|
|
6658
|
+
return new DbTable(this.deps.http, name, this.deps.offline);
|
|
6659
|
+
}
|
|
6660
|
+
/** Replay one queued db mutation (called by the sync engine). */
|
|
6661
|
+
async replay(entry) {
|
|
6662
|
+
const a = entry.args;
|
|
6663
|
+
const base = `/api/db/${encodeURIComponent(a.table)}`;
|
|
6664
|
+
if (entry.method === "insert") {
|
|
6665
|
+
await this.deps.http.post(base, { values: a.values, _hlc: a._hlc });
|
|
6666
|
+
}
|
|
6667
|
+
else if (entry.method === "update") {
|
|
6668
|
+
await this.deps.http.patch(`${base}/${encodeURIComponent(String(a.id))}`, { values: a.values, _hlc: a._hlc });
|
|
6669
|
+
}
|
|
6670
|
+
else if (entry.method === "delete") {
|
|
6671
|
+
await this.deps.http.del(`${base}/${encodeURIComponent(String(a.id))}`, { _hlc: a.hlc });
|
|
6672
|
+
}
|
|
6436
6673
|
}
|
|
6437
6674
|
}
|
|
6438
6675
|
|
|
@@ -6560,6 +6797,8 @@ class StorageNamespace {
|
|
|
6560
6797
|
baseUrl: this.deps.baseUrl,
|
|
6561
6798
|
pathPrefix: "/api/shards",
|
|
6562
6799
|
fetch: this.deps.http.fetch,
|
|
6800
|
+
cache: this.deps.shardCache,
|
|
6801
|
+
deferUpload: this.deps.deferShardUpload,
|
|
6563
6802
|
});
|
|
6564
6803
|
}
|
|
6565
6804
|
return this._shards;
|
|
@@ -6797,6 +7036,7 @@ class Space {
|
|
|
6797
7036
|
/** Per-instance event bus for fan-out events (isolated across spaces). */
|
|
6798
7037
|
events = new EventTarget();
|
|
6799
7038
|
rawWired = false;
|
|
7039
|
+
catchUpUnsub = null;
|
|
6800
7040
|
constructor(deps) {
|
|
6801
7041
|
this.deps = deps;
|
|
6802
7042
|
this.name = deps.name;
|
|
@@ -6847,12 +7087,19 @@ class Space {
|
|
|
6847
7087
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
6848
7088
|
});
|
|
6849
7089
|
this.wireFanout();
|
|
7090
|
+
// Register offline catch-up once: on each reconnect the sync
|
|
7091
|
+
// engine pages history forward to fill any gap we missed.
|
|
7092
|
+
if (this.deps.offline?.enabled && !this.catchUpUnsub) {
|
|
7093
|
+
this.catchUpUnsub = this.deps.offline.registerCatchUp(() => this.catchUp());
|
|
7094
|
+
}
|
|
6850
7095
|
}
|
|
6851
7096
|
}
|
|
6852
7097
|
await this.channel.connect();
|
|
6853
7098
|
}
|
|
6854
7099
|
disconnect() {
|
|
6855
7100
|
this.channel?.disconnect();
|
|
7101
|
+
this.catchUpUnsub?.();
|
|
7102
|
+
this.catchUpUnsub = null;
|
|
6856
7103
|
}
|
|
6857
7104
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
6858
7105
|
on(event, handler) {
|
|
@@ -6899,7 +7146,30 @@ class Space {
|
|
|
6899
7146
|
* persisted `spaceMessage`. Requires a keyring.
|
|
6900
7147
|
*/
|
|
6901
7148
|
async sendMessage(payload, opts = {}) {
|
|
6902
|
-
|
|
7149
|
+
const offline = this.deps.offline;
|
|
7150
|
+
if (!offline?.enabled) {
|
|
7151
|
+
this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7154
|
+
// Offline-aware path: stamp the message, cache it optimistically (so the
|
|
7155
|
+
// UI can show it instantly via cachedMessages), then send if connected
|
|
7156
|
+
// or durably queue it for replay on reconnect.
|
|
7157
|
+
const clientId = offline.newClientId();
|
|
7158
|
+
const hlc = await offline.nextHlc();
|
|
7159
|
+
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
7160
|
+
const frame = { spaceMessage: packet };
|
|
7161
|
+
await offline.putMessage(this.id, {
|
|
7162
|
+
handle: provisionalSpaceKey(clientId),
|
|
7163
|
+
packet,
|
|
7164
|
+
op: "msg",
|
|
7165
|
+
clientId,
|
|
7166
|
+
hlc,
|
|
7167
|
+
pending: true,
|
|
7168
|
+
});
|
|
7169
|
+
if (this.isConnected())
|
|
7170
|
+
this.sendRaw(frame);
|
|
7171
|
+
else
|
|
7172
|
+
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
6903
7173
|
}
|
|
6904
7174
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
6905
7175
|
onMessage(handler) {
|
|
@@ -6917,7 +7187,22 @@ class Space {
|
|
|
6917
7187
|
*/
|
|
6918
7188
|
async editMessage(handle, payload, opts = {}) {
|
|
6919
7189
|
const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
|
|
6920
|
-
|
|
7190
|
+
const frame = { editSpaceMessage: { ts: handle, spaceMessage } };
|
|
7191
|
+
const offline = this.deps.offline;
|
|
7192
|
+
if (offline?.enabled) {
|
|
7193
|
+
await offline.putMessage(this.id, {
|
|
7194
|
+
handle: padSpaceHandle(handle),
|
|
7195
|
+
packet: spaceMessage,
|
|
7196
|
+
op: "edit",
|
|
7197
|
+
hlc: await offline.nextHlc(),
|
|
7198
|
+
});
|
|
7199
|
+
if (this.isConnected())
|
|
7200
|
+
this.sendRaw(frame);
|
|
7201
|
+
else
|
|
7202
|
+
await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
|
|
7203
|
+
return;
|
|
7204
|
+
}
|
|
7205
|
+
this.sendRaw(frame);
|
|
6921
7206
|
}
|
|
6922
7207
|
/**
|
|
6923
7208
|
* Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
|
|
@@ -6925,7 +7210,45 @@ class Space {
|
|
|
6925
7210
|
* original author. Receivers get an {@link onMessageDeleted} event.
|
|
6926
7211
|
*/
|
|
6927
7212
|
async deleteMessage(handle) {
|
|
6928
|
-
|
|
7213
|
+
const frame = { deleteSpaceMessage: { ts: handle } };
|
|
7214
|
+
const offline = this.deps.offline;
|
|
7215
|
+
if (offline?.enabled) {
|
|
7216
|
+
await offline.putDeleted(this.id, handle);
|
|
7217
|
+
if (this.isConnected())
|
|
7218
|
+
this.sendRaw(frame);
|
|
7219
|
+
else
|
|
7220
|
+
await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
|
|
7221
|
+
return;
|
|
7222
|
+
}
|
|
7223
|
+
this.sendRaw(frame);
|
|
7224
|
+
}
|
|
7225
|
+
/**
|
|
7226
|
+
* Decrypt and return the locally-cached message log for this space, in
|
|
7227
|
+
* handle order (pending sends last). Works offline — the source is the
|
|
7228
|
+
* IndexedDB cache populated by live frames + {@link history}. Apps call this
|
|
7229
|
+
* on boot to paint instantly, then subscribe to {@link onMessage} for live
|
|
7230
|
+
* updates. Returns `[]` when offline support is off.
|
|
7231
|
+
*/
|
|
7232
|
+
async cachedMessages() {
|
|
7233
|
+
const offline = this.deps.offline;
|
|
7234
|
+
if (!offline?.enabled || !this.cipher)
|
|
7235
|
+
return [];
|
|
7236
|
+
const entries = await offline.loadMessages(this.id);
|
|
7237
|
+
const out = [];
|
|
7238
|
+
for (const e of entries) {
|
|
7239
|
+
if (e.deleted || !e.packet)
|
|
7240
|
+
continue;
|
|
7241
|
+
// Already verified when first cached → skip re-verification (which
|
|
7242
|
+
// would also fail offline without a warm member directory).
|
|
7243
|
+
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
7244
|
+
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
7245
|
+
/*skipVerify*/ true);
|
|
7246
|
+
if (decoded) {
|
|
7247
|
+
decoded.pending = e.pending;
|
|
7248
|
+
out.push(decoded);
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
return out;
|
|
6929
7252
|
}
|
|
6930
7253
|
/** Subscribe to in-place edits of persisted messages (new content at the same handle). */
|
|
6931
7254
|
onMessageEdited(handler) {
|
|
@@ -6986,8 +7309,21 @@ class Space {
|
|
|
6986
7309
|
const messages = [];
|
|
6987
7310
|
for (const raw of body.messages ?? []) {
|
|
6988
7311
|
const decoded = await this.decodeFrame(raw, cipher);
|
|
6989
|
-
if (decoded)
|
|
6990
|
-
|
|
7312
|
+
if (!decoded)
|
|
7313
|
+
continue;
|
|
7314
|
+
messages.push(decoded);
|
|
7315
|
+
// Warm the offline cache with the inner sealed packet at its handle.
|
|
7316
|
+
if (this.deps.offline?.enabled) {
|
|
7317
|
+
try {
|
|
7318
|
+
const outer = JSON.parse(raw);
|
|
7319
|
+
if (typeof outer.spaceMessage === "string") {
|
|
7320
|
+
await this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
catch {
|
|
7324
|
+
/* unparseable history row — skip caching, still surfaced above */
|
|
7325
|
+
}
|
|
7326
|
+
}
|
|
6991
7327
|
}
|
|
6992
7328
|
return { messages, nextCursor: body.nextCursor ?? null };
|
|
6993
7329
|
}
|
|
@@ -7054,6 +7390,8 @@ class Space {
|
|
|
7054
7390
|
baseUrl: this.deps.httpBaseUrl,
|
|
7055
7391
|
pathPrefix: "/api/shards",
|
|
7056
7392
|
fetch: this.deps.fetch,
|
|
7393
|
+
cache: this.deps.shardCache,
|
|
7394
|
+
deferUpload: this.deps.deferShardUpload,
|
|
7057
7395
|
});
|
|
7058
7396
|
return new FileStorage({ shards });
|
|
7059
7397
|
}
|
|
@@ -7072,9 +7410,12 @@ class Space {
|
|
|
7072
7410
|
if (!f || typeof f !== "object")
|
|
7073
7411
|
return;
|
|
7074
7412
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
7075
|
-
const
|
|
7076
|
-
|
|
7413
|
+
const handle = Number(f.timestamp ?? 0);
|
|
7414
|
+
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7415
|
+
if (decoded) {
|
|
7077
7416
|
this.emitEvent("message", decoded);
|
|
7417
|
+
await this.cacheFrame(f.spaceMessage, decoded, "msg");
|
|
7418
|
+
}
|
|
7078
7419
|
return;
|
|
7079
7420
|
}
|
|
7080
7421
|
if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
|
|
@@ -7083,14 +7424,21 @@ class Space {
|
|
|
7083
7424
|
const e = f.editSpaceMessage;
|
|
7084
7425
|
if (typeof e.spaceMessage === "string") {
|
|
7085
7426
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
7086
|
-
if (decoded)
|
|
7427
|
+
if (decoded) {
|
|
7087
7428
|
this.emitEvent("message-edited", decoded);
|
|
7429
|
+
await this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7430
|
+
}
|
|
7088
7431
|
}
|
|
7089
7432
|
return;
|
|
7090
7433
|
}
|
|
7091
7434
|
if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
|
|
7092
7435
|
const d = f.deleteSpaceMessage;
|
|
7093
|
-
|
|
7436
|
+
const handle = Number(d.ts ?? 0);
|
|
7437
|
+
this.emitEvent("message-deleted", { handle });
|
|
7438
|
+
if (this.deps.offline?.enabled) {
|
|
7439
|
+
await this.deps.offline.putDeleted(this.id, handle);
|
|
7440
|
+
await this.deps.offline.observeHandle(this.id, handle);
|
|
7441
|
+
}
|
|
7094
7442
|
return;
|
|
7095
7443
|
}
|
|
7096
7444
|
if (f.pub && typeof f.pub === "object") {
|
|
@@ -7126,12 +7474,17 @@ class Space {
|
|
|
7126
7474
|
* serialized `Packet` string. Shared by send (a new message) and edit
|
|
7127
7475
|
* (replacement content for an existing handle). Requires a keyring.
|
|
7128
7476
|
*/
|
|
7129
|
-
async sealMessage(payload, channel, contentType) {
|
|
7477
|
+
async sealMessage(payload, channel, contentType, clientId) {
|
|
7130
7478
|
const cipher = this.requireCipher(contentType);
|
|
7131
7479
|
const message = new Message(payload);
|
|
7132
7480
|
const headers = await cipher.seal(message.serialize());
|
|
7133
7481
|
const source = this.deps.myId();
|
|
7134
7482
|
const subject = channel ?? "default";
|
|
7483
|
+
// Cleartext dedupe hint — lets the sender match its optimistic entry to
|
|
7484
|
+
// the server echo. Not part of the signed canonical form (it carries no
|
|
7485
|
+
// authority); worst case a stale hint just shows a transient duplicate.
|
|
7486
|
+
if (clientId)
|
|
7487
|
+
headers.cid = clientId;
|
|
7135
7488
|
// Sign with our identity ECDSA key so receivers can verify authorship
|
|
7136
7489
|
// end-to-end (independent of the relay's `source` stamp).
|
|
7137
7490
|
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
@@ -7151,7 +7504,7 @@ class Space {
|
|
|
7151
7504
|
* the enclosing frame); false means `input` is the full
|
|
7152
7505
|
* `{spaceMessage, name, timestamp}` frame and the handle is read from it.
|
|
7153
7506
|
*/
|
|
7154
|
-
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
|
|
7507
|
+
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0, skipVerify = false) {
|
|
7155
7508
|
try {
|
|
7156
7509
|
let packetJson = input;
|
|
7157
7510
|
let handle = serverTs;
|
|
@@ -7169,8 +7522,9 @@ class Space {
|
|
|
7169
7522
|
// by `source`'s identity ECDSA key (looked up in the member
|
|
7170
7523
|
// directory). Drop anything unsigned, signed by an unknown member,
|
|
7171
7524
|
// or whose signature doesn't match — this defeats both member
|
|
7172
|
-
// impersonation and a relay rewriting `source`.
|
|
7173
|
-
|
|
7525
|
+
// impersonation and a relay rewriting `source`. Skipped only when
|
|
7526
|
+
// re-reading from the local cache (already verified before caching).
|
|
7527
|
+
if (!skipVerify && !(await this.verifySender(packet)))
|
|
7174
7528
|
return null;
|
|
7175
7529
|
const serialized = await cipher.open(packet.headers);
|
|
7176
7530
|
if (serialized === null)
|
|
@@ -7182,6 +7536,7 @@ class Space {
|
|
|
7182
7536
|
epoch: Number(packet.headers?.epoch ?? 0),
|
|
7183
7537
|
contentType: packet.headers?.contentType,
|
|
7184
7538
|
handle,
|
|
7539
|
+
clientId: packet.headers?.cid,
|
|
7185
7540
|
message,
|
|
7186
7541
|
};
|
|
7187
7542
|
}
|
|
@@ -7224,6 +7579,62 @@ class Space {
|
|
|
7224
7579
|
});
|
|
7225
7580
|
return verifySpaceMessage(canonical, sig, key);
|
|
7226
7581
|
}
|
|
7582
|
+
/** Persist a decoded live/history/edit frame to the offline cache. */
|
|
7583
|
+
async cacheFrame(packet, decoded, op) {
|
|
7584
|
+
const offline = this.deps.offline;
|
|
7585
|
+
if (!offline?.enabled)
|
|
7586
|
+
return;
|
|
7587
|
+
// An incoming real message supersedes any optimistic pending entry.
|
|
7588
|
+
if (decoded.clientId)
|
|
7589
|
+
await offline.dropPending(this.id, decoded.clientId);
|
|
7590
|
+
await offline.putMessage(this.id, {
|
|
7591
|
+
handle: padSpaceHandle(decoded.handle),
|
|
7592
|
+
packet,
|
|
7593
|
+
op,
|
|
7594
|
+
clientId: decoded.clientId,
|
|
7595
|
+
});
|
|
7596
|
+
await offline.observeHandle(this.id, decoded.handle);
|
|
7597
|
+
}
|
|
7598
|
+
/**
|
|
7599
|
+
* Inbound reconciliation run on reconnect: page the server's forward delta
|
|
7600
|
+
* (`?since=<lastSeenHandle>`) to the head, pulling exactly the messages that
|
|
7601
|
+
* landed while we were offline into the cache. Bounded so a long absence
|
|
7602
|
+
* can't page forever. Falls back gracefully if the endpoint is unavailable.
|
|
7603
|
+
*/
|
|
7604
|
+
async catchUp() {
|
|
7605
|
+
const offline = this.deps.offline;
|
|
7606
|
+
if (!offline?.enabled || !this.cipher)
|
|
7607
|
+
return;
|
|
7608
|
+
const cursor = await offline.getCursor(this.id);
|
|
7609
|
+
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7610
|
+
// Warm the member directory once so historical senders' signatures verify.
|
|
7611
|
+
await this.deps.keyring?.refreshDirectory();
|
|
7612
|
+
for (let page = 0; page < 50; page++) {
|
|
7613
|
+
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7614
|
+
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
7615
|
+
const res = await this.deps.fetch(url);
|
|
7616
|
+
if (!res.ok)
|
|
7617
|
+
break;
|
|
7618
|
+
const body = (await res.json());
|
|
7619
|
+
for (const raw of body.messages ?? []) {
|
|
7620
|
+
const decoded = await this.decodeFrame(raw, this.cipher);
|
|
7621
|
+
if (!decoded)
|
|
7622
|
+
continue;
|
|
7623
|
+
try {
|
|
7624
|
+
const outer = JSON.parse(raw);
|
|
7625
|
+
if (typeof outer.spaceMessage === "string") {
|
|
7626
|
+
await this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
catch {
|
|
7630
|
+
/* unparseable row — skip */
|
|
7631
|
+
}
|
|
7632
|
+
}
|
|
7633
|
+
if (!body.nextCursor)
|
|
7634
|
+
break;
|
|
7635
|
+
since = body.nextCursor;
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7227
7638
|
emitEvent(type, detail) {
|
|
7228
7639
|
this.events.dispatchEvent(new CustomEvent(type, { detail }));
|
|
7229
7640
|
}
|
|
@@ -7243,6 +7654,18 @@ class Space {
|
|
|
7243
7654
|
function delay(ms) {
|
|
7244
7655
|
return new Promise((r) => setTimeout(r, ms));
|
|
7245
7656
|
}
|
|
7657
|
+
/**
|
|
7658
|
+
* Zero-pad a numeric server handle for use as a cache key, so string-ordered
|
|
7659
|
+
* keys sort in handle order. Must match the offline layer's `padHandle`.
|
|
7660
|
+
*/
|
|
7661
|
+
function padSpaceHandle(handle) {
|
|
7662
|
+
return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
|
|
7663
|
+
}
|
|
7664
|
+
/** Cache key for an un-acked optimistic send. Must match the offline layer's
|
|
7665
|
+
* `provisionalHandle` (sorts after every real, zero-padded handle). */
|
|
7666
|
+
function provisionalSpaceKey(clientId) {
|
|
7667
|
+
return `~local:${clientId}`;
|
|
7668
|
+
}
|
|
7246
7669
|
|
|
7247
7670
|
/**
|
|
7248
7671
|
* `client.message` — realtime messaging over the shared-space primitive.
|
|
@@ -7737,6 +8160,156 @@ class KeyringClient {
|
|
|
7737
8160
|
}
|
|
7738
8161
|
}
|
|
7739
8162
|
|
|
8163
|
+
/**
|
|
8164
|
+
* Message-log helpers — the conflict model for a Space's message history.
|
|
8165
|
+
*
|
|
8166
|
+
* Messages are the easy CRDT: the accelerator assigns every persisted message a
|
|
8167
|
+
* monotonic, zero-padded `handle` that already totally-orders the log, and the
|
|
8168
|
+
* sealed body is immutable. So the "log" is just a grow-only set keyed by
|
|
8169
|
+
* `handle`, and convergence is structural — no server change needed.
|
|
8170
|
+
*
|
|
8171
|
+
* The only subtleties this module encodes:
|
|
8172
|
+
* - **Optimistic sends** have no server handle yet. We key them by a
|
|
8173
|
+
* provisional handle that sorts *after* every real one, and tag them with
|
|
8174
|
+
* the sender's `clientId` so the real frame (carrying the same `clientId`)
|
|
8175
|
+
* can replace the placeholder instead of duplicating it.
|
|
8176
|
+
* - **Edits** are a per-message LWW register on the body (greater HLC wins).
|
|
8177
|
+
* - **Deletes** are a tombstone at the message's handle.
|
|
8178
|
+
*/
|
|
8179
|
+
/** IndexedDB key for a message within a space. */
|
|
8180
|
+
function messageKey(spaceId, handle) {
|
|
8181
|
+
return `${spaceId}|${handle}`;
|
|
8182
|
+
}
|
|
8183
|
+
/**
|
|
8184
|
+
* A handle for an un-acked optimistic message. Prefixed with `"~"` (0x7E),
|
|
8185
|
+
* which sorts after every digit, so pending sends always appear at the tail of
|
|
8186
|
+
* the handle-ordered log until the real handle arrives.
|
|
8187
|
+
*/
|
|
8188
|
+
function provisionalHandle(clientId) {
|
|
8189
|
+
return `~local:${clientId}`;
|
|
8190
|
+
}
|
|
8191
|
+
function isProvisional(handle) {
|
|
8192
|
+
return handle.startsWith("~local:");
|
|
8193
|
+
}
|
|
8194
|
+
/**
|
|
8195
|
+
* Merge two entries for the SAME handle. A delete always wins (tombstone); an
|
|
8196
|
+
* edit wins over the entry it supersedes when its HLC is greater; otherwise the
|
|
8197
|
+
* existing entry stands (a re-delivered `msg` is idempotent).
|
|
8198
|
+
*/
|
|
8199
|
+
function mergeMessage(local, incoming) {
|
|
8200
|
+
if (!local)
|
|
8201
|
+
return incoming;
|
|
8202
|
+
if (incoming.op === "delete" || local.deleted) {
|
|
8203
|
+
return { ...local, op: "delete", packet: null, deleted: true };
|
|
8204
|
+
}
|
|
8205
|
+
if (incoming.op === "edit") {
|
|
8206
|
+
if (!local.hlc || (incoming.hlc && incoming.hlc > local.hlc)) {
|
|
8207
|
+
return { ...local, packet: incoming.packet, hlc: incoming.hlc, op: "edit" };
|
|
8208
|
+
}
|
|
8209
|
+
return local;
|
|
8210
|
+
}
|
|
8211
|
+
// incoming.op === "msg": keep the already-stored (de-pending) entry.
|
|
8212
|
+
return { ...local, pending: false };
|
|
8213
|
+
}
|
|
8214
|
+
|
|
8215
|
+
/**
|
|
8216
|
+
* `SpaceCache` — the offline adapter a {@link ../spaces/Space} talks to. It
|
|
8217
|
+
* persists sealed message frames (ciphertext at rest), tracks per-space sync
|
|
8218
|
+
* cursors, and records offline sends in the durable queue. Kept as its own
|
|
8219
|
+
* object (rather than wiring the Space straight to the store + clock) so the
|
|
8220
|
+
* Space stays decoupled from IndexedDB and so the no-op path is a single
|
|
8221
|
+
* `enabled` check.
|
|
8222
|
+
*
|
|
8223
|
+
* Messages are the structural CRDT (see {@link ./crdt/MessageLog}): the server
|
|
8224
|
+
* assigns a monotonic `handle` that totally-orders the log, so the cache just
|
|
8225
|
+
* stores frames keyed by a zero-padded handle (real) or a provisional key
|
|
8226
|
+
* (pending sends, which sort to the tail until their echo arrives).
|
|
8227
|
+
*/
|
|
8228
|
+
/** Zero-pad a numeric server handle so string keys sort in handle order. */
|
|
8229
|
+
function padHandle(handle) {
|
|
8230
|
+
return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
|
|
8231
|
+
}
|
|
8232
|
+
/**
|
|
8233
|
+
* The surface a {@link ../spaces/Space} uses. Declared structurally on the
|
|
8234
|
+
* Space side too (as `SpaceOfflineAdapter`) so the spaces module never imports
|
|
8235
|
+
* the offline module.
|
|
8236
|
+
*/
|
|
8237
|
+
class SpaceCache {
|
|
8238
|
+
manager;
|
|
8239
|
+
store;
|
|
8240
|
+
constructor(manager) {
|
|
8241
|
+
this.manager = manager;
|
|
8242
|
+
this.store = manager.store;
|
|
8243
|
+
}
|
|
8244
|
+
get enabled() {
|
|
8245
|
+
return this.manager.enabled;
|
|
8246
|
+
}
|
|
8247
|
+
newClientId() {
|
|
8248
|
+
return this.manager.newClientId();
|
|
8249
|
+
}
|
|
8250
|
+
nextHlc() {
|
|
8251
|
+
return this.manager.nextHlc();
|
|
8252
|
+
}
|
|
8253
|
+
/** Persist a sealed message frame keyed by its (real or provisional) handle. */
|
|
8254
|
+
async putMessage(spaceId, entry) {
|
|
8255
|
+
await this.store.put("space-messages", messageKey(spaceId, entry.handle), entry);
|
|
8256
|
+
}
|
|
8257
|
+
/** Tombstone the message at `handle`. */
|
|
8258
|
+
async putDeleted(spaceId, handle) {
|
|
8259
|
+
const key = messageKey(spaceId, padHandle(handle));
|
|
8260
|
+
const existing = await this.store.get("space-messages", key);
|
|
8261
|
+
await this.store.put("space-messages", key, {
|
|
8262
|
+
handle: padHandle(handle),
|
|
8263
|
+
packet: null,
|
|
8264
|
+
op: "delete",
|
|
8265
|
+
deleted: true,
|
|
8266
|
+
clientId: existing?.clientId,
|
|
8267
|
+
});
|
|
8268
|
+
}
|
|
8269
|
+
/** Drop a pending (optimistic) entry once its real echo has been stored. */
|
|
8270
|
+
async dropPending(spaceId, clientId) {
|
|
8271
|
+
await this.store.delete("space-messages", messageKey(spaceId, provisionalHandle(clientId)));
|
|
8272
|
+
}
|
|
8273
|
+
/** All cached frames for a space, in handle order (pending ones last). */
|
|
8274
|
+
async loadMessages(spaceId) {
|
|
8275
|
+
const rows = await this.store.range("space-messages", `${spaceId}|`, `${spaceId}|`);
|
|
8276
|
+
return rows.map((r) => r.value);
|
|
8277
|
+
}
|
|
8278
|
+
async getCursor(spaceId) {
|
|
8279
|
+
return this.store.get("space-cursors", spaceId);
|
|
8280
|
+
}
|
|
8281
|
+
/** Advance the last-seen handle (monotonic) and track the oldest fetched. */
|
|
8282
|
+
async observeHandle(spaceId, handle) {
|
|
8283
|
+
if (!handle)
|
|
8284
|
+
return;
|
|
8285
|
+
const cur = (await this.getCursor(spaceId)) ?? {
|
|
8286
|
+
lastSeenHandle: 0,
|
|
8287
|
+
oldestHandle: handle,
|
|
8288
|
+
fullyBackfilled: false,
|
|
8289
|
+
};
|
|
8290
|
+
const next = {
|
|
8291
|
+
lastSeenHandle: Math.max(cur.lastSeenHandle, handle),
|
|
8292
|
+
oldestHandle: cur.oldestHandle ? Math.min(cur.oldestHandle, handle) : handle,
|
|
8293
|
+
fullyBackfilled: cur.fullyBackfilled,
|
|
8294
|
+
};
|
|
8295
|
+
await this.store.put("space-cursors", spaceId, next);
|
|
8296
|
+
}
|
|
8297
|
+
/** Queue a raw space frame for replay when the connection returns. */
|
|
8298
|
+
async enqueueFrame(spaceId, frame, clientId, hlc) {
|
|
8299
|
+
await this.manager.enqueue({
|
|
8300
|
+
hlc,
|
|
8301
|
+
clientId,
|
|
8302
|
+
domain: "space",
|
|
8303
|
+
method: "sendRaw",
|
|
8304
|
+
args: { spaceId, frame },
|
|
8305
|
+
});
|
|
8306
|
+
}
|
|
8307
|
+
/** Register an inbound catch-up task (forward/back history paging). */
|
|
8308
|
+
registerCatchUp(task) {
|
|
8309
|
+
return this.manager.registerCatchUp(task);
|
|
8310
|
+
}
|
|
8311
|
+
}
|
|
8312
|
+
|
|
7740
8313
|
/**
|
|
7741
8314
|
* `client.space` — the fan-out group-encryption surface.
|
|
7742
8315
|
*
|
|
@@ -7772,8 +8345,26 @@ class ChannelExistsError extends Error {
|
|
|
7772
8345
|
}
|
|
7773
8346
|
class SpaceNamespace {
|
|
7774
8347
|
deps;
|
|
8348
|
+
spaceCache;
|
|
8349
|
+
/** Spaces built this session, by id — used to route offline send replays. */
|
|
8350
|
+
openSpaces = new Map();
|
|
7775
8351
|
constructor(deps) {
|
|
7776
8352
|
this.deps = deps;
|
|
8353
|
+
if (deps.offline?.enabled) {
|
|
8354
|
+
this.spaceCache = new SpaceCache(deps.offline);
|
|
8355
|
+
// Replay queued space frames on reconnect: route each to its space
|
|
8356
|
+
// (reopening + connecting it if needed), then re-send the raw frame.
|
|
8357
|
+
deps.offline.registerReplayer("space", async (entry) => {
|
|
8358
|
+
const { spaceId, frame } = entry.args;
|
|
8359
|
+
let space = this.openSpaces.get(spaceId);
|
|
8360
|
+
if (!space)
|
|
8361
|
+
space = await this.get(spaceId);
|
|
8362
|
+
await space.connect();
|
|
8363
|
+
if (!space.isConnected())
|
|
8364
|
+
throw new Error(`space ${spaceId} not connected for replay`);
|
|
8365
|
+
space.sendRaw(frame);
|
|
8366
|
+
});
|
|
8367
|
+
}
|
|
7777
8368
|
}
|
|
7778
8369
|
/**
|
|
7779
8370
|
* Create a new space: generate its keypair (public key = id), register
|
|
@@ -7934,7 +8525,7 @@ class SpaceNamespace {
|
|
|
7934
8525
|
cache: this.deps.cache,
|
|
7935
8526
|
historyPolicy,
|
|
7936
8527
|
});
|
|
7937
|
-
|
|
8528
|
+
const space = new Space({
|
|
7938
8529
|
name: spaceId,
|
|
7939
8530
|
wsBaseUrl: this.deps.wsBaseUrl,
|
|
7940
8531
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
@@ -7943,7 +8534,14 @@ class SpaceNamespace {
|
|
|
7943
8534
|
fetchTicket: () => this.fetchTicket(),
|
|
7944
8535
|
keyring,
|
|
7945
8536
|
historyPolicy,
|
|
8537
|
+
offline: this.spaceCache,
|
|
8538
|
+
shardCache: this.deps.offline?.fileCache,
|
|
8539
|
+
deferShardUpload: this.deps.offline
|
|
8540
|
+
? (hash) => this.deps.offline.deferShardUpload(hash)
|
|
8541
|
+
: undefined,
|
|
7946
8542
|
});
|
|
8543
|
+
this.openSpaces.set(spaceId, space);
|
|
8544
|
+
return space;
|
|
7947
8545
|
}
|
|
7948
8546
|
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
7949
8547
|
async ensureIdentity(memberId) {
|
|
@@ -8190,6 +8788,1171 @@ class FunctionsNamespace {
|
|
|
8190
8788
|
}
|
|
8191
8789
|
}
|
|
8192
8790
|
|
|
8791
|
+
/**
|
|
8792
|
+
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
8793
|
+
* lexicographically-sortable string so the whole offline layer (and the
|
|
8794
|
+
* accelerator) can compare causality with one plain string comparison.
|
|
8795
|
+
*
|
|
8796
|
+
* An HLC fuses a physical wall-clock millisecond with a logical `counter`
|
|
8797
|
+
* (breaks ties inside the same millisecond and lets the clock advance past a
|
|
8798
|
+
* remote event without the wall clock moving) and a stable `nodeId` (a final,
|
|
8799
|
+
* deterministic tiebreak so two devices never produce an "equal but different"
|
|
8800
|
+
* stamp). See {@link ./HLC}.
|
|
8801
|
+
*
|
|
8802
|
+
* The packed form is `"{wall}:{counter}:{nodeId}"` with `wall` and `counter`
|
|
8803
|
+
* zero-padded to a FIXED width. Fixed width is what makes lexicographic order
|
|
8804
|
+
* equal to numeric order — `"0000000000123:..."` < `"0000000000124:..."`. The
|
|
8805
|
+
* server only ever compares these strings; it never has to parse them.
|
|
8806
|
+
*/
|
|
8807
|
+
/** Zero-padded width of the wall-clock field. 15 digits holds ms timestamps
|
|
8808
|
+
* past the year 5000, so `Date.now()` (currently 13 digits) never overflows. */
|
|
8809
|
+
const WALL_DIGITS = 15;
|
|
8810
|
+
/** Zero-padded width of the logical counter. 5 digits allows 100k events in a
|
|
8811
|
+
* single wall-clock millisecond before the clock is forced forward — far more
|
|
8812
|
+
* than any real burst. */
|
|
8813
|
+
const COUNTER_DIGITS = 5;
|
|
8814
|
+
/** The smallest possible stamp — used as the "nothing stored yet" sentinel so a
|
|
8815
|
+
* first real write always wins (`compareHlc(real, ZERO_HLC) > 0`). */
|
|
8816
|
+
const ZERO_HLC = pack(0, 0, "");
|
|
8817
|
+
function padNum(n, width) {
|
|
8818
|
+
const s = Math.max(0, Math.floor(n)).toString();
|
|
8819
|
+
return s.length >= width ? s : "0".repeat(width - s.length) + s;
|
|
8820
|
+
}
|
|
8821
|
+
/** Pack the three components into the sortable string form. */
|
|
8822
|
+
function pack(wall, counter, nodeId) {
|
|
8823
|
+
return `${padNum(wall, WALL_DIGITS)}:${padNum(counter, COUNTER_DIGITS)}:${nodeId}`;
|
|
8824
|
+
}
|
|
8825
|
+
/** Parse a packed stamp back into its components. Tolerates the empty/zero
|
|
8826
|
+
* sentinel. Throws on a malformed string so corruption surfaces loudly. */
|
|
8827
|
+
function unpack(stamp) {
|
|
8828
|
+
const idx1 = stamp.indexOf(":");
|
|
8829
|
+
const idx2 = stamp.indexOf(":", idx1 + 1);
|
|
8830
|
+
if (idx1 < 0 || idx2 < 0) {
|
|
8831
|
+
throw new Error(`malformed HLC timestamp: ${JSON.stringify(stamp)}`);
|
|
8832
|
+
}
|
|
8833
|
+
return {
|
|
8834
|
+
wall: Number(stamp.slice(0, idx1)),
|
|
8835
|
+
counter: Number(stamp.slice(idx1 + 1, idx2)),
|
|
8836
|
+
nodeId: stamp.slice(idx2 + 1),
|
|
8837
|
+
};
|
|
8838
|
+
}
|
|
8839
|
+
/**
|
|
8840
|
+
* Total order over packed HLC strings: negative if `a < b`, positive if
|
|
8841
|
+
* `a > b`, `0` only when they are byte-identical. Because the fields are
|
|
8842
|
+
* fixed-width and ordered (wall, counter, nodeId), a single string comparison
|
|
8843
|
+
* already yields causal order — this helper just normalizes the result to the
|
|
8844
|
+
* `-1 | 0 | 1` shape callers expect.
|
|
8845
|
+
*/
|
|
8846
|
+
function compareHlc(a, b) {
|
|
8847
|
+
if (a === b)
|
|
8848
|
+
return 0;
|
|
8849
|
+
return a < b ? -1 : 1;
|
|
8850
|
+
}
|
|
8851
|
+
/** Convenience: is `a` strictly newer than `b`? */
|
|
8852
|
+
function isNewer(a, b) {
|
|
8853
|
+
return compareHlc(a, b) > 0;
|
|
8854
|
+
}
|
|
8855
|
+
|
|
8856
|
+
/**
|
|
8857
|
+
* `HLC` — a Hybrid Logical Clock. Hands out monotonically increasing
|
|
8858
|
+
* {@link HlcTimestamp} stamps that (a) track real time closely enough to be
|
|
8859
|
+
* human-meaningful, yet (b) never go backwards, even across a reload or when a
|
|
8860
|
+
* peer's clock is ahead of ours.
|
|
8861
|
+
*
|
|
8862
|
+
* Two operations, both the textbook HLC algorithm (Kulkarni et al.):
|
|
8863
|
+
* - `now()` — stamp a local event.
|
|
8864
|
+
* - `update(remote)` — observe a remote stamp (e.g. arriving on the realtime
|
|
8865
|
+
* change feed) and advance our clock past it, so any event we generate
|
|
8866
|
+
* afterwards is causally `> remote`.
|
|
8867
|
+
*
|
|
8868
|
+
* State is just `{ wall, counter }`; `nodeId` is fixed for the install. The
|
|
8869
|
+
* owner ({@link ../OfflineManager}) seeds the clock from persisted state on
|
|
8870
|
+
* boot and persists `getState()` after each tick so monotonicity survives page
|
|
8871
|
+
* reloads — without that, a reload could mint a stamp that loses to one written
|
|
8872
|
+
* seconds earlier.
|
|
8873
|
+
*/
|
|
8874
|
+
class HLC {
|
|
8875
|
+
wall;
|
|
8876
|
+
counter;
|
|
8877
|
+
nodeId;
|
|
8878
|
+
physical;
|
|
8879
|
+
constructor(nodeId, opts = {}) {
|
|
8880
|
+
this.nodeId = nodeId;
|
|
8881
|
+
this.physical = opts.physical ?? (() => Date.now());
|
|
8882
|
+
this.wall = opts.state?.wall ?? 0;
|
|
8883
|
+
this.counter = opts.state?.counter ?? 0;
|
|
8884
|
+
}
|
|
8885
|
+
/** Stamp a locally-generated event. Advances the clock. */
|
|
8886
|
+
now() {
|
|
8887
|
+
const pt = this.physical();
|
|
8888
|
+
const prevWall = this.wall;
|
|
8889
|
+
this.wall = Math.max(prevWall, pt);
|
|
8890
|
+
this.counter = this.wall === prevWall ? this.counter + 1 : 0;
|
|
8891
|
+
return this.current();
|
|
8892
|
+
}
|
|
8893
|
+
/**
|
|
8894
|
+
* Merge in an observed remote stamp and advance our clock strictly past it.
|
|
8895
|
+
* Call this for every inbound HLC (change-feed frames, history rows) so the
|
|
8896
|
+
* next `now()` is causally after anything we've seen.
|
|
8897
|
+
*/
|
|
8898
|
+
update(remote) {
|
|
8899
|
+
const pt = this.physical();
|
|
8900
|
+
const prevWall = this.wall;
|
|
8901
|
+
const newWall = Math.max(prevWall, remote.wall, pt);
|
|
8902
|
+
if (newWall === prevWall && newWall === remote.wall) {
|
|
8903
|
+
this.counter = Math.max(this.counter, remote.counter) + 1;
|
|
8904
|
+
}
|
|
8905
|
+
else if (newWall === prevWall) {
|
|
8906
|
+
this.counter = this.counter + 1;
|
|
8907
|
+
}
|
|
8908
|
+
else if (newWall === remote.wall) {
|
|
8909
|
+
this.counter = remote.counter + 1;
|
|
8910
|
+
}
|
|
8911
|
+
else {
|
|
8912
|
+
this.counter = 0;
|
|
8913
|
+
}
|
|
8914
|
+
this.wall = newWall;
|
|
8915
|
+
return this.current();
|
|
8916
|
+
}
|
|
8917
|
+
/** The current stamp without advancing the clock. */
|
|
8918
|
+
current() {
|
|
8919
|
+
return pack(this.wall, this.counter, this.nodeId);
|
|
8920
|
+
}
|
|
8921
|
+
/** Serializable `{ wall, counter }` for persistence. */
|
|
8922
|
+
getState() {
|
|
8923
|
+
return { wall: this.wall, counter: this.counter };
|
|
8924
|
+
}
|
|
8925
|
+
}
|
|
8926
|
+
|
|
8927
|
+
/**
|
|
8928
|
+
* `ConnectivityManager` — the single source of truth for "are we online", which
|
|
8929
|
+
* the offline layer needs to decide between a network call and a cache read, and
|
|
8930
|
+
* to know when to flush the durable outbound queue.
|
|
8931
|
+
*
|
|
8932
|
+
* `navigator.onLine` alone lies often (captive portals, VPN flaps, a laptop
|
|
8933
|
+
* that's "connected" to a network with no route), so we fuse three signals:
|
|
8934
|
+
* 1. the browser `online`/`offline` events (coarse but free),
|
|
8935
|
+
* 2. real fetch outcomes reported by {@link ../core/HttpClient} and the shard
|
|
8936
|
+
* client — a thrown `TypeError`/abort is the most reliable "offline" tell,
|
|
8937
|
+
* and a 2xx is the most reliable "online" tell,
|
|
8938
|
+
* 3. the sync engine, which flips us to `syncing` while it drains the queue.
|
|
8939
|
+
*
|
|
8940
|
+
* The offline→online edge fires `onReconnect` (wired to the sync engine) so
|
|
8941
|
+
* queued writes replay automatically. Transitions are debounced to avoid
|
|
8942
|
+
* flapping the UI on a brief blip. State changes also emit on {@link EventCore}
|
|
8943
|
+
* (`ONLINE`/`OFFLINE`/`SYNCING`) so apps can render a status indicator.
|
|
8944
|
+
*/
|
|
8945
|
+
class ConnectivityManager {
|
|
8946
|
+
state;
|
|
8947
|
+
listeners = new Set();
|
|
8948
|
+
onReconnect;
|
|
8949
|
+
debounceMs;
|
|
8950
|
+
offlineTimer = null;
|
|
8951
|
+
started = false;
|
|
8952
|
+
handleOnline = () => this.markOnline();
|
|
8953
|
+
handleOffline = () => this.scheduleOffline();
|
|
8954
|
+
constructor(opts = {}) {
|
|
8955
|
+
this.onReconnect = opts.onReconnect;
|
|
8956
|
+
this.debounceMs = opts.debounceMs ?? 1500;
|
|
8957
|
+
this.state =
|
|
8958
|
+
typeof navigator !== "undefined" && navigator.onLine === false ? "offline" : "online";
|
|
8959
|
+
}
|
|
8960
|
+
/** Attach to the browser online/offline events. Safe to call in Node (no-op). */
|
|
8961
|
+
start() {
|
|
8962
|
+
if (this.started || typeof window === "undefined")
|
|
8963
|
+
return;
|
|
8964
|
+
window.addEventListener("online", this.handleOnline);
|
|
8965
|
+
window.addEventListener("offline", this.handleOffline);
|
|
8966
|
+
this.started = true;
|
|
8967
|
+
}
|
|
8968
|
+
stop() {
|
|
8969
|
+
if (!this.started || typeof window === "undefined")
|
|
8970
|
+
return;
|
|
8971
|
+
window.removeEventListener("online", this.handleOnline);
|
|
8972
|
+
window.removeEventListener("offline", this.handleOffline);
|
|
8973
|
+
if (this.offlineTimer)
|
|
8974
|
+
clearTimeout(this.offlineTimer);
|
|
8975
|
+
this.started = false;
|
|
8976
|
+
}
|
|
8977
|
+
get current() {
|
|
8978
|
+
return this.state;
|
|
8979
|
+
}
|
|
8980
|
+
get isOnline() {
|
|
8981
|
+
return this.state !== "offline";
|
|
8982
|
+
}
|
|
8983
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
8984
|
+
onChange(cb) {
|
|
8985
|
+
this.listeners.add(cb);
|
|
8986
|
+
return () => this.listeners.delete(cb);
|
|
8987
|
+
}
|
|
8988
|
+
/** Report a failed network attempt (thrown fetch / abort). */
|
|
8989
|
+
reportFetchFailure() {
|
|
8990
|
+
this.scheduleOffline();
|
|
8991
|
+
}
|
|
8992
|
+
/** Report a successful network round-trip — strongest "we're online" signal. */
|
|
8993
|
+
reportFetchSuccess() {
|
|
8994
|
+
this.markOnline();
|
|
8995
|
+
}
|
|
8996
|
+
/** The sync engine entering its drain phase. */
|
|
8997
|
+
markSyncing() {
|
|
8998
|
+
if (this.offlineTimer) {
|
|
8999
|
+
clearTimeout(this.offlineTimer);
|
|
9000
|
+
this.offlineTimer = null;
|
|
9001
|
+
}
|
|
9002
|
+
this.set("syncing");
|
|
9003
|
+
}
|
|
9004
|
+
/** The sync engine finished — settle back to online. */
|
|
9005
|
+
markSynced() {
|
|
9006
|
+
this.set("online");
|
|
9007
|
+
}
|
|
9008
|
+
markOnline() {
|
|
9009
|
+
if (this.offlineTimer) {
|
|
9010
|
+
clearTimeout(this.offlineTimer);
|
|
9011
|
+
this.offlineTimer = null;
|
|
9012
|
+
}
|
|
9013
|
+
// The sync engine owns the syncing→online transition (via markSynced).
|
|
9014
|
+
// A request succeeding mid-drain must not cut the syncing state short.
|
|
9015
|
+
if (this.state === "syncing")
|
|
9016
|
+
return;
|
|
9017
|
+
const wasOffline = this.state === "offline";
|
|
9018
|
+
this.set("online");
|
|
9019
|
+
if (wasOffline) {
|
|
9020
|
+
try {
|
|
9021
|
+
this.onReconnect?.();
|
|
9022
|
+
}
|
|
9023
|
+
catch {
|
|
9024
|
+
/* a reconnect handler must not break connectivity tracking */
|
|
9025
|
+
}
|
|
9026
|
+
}
|
|
9027
|
+
}
|
|
9028
|
+
scheduleOffline() {
|
|
9029
|
+
if (this.state === "offline" || this.offlineTimer)
|
|
9030
|
+
return;
|
|
9031
|
+
this.offlineTimer = setTimeout(() => {
|
|
9032
|
+
this.offlineTimer = null;
|
|
9033
|
+
this.set("offline");
|
|
9034
|
+
}, this.debounceMs);
|
|
9035
|
+
}
|
|
9036
|
+
set(state) {
|
|
9037
|
+
if (state === this.state)
|
|
9038
|
+
return;
|
|
9039
|
+
this.state = state;
|
|
9040
|
+
for (const cb of this.listeners) {
|
|
9041
|
+
try {
|
|
9042
|
+
cb(state);
|
|
9043
|
+
}
|
|
9044
|
+
catch {
|
|
9045
|
+
/* a bad listener mustn't break the others */
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
9048
|
+
const event = state === "online"
|
|
9049
|
+
? EventCoreEvents.ONLINE
|
|
9050
|
+
: state === "offline"
|
|
9051
|
+
? EventCoreEvents.OFFLINE
|
|
9052
|
+
: EventCoreEvents.SYNCING;
|
|
9053
|
+
EventCore.emit(event, { state });
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
9056
|
+
|
|
9057
|
+
/**
|
|
9058
|
+
* `OutboundQueue` — the durable record of writes made while offline (or that
|
|
9059
|
+
* failed mid-flight) plus the machinery to replay them on reconnect. This is
|
|
9060
|
+
* what makes offline writes *survive a reload*: unlike the in-memory buffer in
|
|
9061
|
+
* {@link ../transport/WSTransport}, every entry lives in IndexedDB until it has
|
|
9062
|
+
* been acknowledged by the server.
|
|
9063
|
+
*
|
|
9064
|
+
* Each domain registers a **replayer** — a function that re-applies one queued
|
|
9065
|
+
* mutation by re-invoking the real namespace method (with an `isReplay` flag so
|
|
9066
|
+
* it doesn't re-enqueue). `drain()` replays entries in HLC order:
|
|
9067
|
+
* - success → remove the entry;
|
|
9068
|
+
* - permanent failure (4xx) → drop it and surface a `SYNC_ERROR` (a write the
|
|
9069
|
+
* server will never accept must not wedge the queue forever);
|
|
9070
|
+
* - transient/network failure → stop draining and leave the rest for the next
|
|
9071
|
+
* reconnect.
|
|
9072
|
+
*
|
|
9073
|
+
* Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
|
|
9074
|
+
* by `clientId`, file PUTs are content-addressed), so re-running a partially
|
|
9075
|
+
* drained queue is safe.
|
|
9076
|
+
*/
|
|
9077
|
+
class OutboundQueue {
|
|
9078
|
+
store;
|
|
9079
|
+
replayers = new Map();
|
|
9080
|
+
draining = null;
|
|
9081
|
+
constructor(store) {
|
|
9082
|
+
this.store = store;
|
|
9083
|
+
}
|
|
9084
|
+
/** A domain registers how to replay its mutations. */
|
|
9085
|
+
register(domain, replayer) {
|
|
9086
|
+
this.replayers.set(domain, replayer);
|
|
9087
|
+
}
|
|
9088
|
+
/** Record a mutation for later replay; resolves with its assigned seq. */
|
|
9089
|
+
enqueue(entry) {
|
|
9090
|
+
return this.store.enqueue(entry);
|
|
9091
|
+
}
|
|
9092
|
+
/** Number of writes still awaiting sync. */
|
|
9093
|
+
async pending() {
|
|
9094
|
+
return (await this.store.queued()).length;
|
|
9095
|
+
}
|
|
9096
|
+
/**
|
|
9097
|
+
* Replay queued mutations in HLC order. Coalesces concurrent calls into a
|
|
9098
|
+
* single in-flight drain. Stops on the first transient failure, leaving the
|
|
9099
|
+
* remaining entries for the next attempt.
|
|
9100
|
+
*/
|
|
9101
|
+
drain() {
|
|
9102
|
+
if (this.draining)
|
|
9103
|
+
return this.draining;
|
|
9104
|
+
this.draining = this.runDrain().finally(() => {
|
|
9105
|
+
this.draining = null;
|
|
9106
|
+
});
|
|
9107
|
+
return this.draining;
|
|
9108
|
+
}
|
|
9109
|
+
async runDrain() {
|
|
9110
|
+
const entries = await this.store.queued();
|
|
9111
|
+
for (const entry of entries) {
|
|
9112
|
+
const replayer = this.replayers.get(entry.domain);
|
|
9113
|
+
if (!replayer) {
|
|
9114
|
+
// No handler registered (namespace not wired) — drop it rather
|
|
9115
|
+
// than block the queue; nothing can ever replay it.
|
|
9116
|
+
await this.store.dequeue(entry.seq);
|
|
9117
|
+
continue;
|
|
9118
|
+
}
|
|
9119
|
+
try {
|
|
9120
|
+
await replayer(entry);
|
|
9121
|
+
await this.store.dequeue(entry.seq);
|
|
9122
|
+
EventCore.emit(EventCoreEvents.SYNC_PROGRESS, {
|
|
9123
|
+
domain: entry.domain,
|
|
9124
|
+
method: entry.method,
|
|
9125
|
+
});
|
|
9126
|
+
}
|
|
9127
|
+
catch (err) {
|
|
9128
|
+
if (isPermanent(err)) {
|
|
9129
|
+
await this.store.dequeue(entry.seq);
|
|
9130
|
+
EventCore.emit(EventCoreEvents.SYNC_ERROR, {
|
|
9131
|
+
domain: entry.domain,
|
|
9132
|
+
method: entry.method,
|
|
9133
|
+
error: String(err?.message ?? err),
|
|
9134
|
+
dropped: true,
|
|
9135
|
+
});
|
|
9136
|
+
continue;
|
|
9137
|
+
}
|
|
9138
|
+
// Transient (offline again, 5xx) — keep the entry, stop here.
|
|
9139
|
+
throw err;
|
|
9140
|
+
}
|
|
9141
|
+
}
|
|
9142
|
+
}
|
|
9143
|
+
}
|
|
9144
|
+
/** A 4xx is the server permanently refusing the write; anything else is worth
|
|
9145
|
+
* retrying on the next reconnect. */
|
|
9146
|
+
function isPermanent(err) {
|
|
9147
|
+
return err instanceof HttpError && err.status >= 400 && err.status < 500;
|
|
9148
|
+
}
|
|
9149
|
+
|
|
9150
|
+
/**
|
|
9151
|
+
* `SyncEngine` — orchestrates catch-up when the client comes back online. It's
|
|
9152
|
+
* the thing wired to the {@link ConnectivityManager}'s offline→online edge.
|
|
9153
|
+
*
|
|
9154
|
+
* One `run()` does, in order:
|
|
9155
|
+
* 1. **Guard** — only proceed when the session is authenticated and unlocked
|
|
9156
|
+
* (replays may need to re-seal/re-encrypt, and there's no point pushing
|
|
9157
|
+
* writes for a logged-out user).
|
|
9158
|
+
* 2. **Drain** the durable {@link OutboundQueue} — push every offline write.
|
|
9159
|
+
* 3. **Catch-up tasks** — domain-registered pulls that reconcile *inbound*
|
|
9160
|
+
* state we missed while offline (e.g. a Space paging history forward to its
|
|
9161
|
+
* last-seen handle). Registered by the namespaces.
|
|
9162
|
+
*
|
|
9163
|
+
* Concurrent triggers coalesce into a single in-flight run (a reconnect often
|
|
9164
|
+
* fires several signals at once), mirroring `Client.recoverInFlight`.
|
|
9165
|
+
*/
|
|
9166
|
+
class SyncEngine {
|
|
9167
|
+
deps;
|
|
9168
|
+
catchUpTasks = new Set();
|
|
9169
|
+
running = null;
|
|
9170
|
+
constructor(deps) {
|
|
9171
|
+
this.deps = deps;
|
|
9172
|
+
}
|
|
9173
|
+
/** Register an inbound reconciliation task run after the queue drains. */
|
|
9174
|
+
registerCatchUp(task) {
|
|
9175
|
+
this.catchUpTasks.add(task);
|
|
9176
|
+
return () => this.catchUpTasks.delete(task);
|
|
9177
|
+
}
|
|
9178
|
+
/** Drain + catch up. Concurrent calls share one in-flight run. */
|
|
9179
|
+
run() {
|
|
9180
|
+
if (this.running)
|
|
9181
|
+
return this.running;
|
|
9182
|
+
this.running = this.execute().finally(() => {
|
|
9183
|
+
this.running = null;
|
|
9184
|
+
});
|
|
9185
|
+
return this.running;
|
|
9186
|
+
}
|
|
9187
|
+
async execute() {
|
|
9188
|
+
if (!this.deps.canSync())
|
|
9189
|
+
return;
|
|
9190
|
+
this.deps.connectivity.markSyncing();
|
|
9191
|
+
try {
|
|
9192
|
+
await this.deps.queue.drain();
|
|
9193
|
+
for (const task of this.catchUpTasks) {
|
|
9194
|
+
try {
|
|
9195
|
+
await task();
|
|
9196
|
+
}
|
|
9197
|
+
catch {
|
|
9198
|
+
// One domain's catch-up failing shouldn't abort the others
|
|
9199
|
+
// or wedge the sync; it'll retry on the next reconnect.
|
|
9200
|
+
}
|
|
9201
|
+
}
|
|
9202
|
+
EventCore.emit(EventCoreEvents.SYNC_COMPLETE, {});
|
|
9203
|
+
}
|
|
9204
|
+
finally {
|
|
9205
|
+
this.deps.connectivity.markSynced();
|
|
9206
|
+
}
|
|
9207
|
+
}
|
|
9208
|
+
}
|
|
9209
|
+
|
|
9210
|
+
/**
|
|
9211
|
+
* Errors specific to the offline layer.
|
|
9212
|
+
*/
|
|
9213
|
+
/**
|
|
9214
|
+
* Thrown when an offline read/write needs the user's identity (to decrypt or
|
|
9215
|
+
* re-encrypt cached data) but the client is locked — typically right after a
|
|
9216
|
+
* reload, where only the session token was restored and `unlock()` hasn't run
|
|
9217
|
+
* yet. Distinct from the generic "not signed in" identity error so apps can
|
|
9218
|
+
* specifically prompt the user to unlock to see their offline data.
|
|
9219
|
+
*/
|
|
9220
|
+
class OfflineLockedError extends Error {
|
|
9221
|
+
constructor(message = "Unlock required to access encrypted offline data. Call client.auth.zk.unlock(password).") {
|
|
9222
|
+
super(message);
|
|
9223
|
+
this.name = "OfflineLockedError";
|
|
9224
|
+
}
|
|
9225
|
+
}
|
|
9226
|
+
|
|
9227
|
+
/**
|
|
9228
|
+
* `client.offline.snapshot` — a small, encrypted, app-state cache. Apps stash
|
|
9229
|
+
* whatever they need to repaint instantly on a cold offline boot: the last open
|
|
9230
|
+
* Space, a draft message, scroll position, the current route. It's deliberately
|
|
9231
|
+
* separate from `client.kv`: snapshots are **local-only by default** (instant,
|
|
9232
|
+
* never metered, never leave the device) and hold view state rather than domain
|
|
9233
|
+
* data.
|
|
9234
|
+
*
|
|
9235
|
+
* await client.offline.snapshot.save('ui', { route, openSpaceId, draft })
|
|
9236
|
+
* const ui = await client.offline.snapshot.load<UiState>('ui')
|
|
9237
|
+
*
|
|
9238
|
+
* Values are encrypted at rest with the same identity-derived {@link StorageCipher}
|
|
9239
|
+
* as `client.kv`, so a snapshot is unreadable until the user unlocks — `load`
|
|
9240
|
+
* returns `null` when locked or absent, and `save` throws {@link OfflineLockedError}.
|
|
9241
|
+
* Conflict model is a single-writer LWW register (the latest save wins).
|
|
9242
|
+
*/
|
|
9243
|
+
class SnapshotNamespace {
|
|
9244
|
+
deps;
|
|
9245
|
+
cipher = null;
|
|
9246
|
+
cipherIdentity = null;
|
|
9247
|
+
constructor(deps) {
|
|
9248
|
+
this.deps = deps;
|
|
9249
|
+
}
|
|
9250
|
+
/** Persist `state` under `name` (encrypted). No-op when offline support is off. */
|
|
9251
|
+
async save(name, state) {
|
|
9252
|
+
if (!this.deps.store.enabled)
|
|
9253
|
+
return;
|
|
9254
|
+
const envelope = await this.requireCipher().encrypt(state);
|
|
9255
|
+
const record = {
|
|
9256
|
+
envelope,
|
|
9257
|
+
hlc: await this.deps.nextHlc(),
|
|
9258
|
+
updatedAt: Date.now(),
|
|
9259
|
+
};
|
|
9260
|
+
await this.deps.store.put("snapshots", this.key(name), record);
|
|
9261
|
+
}
|
|
9262
|
+
/** Load and decrypt the snapshot `name`, or `null` if absent/locked. */
|
|
9263
|
+
async load(name) {
|
|
9264
|
+
if (!this.deps.store.enabled)
|
|
9265
|
+
return null;
|
|
9266
|
+
const record = await this.deps.store.get("snapshots", this.key(name));
|
|
9267
|
+
if (!record)
|
|
9268
|
+
return null;
|
|
9269
|
+
if (!this.deps.session.isUnlocked)
|
|
9270
|
+
return null;
|
|
9271
|
+
return this.requireCipher().decrypt(record.envelope);
|
|
9272
|
+
}
|
|
9273
|
+
/** Remove the snapshot `name`. */
|
|
9274
|
+
async delete(name) {
|
|
9275
|
+
if (!this.deps.store.enabled)
|
|
9276
|
+
return;
|
|
9277
|
+
await this.deps.store.delete("snapshots", this.key(name));
|
|
9278
|
+
}
|
|
9279
|
+
/** Names of all snapshots for the current user. */
|
|
9280
|
+
async list() {
|
|
9281
|
+
if (!this.deps.store.enabled)
|
|
9282
|
+
return [];
|
|
9283
|
+
const prefix = `${this.commitment()}|`;
|
|
9284
|
+
const rows = await this.deps.store.prefix("snapshots", prefix);
|
|
9285
|
+
return rows.map((r) => r.key.slice(prefix.length));
|
|
9286
|
+
}
|
|
9287
|
+
key(name) {
|
|
9288
|
+
return `${this.commitment()}|${name}`;
|
|
9289
|
+
}
|
|
9290
|
+
commitment() {
|
|
9291
|
+
// Snapshots are scoped per user; before sign-in they share an "anon"
|
|
9292
|
+
// bucket so pre-auth UI state (e.g. a chosen theme) still persists.
|
|
9293
|
+
return this.deps.session.commitment ?? "anon";
|
|
9294
|
+
}
|
|
9295
|
+
requireCipher() {
|
|
9296
|
+
if (!this.deps.session.isUnlocked)
|
|
9297
|
+
throw new OfflineLockedError();
|
|
9298
|
+
const identity = this.deps.session.requireIdentity();
|
|
9299
|
+
if (this.cipher && this.cipherIdentity === identity)
|
|
9300
|
+
return this.cipher;
|
|
9301
|
+
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
9302
|
+
this.cipherIdentity = identity;
|
|
9303
|
+
return this.cipher;
|
|
9304
|
+
}
|
|
9305
|
+
}
|
|
9306
|
+
|
|
9307
|
+
/**
|
|
9308
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
9309
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
9310
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
9311
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
9312
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
9313
|
+
* it has its own eviction.
|
|
9314
|
+
*
|
|
9315
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
9316
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
9317
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
9318
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
9319
|
+
* exist only as cache keys.
|
|
9320
|
+
*
|
|
9321
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
9322
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
9323
|
+
*/
|
|
9324
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
9325
|
+
class ShardCache {
|
|
9326
|
+
static available() {
|
|
9327
|
+
try {
|
|
9328
|
+
return typeof caches !== "undefined";
|
|
9329
|
+
}
|
|
9330
|
+
catch {
|
|
9331
|
+
return false;
|
|
9332
|
+
}
|
|
9333
|
+
}
|
|
9334
|
+
key(hash) {
|
|
9335
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
9336
|
+
}
|
|
9337
|
+
open() {
|
|
9338
|
+
return caches.open(CACHE_NAME);
|
|
9339
|
+
}
|
|
9340
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
9341
|
+
async get(hash) {
|
|
9342
|
+
if (!ShardCache.available())
|
|
9343
|
+
return null;
|
|
9344
|
+
try {
|
|
9345
|
+
const cache = await this.open();
|
|
9346
|
+
const res = await cache.match(this.key(hash));
|
|
9347
|
+
if (!res)
|
|
9348
|
+
return null;
|
|
9349
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
9350
|
+
}
|
|
9351
|
+
catch {
|
|
9352
|
+
return null;
|
|
9353
|
+
}
|
|
9354
|
+
}
|
|
9355
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
9356
|
+
async put(hash, bytes) {
|
|
9357
|
+
if (!ShardCache.available())
|
|
9358
|
+
return;
|
|
9359
|
+
try {
|
|
9360
|
+
const cache = await this.open();
|
|
9361
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
9362
|
+
headers: { "content-type": "application/octet-stream" },
|
|
9363
|
+
}));
|
|
9364
|
+
}
|
|
9365
|
+
catch {
|
|
9366
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
9367
|
+
}
|
|
9368
|
+
}
|
|
9369
|
+
/** Whether a shard is present in the cache. */
|
|
9370
|
+
async has(hash) {
|
|
9371
|
+
if (!ShardCache.available())
|
|
9372
|
+
return false;
|
|
9373
|
+
try {
|
|
9374
|
+
const cache = await this.open();
|
|
9375
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
9376
|
+
}
|
|
9377
|
+
catch {
|
|
9378
|
+
return false;
|
|
9379
|
+
}
|
|
9380
|
+
}
|
|
9381
|
+
}
|
|
9382
|
+
|
|
9383
|
+
/**
|
|
9384
|
+
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9385
|
+
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
9386
|
+
* the store set changes; {@link ./idb}'s `onupgradeneeded` creates any store
|
|
9387
|
+
* that doesn't exist yet (additive migrations only — never drop
|
|
9388
|
+
* `outbound-queue`, it holds un-synced writes).
|
|
9389
|
+
*
|
|
9390
|
+
* One database per origin. Records are partitioned *by key* (most keys are
|
|
9391
|
+
* prefixed with the user's `commitment`) rather than by separate databases, so
|
|
9392
|
+
* a single connection serves every signed-in user on the origin and prefix
|
|
9393
|
+
* scans isolate a user's data.
|
|
9394
|
+
*/
|
|
9395
|
+
const DB_NAME = "muhkoo.offline";
|
|
9396
|
+
const DB_VERSION = 1;
|
|
9397
|
+
const STORES = [
|
|
9398
|
+
{ name: "kv-cache" },
|
|
9399
|
+
{ name: "db-row-cache" },
|
|
9400
|
+
{ name: "db-query-cache" },
|
|
9401
|
+
{ name: "space-messages" },
|
|
9402
|
+
{ name: "space-cursors" },
|
|
9403
|
+
{ name: "outbound-queue", autoIncrement: true, keyPath: "seq" },
|
|
9404
|
+
{ name: "snapshots" },
|
|
9405
|
+
{ name: "hlc-state" },
|
|
9406
|
+
{ name: "meta" },
|
|
9407
|
+
];
|
|
9408
|
+
/** Fixed keys in the `meta` store. */
|
|
9409
|
+
const META_NODE_ID = "nodeId";
|
|
9410
|
+
|
|
9411
|
+
/**
|
|
9412
|
+
* `OfflineManager` — the object `client.offline` points at, and the single
|
|
9413
|
+
* owner of everything the offline layer needs at runtime: the {@link
|
|
9414
|
+
* OfflineStore}, the {@link HLC} (Hybrid Logical Clock), the {@link
|
|
9415
|
+
* ConnectivityManager}, the durable {@link OutboundQueue}, the {@link
|
|
9416
|
+
* SyncEngine}, and the {@link SnapshotNamespace}.
|
|
9417
|
+
*
|
|
9418
|
+
* The {@link ../core/Client} constructs one of these (always — it's a cheap
|
|
9419
|
+
* no-op when offline support is off) and hands the namespaces the small set of
|
|
9420
|
+
* primitives they need to participate: `nextHlc()` to stamp a write,
|
|
9421
|
+
* `observeHlc()` to fold in a remote stamp, `enqueue()` to record an offline
|
|
9422
|
+
* mutation, and `registerReplayer()`/`registerCatchUp()` to define how their
|
|
9423
|
+
* domain re-syncs on reconnect. Keeping that wiring here means the namespaces
|
|
9424
|
+
* stay decoupled from IndexedDB and the clock.
|
|
9425
|
+
*/
|
|
9426
|
+
const HLC_STATE_KEY = "global";
|
|
9427
|
+
class OfflineManager {
|
|
9428
|
+
/** True in browsers with IndexedDB; false when running over a NoopStore. */
|
|
9429
|
+
enabled;
|
|
9430
|
+
store;
|
|
9431
|
+
connectivity;
|
|
9432
|
+
queue;
|
|
9433
|
+
sync;
|
|
9434
|
+
/** `client.offline.snapshot` — encrypted app-state cache. */
|
|
9435
|
+
snapshot;
|
|
9436
|
+
/** Cache-API store for file-shard bytes (browser only). */
|
|
9437
|
+
fileCache;
|
|
9438
|
+
hlc = null;
|
|
9439
|
+
nodeId = "";
|
|
9440
|
+
bootPromise;
|
|
9441
|
+
persistTimer = null;
|
|
9442
|
+
constructor(deps) {
|
|
9443
|
+
this.enabled = deps.enabled;
|
|
9444
|
+
this.store = deps.store;
|
|
9445
|
+
this.queue = new OutboundQueue(deps.store);
|
|
9446
|
+
this.connectivity = new ConnectivityManager({
|
|
9447
|
+
onReconnect: () => void this.sync.run(),
|
|
9448
|
+
});
|
|
9449
|
+
this.sync = new SyncEngine({
|
|
9450
|
+
queue: this.queue,
|
|
9451
|
+
connectivity: this.connectivity,
|
|
9452
|
+
// Replays may need to re-seal/re-encrypt, and there's no point
|
|
9453
|
+
// pushing a logged-out user's writes — gate on a usable session.
|
|
9454
|
+
canSync: () => deps.session.isAuthenticated && deps.session.isUnlocked,
|
|
9455
|
+
});
|
|
9456
|
+
this.snapshot = new SnapshotNamespace({
|
|
9457
|
+
store: deps.store,
|
|
9458
|
+
session: deps.session,
|
|
9459
|
+
nextHlc: () => this.nextHlc(),
|
|
9460
|
+
});
|
|
9461
|
+
if (this.enabled && ShardCache.available())
|
|
9462
|
+
this.fileCache = new ShardCache();
|
|
9463
|
+
this.bootPromise = this.boot();
|
|
9464
|
+
if (this.enabled)
|
|
9465
|
+
this.connectivity.start();
|
|
9466
|
+
}
|
|
9467
|
+
/**
|
|
9468
|
+
* Queue a shard upload that couldn't reach the network. The bytes are
|
|
9469
|
+
* already cached (keyed by `hash`), so only the hash is recorded; the file
|
|
9470
|
+
* replayer re-PUTs from the cache on reconnect.
|
|
9471
|
+
*/
|
|
9472
|
+
async deferShardUpload(hash) {
|
|
9473
|
+
await this.enqueue({
|
|
9474
|
+
hlc: await this.nextHlc(),
|
|
9475
|
+
clientId: this.newClientId(),
|
|
9476
|
+
domain: "file",
|
|
9477
|
+
method: "putShard",
|
|
9478
|
+
args: { hash },
|
|
9479
|
+
});
|
|
9480
|
+
}
|
|
9481
|
+
/** Resolve once the clock + node id are loaded. */
|
|
9482
|
+
ready() {
|
|
9483
|
+
return this.bootPromise;
|
|
9484
|
+
}
|
|
9485
|
+
/** Current connectivity state (`online` | `offline` | `syncing`). */
|
|
9486
|
+
get status() {
|
|
9487
|
+
return this.connectivity.current;
|
|
9488
|
+
}
|
|
9489
|
+
/** Subscribe to connectivity changes. Returns an unsubscribe function. */
|
|
9490
|
+
onStatusChange(cb) {
|
|
9491
|
+
return this.connectivity.onChange(cb);
|
|
9492
|
+
}
|
|
9493
|
+
/** Stamp a locally-generated write with a fresh, monotonic HLC. */
|
|
9494
|
+
async nextHlc() {
|
|
9495
|
+
await this.bootPromise;
|
|
9496
|
+
const stamp = this.hlc.now();
|
|
9497
|
+
this.schedulePersist();
|
|
9498
|
+
return stamp;
|
|
9499
|
+
}
|
|
9500
|
+
/** Fold an observed remote HLC into the clock (call for inbound stamps). */
|
|
9501
|
+
async observeHlc(remote) {
|
|
9502
|
+
await this.bootPromise;
|
|
9503
|
+
this.hlc.update(unpack(remote));
|
|
9504
|
+
this.schedulePersist();
|
|
9505
|
+
}
|
|
9506
|
+
/** A fresh client-generated id (idempotent replay / message dedupe). */
|
|
9507
|
+
newClientId() {
|
|
9508
|
+
return generateId();
|
|
9509
|
+
}
|
|
9510
|
+
/** Record an offline mutation for replay on reconnect. */
|
|
9511
|
+
enqueue(entry) {
|
|
9512
|
+
return this.queue.enqueue(entry);
|
|
9513
|
+
}
|
|
9514
|
+
/** A domain registers how to replay its queued mutations. */
|
|
9515
|
+
registerReplayer(domain, replayer) {
|
|
9516
|
+
this.queue.register(domain, replayer);
|
|
9517
|
+
}
|
|
9518
|
+
/** A domain registers an inbound catch-up task (run after the queue drains). */
|
|
9519
|
+
registerCatchUp(task) {
|
|
9520
|
+
return this.sync.registerCatchUp(task);
|
|
9521
|
+
}
|
|
9522
|
+
/** Surface a network failure to connectivity tracking. */
|
|
9523
|
+
reportFetchFailure() {
|
|
9524
|
+
this.connectivity.reportFetchFailure();
|
|
9525
|
+
}
|
|
9526
|
+
/** Surface a successful round-trip to connectivity tracking. */
|
|
9527
|
+
reportFetchSuccess() {
|
|
9528
|
+
this.connectivity.reportFetchSuccess();
|
|
9529
|
+
}
|
|
9530
|
+
/** Tear down listeners (tests / hot-reload). */
|
|
9531
|
+
dispose() {
|
|
9532
|
+
this.connectivity.stop();
|
|
9533
|
+
if (this.persistTimer)
|
|
9534
|
+
clearTimeout(this.persistTimer);
|
|
9535
|
+
}
|
|
9536
|
+
async boot() {
|
|
9537
|
+
if (!this.enabled) {
|
|
9538
|
+
// In-memory clock only; nothing is persisted over a NoopStore.
|
|
9539
|
+
this.nodeId = generateId();
|
|
9540
|
+
this.hlc = new HLC(this.nodeId);
|
|
9541
|
+
return;
|
|
9542
|
+
}
|
|
9543
|
+
let nodeId = await this.store.get("meta", META_NODE_ID);
|
|
9544
|
+
if (!nodeId) {
|
|
9545
|
+
nodeId = generateId();
|
|
9546
|
+
await this.store.put("meta", META_NODE_ID, nodeId);
|
|
9547
|
+
}
|
|
9548
|
+
this.nodeId = nodeId;
|
|
9549
|
+
const state = await this.store.get("hlc-state", HLC_STATE_KEY);
|
|
9550
|
+
this.hlc = new HLC(nodeId, { state });
|
|
9551
|
+
}
|
|
9552
|
+
/** Coalesce HLC-state writes — a burst of stamps persists once. */
|
|
9553
|
+
schedulePersist() {
|
|
9554
|
+
if (!this.enabled || this.persistTimer)
|
|
9555
|
+
return;
|
|
9556
|
+
this.persistTimer = setTimeout(() => {
|
|
9557
|
+
this.persistTimer = null;
|
|
9558
|
+
if (this.hlc)
|
|
9559
|
+
void this.store.put("hlc-state", HLC_STATE_KEY, this.hlc.getState());
|
|
9560
|
+
}, 200);
|
|
9561
|
+
}
|
|
9562
|
+
}
|
|
9563
|
+
|
|
9564
|
+
/**
|
|
9565
|
+
* Environment probe deciding whether the offline layer turns itself on. Offline
|
|
9566
|
+
* support is **on by default in browsers** and a no-op everywhere else, so this
|
|
9567
|
+
* is the gate the {@link ../core/Client} consults to pick {@link
|
|
9568
|
+
* ./store/IndexedDbStore} vs {@link ./store/NoopStore}.
|
|
9569
|
+
*
|
|
9570
|
+
* We require all three web APIs the layer leans on — `indexedDB` (structured
|
|
9571
|
+
* cache + durable queue), the Cache API (file-shard bytes), and a `window`
|
|
9572
|
+
* (so we're in a real document with online/offline events) — and we *touch*
|
|
9573
|
+
* them inside a try/catch, because some sandboxed or private-mode contexts
|
|
9574
|
+
* expose the globals but throw on use (mirrors `defaultSessionStore`).
|
|
9575
|
+
*/
|
|
9576
|
+
function isOfflineCapable() {
|
|
9577
|
+
try {
|
|
9578
|
+
return (typeof window !== "undefined" &&
|
|
9579
|
+
typeof indexedDB !== "undefined" &&
|
|
9580
|
+
typeof caches !== "undefined");
|
|
9581
|
+
}
|
|
9582
|
+
catch {
|
|
9583
|
+
return false;
|
|
9584
|
+
}
|
|
9585
|
+
}
|
|
9586
|
+
|
|
9587
|
+
/**
|
|
9588
|
+
* A ~thin promise wrapper over the raw IndexedDB API — just enough surface for
|
|
9589
|
+
* {@link ./IndexedDbStore}: open-with-migration, get/put/delete, prefix and
|
|
9590
|
+
* range cursor scans, autoincrement append, and clear. We hand-roll this rather
|
|
9591
|
+
* than depend on `idb`/`dexie` because the access patterns are simple and
|
|
9592
|
+
* `@muhkoo/connect` keeps its runtime dependency surface minimal.
|
|
9593
|
+
*
|
|
9594
|
+
* Everything here assumes a browser (or a `fake-indexeddb` test shim) — callers
|
|
9595
|
+
* gate construction behind {@link ../detect}.
|
|
9596
|
+
*/
|
|
9597
|
+
function req(request) {
|
|
9598
|
+
return new Promise((resolve, reject) => {
|
|
9599
|
+
request.onsuccess = () => resolve(request.result);
|
|
9600
|
+
request.onerror = () => reject(request.error);
|
|
9601
|
+
});
|
|
9602
|
+
}
|
|
9603
|
+
/** Open (and migrate) the offline database. Creates any missing object store. */
|
|
9604
|
+
function openDatabase() {
|
|
9605
|
+
return new Promise((resolve, reject) => {
|
|
9606
|
+
const open = indexedDB.open(DB_NAME, DB_VERSION);
|
|
9607
|
+
open.onupgradeneeded = () => {
|
|
9608
|
+
const db = open.result;
|
|
9609
|
+
for (const def of STORES) {
|
|
9610
|
+
if (db.objectStoreNames.contains(def.name))
|
|
9611
|
+
continue;
|
|
9612
|
+
db.createObjectStore(def.name, def.autoIncrement ? { keyPath: def.keyPath, autoIncrement: true } : undefined);
|
|
9613
|
+
}
|
|
9614
|
+
};
|
|
9615
|
+
open.onsuccess = () => resolve(open.result);
|
|
9616
|
+
open.onerror = () => reject(open.error);
|
|
9617
|
+
// A blocked upgrade means another tab holds an older version open; let
|
|
9618
|
+
// it surface as an error rather than hang forever.
|
|
9619
|
+
open.onblocked = () => reject(new Error("offline DB upgrade blocked by another tab"));
|
|
9620
|
+
});
|
|
9621
|
+
}
|
|
9622
|
+
/** Wraps an open connection with the typed operations the store needs. */
|
|
9623
|
+
class IdbConnection {
|
|
9624
|
+
db;
|
|
9625
|
+
constructor(db) {
|
|
9626
|
+
this.db = db;
|
|
9627
|
+
}
|
|
9628
|
+
tx(store, mode) {
|
|
9629
|
+
return this.db.transaction(store, mode).objectStore(store);
|
|
9630
|
+
}
|
|
9631
|
+
get(store, key) {
|
|
9632
|
+
return req(this.tx(store, "readonly").get(key)).then((v) => v ?? null);
|
|
9633
|
+
}
|
|
9634
|
+
async put(store, key, value) {
|
|
9635
|
+
await req(this.tx(store, "readwrite").put(value, key));
|
|
9636
|
+
}
|
|
9637
|
+
async delete(store, key) {
|
|
9638
|
+
await req(this.tx(store, "readwrite").delete(key));
|
|
9639
|
+
}
|
|
9640
|
+
async clear(store) {
|
|
9641
|
+
await req(this.tx(store, "readwrite").clear());
|
|
9642
|
+
}
|
|
9643
|
+
/** Append to an autoincrement store; resolves with the generated key. */
|
|
9644
|
+
async append(store, value) {
|
|
9645
|
+
const key = await req(this.tx(store, "readwrite").add(value));
|
|
9646
|
+
return key;
|
|
9647
|
+
}
|
|
9648
|
+
/** All entries whose string key falls in `[lower, upper)`, in key order. */
|
|
9649
|
+
range(store, lower, upper) {
|
|
9650
|
+
return this.scan(store, IDBKeyRange.bound(lower, upper, false, true));
|
|
9651
|
+
}
|
|
9652
|
+
/** All entries whose key starts with `prefix`, in key order. */
|
|
9653
|
+
prefix(store, prefix) {
|
|
9654
|
+
// "" is the largest BMP code unit, so `[prefix, prefix+)`
|
|
9655
|
+
// bounds every key that begins with `prefix`.
|
|
9656
|
+
return this.scan(store, IDBKeyRange.bound(prefix, prefix + "", false, true));
|
|
9657
|
+
}
|
|
9658
|
+
/** Every entry in the store, in key order. */
|
|
9659
|
+
all(store) {
|
|
9660
|
+
return this.scan(store, null);
|
|
9661
|
+
}
|
|
9662
|
+
scan(store, range) {
|
|
9663
|
+
return new Promise((resolve, reject) => {
|
|
9664
|
+
const out = [];
|
|
9665
|
+
const cursorReq = this.tx(store, "readonly").openCursor(range ?? undefined);
|
|
9666
|
+
cursorReq.onsuccess = () => {
|
|
9667
|
+
const cursor = cursorReq.result;
|
|
9668
|
+
if (!cursor)
|
|
9669
|
+
return resolve(out);
|
|
9670
|
+
out.push({ key: String(cursor.key), value: cursor.value });
|
|
9671
|
+
cursor.continue();
|
|
9672
|
+
};
|
|
9673
|
+
cursorReq.onerror = () => reject(cursorReq.error);
|
|
9674
|
+
});
|
|
9675
|
+
}
|
|
9676
|
+
}
|
|
9677
|
+
|
|
9678
|
+
/**
|
|
9679
|
+
* `IndexedDbStore` — the browser {@link OfflineStore}, backed by one IndexedDB
|
|
9680
|
+
* database per origin (see {@link ./schema}). It opens lazily on first use and
|
|
9681
|
+
* memoizes the connection. The queue methods sit on the autoincrement
|
|
9682
|
+
* `outbound-queue` store and return mutations in HLC order for the sync engine.
|
|
9683
|
+
*
|
|
9684
|
+
* Construct only where `indexedDB` exists — {@link ../detect.isOfflineCapable}
|
|
9685
|
+
* gates this, and the {@link ../../core/Client} falls back to {@link ./NoopStore}
|
|
9686
|
+
* everywhere else.
|
|
9687
|
+
*/
|
|
9688
|
+
class IndexedDbStore {
|
|
9689
|
+
enabled = true;
|
|
9690
|
+
conn = null;
|
|
9691
|
+
opening = null;
|
|
9692
|
+
connect() {
|
|
9693
|
+
if (this.conn)
|
|
9694
|
+
return Promise.resolve(this.conn);
|
|
9695
|
+
if (!this.opening) {
|
|
9696
|
+
this.opening = openDatabase()
|
|
9697
|
+
.then((db) => {
|
|
9698
|
+
this.conn = new IdbConnection(db);
|
|
9699
|
+
return this.conn;
|
|
9700
|
+
})
|
|
9701
|
+
.finally(() => {
|
|
9702
|
+
this.opening = null;
|
|
9703
|
+
});
|
|
9704
|
+
}
|
|
9705
|
+
return this.opening;
|
|
9706
|
+
}
|
|
9707
|
+
async ready() {
|
|
9708
|
+
await this.connect();
|
|
9709
|
+
}
|
|
9710
|
+
async get(store, key) {
|
|
9711
|
+
return (await this.connect()).get(store, key);
|
|
9712
|
+
}
|
|
9713
|
+
async put(store, key, value) {
|
|
9714
|
+
await (await this.connect()).put(store, key, value);
|
|
9715
|
+
}
|
|
9716
|
+
async delete(store, key) {
|
|
9717
|
+
await (await this.connect()).delete(store, key);
|
|
9718
|
+
}
|
|
9719
|
+
async prefix(store, prefix) {
|
|
9720
|
+
return (await this.connect()).prefix(store, prefix);
|
|
9721
|
+
}
|
|
9722
|
+
async range(store, lower, upper) {
|
|
9723
|
+
return (await this.connect()).range(store, lower, upper);
|
|
9724
|
+
}
|
|
9725
|
+
async all(store) {
|
|
9726
|
+
return (await this.connect()).all(store);
|
|
9727
|
+
}
|
|
9728
|
+
async clear(store) {
|
|
9729
|
+
await (await this.connect()).clear(store);
|
|
9730
|
+
}
|
|
9731
|
+
async enqueue(entry) {
|
|
9732
|
+
// keyPath autoincrement writes the generated `seq` back into the record.
|
|
9733
|
+
return (await this.connect()).append("outbound-queue", entry);
|
|
9734
|
+
}
|
|
9735
|
+
async dequeue(seq) {
|
|
9736
|
+
await (await this.connect()).delete("outbound-queue", seq);
|
|
9737
|
+
}
|
|
9738
|
+
async queued() {
|
|
9739
|
+
const rows = await (await this.connect()).all("outbound-queue");
|
|
9740
|
+
return rows.map((r) => r.value).sort((a, b) => compareHlc(a.hlc, b.hlc));
|
|
9741
|
+
}
|
|
9742
|
+
}
|
|
9743
|
+
|
|
9744
|
+
/**
|
|
9745
|
+
* `NoopStore` — the {@link OfflineStore} used wherever offline support is off
|
|
9746
|
+
* (Node, Workers, SSR, or when a consumer disables it). Every read returns
|
|
9747
|
+
* empty and every write is dropped, so the namespace hooks that call into it
|
|
9748
|
+
* behave exactly as they did before the offline layer existed. `enabled` is
|
|
9749
|
+
* `false` so hot paths can skip the call entirely.
|
|
9750
|
+
*/
|
|
9751
|
+
class NoopStore {
|
|
9752
|
+
enabled = false;
|
|
9753
|
+
async ready() { }
|
|
9754
|
+
async get(_store, _key) {
|
|
9755
|
+
return null;
|
|
9756
|
+
}
|
|
9757
|
+
async put(_store, _key, _value) { }
|
|
9758
|
+
async delete(_store, _key) { }
|
|
9759
|
+
async prefix(_store, _prefix) {
|
|
9760
|
+
return [];
|
|
9761
|
+
}
|
|
9762
|
+
async range(_store, _lower, _upper) {
|
|
9763
|
+
return [];
|
|
9764
|
+
}
|
|
9765
|
+
async all(_store) {
|
|
9766
|
+
return [];
|
|
9767
|
+
}
|
|
9768
|
+
async clear(_store) { }
|
|
9769
|
+
async enqueue(_entry) {
|
|
9770
|
+
return -1;
|
|
9771
|
+
}
|
|
9772
|
+
async dequeue(_seq) { }
|
|
9773
|
+
async queued() {
|
|
9774
|
+
return [];
|
|
9775
|
+
}
|
|
9776
|
+
}
|
|
9777
|
+
|
|
9778
|
+
/**
|
|
9779
|
+
* `LWWRegister` — a last-write-wins register CRDT, the conflict model for a
|
|
9780
|
+
* `kv` value and for an app-state snapshot. A register holds a single value
|
|
9781
|
+
* stamped with the {@link HlcTimestamp} of the write that produced it. Two
|
|
9782
|
+
* concurrent writes converge to the one with the greater HLC; the loser is
|
|
9783
|
+
* discarded. A delete is just a write that sets `deleted` (a tombstone), so a
|
|
9784
|
+
* stale write that arrives after a newer delete can't resurrect the value.
|
|
9785
|
+
*
|
|
9786
|
+
* Because the HLC total-orders every write deterministically (down to a nodeId
|
|
9787
|
+
* tiebreak), every replica that has seen the same set of writes lands on the
|
|
9788
|
+
* same value — that's the convergence guarantee.
|
|
9789
|
+
*/
|
|
9790
|
+
/** An empty register that loses to any real write. */
|
|
9791
|
+
function emptyRegister() {
|
|
9792
|
+
return { value: null, hlc: ZERO_HLC, deleted: true };
|
|
9793
|
+
}
|
|
9794
|
+
/**
|
|
9795
|
+
* Merge two register states, returning the winner (greater HLC). Pure: neither
|
|
9796
|
+
* input is mutated. Used identically on the inbound change feed and on
|
|
9797
|
+
* sync-replay reconciliation so there's exactly one definition of "who wins".
|
|
9798
|
+
*/
|
|
9799
|
+
function mergeRegister(a, b) {
|
|
9800
|
+
return compareHlc(a.hlc, b.hlc) >= 0 ? a : b;
|
|
9801
|
+
}
|
|
9802
|
+
/** Build the register produced by a `set`. */
|
|
9803
|
+
function writeRegister(value, hlc) {
|
|
9804
|
+
return { value, hlc, deleted: false };
|
|
9805
|
+
}
|
|
9806
|
+
/** Build the register produced by a `delete` (a tombstone at `hlc`). */
|
|
9807
|
+
function deleteRegister(hlc) {
|
|
9808
|
+
return { value: null, hlc, deleted: true };
|
|
9809
|
+
}
|
|
9810
|
+
|
|
9811
|
+
/**
|
|
9812
|
+
* `KvCache` — the offline adapter for `client.kv`. Stores each value's
|
|
9813
|
+
* ciphertext envelope (never plaintext) under `${commitment}|${collection}/${id}`
|
|
9814
|
+
* as an LWW-Register stamped with an HLC, so reads work offline and concurrent
|
|
9815
|
+
* writes converge (see {@link ./crdt/LWWRegister}). Deletes are causal
|
|
9816
|
+
* tombstones — a stale write can't resurrect a key a newer delete removed.
|
|
9817
|
+
*
|
|
9818
|
+
* True cross-device convergence also needs the accelerator to honor the HLC
|
|
9819
|
+
* (see the server's `kvmeta` sidecar); this adapter embeds the stamp now and
|
|
9820
|
+
* degrades gracefully (last-writer-by-arrival) until that ships.
|
|
9821
|
+
*/
|
|
9822
|
+
class KvCache {
|
|
9823
|
+
manager;
|
|
9824
|
+
store;
|
|
9825
|
+
constructor(manager) {
|
|
9826
|
+
this.manager = manager;
|
|
9827
|
+
this.store = manager.store;
|
|
9828
|
+
}
|
|
9829
|
+
get enabled() {
|
|
9830
|
+
return this.manager.enabled;
|
|
9831
|
+
}
|
|
9832
|
+
nextHlc() {
|
|
9833
|
+
return this.manager.nextHlc();
|
|
9834
|
+
}
|
|
9835
|
+
newClientId() {
|
|
9836
|
+
return this.manager.newClientId();
|
|
9837
|
+
}
|
|
9838
|
+
key(commitment, fullKey) {
|
|
9839
|
+
return `${commitment}|${fullKey}`;
|
|
9840
|
+
}
|
|
9841
|
+
/** Read the cached register for `collection/id`, or null when absent. */
|
|
9842
|
+
read(commitment, fullKey) {
|
|
9843
|
+
return this.store.get("kv-cache", this.key(commitment, fullKey));
|
|
9844
|
+
}
|
|
9845
|
+
/** Optimistically record a local `set` (ciphertext envelope + HLC). */
|
|
9846
|
+
async writeLocal(commitment, fullKey, envelope, hlc) {
|
|
9847
|
+
await this.store.put("kv-cache", this.key(commitment, fullKey), writeRegister(envelope, hlc));
|
|
9848
|
+
}
|
|
9849
|
+
/** Optimistically record a local `delete` (tombstone at HLC). */
|
|
9850
|
+
async deleteLocal(commitment, fullKey, hlc) {
|
|
9851
|
+
await this.store.put("kv-cache", this.key(commitment, fullKey), deleteRegister(hlc));
|
|
9852
|
+
}
|
|
9853
|
+
/**
|
|
9854
|
+
* Merge an authoritative value (from a GET refresh or a realtime frame) into
|
|
9855
|
+
* the cache by HLC. When the server doesn't supply an HLC yet, treat it as
|
|
9856
|
+
* "now-ish" by stamping our clock so a server value still beats stale local
|
|
9857
|
+
* state but a fresh local write wins.
|
|
9858
|
+
*/
|
|
9859
|
+
async mergeRemote(commitment, fullKey, envelope, op, hlc) {
|
|
9860
|
+
const k = this.key(commitment, fullKey);
|
|
9861
|
+
const incoming = op === "delete"
|
|
9862
|
+
? deleteRegister(hlc ?? (await this.nextHlc()))
|
|
9863
|
+
: writeRegister(envelope, hlc ?? (await this.nextHlc()));
|
|
9864
|
+
const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
|
|
9865
|
+
await this.store.put("kv-cache", k, mergeRegister(local, incoming));
|
|
9866
|
+
}
|
|
9867
|
+
/** Non-tombstoned full keys for a commitment (used for offline `list`). */
|
|
9868
|
+
async liveKeys(commitment) {
|
|
9869
|
+
const prefix = `${commitment}|`;
|
|
9870
|
+
const rows = await this.store.prefix("kv-cache", prefix);
|
|
9871
|
+
return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
|
|
9872
|
+
}
|
|
9873
|
+
/** Queue a kv mutation for replay on reconnect. */
|
|
9874
|
+
async enqueue(method, args, hlc, clientId) {
|
|
9875
|
+
await this.manager.enqueue({ hlc, clientId, domain: "kv", method, args });
|
|
9876
|
+
}
|
|
9877
|
+
}
|
|
9878
|
+
|
|
9879
|
+
/**
|
|
9880
|
+
* `DbCache` — the offline adapter for `client.db`. Unlike kv, db rows are
|
|
9881
|
+
* server-plaintext, so they're cached as-is. The SDK doesn't know a table's
|
|
9882
|
+
* primary-key column, so it caches schema-agnostically:
|
|
9883
|
+
* - `get(id)` results keyed by the requested id (`${table}|row|${id}`),
|
|
9884
|
+
* - `query(q)` result pages keyed by a stable hash of the query.
|
|
9885
|
+
*
|
|
9886
|
+
* Writes carry per-column HLC stamps (`_hlc`) so the accelerator's per-column
|
|
9887
|
+
* LWW merge can converge concurrent edits; offline writes are queued and
|
|
9888
|
+
* replayed. Optimistic row patches keep `get` fresh between sync cycles.
|
|
9889
|
+
*/
|
|
9890
|
+
/** Stable JSON for query-cache keys (sorted object keys → deterministic). */
|
|
9891
|
+
function stableStringify(value) {
|
|
9892
|
+
if (value === null || typeof value !== "object")
|
|
9893
|
+
return JSON.stringify(value);
|
|
9894
|
+
if (Array.isArray(value))
|
|
9895
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
9896
|
+
const obj = value;
|
|
9897
|
+
return `{${Object.keys(obj)
|
|
9898
|
+
.sort()
|
|
9899
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
|
|
9900
|
+
.join(",")}}`;
|
|
9901
|
+
}
|
|
9902
|
+
class DbCache {
|
|
9903
|
+
manager;
|
|
9904
|
+
store;
|
|
9905
|
+
constructor(manager) {
|
|
9906
|
+
this.manager = manager;
|
|
9907
|
+
this.store = manager.store;
|
|
9908
|
+
}
|
|
9909
|
+
get enabled() {
|
|
9910
|
+
return this.manager.enabled;
|
|
9911
|
+
}
|
|
9912
|
+
nextHlc() {
|
|
9913
|
+
return this.manager.nextHlc();
|
|
9914
|
+
}
|
|
9915
|
+
newClientId() {
|
|
9916
|
+
return this.manager.newClientId();
|
|
9917
|
+
}
|
|
9918
|
+
/** Build the `{ [col]: hlc }` map for a write (one stamp across its columns). */
|
|
9919
|
+
columnStamps(values, hlc) {
|
|
9920
|
+
const out = {};
|
|
9921
|
+
for (const col of Object.keys(values))
|
|
9922
|
+
out[col] = hlc;
|
|
9923
|
+
return out;
|
|
9924
|
+
}
|
|
9925
|
+
rowKey(table, id) {
|
|
9926
|
+
return `${table}|row|${id}`;
|
|
9927
|
+
}
|
|
9928
|
+
queryKey(table, q) {
|
|
9929
|
+
return `${table}|query|${stableStringify(q)}`;
|
|
9930
|
+
}
|
|
9931
|
+
async readRow(table, id) {
|
|
9932
|
+
return this.store.get("db-row-cache", this.rowKey(table, id));
|
|
9933
|
+
}
|
|
9934
|
+
async writeRow(table, id, row) {
|
|
9935
|
+
await this.store.put("db-row-cache", this.rowKey(table, id), row);
|
|
9936
|
+
}
|
|
9937
|
+
/** Shallow-merge a partial update into a cached row (optimistic). */
|
|
9938
|
+
async patchRow(table, id, values) {
|
|
9939
|
+
const existing = (await this.readRow(table, id)) ?? {};
|
|
9940
|
+
await this.writeRow(table, id, { ...existing, ...values });
|
|
9941
|
+
}
|
|
9942
|
+
async deleteRow(table, id) {
|
|
9943
|
+
await this.store.delete("db-row-cache", this.rowKey(table, id));
|
|
9944
|
+
}
|
|
9945
|
+
async readQuery(table, q) {
|
|
9946
|
+
return this.store.get("db-query-cache", this.queryKey(table, q));
|
|
9947
|
+
}
|
|
9948
|
+
async writeQuery(table, q, page) {
|
|
9949
|
+
await this.store.put("db-query-cache", this.queryKey(table, q), page);
|
|
9950
|
+
}
|
|
9951
|
+
async enqueue(method, args, hlc, clientId) {
|
|
9952
|
+
await this.manager.enqueue({ hlc, clientId, domain: "db", method, args });
|
|
9953
|
+
}
|
|
9954
|
+
}
|
|
9955
|
+
|
|
8193
9956
|
/**
|
|
8194
9957
|
* `Client` — the single entry point to the Muhkoo SDK.
|
|
8195
9958
|
*
|
|
@@ -8242,6 +10005,8 @@ class Client {
|
|
|
8242
10005
|
agents;
|
|
8243
10006
|
/** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
|
|
8244
10007
|
functions;
|
|
10008
|
+
/** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
|
|
10009
|
+
offline;
|
|
8245
10010
|
session;
|
|
8246
10011
|
http;
|
|
8247
10012
|
/** Subscribers notified when a session expires and can't be recovered. */
|
|
@@ -8261,11 +10026,40 @@ class Client {
|
|
|
8261
10026
|
}
|
|
8262
10027
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
8263
10028
|
this.session = new SessionState(options.sessionStore ?? defaultSessionStore());
|
|
10029
|
+
// Offline layer. On by default in browsers, a no-op (NoopStore) in Node,
|
|
10030
|
+
// Workers, and SSR — so existing consumers are unaffected unless they're
|
|
10031
|
+
// in a browser that can actually cache.
|
|
10032
|
+
const offlineEnabled = options.offline?.enabled ?? isOfflineCapable();
|
|
10033
|
+
const offlineStore = offlineEnabled
|
|
10034
|
+
? (options.offline?.store ?? new IndexedDbStore())
|
|
10035
|
+
: new NoopStore();
|
|
10036
|
+
this.offline = new OfflineManager({
|
|
10037
|
+
store: offlineStore,
|
|
10038
|
+
session: this.session,
|
|
10039
|
+
enabled: offlineEnabled,
|
|
10040
|
+
});
|
|
10041
|
+
// Wrap fetch so every accelerator round-trip feeds the connectivity
|
|
10042
|
+
// detector: a thrown fetch is the strongest "offline" signal, a 2xx the
|
|
10043
|
+
// strongest "online" one. Only when offline support is active.
|
|
10044
|
+
const baseFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
10045
|
+
const reportingFetch = offlineEnabled && baseFetch
|
|
10046
|
+
? async (input, init) => {
|
|
10047
|
+
try {
|
|
10048
|
+
const res = await baseFetch(input, init);
|
|
10049
|
+
this.offline.reportFetchSuccess();
|
|
10050
|
+
return res;
|
|
10051
|
+
}
|
|
10052
|
+
catch (err) {
|
|
10053
|
+
this.offline.reportFetchFailure();
|
|
10054
|
+
throw err;
|
|
10055
|
+
}
|
|
10056
|
+
}
|
|
10057
|
+
: options.fetch;
|
|
8264
10058
|
this.http = new HttpClient({
|
|
8265
10059
|
baseUrl: this.baseUrl,
|
|
8266
10060
|
apiKey: options.apiKey,
|
|
8267
10061
|
getSessionToken: () => this.session.token,
|
|
8268
|
-
fetch:
|
|
10062
|
+
fetch: reportingFetch,
|
|
8269
10063
|
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
8270
10064
|
// works while unlocked). If it can't, `recoverSession` fires the
|
|
8271
10065
|
// session-expired event so the app can redirect to login.
|
|
@@ -8276,9 +10070,24 @@ class Client {
|
|
|
8276
10070
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
8277
10071
|
const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
|
|
8278
10072
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
10073
|
+
const deferShardUpload = offlineEnabled
|
|
10074
|
+
? (hash) => this.offline.deferShardUpload(hash)
|
|
10075
|
+
: undefined;
|
|
10076
|
+
const kvCache = offlineEnabled ? new KvCache(this.offline) : undefined;
|
|
10077
|
+
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl, offline: kvCache });
|
|
10078
|
+
if (offlineEnabled)
|
|
10079
|
+
this.offline.registerReplayer("kv", (e) => this.kv.replay(e));
|
|
10080
|
+
const dbCache = offlineEnabled ? new DbCache(this.offline) : undefined;
|
|
10081
|
+
this.db = new DbNamespace({ http: this.http, offline: dbCache });
|
|
10082
|
+
if (offlineEnabled)
|
|
10083
|
+
this.offline.registerReplayer("db", (e) => this.db.replay(e));
|
|
10084
|
+
this.storage = new StorageNamespace({
|
|
10085
|
+
http: this.http,
|
|
10086
|
+
baseUrl: this.baseUrl,
|
|
10087
|
+
kv: this.kv,
|
|
10088
|
+
shardCache: this.offline.fileCache,
|
|
10089
|
+
deferShardUpload,
|
|
10090
|
+
});
|
|
8282
10091
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
8283
10092
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
8284
10093
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
@@ -8292,9 +10101,28 @@ class Client {
|
|
|
8292
10101
|
session: this.session,
|
|
8293
10102
|
wsBaseUrl,
|
|
8294
10103
|
cache: spaceKeyCache,
|
|
10104
|
+
offline: this.offline,
|
|
8295
10105
|
});
|
|
8296
10106
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
8297
10107
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
10108
|
+
// Replay deferred shard uploads on reconnect. The bytes live in the
|
|
10109
|
+
// Cache API keyed by hash; re-PUT them through a (defer-free) shard
|
|
10110
|
+
// client so a failure surfaces as transient and the entry stays queued.
|
|
10111
|
+
if (offlineEnabled && this.offline.fileCache) {
|
|
10112
|
+
const replayShards = new ShardClient({
|
|
10113
|
+
baseUrl: this.baseUrl,
|
|
10114
|
+
pathPrefix: "/api/shards",
|
|
10115
|
+
fetch: this.http.fetch,
|
|
10116
|
+
cache: this.offline.fileCache,
|
|
10117
|
+
});
|
|
10118
|
+
this.offline.registerReplayer("file", async (entry) => {
|
|
10119
|
+
const { hash } = entry.args;
|
|
10120
|
+
const bytes = await this.offline.fileCache.get(hash);
|
|
10121
|
+
if (!bytes)
|
|
10122
|
+
return; // evicted from cache — nothing to replay
|
|
10123
|
+
await replayShards.putShard(hash, bytes);
|
|
10124
|
+
});
|
|
10125
|
+
}
|
|
8298
10126
|
}
|
|
8299
10127
|
/** The currently signed-in user, or `null`. */
|
|
8300
10128
|
get user() {
|
|
@@ -8519,6 +10347,116 @@ function ejectAgentTools(appClass, opts) {
|
|
|
8519
10347
|
};
|
|
8520
10348
|
}
|
|
8521
10349
|
|
|
10350
|
+
/**
|
|
10351
|
+
* `LWWMap` — a map of independent last-write-wins registers, one per column.
|
|
10352
|
+
* This is the conflict model for a `db` row: two devices that edit *different*
|
|
10353
|
+
* columns of the same row while offline both keep their edits (no lost update),
|
|
10354
|
+
* and two that edit the *same* column converge to the greater-HLC write. A row
|
|
10355
|
+
* delete is a causal tombstone (see {@link ./ORSet}); a later write to any
|
|
10356
|
+
* column resurrects the row with just the surviving columns.
|
|
10357
|
+
*
|
|
10358
|
+
* This mirrors, on the client, exactly what the accelerator's `_muhkoo_crdt`
|
|
10359
|
+
* sidecar does server-side — so a row materializes identically whether it was
|
|
10360
|
+
* merged locally (offline) or by the server on replay.
|
|
10361
|
+
*/
|
|
10362
|
+
function emptyMap() {
|
|
10363
|
+
return { fields: {}, columnHlc: {}, tombstone: null };
|
|
10364
|
+
}
|
|
10365
|
+
/** Apply a local column write (insert/update) at the given per-column HLCs. */
|
|
10366
|
+
function writeColumns(map, values, hlc) {
|
|
10367
|
+
const next = {
|
|
10368
|
+
fields: { ...map.fields },
|
|
10369
|
+
columnHlc: { ...map.columnHlc },
|
|
10370
|
+
tombstone: map.tombstone,
|
|
10371
|
+
};
|
|
10372
|
+
for (const [col, value] of Object.entries(values)) {
|
|
10373
|
+
const prev = next.columnHlc[col];
|
|
10374
|
+
if (!prev || compareHlc(hlc, prev) > 0) {
|
|
10375
|
+
next.fields[col] = value;
|
|
10376
|
+
next.columnHlc[col] = hlc;
|
|
10377
|
+
}
|
|
10378
|
+
}
|
|
10379
|
+
return next;
|
|
10380
|
+
}
|
|
10381
|
+
/** Apply a row delete at `hlc` (causal tombstone). */
|
|
10382
|
+
function tombstoneRow(map, hlc) {
|
|
10383
|
+
const tombstone = !map.tombstone || compareHlc(hlc, map.tombstone) > 0 ? hlc : map.tombstone;
|
|
10384
|
+
return { fields: { ...map.fields }, columnHlc: { ...map.columnHlc }, tombstone };
|
|
10385
|
+
}
|
|
10386
|
+
/** Merge two row states column-by-column. Pure; neither input is mutated. */
|
|
10387
|
+
function mergeMap(a, b) {
|
|
10388
|
+
const cols = new Set([...Object.keys(a.columnHlc), ...Object.keys(b.columnHlc)]);
|
|
10389
|
+
const out = emptyMap();
|
|
10390
|
+
for (const col of cols) {
|
|
10391
|
+
const ah = a.columnHlc[col];
|
|
10392
|
+
const bh = b.columnHlc[col];
|
|
10393
|
+
const aWins = ah && (!bh || compareHlc(ah, bh) >= 0);
|
|
10394
|
+
const winner = aWins ? a : b;
|
|
10395
|
+
out.fields[col] = winner.fields[col];
|
|
10396
|
+
out.columnHlc[col] = winner.columnHlc[col];
|
|
10397
|
+
}
|
|
10398
|
+
out.tombstone =
|
|
10399
|
+
a.tombstone && b.tombstone
|
|
10400
|
+
? compareHlc(a.tombstone, b.tombstone) >= 0
|
|
10401
|
+
? a.tombstone
|
|
10402
|
+
: b.tombstone
|
|
10403
|
+
: (a.tombstone ?? b.tombstone);
|
|
10404
|
+
return out;
|
|
10405
|
+
}
|
|
10406
|
+
/**
|
|
10407
|
+
* Materialize the visible row from merged state, or `null` when the row is
|
|
10408
|
+
* deleted. A column is visible only when its write is causally newer than the
|
|
10409
|
+
* tombstone — so a delete hides every prior column, but a post-delete write to
|
|
10410
|
+
* a column brings the row back with just that column populated.
|
|
10411
|
+
*/
|
|
10412
|
+
function materializeRow(map) {
|
|
10413
|
+
const visible = {};
|
|
10414
|
+
let any = false;
|
|
10415
|
+
for (const [col, hlc] of Object.entries(map.columnHlc)) {
|
|
10416
|
+
if (!map.tombstone || compareHlc(hlc, map.tombstone) > 0) {
|
|
10417
|
+
visible[col] = map.fields[col];
|
|
10418
|
+
any = true;
|
|
10419
|
+
}
|
|
10420
|
+
}
|
|
10421
|
+
return any ? visible : null;
|
|
10422
|
+
}
|
|
10423
|
+
|
|
10424
|
+
/**
|
|
10425
|
+
* Tombstone helpers shared by the LWW CRDTs. We don't need a full
|
|
10426
|
+
* observed-remove set — every domain that deletes (kv keys, db rows) does so
|
|
10427
|
+
* by stamping a delete with an HLC and keeping that stamp around. The only
|
|
10428
|
+
* rule that matters is the causal one:
|
|
10429
|
+
*
|
|
10430
|
+
* a delete at HLC `d` hides the value, but a *later* write at HLC `w > d`
|
|
10431
|
+
* resurrects it; a *stale* write at `w <= d` is dropped.
|
|
10432
|
+
*
|
|
10433
|
+
* Keeping the delete HLC (rather than physically forgetting the key) is what
|
|
10434
|
+
* prevents a delayed offline write from "un-deleting" something a peer already
|
|
10435
|
+
* removed. Callers GC tombstones older than any possible offline window.
|
|
10436
|
+
*/
|
|
10437
|
+
/**
|
|
10438
|
+
* Does a write at `writeHlc` win against a tombstone at `tombstoneHlc`?
|
|
10439
|
+
* `true` ⇒ the write applies (resurrecting a deleted item if needed); `false`
|
|
10440
|
+
* ⇒ the tombstone still holds and the write is dropped. A missing tombstone
|
|
10441
|
+
* (`null`) means there's nothing suppressing the write.
|
|
10442
|
+
*/
|
|
10443
|
+
function writeBeatsTombstone(writeHlc, tombstoneHlc) {
|
|
10444
|
+
if (!tombstoneHlc)
|
|
10445
|
+
return true;
|
|
10446
|
+
return compareHlc(writeHlc, tombstoneHlc) > 0;
|
|
10447
|
+
}
|
|
10448
|
+
/**
|
|
10449
|
+
* Does a delete at `deleteHlc` win against the latest write at `writeHlc`?
|
|
10450
|
+
* `true` ⇒ the item is now tombstoned. Ties go to the delete (remove-wins) so
|
|
10451
|
+
* that a delete and a write stamped in the same instant resolve deterministically
|
|
10452
|
+
* toward removal.
|
|
10453
|
+
*/
|
|
10454
|
+
function deleteBeatsWrite(deleteHlc, writeHlc) {
|
|
10455
|
+
if (!writeHlc)
|
|
10456
|
+
return true;
|
|
10457
|
+
return compareHlc(deleteHlc, writeHlc) >= 0;
|
|
10458
|
+
}
|
|
10459
|
+
|
|
8522
10460
|
/**
|
|
8523
10461
|
* PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
|
|
8524
10462
|
*
|
|
@@ -8585,5 +10523,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
8585
10523
|
default: loadRsWasm
|
|
8586
10524
|
});
|
|
8587
10525
|
|
|
8588
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VaultUnavailableError, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, toField, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
10526
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
8589
10527
|
//# sourceMappingURL=index.js.map
|