@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.
@@ -1734,16 +1734,15 @@ class DoubleRatchetManager {
1734
1734
  }
1735
1735
  async registerZK(clientId, secret, salt, ecdsaPub) {
1736
1736
  await this._authenticator.initializeZK();
1737
- console.log(`Registering ZK for ${clientId}...`);
1738
- console.log('ECDSA Public Key:', ecdsaPub);
1739
1737
  const ecdsaJwk = await crypto.subtle.exportKey('jwk', ecdsaPub);
1740
1738
  const ecdsaHex = toHex(fromBase64Url(ecdsaJwk.x)).slice(0, 64);
1741
1739
  const ecdsaPubField = new Field(BigInt('0x' + ecdsaHex));
1742
1740
  const ecdsaPubHash = await Poseidon.hash([ecdsaPubField]);
1743
1741
  const commitment = await Poseidon.hash([secret, salt, ecdsaPubHash]);
1744
- console.log(`Commitment for ${clientId}:`, { commitment, salt });
1745
1742
  this.registeredUsers.set(clientId, { commitment, salt });
1746
- appLogger.debug(`Registered ${clientId}: commitment=${commitment.toString()}, salt=${salt.toString()}`);
1743
+ // NB: never log `secret`, `salt`, or `commitment` — `salt` is a private
1744
+ // witness for the ZK circuit and `commitment` is the user's stable id.
1745
+ appLogger.debug(`Registered ZK identity for ${clientId}`);
1747
1746
  }
1748
1747
  async performHandshake(senderId, recipientId, zkProof, publicInput, clientPublicKey, // ECDH pub for session
1749
1748
  clientAuthPublicKey, // ECDSA pub for auth
@@ -2131,6 +2130,14 @@ var EventCoreEvents;
2131
2130
  EventCoreEvents["MESSAGE"] = "message";
2132
2131
  EventCoreEvents["GET_HISTORY"] = "get_history";
2133
2132
  EventCoreEvents["RECEIVED_HISTORY"] = "received_history";
2133
+ // Offline layer — connectivity + background sync lifecycle. Emitted by the
2134
+ // ConnectivityManager / SyncEngine; apps subscribe to drive a status UI.
2135
+ EventCoreEvents["ONLINE"] = "online";
2136
+ EventCoreEvents["OFFLINE"] = "offline";
2137
+ EventCoreEvents["SYNCING"] = "syncing";
2138
+ EventCoreEvents["SYNC_PROGRESS"] = "sync_progress";
2139
+ EventCoreEvents["SYNC_COMPLETE"] = "sync_complete";
2140
+ EventCoreEvents["SYNC_ERROR"] = "sync_error";
2134
2141
  })(EventCoreEvents || (EventCoreEvents = {}));
2135
2142
  /**
2136
2143
  * @public
@@ -2969,7 +2976,7 @@ function base64UrlDecode(s) {
2969
2976
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
2970
2977
  * `package.json` `version`.
2971
2978
  */
2972
- const VERSION = "0.7.0-alpha.4";
2979
+ const VERSION = "0.8.0-alpha.0";
2973
2980
 
2974
2981
  /**
2975
2982
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -3005,6 +3012,8 @@ class HttpError extends Error {
3005
3012
  }
3006
3013
  class HttpClient {
3007
3014
  baseUrl;
3015
+ /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
3016
+ baseOrigin;
3008
3017
  apiKey;
3009
3018
  getSessionToken;
3010
3019
  onUnauthorized;
@@ -3013,6 +3022,12 @@ class HttpClient {
3013
3022
  if (!opts?.baseUrl)
3014
3023
  throw new Error("HttpClient: `baseUrl` is required");
3015
3024
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
3025
+ try {
3026
+ this.baseOrigin = new URL(this.baseUrl).origin;
3027
+ }
3028
+ catch {
3029
+ this.baseOrigin = "";
3030
+ }
3016
3031
  this.apiKey = opts.apiKey ?? null;
3017
3032
  this.getSessionToken = opts.getSessionToken ?? (() => null);
3018
3033
  this.onUnauthorized = opts.onUnauthorized ?? null;
@@ -3029,13 +3044,34 @@ class HttpClient {
3029
3044
  */
3030
3045
  fetch = (input, init) => {
3031
3046
  const headers = new Headers(init?.headers);
3032
- if (this.apiKey)
3033
- headers.set("X-Muhkoo-Key", this.apiKey);
3034
- const token = this.getSessionToken();
3035
- if (token)
3036
- headers.set("X-Muhkoo-Session", token);
3047
+ // Only attach credentials when the request stays on the accelerator's
3048
+ // origin. An absolute URL pointing elsewhere (e.g. a caller-supplied or
3049
+ // server-reflected path that escapes `baseUrl`) must NOT receive the app
3050
+ // key or session token, or it becomes a token-exfiltration vector.
3051
+ if (this.isSameOrigin(input)) {
3052
+ if (this.apiKey)
3053
+ headers.set("X-Muhkoo-Key", this.apiKey);
3054
+ const token = this.getSessionToken();
3055
+ if (token)
3056
+ headers.set("X-Muhkoo-Session", token);
3057
+ }
3037
3058
  return this.fetchFn(input, { ...init, headers });
3038
3059
  };
3060
+ /** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
3061
+ isSameOrigin(input) {
3062
+ try {
3063
+ const urlStr = typeof input === "string" ? input
3064
+ : input instanceof URL ? input.href
3065
+ : input.url;
3066
+ // Relative URLs are resolved against baseUrl downstream → same origin.
3067
+ if (!/^https?:\/\//i.test(urlStr))
3068
+ return true;
3069
+ return new URL(urlStr).origin === this.baseOrigin;
3070
+ }
3071
+ catch {
3072
+ return true;
3073
+ }
3074
+ }
3039
3075
  // -------------------------------------------------------------------------
3040
3076
  // JSON convenience helpers (path is relative to baseUrl)
3041
3077
  // -------------------------------------------------------------------------
@@ -4602,30 +4638,104 @@ class KvNamespace {
4602
4638
  cipherIdentity = null;
4603
4639
  constructor(deps) {
4604
4640
  this.deps = deps;
4641
+ // Replay queued kv writes on reconnect by re-issuing the original request.
4642
+ if (deps.offline?.enabled) ;
4643
+ }
4644
+ /** Replay one queued kv mutation (called by the sync engine). */
4645
+ async replay(entry) {
4646
+ const a = entry.args;
4647
+ if (entry.method === "set") {
4648
+ await this.deps.http.post(this.kvPath(a.collection, a.id), { value: a.stored, hlc: a.hlc });
4649
+ }
4650
+ else {
4651
+ await this.deps.http.del(this.kvPath(a.collection, a.id), { hlc: a.hlc });
4652
+ }
4605
4653
  }
4606
4654
  /** Store `value` under `collection`/`id` (encrypted at rest by default). */
4607
4655
  async set(collection, id, value, opts) {
4608
4656
  const encrypt = opts?.encrypt !== false;
4609
4657
  const stored = encrypt ? await this.getCipher().encrypt(value) : value;
4610
- await this.deps.http.post(this.kvPath(collection, id), { value: stored });
4658
+ const offline = this.deps.offline;
4659
+ if (!offline?.enabled) {
4660
+ await this.deps.http.post(this.kvPath(collection, id), { value: stored });
4661
+ return;
4662
+ }
4663
+ const fullKey = `${collection}/${id}`;
4664
+ const hlc = await offline.nextHlc();
4665
+ await offline.writeLocal(this.commitment(), fullKey, stored, hlc);
4666
+ try {
4667
+ await this.deps.http.post(this.kvPath(collection, id), { value: stored, hlc });
4668
+ }
4669
+ catch (err) {
4670
+ if (err instanceof HttpError)
4671
+ throw err; // server reachable + refused
4672
+ await offline.enqueue("set", { collection, id, stored, hlc }, hlc, offline.newClientId());
4673
+ }
4611
4674
  }
4612
4675
  /** Fetch the value at `collection`/`id`, or `null` if absent. */
4613
4676
  async get(collection, id) {
4614
- const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
4615
- return this.decode(res.value);
4677
+ const offline = this.deps.offline;
4678
+ if (!offline?.enabled) {
4679
+ const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
4680
+ return this.decode(res.value);
4681
+ }
4682
+ const fullKey = `${collection}/${id}`;
4683
+ try {
4684
+ const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
4685
+ // Refresh the cache from the authoritative read (merge by HLC).
4686
+ await offline.mergeRemote(this.commitment(), fullKey, res.value ?? null, res.value == null ? "delete" : "set", res.hlc);
4687
+ return this.decode(res.value);
4688
+ }
4689
+ catch (err) {
4690
+ if (err instanceof HttpError)
4691
+ throw err; // a real server error, not offline
4692
+ const entry = await offline.read(this.commitment(), fullKey);
4693
+ if (!entry || entry.deleted)
4694
+ return null;
4695
+ return this.decode(entry.value);
4696
+ }
4616
4697
  }
4617
4698
  /** Delete `collection`/`id`. Returns whether the key existed. */
4618
4699
  async delete(collection, id) {
4619
- const res = await this.deps.http.del(this.kvPath(collection, id), {});
4620
- return Boolean(res.existed);
4700
+ const offline = this.deps.offline;
4701
+ if (!offline?.enabled) {
4702
+ const res = await this.deps.http.del(this.kvPath(collection, id), {});
4703
+ return Boolean(res.existed);
4704
+ }
4705
+ const fullKey = `${collection}/${id}`;
4706
+ const hlc = await offline.nextHlc();
4707
+ await offline.deleteLocal(this.commitment(), fullKey, hlc);
4708
+ try {
4709
+ const res = await this.deps.http.del(this.kvPath(collection, id), { hlc });
4710
+ return Boolean(res.existed);
4711
+ }
4712
+ catch (err) {
4713
+ if (err instanceof HttpError)
4714
+ throw err;
4715
+ await offline.enqueue("delete", { collection, id, hlc }, hlc, offline.newClientId());
4716
+ return true; // optimistic
4717
+ }
4621
4718
  }
4622
4719
  /** List the ids present in `collection`. */
4623
4720
  async list(collection) {
4624
- const res = await this.deps.http.post(this.spacePath("/list"), {});
4625
4721
  const prefix = `${collection}/`;
4626
- return (res.keys ?? [])
4627
- .filter((k) => k.startsWith(prefix))
4628
- .map((k) => k.slice(prefix.length));
4722
+ const offline = this.deps.offline;
4723
+ const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
4724
+ if (!offline?.enabled) {
4725
+ const res = await this.deps.http.post(this.spacePath("/list"), {});
4726
+ return fromKeys(res.keys ?? []);
4727
+ }
4728
+ try {
4729
+ const res = await this.deps.http.post(this.spacePath("/list"), {});
4730
+ // Union with cached live keys so un-synced offline creates still show.
4731
+ const cached = await offline.liveKeys(this.commitment());
4732
+ return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
4733
+ }
4734
+ catch (err) {
4735
+ if (err instanceof HttpError)
4736
+ throw err;
4737
+ return fromKeys(await offline.liveKeys(this.commitment()));
4738
+ }
4629
4739
  }
4630
4740
  /** Reserved — server-side query is deferred (see the overhaul plan). */
4631
4741
  async query(_collection, _filter) {
@@ -4684,6 +4794,10 @@ class KvNamespace {
4684
4794
  return;
4685
4795
  const collection = frame.key.slice(0, slash);
4686
4796
  const id = frame.key.slice(slash + 1);
4797
+ // Keep the offline cache warm from the realtime feed (merge by HLC).
4798
+ if (this.deps.offline?.enabled) {
4799
+ await this.deps.offline.mergeRemote(this.commitment(), frame.key, frame.op === "set" ? frame.value ?? null : null, frame.op, frame.hlc);
4800
+ }
4687
4801
  const data = frame.op === "set" ? await this.decode(frame.value) : null;
4688
4802
  handler({ collection, id, type: frame.op, data });
4689
4803
  }
@@ -4751,41 +4865,109 @@ class KvNamespace {
4751
4865
  class DbTable {
4752
4866
  http;
4753
4867
  name;
4754
- constructor(http, name) {
4868
+ offline;
4869
+ constructor(http, name, offline) {
4755
4870
  this.http = http;
4756
4871
  this.name = name;
4872
+ this.offline = offline;
4757
4873
  }
4758
4874
  base() {
4759
4875
  return `/api/db/${encodeURIComponent(this.name)}`;
4760
4876
  }
4877
+ rowUrl(id) {
4878
+ return `${this.base()}/${encodeURIComponent(String(id))}`;
4879
+ }
4761
4880
  /** Insert a row. Returns the created row and its primary-key value. */
4762
4881
  async insert(values) {
4763
- return this.http.post(this.base(), { values });
4882
+ if (!this.offline?.enabled)
4883
+ return this.http.post(this.base(), { values });
4884
+ const hlc = await this.offline.nextHlc();
4885
+ const _hlc = this.offline.columnStamps(values, hlc);
4886
+ try {
4887
+ const res = await this.http.post(this.base(), { values, _hlc });
4888
+ await this.offline.writeRow(this.name, String(res.id), res.row);
4889
+ return res;
4890
+ }
4891
+ catch (err) {
4892
+ if (err instanceof HttpError)
4893
+ throw err;
4894
+ // Offline: queue for replay. No server pk yet — return a provisional
4895
+ // client id so the caller has a stable handle until sync.
4896
+ const clientId = this.offline.newClientId();
4897
+ await this.offline.enqueue("insert", { table: this.name, values, _hlc }, hlc, clientId);
4898
+ return { row: values, id: clientId };
4899
+ }
4764
4900
  }
4765
4901
  /** Fetch a row by primary key, or `null` if it doesn't exist. */
4766
4902
  async get(id) {
4767
4903
  try {
4768
- const res = await this.http.get(`${this.base()}/${encodeURIComponent(String(id))}`);
4904
+ const res = await this.http.get(this.rowUrl(id));
4905
+ if (this.offline?.enabled)
4906
+ await this.offline.writeRow(this.name, String(id), res.row);
4769
4907
  return res.row;
4770
4908
  }
4771
4909
  catch (e) {
4772
4910
  if (e?.status === 404)
4773
4911
  return null;
4912
+ if (this.offline?.enabled && !(e instanceof HttpError)) {
4913
+ return (await this.offline.readRow(this.name, String(id)));
4914
+ }
4774
4915
  throw e;
4775
4916
  }
4776
4917
  }
4777
4918
  /** Query rows with filters, ordering, and keyset pagination. */
4778
4919
  async query(query = {}) {
4779
- return this.http.post(`${this.base()}/query`, query);
4920
+ if (!this.offline?.enabled)
4921
+ return this.http.post(`${this.base()}/query`, query);
4922
+ try {
4923
+ const res = await this.http.post(`${this.base()}/query`, query);
4924
+ await this.offline.writeQuery(this.name, query, res);
4925
+ return res;
4926
+ }
4927
+ catch (e) {
4928
+ if (e instanceof HttpError)
4929
+ throw e;
4930
+ const cached = await this.offline.readQuery(this.name, query);
4931
+ return cached ?? { rows: [], nextCursor: null };
4932
+ }
4780
4933
  }
4781
4934
  /** Update a row by primary key. Returns the updated row. */
4782
4935
  async update(id, values) {
4783
- return this.http.patch(`${this.base()}/${encodeURIComponent(String(id))}`, { values });
4936
+ if (!this.offline?.enabled)
4937
+ return this.http.patch(this.rowUrl(id), { values });
4938
+ const hlc = await this.offline.nextHlc();
4939
+ const _hlc = this.offline.columnStamps(values, hlc);
4940
+ await this.offline.patchRow(this.name, String(id), values); // optimistic
4941
+ try {
4942
+ const res = await this.http.patch(this.rowUrl(id), { values, _hlc });
4943
+ await this.offline.writeRow(this.name, String(id), res.row);
4944
+ return res;
4945
+ }
4946
+ catch (err) {
4947
+ if (err instanceof HttpError)
4948
+ throw err;
4949
+ await this.offline.enqueue("update", { table: this.name, id, values, _hlc }, hlc, this.offline.newClientId());
4950
+ return { row: (await this.offline.readRow(this.name, String(id))) };
4951
+ }
4784
4952
  }
4785
4953
  /** Delete a row by primary key. Returns the number of rows removed (0 or 1). */
4786
4954
  async delete(id) {
4787
- const res = await this.http.del(`${this.base()}/${encodeURIComponent(String(id))}`);
4788
- return res.deleted ?? 0;
4955
+ if (!this.offline?.enabled) {
4956
+ const res = await this.http.del(this.rowUrl(id));
4957
+ return res.deleted ?? 0;
4958
+ }
4959
+ const hlc = await this.offline.nextHlc();
4960
+ await this.offline.deleteRow(this.name, String(id)); // optimistic
4961
+ try {
4962
+ const res = await this.http.del(this.rowUrl(id), { _hlc: hlc });
4963
+ return res.deleted ?? 0;
4964
+ }
4965
+ catch (err) {
4966
+ if (err instanceof HttpError)
4967
+ throw err;
4968
+ await this.offline.enqueue("delete", { table: this.name, id, hlc }, hlc, this.offline.newClientId());
4969
+ return 1; // optimistic
4970
+ }
4789
4971
  }
4790
4972
  }
4791
4973
  class DbNamespace {
@@ -4801,7 +4983,21 @@ class DbNamespace {
4801
4983
  * const todos = client.db.table<Todo>('todos');
4802
4984
  */
4803
4985
  table(name) {
4804
- return new DbTable(this.deps.http, name);
4986
+ return new DbTable(this.deps.http, name, this.deps.offline);
4987
+ }
4988
+ /** Replay one queued db mutation (called by the sync engine). */
4989
+ async replay(entry) {
4990
+ const a = entry.args;
4991
+ const base = `/api/db/${encodeURIComponent(a.table)}`;
4992
+ if (entry.method === "insert") {
4993
+ await this.deps.http.post(base, { values: a.values, _hlc: a._hlc });
4994
+ }
4995
+ else if (entry.method === "update") {
4996
+ await this.deps.http.patch(`${base}/${encodeURIComponent(String(a.id))}`, { values: a.values, _hlc: a._hlc });
4997
+ }
4998
+ else if (entry.method === "delete") {
4999
+ await this.deps.http.del(`${base}/${encodeURIComponent(String(a.id))}`, { _hlc: a.hlc });
5000
+ }
4805
5001
  }
4806
5002
  }
4807
5003
 
@@ -5265,6 +5461,8 @@ class ShardClient {
5265
5461
  baseUrl;
5266
5462
  pathPrefix;
5267
5463
  fetchFn;
5464
+ cache;
5465
+ deferUpload;
5268
5466
  constructor(opts) {
5269
5467
  if (!opts?.baseUrl)
5270
5468
  throw new Error("ShardClient: `baseUrl` is required");
@@ -5280,6 +5478,8 @@ class ShardClient {
5280
5478
  throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
5281
5479
  }
5282
5480
  this.fetchFn = f.bind(globalThis);
5481
+ this.cache = opts.cache;
5482
+ this.deferUpload = opts.deferUpload;
5283
5483
  }
5284
5484
  /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
5285
5485
  urlForHash(hash) {
@@ -5293,12 +5493,29 @@ class ShardClient {
5293
5493
  */
5294
5494
  async putShard(hash, bytes) {
5295
5495
  const url = this.urlForHash(hash);
5296
- const response = await this.fetchFn(url, {
5297
- method: "PUT",
5298
- headers: { "content-type": "application/octet-stream" },
5299
- // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
5300
- body: bytes,
5301
- });
5496
+ // Write the cache first so a just-uploaded file reads back even if the
5497
+ // network PUT then fails (offline). The bytes are content-addressed, so
5498
+ // this is safe to keep regardless of upload outcome.
5499
+ if (this.cache)
5500
+ await this.cache.put(hash, bytes);
5501
+ let response;
5502
+ try {
5503
+ response = await this.fetchFn(url, {
5504
+ method: "PUT",
5505
+ headers: { "content-type": "application/octet-stream" },
5506
+ // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
5507
+ body: bytes,
5508
+ });
5509
+ }
5510
+ catch (err) {
5511
+ // Network unreachable (offline). If a defer hook is wired, queue the
5512
+ // upload for replay and report success — the bytes are cached.
5513
+ if (this.deferUpload) {
5514
+ await this.deferUpload(hash);
5515
+ return { dedup: false };
5516
+ }
5517
+ throw err;
5518
+ }
5302
5519
  if (!response.ok) {
5303
5520
  const text = await safeText(response);
5304
5521
  throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
@@ -5318,8 +5535,25 @@ class ShardClient {
5318
5535
  * `null` for 404 so callers can distinguish "missing" from "errored".
5319
5536
  */
5320
5537
  async getShard(hash) {
5538
+ // Cache-first: a hit is content-verified by construction (key == hash).
5539
+ if (this.cache) {
5540
+ const cached = await this.cache.get(hash);
5541
+ if (cached)
5542
+ return cached;
5543
+ }
5321
5544
  const url = this.urlForHash(hash);
5322
- const response = await this.fetchFn(url, { method: "GET" });
5545
+ let response;
5546
+ try {
5547
+ response = await this.fetchFn(url, { method: "GET" });
5548
+ }
5549
+ catch (err) {
5550
+ // Offline with no cache hit: report the shard as missing so
5551
+ // FileStorage's Reed–Solomon recovery can still reconstruct from the
5552
+ // shards that ARE cached. Rethrow when there's no cache to fall back on.
5553
+ if (this.cache)
5554
+ return null;
5555
+ throw err;
5556
+ }
5323
5557
  if (response.status === 404)
5324
5558
  return null;
5325
5559
  if (!response.ok) {
@@ -5327,7 +5561,10 @@ class ShardClient {
5327
5561
  throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
5328
5562
  }
5329
5563
  const buf = await response.arrayBuffer();
5330
- return new Uint8Array(buf);
5564
+ const bytes = new Uint8Array(buf);
5565
+ if (this.cache)
5566
+ void this.cache.put(hash, bytes);
5567
+ return bytes;
5331
5568
  }
5332
5569
  /**
5333
5570
  * Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
@@ -5832,6 +6069,8 @@ class StorageNamespace {
5832
6069
  baseUrl: this.deps.baseUrl,
5833
6070
  pathPrefix: "/api/shards",
5834
6071
  fetch: this.deps.http.fetch,
6072
+ cache: this.deps.shardCache,
6073
+ deferUpload: this.deps.deferShardUpload,
5835
6074
  });
5836
6075
  }
5837
6076
  return this._shards;
@@ -6528,6 +6767,7 @@ class Space {
6528
6767
  /** Per-instance event bus for fan-out events (isolated across spaces). */
6529
6768
  events = new EventTarget();
6530
6769
  rawWired = false;
6770
+ catchUpUnsub = null;
6531
6771
  constructor(deps) {
6532
6772
  this.deps = deps;
6533
6773
  this.name = deps.name;
@@ -6578,12 +6818,19 @@ class Space {
6578
6818
  this.channel?.sendRaw({ name: this.deps.myId() });
6579
6819
  });
6580
6820
  this.wireFanout();
6821
+ // Register offline catch-up once: on each reconnect the sync
6822
+ // engine pages history forward to fill any gap we missed.
6823
+ if (this.deps.offline?.enabled && !this.catchUpUnsub) {
6824
+ this.catchUpUnsub = this.deps.offline.registerCatchUp(() => this.catchUp());
6825
+ }
6581
6826
  }
6582
6827
  }
6583
6828
  await this.channel.connect();
6584
6829
  }
6585
6830
  disconnect() {
6586
6831
  this.channel?.disconnect();
6832
+ this.catchUpUnsub?.();
6833
+ this.catchUpUnsub = null;
6587
6834
  }
6588
6835
  /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
6589
6836
  on(event, handler) {
@@ -6630,7 +6877,30 @@ class Space {
6630
6877
  * persisted `spaceMessage`. Requires a keyring.
6631
6878
  */
6632
6879
  async sendMessage(payload, opts = {}) {
6633
- this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
6880
+ const offline = this.deps.offline;
6881
+ if (!offline?.enabled) {
6882
+ this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
6883
+ return;
6884
+ }
6885
+ // Offline-aware path: stamp the message, cache it optimistically (so the
6886
+ // UI can show it instantly via cachedMessages), then send if connected
6887
+ // or durably queue it for replay on reconnect.
6888
+ const clientId = offline.newClientId();
6889
+ const hlc = await offline.nextHlc();
6890
+ const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
6891
+ const frame = { spaceMessage: packet };
6892
+ await offline.putMessage(this.id, {
6893
+ handle: provisionalSpaceKey(clientId),
6894
+ packet,
6895
+ op: "msg",
6896
+ clientId,
6897
+ hlc,
6898
+ pending: true,
6899
+ });
6900
+ if (this.isConnected())
6901
+ this.sendRaw(frame);
6902
+ else
6903
+ await offline.enqueueFrame(this.id, frame, clientId, hlc);
6634
6904
  }
6635
6905
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
6636
6906
  onMessage(handler) {
@@ -6648,7 +6918,22 @@ class Space {
6648
6918
  */
6649
6919
  async editMessage(handle, payload, opts = {}) {
6650
6920
  const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
6651
- this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
6921
+ const frame = { editSpaceMessage: { ts: handle, spaceMessage } };
6922
+ const offline = this.deps.offline;
6923
+ if (offline?.enabled) {
6924
+ await offline.putMessage(this.id, {
6925
+ handle: padSpaceHandle(handle),
6926
+ packet: spaceMessage,
6927
+ op: "edit",
6928
+ hlc: await offline.nextHlc(),
6929
+ });
6930
+ if (this.isConnected())
6931
+ this.sendRaw(frame);
6932
+ else
6933
+ await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
6934
+ return;
6935
+ }
6936
+ this.sendRaw(frame);
6652
6937
  }
6653
6938
  /**
6654
6939
  * Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
@@ -6656,7 +6941,45 @@ class Space {
6656
6941
  * original author. Receivers get an {@link onMessageDeleted} event.
6657
6942
  */
6658
6943
  async deleteMessage(handle) {
6659
- this.sendRaw({ deleteSpaceMessage: { ts: handle } });
6944
+ const frame = { deleteSpaceMessage: { ts: handle } };
6945
+ const offline = this.deps.offline;
6946
+ if (offline?.enabled) {
6947
+ await offline.putDeleted(this.id, handle);
6948
+ if (this.isConnected())
6949
+ this.sendRaw(frame);
6950
+ else
6951
+ await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
6952
+ return;
6953
+ }
6954
+ this.sendRaw(frame);
6955
+ }
6956
+ /**
6957
+ * Decrypt and return the locally-cached message log for this space, in
6958
+ * handle order (pending sends last). Works offline — the source is the
6959
+ * IndexedDB cache populated by live frames + {@link history}. Apps call this
6960
+ * on boot to paint instantly, then subscribe to {@link onMessage} for live
6961
+ * updates. Returns `[]` when offline support is off.
6962
+ */
6963
+ async cachedMessages() {
6964
+ const offline = this.deps.offline;
6965
+ if (!offline?.enabled || !this.cipher)
6966
+ return [];
6967
+ const entries = await offline.loadMessages(this.id);
6968
+ const out = [];
6969
+ for (const e of entries) {
6970
+ if (e.deleted || !e.packet)
6971
+ continue;
6972
+ // Already verified when first cached → skip re-verification (which
6973
+ // would also fail offline without a warm member directory).
6974
+ const decoded = await this.decodeFrame(e.packet, this.cipher,
6975
+ /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
6976
+ /*skipVerify*/ true);
6977
+ if (decoded) {
6978
+ decoded.pending = e.pending;
6979
+ out.push(decoded);
6980
+ }
6981
+ }
6982
+ return out;
6660
6983
  }
6661
6984
  /** Subscribe to in-place edits of persisted messages (new content at the same handle). */
6662
6985
  onMessageEdited(handler) {
@@ -6717,8 +7040,21 @@ class Space {
6717
7040
  const messages = [];
6718
7041
  for (const raw of body.messages ?? []) {
6719
7042
  const decoded = await this.decodeFrame(raw, cipher);
6720
- if (decoded)
6721
- messages.push(decoded);
7043
+ if (!decoded)
7044
+ continue;
7045
+ messages.push(decoded);
7046
+ // Warm the offline cache with the inner sealed packet at its handle.
7047
+ if (this.deps.offline?.enabled) {
7048
+ try {
7049
+ const outer = JSON.parse(raw);
7050
+ if (typeof outer.spaceMessage === "string") {
7051
+ await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7052
+ }
7053
+ }
7054
+ catch {
7055
+ /* unparseable history row — skip caching, still surfaced above */
7056
+ }
7057
+ }
6722
7058
  }
6723
7059
  return { messages, nextCursor: body.nextCursor ?? null };
6724
7060
  }
@@ -6785,6 +7121,8 @@ class Space {
6785
7121
  baseUrl: this.deps.httpBaseUrl,
6786
7122
  pathPrefix: "/api/shards",
6787
7123
  fetch: this.deps.fetch,
7124
+ cache: this.deps.shardCache,
7125
+ deferUpload: this.deps.deferShardUpload,
6788
7126
  });
6789
7127
  return new FileStorage({ shards });
6790
7128
  }
@@ -6803,9 +7141,12 @@ class Space {
6803
7141
  if (!f || typeof f !== "object")
6804
7142
  return;
6805
7143
  if (typeof f.spaceMessage === "string" && this.cipher) {
6806
- const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
6807
- if (decoded)
7144
+ const handle = Number(f.timestamp ?? 0);
7145
+ const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
7146
+ if (decoded) {
6808
7147
  this.emitEvent("message", decoded);
7148
+ await this.cacheFrame(f.spaceMessage, decoded, "msg");
7149
+ }
6809
7150
  return;
6810
7151
  }
6811
7152
  if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
@@ -6814,14 +7155,21 @@ class Space {
6814
7155
  const e = f.editSpaceMessage;
6815
7156
  if (typeof e.spaceMessage === "string") {
6816
7157
  const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
6817
- if (decoded)
7158
+ if (decoded) {
6818
7159
  this.emitEvent("message-edited", decoded);
7160
+ await this.cacheFrame(e.spaceMessage, decoded, "edit");
7161
+ }
6819
7162
  }
6820
7163
  return;
6821
7164
  }
6822
7165
  if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
6823
7166
  const d = f.deleteSpaceMessage;
6824
- this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
7167
+ const handle = Number(d.ts ?? 0);
7168
+ this.emitEvent("message-deleted", { handle });
7169
+ if (this.deps.offline?.enabled) {
7170
+ await this.deps.offline.putDeleted(this.id, handle);
7171
+ await this.deps.offline.observeHandle(this.id, handle);
7172
+ }
6825
7173
  return;
6826
7174
  }
6827
7175
  if (f.pub && typeof f.pub === "object") {
@@ -6857,12 +7205,17 @@ class Space {
6857
7205
  * serialized `Packet` string. Shared by send (a new message) and edit
6858
7206
  * (replacement content for an existing handle). Requires a keyring.
6859
7207
  */
6860
- async sealMessage(payload, channel, contentType) {
7208
+ async sealMessage(payload, channel, contentType, clientId) {
6861
7209
  const cipher = this.requireCipher(contentType);
6862
7210
  const message = new Message(payload);
6863
7211
  const headers = await cipher.seal(message.serialize());
6864
7212
  const source = this.deps.myId();
6865
7213
  const subject = channel ?? "default";
7214
+ // Cleartext dedupe hint — lets the sender match its optimistic entry to
7215
+ // the server echo. Not part of the signed canonical form (it carries no
7216
+ // authority); worst case a stale hint just shows a transient duplicate.
7217
+ if (clientId)
7218
+ headers.cid = clientId;
6866
7219
  // Sign with our identity ECDSA key so receivers can verify authorship
6867
7220
  // end-to-end (independent of the relay's `source` stamp).
6868
7221
  const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
@@ -6882,7 +7235,7 @@ class Space {
6882
7235
  * the enclosing frame); false means `input` is the full
6883
7236
  * `{spaceMessage, name, timestamp}` frame and the handle is read from it.
6884
7237
  */
6885
- async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
7238
+ async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0, skipVerify = false) {
6886
7239
  try {
6887
7240
  let packetJson = input;
6888
7241
  let handle = serverTs;
@@ -6900,8 +7253,9 @@ class Space {
6900
7253
  // by `source`'s identity ECDSA key (looked up in the member
6901
7254
  // directory). Drop anything unsigned, signed by an unknown member,
6902
7255
  // or whose signature doesn't match — this defeats both member
6903
- // impersonation and a relay rewriting `source`.
6904
- if (!(await this.verifySender(packet)))
7256
+ // impersonation and a relay rewriting `source`. Skipped only when
7257
+ // re-reading from the local cache (already verified before caching).
7258
+ if (!skipVerify && !(await this.verifySender(packet)))
6905
7259
  return null;
6906
7260
  const serialized = await cipher.open(packet.headers);
6907
7261
  if (serialized === null)
@@ -6913,6 +7267,7 @@ class Space {
6913
7267
  epoch: Number(packet.headers?.epoch ?? 0),
6914
7268
  contentType: packet.headers?.contentType,
6915
7269
  handle,
7270
+ clientId: packet.headers?.cid,
6916
7271
  message,
6917
7272
  };
6918
7273
  }
@@ -6955,6 +7310,62 @@ class Space {
6955
7310
  });
6956
7311
  return verifySpaceMessage(canonical, sig, key);
6957
7312
  }
7313
+ /** Persist a decoded live/history/edit frame to the offline cache. */
7314
+ async cacheFrame(packet, decoded, op) {
7315
+ const offline = this.deps.offline;
7316
+ if (!offline?.enabled)
7317
+ return;
7318
+ // An incoming real message supersedes any optimistic pending entry.
7319
+ if (decoded.clientId)
7320
+ await offline.dropPending(this.id, decoded.clientId);
7321
+ await offline.putMessage(this.id, {
7322
+ handle: padSpaceHandle(decoded.handle),
7323
+ packet,
7324
+ op,
7325
+ clientId: decoded.clientId,
7326
+ });
7327
+ await offline.observeHandle(this.id, decoded.handle);
7328
+ }
7329
+ /**
7330
+ * Inbound reconciliation run on reconnect: page the server's forward delta
7331
+ * (`?since=<lastSeenHandle>`) to the head, pulling exactly the messages that
7332
+ * landed while we were offline into the cache. Bounded so a long absence
7333
+ * can't page forever. Falls back gracefully if the endpoint is unavailable.
7334
+ */
7335
+ async catchUp() {
7336
+ const offline = this.deps.offline;
7337
+ if (!offline?.enabled || !this.cipher)
7338
+ return;
7339
+ const cursor = await offline.getCursor(this.id);
7340
+ let since = String(cursor?.lastSeenHandle ?? 0);
7341
+ // Warm the member directory once so historical senders' signatures verify.
7342
+ await this.deps.keyring?.refreshDirectory();
7343
+ for (let page = 0; page < 50; page++) {
7344
+ const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7345
+ `?since=${encodeURIComponent(since)}&limit=100`;
7346
+ const res = await this.deps.fetch(url);
7347
+ if (!res.ok)
7348
+ break;
7349
+ const body = (await res.json());
7350
+ for (const raw of body.messages ?? []) {
7351
+ const decoded = await this.decodeFrame(raw, this.cipher);
7352
+ if (!decoded)
7353
+ continue;
7354
+ try {
7355
+ const outer = JSON.parse(raw);
7356
+ if (typeof outer.spaceMessage === "string") {
7357
+ await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7358
+ }
7359
+ }
7360
+ catch {
7361
+ /* unparseable row — skip */
7362
+ }
7363
+ }
7364
+ if (!body.nextCursor)
7365
+ break;
7366
+ since = body.nextCursor;
7367
+ }
7368
+ }
6958
7369
  emitEvent(type, detail) {
6959
7370
  this.events.dispatchEvent(new CustomEvent(type, { detail }));
6960
7371
  }
@@ -6974,6 +7385,18 @@ class Space {
6974
7385
  function delay(ms) {
6975
7386
  return new Promise((r) => setTimeout(r, ms));
6976
7387
  }
7388
+ /**
7389
+ * Zero-pad a numeric server handle for use as a cache key, so string-ordered
7390
+ * keys sort in handle order. Must match the offline layer's `padHandle`.
7391
+ */
7392
+ function padSpaceHandle(handle) {
7393
+ return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
7394
+ }
7395
+ /** Cache key for an un-acked optimistic send. Must match the offline layer's
7396
+ * `provisionalHandle` (sorts after every real, zero-padded handle). */
7397
+ function provisionalSpaceKey(clientId) {
7398
+ return `~local:${clientId}`;
7399
+ }
6977
7400
 
6978
7401
  /**
6979
7402
  * `client.message` — realtime messaging over the shared-space primitive.
@@ -7468,6 +7891,156 @@ class KeyringClient {
7468
7891
  }
7469
7892
  }
7470
7893
 
7894
+ /**
7895
+ * Message-log helpers — the conflict model for a Space's message history.
7896
+ *
7897
+ * Messages are the easy CRDT: the accelerator assigns every persisted message a
7898
+ * monotonic, zero-padded `handle` that already totally-orders the log, and the
7899
+ * sealed body is immutable. So the "log" is just a grow-only set keyed by
7900
+ * `handle`, and convergence is structural — no server change needed.
7901
+ *
7902
+ * The only subtleties this module encodes:
7903
+ * - **Optimistic sends** have no server handle yet. We key them by a
7904
+ * provisional handle that sorts *after* every real one, and tag them with
7905
+ * the sender's `clientId` so the real frame (carrying the same `clientId`)
7906
+ * can replace the placeholder instead of duplicating it.
7907
+ * - **Edits** are a per-message LWW register on the body (greater HLC wins).
7908
+ * - **Deletes** are a tombstone at the message's handle.
7909
+ */
7910
+ /** IndexedDB key for a message within a space. */
7911
+ function messageKey(spaceId, handle) {
7912
+ return `${spaceId}|${handle}`;
7913
+ }
7914
+ /**
7915
+ * A handle for an un-acked optimistic message. Prefixed with `"~"` (0x7E),
7916
+ * which sorts after every digit, so pending sends always appear at the tail of
7917
+ * the handle-ordered log until the real handle arrives.
7918
+ */
7919
+ function provisionalHandle(clientId) {
7920
+ return `~local:${clientId}`;
7921
+ }
7922
+ function isProvisional(handle) {
7923
+ return handle.startsWith("~local:");
7924
+ }
7925
+ /**
7926
+ * Merge two entries for the SAME handle. A delete always wins (tombstone); an
7927
+ * edit wins over the entry it supersedes when its HLC is greater; otherwise the
7928
+ * existing entry stands (a re-delivered `msg` is idempotent).
7929
+ */
7930
+ function mergeMessage(local, incoming) {
7931
+ if (!local)
7932
+ return incoming;
7933
+ if (incoming.op === "delete" || local.deleted) {
7934
+ return { ...local, op: "delete", packet: null, deleted: true };
7935
+ }
7936
+ if (incoming.op === "edit") {
7937
+ if (!local.hlc || (incoming.hlc && incoming.hlc > local.hlc)) {
7938
+ return { ...local, packet: incoming.packet, hlc: incoming.hlc, op: "edit" };
7939
+ }
7940
+ return local;
7941
+ }
7942
+ // incoming.op === "msg": keep the already-stored (de-pending) entry.
7943
+ return { ...local, pending: false };
7944
+ }
7945
+
7946
+ /**
7947
+ * `SpaceCache` — the offline adapter a {@link ../spaces/Space} talks to. It
7948
+ * persists sealed message frames (ciphertext at rest), tracks per-space sync
7949
+ * cursors, and records offline sends in the durable queue. Kept as its own
7950
+ * object (rather than wiring the Space straight to the store + clock) so the
7951
+ * Space stays decoupled from IndexedDB and so the no-op path is a single
7952
+ * `enabled` check.
7953
+ *
7954
+ * Messages are the structural CRDT (see {@link ./crdt/MessageLog}): the server
7955
+ * assigns a monotonic `handle` that totally-orders the log, so the cache just
7956
+ * stores frames keyed by a zero-padded handle (real) or a provisional key
7957
+ * (pending sends, which sort to the tail until their echo arrives).
7958
+ */
7959
+ /** Zero-pad a numeric server handle so string keys sort in handle order. */
7960
+ function padHandle(handle) {
7961
+ return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
7962
+ }
7963
+ /**
7964
+ * The surface a {@link ../spaces/Space} uses. Declared structurally on the
7965
+ * Space side too (as `SpaceOfflineAdapter`) so the spaces module never imports
7966
+ * the offline module.
7967
+ */
7968
+ class SpaceCache {
7969
+ manager;
7970
+ store;
7971
+ constructor(manager) {
7972
+ this.manager = manager;
7973
+ this.store = manager.store;
7974
+ }
7975
+ get enabled() {
7976
+ return this.manager.enabled;
7977
+ }
7978
+ newClientId() {
7979
+ return this.manager.newClientId();
7980
+ }
7981
+ nextHlc() {
7982
+ return this.manager.nextHlc();
7983
+ }
7984
+ /** Persist a sealed message frame keyed by its (real or provisional) handle. */
7985
+ async putMessage(spaceId, entry) {
7986
+ await this.store.put("space-messages", messageKey(spaceId, entry.handle), entry);
7987
+ }
7988
+ /** Tombstone the message at `handle`. */
7989
+ async putDeleted(spaceId, handle) {
7990
+ const key = messageKey(spaceId, padHandle(handle));
7991
+ const existing = await this.store.get("space-messages", key);
7992
+ await this.store.put("space-messages", key, {
7993
+ handle: padHandle(handle),
7994
+ packet: null,
7995
+ op: "delete",
7996
+ deleted: true,
7997
+ clientId: existing?.clientId,
7998
+ });
7999
+ }
8000
+ /** Drop a pending (optimistic) entry once its real echo has been stored. */
8001
+ async dropPending(spaceId, clientId) {
8002
+ await this.store.delete("space-messages", messageKey(spaceId, provisionalHandle(clientId)));
8003
+ }
8004
+ /** All cached frames for a space, in handle order (pending ones last). */
8005
+ async loadMessages(spaceId) {
8006
+ const rows = await this.store.range("space-messages", `${spaceId}|`, `${spaceId}|￿`);
8007
+ return rows.map((r) => r.value);
8008
+ }
8009
+ async getCursor(spaceId) {
8010
+ return this.store.get("space-cursors", spaceId);
8011
+ }
8012
+ /** Advance the last-seen handle (monotonic) and track the oldest fetched. */
8013
+ async observeHandle(spaceId, handle) {
8014
+ if (!handle)
8015
+ return;
8016
+ const cur = (await this.getCursor(spaceId)) ?? {
8017
+ lastSeenHandle: 0,
8018
+ oldestHandle: handle,
8019
+ fullyBackfilled: false,
8020
+ };
8021
+ const next = {
8022
+ lastSeenHandle: Math.max(cur.lastSeenHandle, handle),
8023
+ oldestHandle: cur.oldestHandle ? Math.min(cur.oldestHandle, handle) : handle,
8024
+ fullyBackfilled: cur.fullyBackfilled,
8025
+ };
8026
+ await this.store.put("space-cursors", spaceId, next);
8027
+ }
8028
+ /** Queue a raw space frame for replay when the connection returns. */
8029
+ async enqueueFrame(spaceId, frame, clientId, hlc) {
8030
+ await this.manager.enqueue({
8031
+ hlc,
8032
+ clientId,
8033
+ domain: "space",
8034
+ method: "sendRaw",
8035
+ args: { spaceId, frame },
8036
+ });
8037
+ }
8038
+ /** Register an inbound catch-up task (forward/back history paging). */
8039
+ registerCatchUp(task) {
8040
+ return this.manager.registerCatchUp(task);
8041
+ }
8042
+ }
8043
+
7471
8044
  /**
7472
8045
  * `client.space` — the fan-out group-encryption surface.
7473
8046
  *
@@ -7503,8 +8076,26 @@ class ChannelExistsError extends Error {
7503
8076
  }
7504
8077
  class SpaceNamespace {
7505
8078
  deps;
8079
+ spaceCache;
8080
+ /** Spaces built this session, by id — used to route offline send replays. */
8081
+ openSpaces = new Map();
7506
8082
  constructor(deps) {
7507
8083
  this.deps = deps;
8084
+ if (deps.offline?.enabled) {
8085
+ this.spaceCache = new SpaceCache(deps.offline);
8086
+ // Replay queued space frames on reconnect: route each to its space
8087
+ // (reopening + connecting it if needed), then re-send the raw frame.
8088
+ deps.offline.registerReplayer("space", async (entry) => {
8089
+ const { spaceId, frame } = entry.args;
8090
+ let space = this.openSpaces.get(spaceId);
8091
+ if (!space)
8092
+ space = await this.get(spaceId);
8093
+ await space.connect();
8094
+ if (!space.isConnected())
8095
+ throw new Error(`space ${spaceId} not connected for replay`);
8096
+ space.sendRaw(frame);
8097
+ });
8098
+ }
7508
8099
  }
7509
8100
  /**
7510
8101
  * Create a new space: generate its keypair (public key = id), register
@@ -7665,7 +8256,7 @@ class SpaceNamespace {
7665
8256
  cache: this.deps.cache,
7666
8257
  historyPolicy,
7667
8258
  });
7668
- return new Space({
8259
+ const space = new Space({
7669
8260
  name: spaceId,
7670
8261
  wsBaseUrl: this.deps.wsBaseUrl,
7671
8262
  httpBaseUrl: this.deps.http.baseUrl,
@@ -7674,7 +8265,14 @@ class SpaceNamespace {
7674
8265
  fetchTicket: () => this.fetchTicket(),
7675
8266
  keyring,
7676
8267
  historyPolicy,
8268
+ offline: this.spaceCache,
8269
+ shardCache: this.deps.offline?.fileCache,
8270
+ deferShardUpload: this.deps.offline
8271
+ ? (hash) => this.deps.offline.deferShardUpload(hash)
8272
+ : undefined,
7677
8273
  });
8274
+ this.openSpaces.set(spaceId, space);
8275
+ return space;
7678
8276
  }
7679
8277
  /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
7680
8278
  async ensureIdentity(memberId) {
@@ -7921,6 +8519,1171 @@ class FunctionsNamespace {
7921
8519
  }
7922
8520
  }
7923
8521
 
8522
+ /**
8523
+ * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
8524
+ * lexicographically-sortable string so the whole offline layer (and the
8525
+ * accelerator) can compare causality with one plain string comparison.
8526
+ *
8527
+ * An HLC fuses a physical wall-clock millisecond with a logical `counter`
8528
+ * (breaks ties inside the same millisecond and lets the clock advance past a
8529
+ * remote event without the wall clock moving) and a stable `nodeId` (a final,
8530
+ * deterministic tiebreak so two devices never produce an "equal but different"
8531
+ * stamp). See {@link ./HLC}.
8532
+ *
8533
+ * The packed form is `"{wall}:{counter}:{nodeId}"` with `wall` and `counter`
8534
+ * zero-padded to a FIXED width. Fixed width is what makes lexicographic order
8535
+ * equal to numeric order — `"0000000000123:..."` < `"0000000000124:..."`. The
8536
+ * server only ever compares these strings; it never has to parse them.
8537
+ */
8538
+ /** Zero-padded width of the wall-clock field. 15 digits holds ms timestamps
8539
+ * past the year 5000, so `Date.now()` (currently 13 digits) never overflows. */
8540
+ const WALL_DIGITS = 15;
8541
+ /** Zero-padded width of the logical counter. 5 digits allows 100k events in a
8542
+ * single wall-clock millisecond before the clock is forced forward — far more
8543
+ * than any real burst. */
8544
+ const COUNTER_DIGITS = 5;
8545
+ /** The smallest possible stamp — used as the "nothing stored yet" sentinel so a
8546
+ * first real write always wins (`compareHlc(real, ZERO_HLC) > 0`). */
8547
+ const ZERO_HLC = pack(0, 0, "");
8548
+ function padNum(n, width) {
8549
+ const s = Math.max(0, Math.floor(n)).toString();
8550
+ return s.length >= width ? s : "0".repeat(width - s.length) + s;
8551
+ }
8552
+ /** Pack the three components into the sortable string form. */
8553
+ function pack(wall, counter, nodeId) {
8554
+ return `${padNum(wall, WALL_DIGITS)}:${padNum(counter, COUNTER_DIGITS)}:${nodeId}`;
8555
+ }
8556
+ /** Parse a packed stamp back into its components. Tolerates the empty/zero
8557
+ * sentinel. Throws on a malformed string so corruption surfaces loudly. */
8558
+ function unpack(stamp) {
8559
+ const idx1 = stamp.indexOf(":");
8560
+ const idx2 = stamp.indexOf(":", idx1 + 1);
8561
+ if (idx1 < 0 || idx2 < 0) {
8562
+ throw new Error(`malformed HLC timestamp: ${JSON.stringify(stamp)}`);
8563
+ }
8564
+ return {
8565
+ wall: Number(stamp.slice(0, idx1)),
8566
+ counter: Number(stamp.slice(idx1 + 1, idx2)),
8567
+ nodeId: stamp.slice(idx2 + 1),
8568
+ };
8569
+ }
8570
+ /**
8571
+ * Total order over packed HLC strings: negative if `a < b`, positive if
8572
+ * `a > b`, `0` only when they are byte-identical. Because the fields are
8573
+ * fixed-width and ordered (wall, counter, nodeId), a single string comparison
8574
+ * already yields causal order — this helper just normalizes the result to the
8575
+ * `-1 | 0 | 1` shape callers expect.
8576
+ */
8577
+ function compareHlc(a, b) {
8578
+ if (a === b)
8579
+ return 0;
8580
+ return a < b ? -1 : 1;
8581
+ }
8582
+ /** Convenience: is `a` strictly newer than `b`? */
8583
+ function isNewer(a, b) {
8584
+ return compareHlc(a, b) > 0;
8585
+ }
8586
+
8587
+ /**
8588
+ * `HLC` — a Hybrid Logical Clock. Hands out monotonically increasing
8589
+ * {@link HlcTimestamp} stamps that (a) track real time closely enough to be
8590
+ * human-meaningful, yet (b) never go backwards, even across a reload or when a
8591
+ * peer's clock is ahead of ours.
8592
+ *
8593
+ * Two operations, both the textbook HLC algorithm (Kulkarni et al.):
8594
+ * - `now()` — stamp a local event.
8595
+ * - `update(remote)` — observe a remote stamp (e.g. arriving on the realtime
8596
+ * change feed) and advance our clock past it, so any event we generate
8597
+ * afterwards is causally `> remote`.
8598
+ *
8599
+ * State is just `{ wall, counter }`; `nodeId` is fixed for the install. The
8600
+ * owner ({@link ../OfflineManager}) seeds the clock from persisted state on
8601
+ * boot and persists `getState()` after each tick so monotonicity survives page
8602
+ * reloads — without that, a reload could mint a stamp that loses to one written
8603
+ * seconds earlier.
8604
+ */
8605
+ class HLC {
8606
+ wall;
8607
+ counter;
8608
+ nodeId;
8609
+ physical;
8610
+ constructor(nodeId, opts = {}) {
8611
+ this.nodeId = nodeId;
8612
+ this.physical = opts.physical ?? (() => Date.now());
8613
+ this.wall = opts.state?.wall ?? 0;
8614
+ this.counter = opts.state?.counter ?? 0;
8615
+ }
8616
+ /** Stamp a locally-generated event. Advances the clock. */
8617
+ now() {
8618
+ const pt = this.physical();
8619
+ const prevWall = this.wall;
8620
+ this.wall = Math.max(prevWall, pt);
8621
+ this.counter = this.wall === prevWall ? this.counter + 1 : 0;
8622
+ return this.current();
8623
+ }
8624
+ /**
8625
+ * Merge in an observed remote stamp and advance our clock strictly past it.
8626
+ * Call this for every inbound HLC (change-feed frames, history rows) so the
8627
+ * next `now()` is causally after anything we've seen.
8628
+ */
8629
+ update(remote) {
8630
+ const pt = this.physical();
8631
+ const prevWall = this.wall;
8632
+ const newWall = Math.max(prevWall, remote.wall, pt);
8633
+ if (newWall === prevWall && newWall === remote.wall) {
8634
+ this.counter = Math.max(this.counter, remote.counter) + 1;
8635
+ }
8636
+ else if (newWall === prevWall) {
8637
+ this.counter = this.counter + 1;
8638
+ }
8639
+ else if (newWall === remote.wall) {
8640
+ this.counter = remote.counter + 1;
8641
+ }
8642
+ else {
8643
+ this.counter = 0;
8644
+ }
8645
+ this.wall = newWall;
8646
+ return this.current();
8647
+ }
8648
+ /** The current stamp without advancing the clock. */
8649
+ current() {
8650
+ return pack(this.wall, this.counter, this.nodeId);
8651
+ }
8652
+ /** Serializable `{ wall, counter }` for persistence. */
8653
+ getState() {
8654
+ return { wall: this.wall, counter: this.counter };
8655
+ }
8656
+ }
8657
+
8658
+ /**
8659
+ * `ConnectivityManager` — the single source of truth for "are we online", which
8660
+ * the offline layer needs to decide between a network call and a cache read, and
8661
+ * to know when to flush the durable outbound queue.
8662
+ *
8663
+ * `navigator.onLine` alone lies often (captive portals, VPN flaps, a laptop
8664
+ * that's "connected" to a network with no route), so we fuse three signals:
8665
+ * 1. the browser `online`/`offline` events (coarse but free),
8666
+ * 2. real fetch outcomes reported by {@link ../core/HttpClient} and the shard
8667
+ * client — a thrown `TypeError`/abort is the most reliable "offline" tell,
8668
+ * and a 2xx is the most reliable "online" tell,
8669
+ * 3. the sync engine, which flips us to `syncing` while it drains the queue.
8670
+ *
8671
+ * The offline→online edge fires `onReconnect` (wired to the sync engine) so
8672
+ * queued writes replay automatically. Transitions are debounced to avoid
8673
+ * flapping the UI on a brief blip. State changes also emit on {@link EventCore}
8674
+ * (`ONLINE`/`OFFLINE`/`SYNCING`) so apps can render a status indicator.
8675
+ */
8676
+ class ConnectivityManager {
8677
+ state;
8678
+ listeners = new Set();
8679
+ onReconnect;
8680
+ debounceMs;
8681
+ offlineTimer = null;
8682
+ started = false;
8683
+ handleOnline = () => this.markOnline();
8684
+ handleOffline = () => this.scheduleOffline();
8685
+ constructor(opts = {}) {
8686
+ this.onReconnect = opts.onReconnect;
8687
+ this.debounceMs = opts.debounceMs ?? 1500;
8688
+ this.state =
8689
+ typeof navigator !== "undefined" && navigator.onLine === false ? "offline" : "online";
8690
+ }
8691
+ /** Attach to the browser online/offline events. Safe to call in Node (no-op). */
8692
+ start() {
8693
+ if (this.started || typeof window === "undefined")
8694
+ return;
8695
+ window.addEventListener("online", this.handleOnline);
8696
+ window.addEventListener("offline", this.handleOffline);
8697
+ this.started = true;
8698
+ }
8699
+ stop() {
8700
+ if (!this.started || typeof window === "undefined")
8701
+ return;
8702
+ window.removeEventListener("online", this.handleOnline);
8703
+ window.removeEventListener("offline", this.handleOffline);
8704
+ if (this.offlineTimer)
8705
+ clearTimeout(this.offlineTimer);
8706
+ this.started = false;
8707
+ }
8708
+ get current() {
8709
+ return this.state;
8710
+ }
8711
+ get isOnline() {
8712
+ return this.state !== "offline";
8713
+ }
8714
+ /** Subscribe to state changes. Returns an unsubscribe function. */
8715
+ onChange(cb) {
8716
+ this.listeners.add(cb);
8717
+ return () => this.listeners.delete(cb);
8718
+ }
8719
+ /** Report a failed network attempt (thrown fetch / abort). */
8720
+ reportFetchFailure() {
8721
+ this.scheduleOffline();
8722
+ }
8723
+ /** Report a successful network round-trip — strongest "we're online" signal. */
8724
+ reportFetchSuccess() {
8725
+ this.markOnline();
8726
+ }
8727
+ /** The sync engine entering its drain phase. */
8728
+ markSyncing() {
8729
+ if (this.offlineTimer) {
8730
+ clearTimeout(this.offlineTimer);
8731
+ this.offlineTimer = null;
8732
+ }
8733
+ this.set("syncing");
8734
+ }
8735
+ /** The sync engine finished — settle back to online. */
8736
+ markSynced() {
8737
+ this.set("online");
8738
+ }
8739
+ markOnline() {
8740
+ if (this.offlineTimer) {
8741
+ clearTimeout(this.offlineTimer);
8742
+ this.offlineTimer = null;
8743
+ }
8744
+ // The sync engine owns the syncing→online transition (via markSynced).
8745
+ // A request succeeding mid-drain must not cut the syncing state short.
8746
+ if (this.state === "syncing")
8747
+ return;
8748
+ const wasOffline = this.state === "offline";
8749
+ this.set("online");
8750
+ if (wasOffline) {
8751
+ try {
8752
+ this.onReconnect?.();
8753
+ }
8754
+ catch {
8755
+ /* a reconnect handler must not break connectivity tracking */
8756
+ }
8757
+ }
8758
+ }
8759
+ scheduleOffline() {
8760
+ if (this.state === "offline" || this.offlineTimer)
8761
+ return;
8762
+ this.offlineTimer = setTimeout(() => {
8763
+ this.offlineTimer = null;
8764
+ this.set("offline");
8765
+ }, this.debounceMs);
8766
+ }
8767
+ set(state) {
8768
+ if (state === this.state)
8769
+ return;
8770
+ this.state = state;
8771
+ for (const cb of this.listeners) {
8772
+ try {
8773
+ cb(state);
8774
+ }
8775
+ catch {
8776
+ /* a bad listener mustn't break the others */
8777
+ }
8778
+ }
8779
+ const event = state === "online"
8780
+ ? EventCoreEvents.ONLINE
8781
+ : state === "offline"
8782
+ ? EventCoreEvents.OFFLINE
8783
+ : EventCoreEvents.SYNCING;
8784
+ EventCore.emit(event, { state });
8785
+ }
8786
+ }
8787
+
8788
+ /**
8789
+ * `OutboundQueue` — the durable record of writes made while offline (or that
8790
+ * failed mid-flight) plus the machinery to replay them on reconnect. This is
8791
+ * what makes offline writes *survive a reload*: unlike the in-memory buffer in
8792
+ * {@link ../transport/WSTransport}, every entry lives in IndexedDB until it has
8793
+ * been acknowledged by the server.
8794
+ *
8795
+ * Each domain registers a **replayer** — a function that re-applies one queued
8796
+ * mutation by re-invoking the real namespace method (with an `isReplay` flag so
8797
+ * it doesn't re-enqueue). `drain()` replays entries in HLC order:
8798
+ * - success → remove the entry;
8799
+ * - permanent failure (4xx) → drop it and surface a `SYNC_ERROR` (a write the
8800
+ * server will never accept must not wedge the queue forever);
8801
+ * - transient/network failure → stop draining and leave the rest for the next
8802
+ * reconnect.
8803
+ *
8804
+ * Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
8805
+ * by `clientId`, file PUTs are content-addressed), so re-running a partially
8806
+ * drained queue is safe.
8807
+ */
8808
+ class OutboundQueue {
8809
+ store;
8810
+ replayers = new Map();
8811
+ draining = null;
8812
+ constructor(store) {
8813
+ this.store = store;
8814
+ }
8815
+ /** A domain registers how to replay its mutations. */
8816
+ register(domain, replayer) {
8817
+ this.replayers.set(domain, replayer);
8818
+ }
8819
+ /** Record a mutation for later replay; resolves with its assigned seq. */
8820
+ enqueue(entry) {
8821
+ return this.store.enqueue(entry);
8822
+ }
8823
+ /** Number of writes still awaiting sync. */
8824
+ async pending() {
8825
+ return (await this.store.queued()).length;
8826
+ }
8827
+ /**
8828
+ * Replay queued mutations in HLC order. Coalesces concurrent calls into a
8829
+ * single in-flight drain. Stops on the first transient failure, leaving the
8830
+ * remaining entries for the next attempt.
8831
+ */
8832
+ drain() {
8833
+ if (this.draining)
8834
+ return this.draining;
8835
+ this.draining = this.runDrain().finally(() => {
8836
+ this.draining = null;
8837
+ });
8838
+ return this.draining;
8839
+ }
8840
+ async runDrain() {
8841
+ const entries = await this.store.queued();
8842
+ for (const entry of entries) {
8843
+ const replayer = this.replayers.get(entry.domain);
8844
+ if (!replayer) {
8845
+ // No handler registered (namespace not wired) — drop it rather
8846
+ // than block the queue; nothing can ever replay it.
8847
+ await this.store.dequeue(entry.seq);
8848
+ continue;
8849
+ }
8850
+ try {
8851
+ await replayer(entry);
8852
+ await this.store.dequeue(entry.seq);
8853
+ EventCore.emit(EventCoreEvents.SYNC_PROGRESS, {
8854
+ domain: entry.domain,
8855
+ method: entry.method,
8856
+ });
8857
+ }
8858
+ catch (err) {
8859
+ if (isPermanent(err)) {
8860
+ await this.store.dequeue(entry.seq);
8861
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
8862
+ domain: entry.domain,
8863
+ method: entry.method,
8864
+ error: String(err?.message ?? err),
8865
+ dropped: true,
8866
+ });
8867
+ continue;
8868
+ }
8869
+ // Transient (offline again, 5xx) — keep the entry, stop here.
8870
+ throw err;
8871
+ }
8872
+ }
8873
+ }
8874
+ }
8875
+ /** A 4xx is the server permanently refusing the write; anything else is worth
8876
+ * retrying on the next reconnect. */
8877
+ function isPermanent(err) {
8878
+ return err instanceof HttpError && err.status >= 400 && err.status < 500;
8879
+ }
8880
+
8881
+ /**
8882
+ * `SyncEngine` — orchestrates catch-up when the client comes back online. It's
8883
+ * the thing wired to the {@link ConnectivityManager}'s offline→online edge.
8884
+ *
8885
+ * One `run()` does, in order:
8886
+ * 1. **Guard** — only proceed when the session is authenticated and unlocked
8887
+ * (replays may need to re-seal/re-encrypt, and there's no point pushing
8888
+ * writes for a logged-out user).
8889
+ * 2. **Drain** the durable {@link OutboundQueue} — push every offline write.
8890
+ * 3. **Catch-up tasks** — domain-registered pulls that reconcile *inbound*
8891
+ * state we missed while offline (e.g. a Space paging history forward to its
8892
+ * last-seen handle). Registered by the namespaces.
8893
+ *
8894
+ * Concurrent triggers coalesce into a single in-flight run (a reconnect often
8895
+ * fires several signals at once), mirroring `Client.recoverInFlight`.
8896
+ */
8897
+ class SyncEngine {
8898
+ deps;
8899
+ catchUpTasks = new Set();
8900
+ running = null;
8901
+ constructor(deps) {
8902
+ this.deps = deps;
8903
+ }
8904
+ /** Register an inbound reconciliation task run after the queue drains. */
8905
+ registerCatchUp(task) {
8906
+ this.catchUpTasks.add(task);
8907
+ return () => this.catchUpTasks.delete(task);
8908
+ }
8909
+ /** Drain + catch up. Concurrent calls share one in-flight run. */
8910
+ run() {
8911
+ if (this.running)
8912
+ return this.running;
8913
+ this.running = this.execute().finally(() => {
8914
+ this.running = null;
8915
+ });
8916
+ return this.running;
8917
+ }
8918
+ async execute() {
8919
+ if (!this.deps.canSync())
8920
+ return;
8921
+ this.deps.connectivity.markSyncing();
8922
+ try {
8923
+ await this.deps.queue.drain();
8924
+ for (const task of this.catchUpTasks) {
8925
+ try {
8926
+ await task();
8927
+ }
8928
+ catch {
8929
+ // One domain's catch-up failing shouldn't abort the others
8930
+ // or wedge the sync; it'll retry on the next reconnect.
8931
+ }
8932
+ }
8933
+ EventCore.emit(EventCoreEvents.SYNC_COMPLETE, {});
8934
+ }
8935
+ finally {
8936
+ this.deps.connectivity.markSynced();
8937
+ }
8938
+ }
8939
+ }
8940
+
8941
+ /**
8942
+ * Errors specific to the offline layer.
8943
+ */
8944
+ /**
8945
+ * Thrown when an offline read/write needs the user's identity (to decrypt or
8946
+ * re-encrypt cached data) but the client is locked — typically right after a
8947
+ * reload, where only the session token was restored and `unlock()` hasn't run
8948
+ * yet. Distinct from the generic "not signed in" identity error so apps can
8949
+ * specifically prompt the user to unlock to see their offline data.
8950
+ */
8951
+ class OfflineLockedError extends Error {
8952
+ constructor(message = "Unlock required to access encrypted offline data. Call client.auth.zk.unlock(password).") {
8953
+ super(message);
8954
+ this.name = "OfflineLockedError";
8955
+ }
8956
+ }
8957
+
8958
+ /**
8959
+ * `client.offline.snapshot` — a small, encrypted, app-state cache. Apps stash
8960
+ * whatever they need to repaint instantly on a cold offline boot: the last open
8961
+ * Space, a draft message, scroll position, the current route. It's deliberately
8962
+ * separate from `client.kv`: snapshots are **local-only by default** (instant,
8963
+ * never metered, never leave the device) and hold view state rather than domain
8964
+ * data.
8965
+ *
8966
+ * await client.offline.snapshot.save('ui', { route, openSpaceId, draft })
8967
+ * const ui = await client.offline.snapshot.load<UiState>('ui')
8968
+ *
8969
+ * Values are encrypted at rest with the same identity-derived {@link StorageCipher}
8970
+ * as `client.kv`, so a snapshot is unreadable until the user unlocks — `load`
8971
+ * returns `null` when locked or absent, and `save` throws {@link OfflineLockedError}.
8972
+ * Conflict model is a single-writer LWW register (the latest save wins).
8973
+ */
8974
+ class SnapshotNamespace {
8975
+ deps;
8976
+ cipher = null;
8977
+ cipherIdentity = null;
8978
+ constructor(deps) {
8979
+ this.deps = deps;
8980
+ }
8981
+ /** Persist `state` under `name` (encrypted). No-op when offline support is off. */
8982
+ async save(name, state) {
8983
+ if (!this.deps.store.enabled)
8984
+ return;
8985
+ const envelope = await this.requireCipher().encrypt(state);
8986
+ const record = {
8987
+ envelope,
8988
+ hlc: await this.deps.nextHlc(),
8989
+ updatedAt: Date.now(),
8990
+ };
8991
+ await this.deps.store.put("snapshots", this.key(name), record);
8992
+ }
8993
+ /** Load and decrypt the snapshot `name`, or `null` if absent/locked. */
8994
+ async load(name) {
8995
+ if (!this.deps.store.enabled)
8996
+ return null;
8997
+ const record = await this.deps.store.get("snapshots", this.key(name));
8998
+ if (!record)
8999
+ return null;
9000
+ if (!this.deps.session.isUnlocked)
9001
+ return null;
9002
+ return this.requireCipher().decrypt(record.envelope);
9003
+ }
9004
+ /** Remove the snapshot `name`. */
9005
+ async delete(name) {
9006
+ if (!this.deps.store.enabled)
9007
+ return;
9008
+ await this.deps.store.delete("snapshots", this.key(name));
9009
+ }
9010
+ /** Names of all snapshots for the current user. */
9011
+ async list() {
9012
+ if (!this.deps.store.enabled)
9013
+ return [];
9014
+ const prefix = `${this.commitment()}|`;
9015
+ const rows = await this.deps.store.prefix("snapshots", prefix);
9016
+ return rows.map((r) => r.key.slice(prefix.length));
9017
+ }
9018
+ key(name) {
9019
+ return `${this.commitment()}|${name}`;
9020
+ }
9021
+ commitment() {
9022
+ // Snapshots are scoped per user; before sign-in they share an "anon"
9023
+ // bucket so pre-auth UI state (e.g. a chosen theme) still persists.
9024
+ return this.deps.session.commitment ?? "anon";
9025
+ }
9026
+ requireCipher() {
9027
+ if (!this.deps.session.isUnlocked)
9028
+ throw new OfflineLockedError();
9029
+ const identity = this.deps.session.requireIdentity();
9030
+ if (this.cipher && this.cipherIdentity === identity)
9031
+ return this.cipher;
9032
+ this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
9033
+ this.cipherIdentity = identity;
9034
+ return this.cipher;
9035
+ }
9036
+ }
9037
+
9038
+ /**
9039
+ * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
9040
+ * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
9041
+ * is uniquely safe: a cache hit is self-validating, immutable, and shareable
9042
+ * across files. The Cache API (rather than IndexedDB) is the right home for
9043
+ * large binary blobs — it streams, it's purpose-built for response bodies, and
9044
+ * it has its own eviction.
9045
+ *
9046
+ * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
9047
+ * than the real shard endpoint, so the cache is addressable by hash alone — the
9048
+ * sync engine can re-PUT a deferred shard knowing only its hash, without
9049
+ * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
9050
+ * exist only as cache keys.
9051
+ *
9052
+ * Every method degrades to a no-op / null where the Cache API is unavailable
9053
+ * (Node, Workers, insecure contexts), so callers don't need to branch.
9054
+ */
9055
+ const CACHE_NAME = "muhkoo-shards-v1";
9056
+ class ShardCache {
9057
+ static available() {
9058
+ try {
9059
+ return typeof caches !== "undefined";
9060
+ }
9061
+ catch {
9062
+ return false;
9063
+ }
9064
+ }
9065
+ key(hash) {
9066
+ return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
9067
+ }
9068
+ open() {
9069
+ return caches.open(CACHE_NAME);
9070
+ }
9071
+ /** Cached shard bytes, or `null` on miss / when unavailable. */
9072
+ async get(hash) {
9073
+ if (!ShardCache.available())
9074
+ return null;
9075
+ try {
9076
+ const cache = await this.open();
9077
+ const res = await cache.match(this.key(hash));
9078
+ if (!res)
9079
+ return null;
9080
+ return new Uint8Array(await res.arrayBuffer());
9081
+ }
9082
+ catch {
9083
+ return null;
9084
+ }
9085
+ }
9086
+ /** Store shard bytes under their hash. Best-effort. */
9087
+ async put(hash, bytes) {
9088
+ if (!ShardCache.available())
9089
+ return;
9090
+ try {
9091
+ const cache = await this.open();
9092
+ await cache.put(this.key(hash), new Response(bytes, {
9093
+ headers: { "content-type": "application/octet-stream" },
9094
+ }));
9095
+ }
9096
+ catch {
9097
+ /* quota / unavailable — degrade to online-only for this shard */
9098
+ }
9099
+ }
9100
+ /** Whether a shard is present in the cache. */
9101
+ async has(hash) {
9102
+ if (!ShardCache.available())
9103
+ return false;
9104
+ try {
9105
+ const cache = await this.open();
9106
+ return Boolean(await cache.match(this.key(hash)));
9107
+ }
9108
+ catch {
9109
+ return false;
9110
+ }
9111
+ }
9112
+ }
9113
+
9114
+ /**
9115
+ * IndexedDB schema for the offline layer — the single source of truth for the
9116
+ * database name, version, and object stores. Bump {@link DB_VERSION} whenever
9117
+ * the store set changes; {@link ./idb}'s `onupgradeneeded` creates any store
9118
+ * that doesn't exist yet (additive migrations only — never drop
9119
+ * `outbound-queue`, it holds un-synced writes).
9120
+ *
9121
+ * One database per origin. Records are partitioned *by key* (most keys are
9122
+ * prefixed with the user's `commitment`) rather than by separate databases, so
9123
+ * a single connection serves every signed-in user on the origin and prefix
9124
+ * scans isolate a user's data.
9125
+ */
9126
+ const DB_NAME = "muhkoo.offline";
9127
+ const DB_VERSION = 1;
9128
+ const STORES = [
9129
+ { name: "kv-cache" },
9130
+ { name: "db-row-cache" },
9131
+ { name: "db-query-cache" },
9132
+ { name: "space-messages" },
9133
+ { name: "space-cursors" },
9134
+ { name: "outbound-queue", autoIncrement: true, keyPath: "seq" },
9135
+ { name: "snapshots" },
9136
+ { name: "hlc-state" },
9137
+ { name: "meta" },
9138
+ ];
9139
+ /** Fixed keys in the `meta` store. */
9140
+ const META_NODE_ID = "nodeId";
9141
+
9142
+ /**
9143
+ * `OfflineManager` — the object `client.offline` points at, and the single
9144
+ * owner of everything the offline layer needs at runtime: the {@link
9145
+ * OfflineStore}, the {@link HLC} (Hybrid Logical Clock), the {@link
9146
+ * ConnectivityManager}, the durable {@link OutboundQueue}, the {@link
9147
+ * SyncEngine}, and the {@link SnapshotNamespace}.
9148
+ *
9149
+ * The {@link ../core/Client} constructs one of these (always — it's a cheap
9150
+ * no-op when offline support is off) and hands the namespaces the small set of
9151
+ * primitives they need to participate: `nextHlc()` to stamp a write,
9152
+ * `observeHlc()` to fold in a remote stamp, `enqueue()` to record an offline
9153
+ * mutation, and `registerReplayer()`/`registerCatchUp()` to define how their
9154
+ * domain re-syncs on reconnect. Keeping that wiring here means the namespaces
9155
+ * stay decoupled from IndexedDB and the clock.
9156
+ */
9157
+ const HLC_STATE_KEY = "global";
9158
+ class OfflineManager {
9159
+ /** True in browsers with IndexedDB; false when running over a NoopStore. */
9160
+ enabled;
9161
+ store;
9162
+ connectivity;
9163
+ queue;
9164
+ sync;
9165
+ /** `client.offline.snapshot` — encrypted app-state cache. */
9166
+ snapshot;
9167
+ /** Cache-API store for file-shard bytes (browser only). */
9168
+ fileCache;
9169
+ hlc = null;
9170
+ nodeId = "";
9171
+ bootPromise;
9172
+ persistTimer = null;
9173
+ constructor(deps) {
9174
+ this.enabled = deps.enabled;
9175
+ this.store = deps.store;
9176
+ this.queue = new OutboundQueue(deps.store);
9177
+ this.connectivity = new ConnectivityManager({
9178
+ onReconnect: () => void this.sync.run(),
9179
+ });
9180
+ this.sync = new SyncEngine({
9181
+ queue: this.queue,
9182
+ connectivity: this.connectivity,
9183
+ // Replays may need to re-seal/re-encrypt, and there's no point
9184
+ // pushing a logged-out user's writes — gate on a usable session.
9185
+ canSync: () => deps.session.isAuthenticated && deps.session.isUnlocked,
9186
+ });
9187
+ this.snapshot = new SnapshotNamespace({
9188
+ store: deps.store,
9189
+ session: deps.session,
9190
+ nextHlc: () => this.nextHlc(),
9191
+ });
9192
+ if (this.enabled && ShardCache.available())
9193
+ this.fileCache = new ShardCache();
9194
+ this.bootPromise = this.boot();
9195
+ if (this.enabled)
9196
+ this.connectivity.start();
9197
+ }
9198
+ /**
9199
+ * Queue a shard upload that couldn't reach the network. The bytes are
9200
+ * already cached (keyed by `hash`), so only the hash is recorded; the file
9201
+ * replayer re-PUTs from the cache on reconnect.
9202
+ */
9203
+ async deferShardUpload(hash) {
9204
+ await this.enqueue({
9205
+ hlc: await this.nextHlc(),
9206
+ clientId: this.newClientId(),
9207
+ domain: "file",
9208
+ method: "putShard",
9209
+ args: { hash },
9210
+ });
9211
+ }
9212
+ /** Resolve once the clock + node id are loaded. */
9213
+ ready() {
9214
+ return this.bootPromise;
9215
+ }
9216
+ /** Current connectivity state (`online` | `offline` | `syncing`). */
9217
+ get status() {
9218
+ return this.connectivity.current;
9219
+ }
9220
+ /** Subscribe to connectivity changes. Returns an unsubscribe function. */
9221
+ onStatusChange(cb) {
9222
+ return this.connectivity.onChange(cb);
9223
+ }
9224
+ /** Stamp a locally-generated write with a fresh, monotonic HLC. */
9225
+ async nextHlc() {
9226
+ await this.bootPromise;
9227
+ const stamp = this.hlc.now();
9228
+ this.schedulePersist();
9229
+ return stamp;
9230
+ }
9231
+ /** Fold an observed remote HLC into the clock (call for inbound stamps). */
9232
+ async observeHlc(remote) {
9233
+ await this.bootPromise;
9234
+ this.hlc.update(unpack(remote));
9235
+ this.schedulePersist();
9236
+ }
9237
+ /** A fresh client-generated id (idempotent replay / message dedupe). */
9238
+ newClientId() {
9239
+ return generateId();
9240
+ }
9241
+ /** Record an offline mutation for replay on reconnect. */
9242
+ enqueue(entry) {
9243
+ return this.queue.enqueue(entry);
9244
+ }
9245
+ /** A domain registers how to replay its queued mutations. */
9246
+ registerReplayer(domain, replayer) {
9247
+ this.queue.register(domain, replayer);
9248
+ }
9249
+ /** A domain registers an inbound catch-up task (run after the queue drains). */
9250
+ registerCatchUp(task) {
9251
+ return this.sync.registerCatchUp(task);
9252
+ }
9253
+ /** Surface a network failure to connectivity tracking. */
9254
+ reportFetchFailure() {
9255
+ this.connectivity.reportFetchFailure();
9256
+ }
9257
+ /** Surface a successful round-trip to connectivity tracking. */
9258
+ reportFetchSuccess() {
9259
+ this.connectivity.reportFetchSuccess();
9260
+ }
9261
+ /** Tear down listeners (tests / hot-reload). */
9262
+ dispose() {
9263
+ this.connectivity.stop();
9264
+ if (this.persistTimer)
9265
+ clearTimeout(this.persistTimer);
9266
+ }
9267
+ async boot() {
9268
+ if (!this.enabled) {
9269
+ // In-memory clock only; nothing is persisted over a NoopStore.
9270
+ this.nodeId = generateId();
9271
+ this.hlc = new HLC(this.nodeId);
9272
+ return;
9273
+ }
9274
+ let nodeId = await this.store.get("meta", META_NODE_ID);
9275
+ if (!nodeId) {
9276
+ nodeId = generateId();
9277
+ await this.store.put("meta", META_NODE_ID, nodeId);
9278
+ }
9279
+ this.nodeId = nodeId;
9280
+ const state = await this.store.get("hlc-state", HLC_STATE_KEY);
9281
+ this.hlc = new HLC(nodeId, { state });
9282
+ }
9283
+ /** Coalesce HLC-state writes — a burst of stamps persists once. */
9284
+ schedulePersist() {
9285
+ if (!this.enabled || this.persistTimer)
9286
+ return;
9287
+ this.persistTimer = setTimeout(() => {
9288
+ this.persistTimer = null;
9289
+ if (this.hlc)
9290
+ void this.store.put("hlc-state", HLC_STATE_KEY, this.hlc.getState());
9291
+ }, 200);
9292
+ }
9293
+ }
9294
+
9295
+ /**
9296
+ * Environment probe deciding whether the offline layer turns itself on. Offline
9297
+ * support is **on by default in browsers** and a no-op everywhere else, so this
9298
+ * is the gate the {@link ../core/Client} consults to pick {@link
9299
+ * ./store/IndexedDbStore} vs {@link ./store/NoopStore}.
9300
+ *
9301
+ * We require all three web APIs the layer leans on — `indexedDB` (structured
9302
+ * cache + durable queue), the Cache API (file-shard bytes), and a `window`
9303
+ * (so we're in a real document with online/offline events) — and we *touch*
9304
+ * them inside a try/catch, because some sandboxed or private-mode contexts
9305
+ * expose the globals but throw on use (mirrors `defaultSessionStore`).
9306
+ */
9307
+ function isOfflineCapable() {
9308
+ try {
9309
+ return (typeof window !== "undefined" &&
9310
+ typeof indexedDB !== "undefined" &&
9311
+ typeof caches !== "undefined");
9312
+ }
9313
+ catch {
9314
+ return false;
9315
+ }
9316
+ }
9317
+
9318
+ /**
9319
+ * A ~thin promise wrapper over the raw IndexedDB API — just enough surface for
9320
+ * {@link ./IndexedDbStore}: open-with-migration, get/put/delete, prefix and
9321
+ * range cursor scans, autoincrement append, and clear. We hand-roll this rather
9322
+ * than depend on `idb`/`dexie` because the access patterns are simple and
9323
+ * `@muhkoo/connect` keeps its runtime dependency surface minimal.
9324
+ *
9325
+ * Everything here assumes a browser (or a `fake-indexeddb` test shim) — callers
9326
+ * gate construction behind {@link ../detect}.
9327
+ */
9328
+ function req(request) {
9329
+ return new Promise((resolve, reject) => {
9330
+ request.onsuccess = () => resolve(request.result);
9331
+ request.onerror = () => reject(request.error);
9332
+ });
9333
+ }
9334
+ /** Open (and migrate) the offline database. Creates any missing object store. */
9335
+ function openDatabase() {
9336
+ return new Promise((resolve, reject) => {
9337
+ const open = indexedDB.open(DB_NAME, DB_VERSION);
9338
+ open.onupgradeneeded = () => {
9339
+ const db = open.result;
9340
+ for (const def of STORES) {
9341
+ if (db.objectStoreNames.contains(def.name))
9342
+ continue;
9343
+ db.createObjectStore(def.name, def.autoIncrement ? { keyPath: def.keyPath, autoIncrement: true } : undefined);
9344
+ }
9345
+ };
9346
+ open.onsuccess = () => resolve(open.result);
9347
+ open.onerror = () => reject(open.error);
9348
+ // A blocked upgrade means another tab holds an older version open; let
9349
+ // it surface as an error rather than hang forever.
9350
+ open.onblocked = () => reject(new Error("offline DB upgrade blocked by another tab"));
9351
+ });
9352
+ }
9353
+ /** Wraps an open connection with the typed operations the store needs. */
9354
+ class IdbConnection {
9355
+ db;
9356
+ constructor(db) {
9357
+ this.db = db;
9358
+ }
9359
+ tx(store, mode) {
9360
+ return this.db.transaction(store, mode).objectStore(store);
9361
+ }
9362
+ get(store, key) {
9363
+ return req(this.tx(store, "readonly").get(key)).then((v) => v ?? null);
9364
+ }
9365
+ async put(store, key, value) {
9366
+ await req(this.tx(store, "readwrite").put(value, key));
9367
+ }
9368
+ async delete(store, key) {
9369
+ await req(this.tx(store, "readwrite").delete(key));
9370
+ }
9371
+ async clear(store) {
9372
+ await req(this.tx(store, "readwrite").clear());
9373
+ }
9374
+ /** Append to an autoincrement store; resolves with the generated key. */
9375
+ async append(store, value) {
9376
+ const key = await req(this.tx(store, "readwrite").add(value));
9377
+ return key;
9378
+ }
9379
+ /** All entries whose string key falls in `[lower, upper)`, in key order. */
9380
+ range(store, lower, upper) {
9381
+ return this.scan(store, IDBKeyRange.bound(lower, upper, false, true));
9382
+ }
9383
+ /** All entries whose key starts with `prefix`, in key order. */
9384
+ prefix(store, prefix) {
9385
+ // "￿" is the largest BMP code unit, so `[prefix, prefix+￿)`
9386
+ // bounds every key that begins with `prefix`.
9387
+ return this.scan(store, IDBKeyRange.bound(prefix, prefix + "￿", false, true));
9388
+ }
9389
+ /** Every entry in the store, in key order. */
9390
+ all(store) {
9391
+ return this.scan(store, null);
9392
+ }
9393
+ scan(store, range) {
9394
+ return new Promise((resolve, reject) => {
9395
+ const out = [];
9396
+ const cursorReq = this.tx(store, "readonly").openCursor(range ?? undefined);
9397
+ cursorReq.onsuccess = () => {
9398
+ const cursor = cursorReq.result;
9399
+ if (!cursor)
9400
+ return resolve(out);
9401
+ out.push({ key: String(cursor.key), value: cursor.value });
9402
+ cursor.continue();
9403
+ };
9404
+ cursorReq.onerror = () => reject(cursorReq.error);
9405
+ });
9406
+ }
9407
+ }
9408
+
9409
+ /**
9410
+ * `IndexedDbStore` — the browser {@link OfflineStore}, backed by one IndexedDB
9411
+ * database per origin (see {@link ./schema}). It opens lazily on first use and
9412
+ * memoizes the connection. The queue methods sit on the autoincrement
9413
+ * `outbound-queue` store and return mutations in HLC order for the sync engine.
9414
+ *
9415
+ * Construct only where `indexedDB` exists — {@link ../detect.isOfflineCapable}
9416
+ * gates this, and the {@link ../../core/Client} falls back to {@link ./NoopStore}
9417
+ * everywhere else.
9418
+ */
9419
+ class IndexedDbStore {
9420
+ enabled = true;
9421
+ conn = null;
9422
+ opening = null;
9423
+ connect() {
9424
+ if (this.conn)
9425
+ return Promise.resolve(this.conn);
9426
+ if (!this.opening) {
9427
+ this.opening = openDatabase()
9428
+ .then((db) => {
9429
+ this.conn = new IdbConnection(db);
9430
+ return this.conn;
9431
+ })
9432
+ .finally(() => {
9433
+ this.opening = null;
9434
+ });
9435
+ }
9436
+ return this.opening;
9437
+ }
9438
+ async ready() {
9439
+ await this.connect();
9440
+ }
9441
+ async get(store, key) {
9442
+ return (await this.connect()).get(store, key);
9443
+ }
9444
+ async put(store, key, value) {
9445
+ await (await this.connect()).put(store, key, value);
9446
+ }
9447
+ async delete(store, key) {
9448
+ await (await this.connect()).delete(store, key);
9449
+ }
9450
+ async prefix(store, prefix) {
9451
+ return (await this.connect()).prefix(store, prefix);
9452
+ }
9453
+ async range(store, lower, upper) {
9454
+ return (await this.connect()).range(store, lower, upper);
9455
+ }
9456
+ async all(store) {
9457
+ return (await this.connect()).all(store);
9458
+ }
9459
+ async clear(store) {
9460
+ await (await this.connect()).clear(store);
9461
+ }
9462
+ async enqueue(entry) {
9463
+ // keyPath autoincrement writes the generated `seq` back into the record.
9464
+ return (await this.connect()).append("outbound-queue", entry);
9465
+ }
9466
+ async dequeue(seq) {
9467
+ await (await this.connect()).delete("outbound-queue", seq);
9468
+ }
9469
+ async queued() {
9470
+ const rows = await (await this.connect()).all("outbound-queue");
9471
+ return rows.map((r) => r.value).sort((a, b) => compareHlc(a.hlc, b.hlc));
9472
+ }
9473
+ }
9474
+
9475
+ /**
9476
+ * `NoopStore` — the {@link OfflineStore} used wherever offline support is off
9477
+ * (Node, Workers, SSR, or when a consumer disables it). Every read returns
9478
+ * empty and every write is dropped, so the namespace hooks that call into it
9479
+ * behave exactly as they did before the offline layer existed. `enabled` is
9480
+ * `false` so hot paths can skip the call entirely.
9481
+ */
9482
+ class NoopStore {
9483
+ enabled = false;
9484
+ async ready() { }
9485
+ async get(_store, _key) {
9486
+ return null;
9487
+ }
9488
+ async put(_store, _key, _value) { }
9489
+ async delete(_store, _key) { }
9490
+ async prefix(_store, _prefix) {
9491
+ return [];
9492
+ }
9493
+ async range(_store, _lower, _upper) {
9494
+ return [];
9495
+ }
9496
+ async all(_store) {
9497
+ return [];
9498
+ }
9499
+ async clear(_store) { }
9500
+ async enqueue(_entry) {
9501
+ return -1;
9502
+ }
9503
+ async dequeue(_seq) { }
9504
+ async queued() {
9505
+ return [];
9506
+ }
9507
+ }
9508
+
9509
+ /**
9510
+ * `LWWRegister` — a last-write-wins register CRDT, the conflict model for a
9511
+ * `kv` value and for an app-state snapshot. A register holds a single value
9512
+ * stamped with the {@link HlcTimestamp} of the write that produced it. Two
9513
+ * concurrent writes converge to the one with the greater HLC; the loser is
9514
+ * discarded. A delete is just a write that sets `deleted` (a tombstone), so a
9515
+ * stale write that arrives after a newer delete can't resurrect the value.
9516
+ *
9517
+ * Because the HLC total-orders every write deterministically (down to a nodeId
9518
+ * tiebreak), every replica that has seen the same set of writes lands on the
9519
+ * same value — that's the convergence guarantee.
9520
+ */
9521
+ /** An empty register that loses to any real write. */
9522
+ function emptyRegister() {
9523
+ return { value: null, hlc: ZERO_HLC, deleted: true };
9524
+ }
9525
+ /**
9526
+ * Merge two register states, returning the winner (greater HLC). Pure: neither
9527
+ * input is mutated. Used identically on the inbound change feed and on
9528
+ * sync-replay reconciliation so there's exactly one definition of "who wins".
9529
+ */
9530
+ function mergeRegister(a, b) {
9531
+ return compareHlc(a.hlc, b.hlc) >= 0 ? a : b;
9532
+ }
9533
+ /** Build the register produced by a `set`. */
9534
+ function writeRegister(value, hlc) {
9535
+ return { value, hlc, deleted: false };
9536
+ }
9537
+ /** Build the register produced by a `delete` (a tombstone at `hlc`). */
9538
+ function deleteRegister(hlc) {
9539
+ return { value: null, hlc, deleted: true };
9540
+ }
9541
+
9542
+ /**
9543
+ * `KvCache` — the offline adapter for `client.kv`. Stores each value's
9544
+ * ciphertext envelope (never plaintext) under `${commitment}|${collection}/${id}`
9545
+ * as an LWW-Register stamped with an HLC, so reads work offline and concurrent
9546
+ * writes converge (see {@link ./crdt/LWWRegister}). Deletes are causal
9547
+ * tombstones — a stale write can't resurrect a key a newer delete removed.
9548
+ *
9549
+ * True cross-device convergence also needs the accelerator to honor the HLC
9550
+ * (see the server's `kvmeta` sidecar); this adapter embeds the stamp now and
9551
+ * degrades gracefully (last-writer-by-arrival) until that ships.
9552
+ */
9553
+ class KvCache {
9554
+ manager;
9555
+ store;
9556
+ constructor(manager) {
9557
+ this.manager = manager;
9558
+ this.store = manager.store;
9559
+ }
9560
+ get enabled() {
9561
+ return this.manager.enabled;
9562
+ }
9563
+ nextHlc() {
9564
+ return this.manager.nextHlc();
9565
+ }
9566
+ newClientId() {
9567
+ return this.manager.newClientId();
9568
+ }
9569
+ key(commitment, fullKey) {
9570
+ return `${commitment}|${fullKey}`;
9571
+ }
9572
+ /** Read the cached register for `collection/id`, or null when absent. */
9573
+ read(commitment, fullKey) {
9574
+ return this.store.get("kv-cache", this.key(commitment, fullKey));
9575
+ }
9576
+ /** Optimistically record a local `set` (ciphertext envelope + HLC). */
9577
+ async writeLocal(commitment, fullKey, envelope, hlc) {
9578
+ await this.store.put("kv-cache", this.key(commitment, fullKey), writeRegister(envelope, hlc));
9579
+ }
9580
+ /** Optimistically record a local `delete` (tombstone at HLC). */
9581
+ async deleteLocal(commitment, fullKey, hlc) {
9582
+ await this.store.put("kv-cache", this.key(commitment, fullKey), deleteRegister(hlc));
9583
+ }
9584
+ /**
9585
+ * Merge an authoritative value (from a GET refresh or a realtime frame) into
9586
+ * the cache by HLC. When the server doesn't supply an HLC yet, treat it as
9587
+ * "now-ish" by stamping our clock so a server value still beats stale local
9588
+ * state but a fresh local write wins.
9589
+ */
9590
+ async mergeRemote(commitment, fullKey, envelope, op, hlc) {
9591
+ const k = this.key(commitment, fullKey);
9592
+ const incoming = op === "delete"
9593
+ ? deleteRegister(hlc ?? (await this.nextHlc()))
9594
+ : writeRegister(envelope, hlc ?? (await this.nextHlc()));
9595
+ const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
9596
+ await this.store.put("kv-cache", k, mergeRegister(local, incoming));
9597
+ }
9598
+ /** Non-tombstoned full keys for a commitment (used for offline `list`). */
9599
+ async liveKeys(commitment) {
9600
+ const prefix = `${commitment}|`;
9601
+ const rows = await this.store.prefix("kv-cache", prefix);
9602
+ return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
9603
+ }
9604
+ /** Queue a kv mutation for replay on reconnect. */
9605
+ async enqueue(method, args, hlc, clientId) {
9606
+ await this.manager.enqueue({ hlc, clientId, domain: "kv", method, args });
9607
+ }
9608
+ }
9609
+
9610
+ /**
9611
+ * `DbCache` — the offline adapter for `client.db`. Unlike kv, db rows are
9612
+ * server-plaintext, so they're cached as-is. The SDK doesn't know a table's
9613
+ * primary-key column, so it caches schema-agnostically:
9614
+ * - `get(id)` results keyed by the requested id (`${table}|row|${id}`),
9615
+ * - `query(q)` result pages keyed by a stable hash of the query.
9616
+ *
9617
+ * Writes carry per-column HLC stamps (`_hlc`) so the accelerator's per-column
9618
+ * LWW merge can converge concurrent edits; offline writes are queued and
9619
+ * replayed. Optimistic row patches keep `get` fresh between sync cycles.
9620
+ */
9621
+ /** Stable JSON for query-cache keys (sorted object keys → deterministic). */
9622
+ function stableStringify(value) {
9623
+ if (value === null || typeof value !== "object")
9624
+ return JSON.stringify(value);
9625
+ if (Array.isArray(value))
9626
+ return `[${value.map(stableStringify).join(",")}]`;
9627
+ const obj = value;
9628
+ return `{${Object.keys(obj)
9629
+ .sort()
9630
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
9631
+ .join(",")}}`;
9632
+ }
9633
+ class DbCache {
9634
+ manager;
9635
+ store;
9636
+ constructor(manager) {
9637
+ this.manager = manager;
9638
+ this.store = manager.store;
9639
+ }
9640
+ get enabled() {
9641
+ return this.manager.enabled;
9642
+ }
9643
+ nextHlc() {
9644
+ return this.manager.nextHlc();
9645
+ }
9646
+ newClientId() {
9647
+ return this.manager.newClientId();
9648
+ }
9649
+ /** Build the `{ [col]: hlc }` map for a write (one stamp across its columns). */
9650
+ columnStamps(values, hlc) {
9651
+ const out = {};
9652
+ for (const col of Object.keys(values))
9653
+ out[col] = hlc;
9654
+ return out;
9655
+ }
9656
+ rowKey(table, id) {
9657
+ return `${table}|row|${id}`;
9658
+ }
9659
+ queryKey(table, q) {
9660
+ return `${table}|query|${stableStringify(q)}`;
9661
+ }
9662
+ async readRow(table, id) {
9663
+ return this.store.get("db-row-cache", this.rowKey(table, id));
9664
+ }
9665
+ async writeRow(table, id, row) {
9666
+ await this.store.put("db-row-cache", this.rowKey(table, id), row);
9667
+ }
9668
+ /** Shallow-merge a partial update into a cached row (optimistic). */
9669
+ async patchRow(table, id, values) {
9670
+ const existing = (await this.readRow(table, id)) ?? {};
9671
+ await this.writeRow(table, id, { ...existing, ...values });
9672
+ }
9673
+ async deleteRow(table, id) {
9674
+ await this.store.delete("db-row-cache", this.rowKey(table, id));
9675
+ }
9676
+ async readQuery(table, q) {
9677
+ return this.store.get("db-query-cache", this.queryKey(table, q));
9678
+ }
9679
+ async writeQuery(table, q, page) {
9680
+ await this.store.put("db-query-cache", this.queryKey(table, q), page);
9681
+ }
9682
+ async enqueue(method, args, hlc, clientId) {
9683
+ await this.manager.enqueue({ hlc, clientId, domain: "db", method, args });
9684
+ }
9685
+ }
9686
+
7924
9687
  /**
7925
9688
  * `Client` — the single entry point to the Muhkoo SDK.
7926
9689
  *
@@ -7973,6 +9736,8 @@ class Client {
7973
9736
  agents;
7974
9737
  /** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
7975
9738
  functions;
9739
+ /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
9740
+ offline;
7976
9741
  session;
7977
9742
  http;
7978
9743
  /** Subscribers notified when a session expires and can't be recovered. */
@@ -7992,11 +9757,40 @@ class Client {
7992
9757
  }
7993
9758
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
7994
9759
  this.session = new SessionState(options.sessionStore ?? defaultSessionStore());
9760
+ // Offline layer. On by default in browsers, a no-op (NoopStore) in Node,
9761
+ // Workers, and SSR — so existing consumers are unaffected unless they're
9762
+ // in a browser that can actually cache.
9763
+ const offlineEnabled = options.offline?.enabled ?? isOfflineCapable();
9764
+ const offlineStore = offlineEnabled
9765
+ ? (options.offline?.store ?? new IndexedDbStore())
9766
+ : new NoopStore();
9767
+ this.offline = new OfflineManager({
9768
+ store: offlineStore,
9769
+ session: this.session,
9770
+ enabled: offlineEnabled,
9771
+ });
9772
+ // Wrap fetch so every accelerator round-trip feeds the connectivity
9773
+ // detector: a thrown fetch is the strongest "offline" signal, a 2xx the
9774
+ // strongest "online" one. Only when offline support is active.
9775
+ const baseFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
9776
+ const reportingFetch = offlineEnabled && baseFetch
9777
+ ? async (input, init) => {
9778
+ try {
9779
+ const res = await baseFetch(input, init);
9780
+ this.offline.reportFetchSuccess();
9781
+ return res;
9782
+ }
9783
+ catch (err) {
9784
+ this.offline.reportFetchFailure();
9785
+ throw err;
9786
+ }
9787
+ }
9788
+ : options.fetch;
7995
9789
  this.http = new HttpClient({
7996
9790
  baseUrl: this.baseUrl,
7997
9791
  apiKey: options.apiKey,
7998
9792
  getSessionToken: () => this.session.token,
7999
- fetch: options.fetch,
9793
+ fetch: reportingFetch,
8000
9794
  // Self-heal stale sessions: on a 401, try a silent re-auth (only
8001
9795
  // works while unlocked). If it can't, `recoverSession` fires the
8002
9796
  // session-expired event so the app can redirect to login.
@@ -8007,9 +9801,24 @@ class Client {
8007
9801
  const wsBaseUrl = toWsBase(this.baseUrl);
8008
9802
  const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
8009
9803
  this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
8010
- this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
8011
- this.db = new DbNamespace({ http: this.http });
8012
- this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
9804
+ const deferShardUpload = offlineEnabled
9805
+ ? (hash) => this.offline.deferShardUpload(hash)
9806
+ : undefined;
9807
+ const kvCache = offlineEnabled ? new KvCache(this.offline) : undefined;
9808
+ this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl, offline: kvCache });
9809
+ if (offlineEnabled)
9810
+ this.offline.registerReplayer("kv", (e) => this.kv.replay(e));
9811
+ const dbCache = offlineEnabled ? new DbCache(this.offline) : undefined;
9812
+ this.db = new DbNamespace({ http: this.http, offline: dbCache });
9813
+ if (offlineEnabled)
9814
+ this.offline.registerReplayer("db", (e) => this.db.replay(e));
9815
+ this.storage = new StorageNamespace({
9816
+ http: this.http,
9817
+ baseUrl: this.baseUrl,
9818
+ kv: this.kv,
9819
+ shardCache: this.offline.fileCache,
9820
+ deferShardUpload,
9821
+ });
8013
9822
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
8014
9823
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
8015
9824
  // by client.kv), so a returning member hydrates without a fresh keyring
@@ -8023,9 +9832,28 @@ class Client {
8023
9832
  session: this.session,
8024
9833
  wsBaseUrl,
8025
9834
  cache: spaceKeyCache,
9835
+ offline: this.offline,
8026
9836
  });
8027
9837
  this.agents = new AgentsNamespace({ http: this.http });
8028
9838
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
9839
+ // Replay deferred shard uploads on reconnect. The bytes live in the
9840
+ // Cache API keyed by hash; re-PUT them through a (defer-free) shard
9841
+ // client so a failure surfaces as transient and the entry stays queued.
9842
+ if (offlineEnabled && this.offline.fileCache) {
9843
+ const replayShards = new ShardClient({
9844
+ baseUrl: this.baseUrl,
9845
+ pathPrefix: "/api/shards",
9846
+ fetch: this.http.fetch,
9847
+ cache: this.offline.fileCache,
9848
+ });
9849
+ this.offline.registerReplayer("file", async (entry) => {
9850
+ const { hash } = entry.args;
9851
+ const bytes = await this.offline.fileCache.get(hash);
9852
+ if (!bytes)
9853
+ return; // evicted from cache — nothing to replay
9854
+ await replayShards.putShard(hash, bytes);
9855
+ });
9856
+ }
8029
9857
  }
8030
9858
  /** The currently signed-in user, or `null`. */
8031
9859
  get user() {
@@ -8079,6 +9907,116 @@ class Client {
8079
9907
  }
8080
9908
  }
8081
9909
 
9910
+ /**
9911
+ * `LWWMap` — a map of independent last-write-wins registers, one per column.
9912
+ * This is the conflict model for a `db` row: two devices that edit *different*
9913
+ * columns of the same row while offline both keep their edits (no lost update),
9914
+ * and two that edit the *same* column converge to the greater-HLC write. A row
9915
+ * delete is a causal tombstone (see {@link ./ORSet}); a later write to any
9916
+ * column resurrects the row with just the surviving columns.
9917
+ *
9918
+ * This mirrors, on the client, exactly what the accelerator's `_muhkoo_crdt`
9919
+ * sidecar does server-side — so a row materializes identically whether it was
9920
+ * merged locally (offline) or by the server on replay.
9921
+ */
9922
+ function emptyMap() {
9923
+ return { fields: {}, columnHlc: {}, tombstone: null };
9924
+ }
9925
+ /** Apply a local column write (insert/update) at the given per-column HLCs. */
9926
+ function writeColumns(map, values, hlc) {
9927
+ const next = {
9928
+ fields: { ...map.fields },
9929
+ columnHlc: { ...map.columnHlc },
9930
+ tombstone: map.tombstone,
9931
+ };
9932
+ for (const [col, value] of Object.entries(values)) {
9933
+ const prev = next.columnHlc[col];
9934
+ if (!prev || compareHlc(hlc, prev) > 0) {
9935
+ next.fields[col] = value;
9936
+ next.columnHlc[col] = hlc;
9937
+ }
9938
+ }
9939
+ return next;
9940
+ }
9941
+ /** Apply a row delete at `hlc` (causal tombstone). */
9942
+ function tombstoneRow(map, hlc) {
9943
+ const tombstone = !map.tombstone || compareHlc(hlc, map.tombstone) > 0 ? hlc : map.tombstone;
9944
+ return { fields: { ...map.fields }, columnHlc: { ...map.columnHlc }, tombstone };
9945
+ }
9946
+ /** Merge two row states column-by-column. Pure; neither input is mutated. */
9947
+ function mergeMap(a, b) {
9948
+ const cols = new Set([...Object.keys(a.columnHlc), ...Object.keys(b.columnHlc)]);
9949
+ const out = emptyMap();
9950
+ for (const col of cols) {
9951
+ const ah = a.columnHlc[col];
9952
+ const bh = b.columnHlc[col];
9953
+ const aWins = ah && (!bh || compareHlc(ah, bh) >= 0);
9954
+ const winner = aWins ? a : b;
9955
+ out.fields[col] = winner.fields[col];
9956
+ out.columnHlc[col] = winner.columnHlc[col];
9957
+ }
9958
+ out.tombstone =
9959
+ a.tombstone && b.tombstone
9960
+ ? compareHlc(a.tombstone, b.tombstone) >= 0
9961
+ ? a.tombstone
9962
+ : b.tombstone
9963
+ : (a.tombstone ?? b.tombstone);
9964
+ return out;
9965
+ }
9966
+ /**
9967
+ * Materialize the visible row from merged state, or `null` when the row is
9968
+ * deleted. A column is visible only when its write is causally newer than the
9969
+ * tombstone — so a delete hides every prior column, but a post-delete write to
9970
+ * a column brings the row back with just that column populated.
9971
+ */
9972
+ function materializeRow(map) {
9973
+ const visible = {};
9974
+ let any = false;
9975
+ for (const [col, hlc] of Object.entries(map.columnHlc)) {
9976
+ if (!map.tombstone || compareHlc(hlc, map.tombstone) > 0) {
9977
+ visible[col] = map.fields[col];
9978
+ any = true;
9979
+ }
9980
+ }
9981
+ return any ? visible : null;
9982
+ }
9983
+
9984
+ /**
9985
+ * Tombstone helpers shared by the LWW CRDTs. We don't need a full
9986
+ * observed-remove set — every domain that deletes (kv keys, db rows) does so
9987
+ * by stamping a delete with an HLC and keeping that stamp around. The only
9988
+ * rule that matters is the causal one:
9989
+ *
9990
+ * a delete at HLC `d` hides the value, but a *later* write at HLC `w > d`
9991
+ * resurrects it; a *stale* write at `w <= d` is dropped.
9992
+ *
9993
+ * Keeping the delete HLC (rather than physically forgetting the key) is what
9994
+ * prevents a delayed offline write from "un-deleting" something a peer already
9995
+ * removed. Callers GC tombstones older than any possible offline window.
9996
+ */
9997
+ /**
9998
+ * Does a write at `writeHlc` win against a tombstone at `tombstoneHlc`?
9999
+ * `true` ⇒ the write applies (resurrecting a deleted item if needed); `false`
10000
+ * ⇒ the tombstone still holds and the write is dropped. A missing tombstone
10001
+ * (`null`) means there's nothing suppressing the write.
10002
+ */
10003
+ function writeBeatsTombstone(writeHlc, tombstoneHlc) {
10004
+ if (!tombstoneHlc)
10005
+ return true;
10006
+ return compareHlc(writeHlc, tombstoneHlc) > 0;
10007
+ }
10008
+ /**
10009
+ * Does a delete at `deleteHlc` win against the latest write at `writeHlc`?
10010
+ * `true` ⇒ the item is now tombstoned. Ties go to the delete (remove-wins) so
10011
+ * that a delete and a write stamped in the same instant resolve deterministically
10012
+ * toward removal.
10013
+ */
10014
+ function deleteBeatsWrite(deleteHlc, writeHlc) {
10015
+ if (!writeHlc)
10016
+ return true;
10017
+ return compareHlc(deleteHlc, writeHlc) >= 0;
10018
+ }
10019
+
8082
10020
  /**
8083
10021
  * Muhkoo app decorators — declare your app's agent-facing surface in code, then
8084
10022
  * "eject" a system prompt you hand to a Programmable Agent.
@@ -8786,5 +10724,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
8786
10724
  default: loadRsWasm
8787
10725
  });
8788
10726
 
8789
- 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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VERSION, VaultUnavailableError, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, toBase64$1 as toBase64, toBase64Url, toField, toHex, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
10727
+ 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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
8790
10728
  //# sourceMappingURL=index.js.map