@muhkoo/connect 0.7.0-alpha.5 → 0.9.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.
@@ -2082,6 +2082,14 @@ var EventCoreEvents;
2082
2082
  EventCoreEvents["MESSAGE"] = "message";
2083
2083
  EventCoreEvents["GET_HISTORY"] = "get_history";
2084
2084
  EventCoreEvents["RECEIVED_HISTORY"] = "received_history";
2085
+ // Offline layer — connectivity + background sync lifecycle. Emitted by the
2086
+ // ConnectivityManager / SyncEngine; apps subscribe to drive a status UI.
2087
+ EventCoreEvents["ONLINE"] = "online";
2088
+ EventCoreEvents["OFFLINE"] = "offline";
2089
+ EventCoreEvents["SYNCING"] = "syncing";
2090
+ EventCoreEvents["SYNC_PROGRESS"] = "sync_progress";
2091
+ EventCoreEvents["SYNC_COMPLETE"] = "sync_complete";
2092
+ EventCoreEvents["SYNC_ERROR"] = "sync_error";
2085
2093
  })(EventCoreEvents || (EventCoreEvents = {}));
2086
2094
  /**
2087
2095
  * @public
@@ -3804,6 +3812,9 @@ class ShardClient {
3804
3812
  baseUrl;
3805
3813
  pathPrefix;
3806
3814
  fetchFn;
3815
+ cache;
3816
+ deferUpload;
3817
+ peers;
3807
3818
  constructor(opts) {
3808
3819
  if (!opts?.baseUrl)
3809
3820
  throw new Error("ShardClient: `baseUrl` is required");
@@ -3819,6 +3830,9 @@ class ShardClient {
3819
3830
  throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
3820
3831
  }
3821
3832
  this.fetchFn = f.bind(globalThis);
3833
+ this.cache = opts.cache;
3834
+ this.deferUpload = opts.deferUpload;
3835
+ this.peers = opts.peers;
3822
3836
  }
3823
3837
  /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
3824
3838
  urlForHash(hash) {
@@ -3832,12 +3846,32 @@ class ShardClient {
3832
3846
  */
3833
3847
  async putShard(hash, bytes) {
3834
3848
  const url = this.urlForHash(hash);
3835
- const response = await this.fetchFn(url, {
3836
- method: "PUT",
3837
- headers: { "content-type": "application/octet-stream" },
3838
- // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
3839
- body: bytes,
3840
- });
3849
+ // Write the cache first so a just-uploaded file reads back even if the
3850
+ // network PUT then fails (offline). The bytes are content-addressed, so
3851
+ // this is safe to keep regardless of upload outcome.
3852
+ if (this.cache)
3853
+ await this.cache.put(hash, bytes);
3854
+ // Tell the swarm we now hold this block, so peers can pull it from us.
3855
+ if (this.peers)
3856
+ this.peers.announce(hash);
3857
+ let response;
3858
+ try {
3859
+ response = await this.fetchFn(url, {
3860
+ method: "PUT",
3861
+ headers: { "content-type": "application/octet-stream" },
3862
+ // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
3863
+ body: bytes,
3864
+ });
3865
+ }
3866
+ catch (err) {
3867
+ // Network unreachable (offline). If a defer hook is wired, queue the
3868
+ // upload for replay and report success — the bytes are cached.
3869
+ if (this.deferUpload) {
3870
+ await this.deferUpload(hash);
3871
+ return { dedup: false };
3872
+ }
3873
+ throw err;
3874
+ }
3841
3875
  if (!response.ok) {
3842
3876
  const text = await safeText(response);
3843
3877
  throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
@@ -3857,8 +3891,41 @@ class ShardClient {
3857
3891
  * `null` for 404 so callers can distinguish "missing" from "errored".
3858
3892
  */
3859
3893
  async getShard(hash) {
3894
+ // Cache-first: a hit is content-verified by construction (key == hash).
3895
+ if (this.cache) {
3896
+ const cached = await this.cache.get(hash);
3897
+ if (cached)
3898
+ return cached;
3899
+ }
3900
+ // Peers next: ask the Space swarm before spending origin egress. Peer
3901
+ // blocks are hash-verified by the block engine; on miss/timeout we fall
3902
+ // through to origin. Best-effort — never let a peer error block a read.
3903
+ if (this.peers) {
3904
+ try {
3905
+ const fromPeer = await this.peers.getBlock(hash);
3906
+ if (fromPeer) {
3907
+ if (this.cache)
3908
+ void this.cache.put(hash, fromPeer);
3909
+ return fromPeer;
3910
+ }
3911
+ }
3912
+ catch {
3913
+ /* peer layer hiccup — fall through to origin */
3914
+ }
3915
+ }
3860
3916
  const url = this.urlForHash(hash);
3861
- const response = await this.fetchFn(url, { method: "GET" });
3917
+ let response;
3918
+ try {
3919
+ response = await this.fetchFn(url, { method: "GET" });
3920
+ }
3921
+ catch (err) {
3922
+ // Offline with no cache hit: report the shard as missing so
3923
+ // FileStorage's Reed–Solomon recovery can still reconstruct from the
3924
+ // shards that ARE cached. Rethrow when there's no cache to fall back on.
3925
+ if (this.cache)
3926
+ return null;
3927
+ throw err;
3928
+ }
3862
3929
  if (response.status === 404)
3863
3930
  return null;
3864
3931
  if (!response.ok) {
@@ -3866,7 +3933,10 @@ class ShardClient {
3866
3933
  throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
3867
3934
  }
3868
3935
  const buf = await response.arrayBuffer();
3869
- return new Uint8Array(buf);
3936
+ const bytes = new Uint8Array(buf);
3937
+ if (this.cache)
3938
+ void this.cache.put(hash, bytes);
3939
+ return bytes;
3870
3940
  }
3871
3941
  /**
3872
3942
  * Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
@@ -4891,7 +4961,7 @@ function base64UrlDecode(s) {
4891
4961
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
4892
4962
  * `package.json` `version`.
4893
4963
  */
4894
- const VERSION = "0.7.0-alpha.4";
4964
+ const VERSION = "0.9.0-alpha.0";
4895
4965
 
4896
4966
  /**
4897
4967
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6261,30 +6331,104 @@ class KvNamespace {
6261
6331
  cipherIdentity = null;
6262
6332
  constructor(deps) {
6263
6333
  this.deps = deps;
6334
+ // Replay queued kv writes on reconnect by re-issuing the original request.
6335
+ if (deps.offline?.enabled) ;
6336
+ }
6337
+ /** Replay one queued kv mutation (called by the sync engine). */
6338
+ async replay(entry) {
6339
+ const a = entry.args;
6340
+ if (entry.method === "set") {
6341
+ await this.deps.http.post(this.kvPath(a.collection, a.id), { value: a.stored, hlc: a.hlc });
6342
+ }
6343
+ else {
6344
+ await this.deps.http.del(this.kvPath(a.collection, a.id), { hlc: a.hlc });
6345
+ }
6264
6346
  }
6265
6347
  /** Store `value` under `collection`/`id` (encrypted at rest by default). */
6266
6348
  async set(collection, id, value, opts) {
6267
6349
  const encrypt = opts?.encrypt !== false;
6268
6350
  const stored = encrypt ? await this.getCipher().encrypt(value) : value;
6269
- await this.deps.http.post(this.kvPath(collection, id), { value: stored });
6351
+ const offline = this.deps.offline;
6352
+ if (!offline?.enabled) {
6353
+ await this.deps.http.post(this.kvPath(collection, id), { value: stored });
6354
+ return;
6355
+ }
6356
+ const fullKey = `${collection}/${id}`;
6357
+ const hlc = await offline.nextHlc();
6358
+ await offline.writeLocal(this.commitment(), fullKey, stored, hlc);
6359
+ try {
6360
+ await this.deps.http.post(this.kvPath(collection, id), { value: stored, hlc });
6361
+ }
6362
+ catch (err) {
6363
+ if (err instanceof HttpError)
6364
+ throw err; // server reachable + refused
6365
+ await offline.enqueue("set", { collection, id, stored, hlc }, hlc, offline.newClientId());
6366
+ }
6270
6367
  }
6271
6368
  /** Fetch the value at `collection`/`id`, or `null` if absent. */
6272
6369
  async get(collection, id) {
6273
- const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
6274
- return this.decode(res.value);
6370
+ const offline = this.deps.offline;
6371
+ if (!offline?.enabled) {
6372
+ const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
6373
+ return this.decode(res.value);
6374
+ }
6375
+ const fullKey = `${collection}/${id}`;
6376
+ try {
6377
+ const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
6378
+ // Refresh the cache from the authoritative read (merge by HLC).
6379
+ await offline.mergeRemote(this.commitment(), fullKey, res.value ?? null, res.value == null ? "delete" : "set", res.hlc);
6380
+ return this.decode(res.value);
6381
+ }
6382
+ catch (err) {
6383
+ if (err instanceof HttpError)
6384
+ throw err; // a real server error, not offline
6385
+ const entry = await offline.read(this.commitment(), fullKey);
6386
+ if (!entry || entry.deleted)
6387
+ return null;
6388
+ return this.decode(entry.value);
6389
+ }
6275
6390
  }
6276
6391
  /** Delete `collection`/`id`. Returns whether the key existed. */
6277
6392
  async delete(collection, id) {
6278
- const res = await this.deps.http.del(this.kvPath(collection, id), {});
6279
- return Boolean(res.existed);
6393
+ const offline = this.deps.offline;
6394
+ if (!offline?.enabled) {
6395
+ const res = await this.deps.http.del(this.kvPath(collection, id), {});
6396
+ return Boolean(res.existed);
6397
+ }
6398
+ const fullKey = `${collection}/${id}`;
6399
+ const hlc = await offline.nextHlc();
6400
+ await offline.deleteLocal(this.commitment(), fullKey, hlc);
6401
+ try {
6402
+ const res = await this.deps.http.del(this.kvPath(collection, id), { hlc });
6403
+ return Boolean(res.existed);
6404
+ }
6405
+ catch (err) {
6406
+ if (err instanceof HttpError)
6407
+ throw err;
6408
+ await offline.enqueue("delete", { collection, id, hlc }, hlc, offline.newClientId());
6409
+ return true; // optimistic
6410
+ }
6280
6411
  }
6281
6412
  /** List the ids present in `collection`. */
6282
6413
  async list(collection) {
6283
- const res = await this.deps.http.post(this.spacePath("/list"), {});
6284
6414
  const prefix = `${collection}/`;
6285
- return (res.keys ?? [])
6286
- .filter((k) => k.startsWith(prefix))
6287
- .map((k) => k.slice(prefix.length));
6415
+ const offline = this.deps.offline;
6416
+ const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
6417
+ if (!offline?.enabled) {
6418
+ const res = await this.deps.http.post(this.spacePath("/list"), {});
6419
+ return fromKeys(res.keys ?? []);
6420
+ }
6421
+ try {
6422
+ const res = await this.deps.http.post(this.spacePath("/list"), {});
6423
+ // Union with cached live keys so un-synced offline creates still show.
6424
+ const cached = await offline.liveKeys(this.commitment());
6425
+ return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
6426
+ }
6427
+ catch (err) {
6428
+ if (err instanceof HttpError)
6429
+ throw err;
6430
+ return fromKeys(await offline.liveKeys(this.commitment()));
6431
+ }
6288
6432
  }
6289
6433
  /** Reserved — server-side query is deferred (see the overhaul plan). */
6290
6434
  async query(_collection, _filter) {
@@ -6343,6 +6487,10 @@ class KvNamespace {
6343
6487
  return;
6344
6488
  const collection = frame.key.slice(0, slash);
6345
6489
  const id = frame.key.slice(slash + 1);
6490
+ // Keep the offline cache warm from the realtime feed (merge by HLC).
6491
+ if (this.deps.offline?.enabled) {
6492
+ await this.deps.offline.mergeRemote(this.commitment(), frame.key, frame.op === "set" ? frame.value ?? null : null, frame.op, frame.hlc);
6493
+ }
6346
6494
  const data = frame.op === "set" ? await this.decode(frame.value) : null;
6347
6495
  handler({ collection, id, type: frame.op, data });
6348
6496
  }
@@ -6410,41 +6558,109 @@ class KvNamespace {
6410
6558
  class DbTable {
6411
6559
  http;
6412
6560
  name;
6413
- constructor(http, name) {
6561
+ offline;
6562
+ constructor(http, name, offline) {
6414
6563
  this.http = http;
6415
6564
  this.name = name;
6565
+ this.offline = offline;
6416
6566
  }
6417
6567
  base() {
6418
6568
  return `/api/db/${encodeURIComponent(this.name)}`;
6419
6569
  }
6570
+ rowUrl(id) {
6571
+ return `${this.base()}/${encodeURIComponent(String(id))}`;
6572
+ }
6420
6573
  /** Insert a row. Returns the created row and its primary-key value. */
6421
6574
  async insert(values) {
6422
- return this.http.post(this.base(), { values });
6575
+ if (!this.offline?.enabled)
6576
+ return this.http.post(this.base(), { values });
6577
+ const hlc = await this.offline.nextHlc();
6578
+ const _hlc = this.offline.columnStamps(values, hlc);
6579
+ try {
6580
+ const res = await this.http.post(this.base(), { values, _hlc });
6581
+ await this.offline.writeRow(this.name, String(res.id), res.row);
6582
+ return res;
6583
+ }
6584
+ catch (err) {
6585
+ if (err instanceof HttpError)
6586
+ throw err;
6587
+ // Offline: queue for replay. No server pk yet — return a provisional
6588
+ // client id so the caller has a stable handle until sync.
6589
+ const clientId = this.offline.newClientId();
6590
+ await this.offline.enqueue("insert", { table: this.name, values, _hlc }, hlc, clientId);
6591
+ return { row: values, id: clientId };
6592
+ }
6423
6593
  }
6424
6594
  /** Fetch a row by primary key, or `null` if it doesn't exist. */
6425
6595
  async get(id) {
6426
6596
  try {
6427
- const res = await this.http.get(`${this.base()}/${encodeURIComponent(String(id))}`);
6597
+ const res = await this.http.get(this.rowUrl(id));
6598
+ if (this.offline?.enabled)
6599
+ await this.offline.writeRow(this.name, String(id), res.row);
6428
6600
  return res.row;
6429
6601
  }
6430
6602
  catch (e) {
6431
6603
  if (e?.status === 404)
6432
6604
  return null;
6605
+ if (this.offline?.enabled && !(e instanceof HttpError)) {
6606
+ return (await this.offline.readRow(this.name, String(id)));
6607
+ }
6433
6608
  throw e;
6434
6609
  }
6435
6610
  }
6436
6611
  /** Query rows with filters, ordering, and keyset pagination. */
6437
6612
  async query(query = {}) {
6438
- return this.http.post(`${this.base()}/query`, query);
6613
+ if (!this.offline?.enabled)
6614
+ return this.http.post(`${this.base()}/query`, query);
6615
+ try {
6616
+ const res = await this.http.post(`${this.base()}/query`, query);
6617
+ await this.offline.writeQuery(this.name, query, res);
6618
+ return res;
6619
+ }
6620
+ catch (e) {
6621
+ if (e instanceof HttpError)
6622
+ throw e;
6623
+ const cached = await this.offline.readQuery(this.name, query);
6624
+ return cached ?? { rows: [], nextCursor: null };
6625
+ }
6439
6626
  }
6440
6627
  /** Update a row by primary key. Returns the updated row. */
6441
6628
  async update(id, values) {
6442
- return this.http.patch(`${this.base()}/${encodeURIComponent(String(id))}`, { values });
6629
+ if (!this.offline?.enabled)
6630
+ return this.http.patch(this.rowUrl(id), { values });
6631
+ const hlc = await this.offline.nextHlc();
6632
+ const _hlc = this.offline.columnStamps(values, hlc);
6633
+ await this.offline.patchRow(this.name, String(id), values); // optimistic
6634
+ try {
6635
+ const res = await this.http.patch(this.rowUrl(id), { values, _hlc });
6636
+ await this.offline.writeRow(this.name, String(id), res.row);
6637
+ return res;
6638
+ }
6639
+ catch (err) {
6640
+ if (err instanceof HttpError)
6641
+ throw err;
6642
+ await this.offline.enqueue("update", { table: this.name, id, values, _hlc }, hlc, this.offline.newClientId());
6643
+ return { row: (await this.offline.readRow(this.name, String(id))) };
6644
+ }
6443
6645
  }
6444
6646
  /** Delete a row by primary key. Returns the number of rows removed (0 or 1). */
6445
6647
  async delete(id) {
6446
- const res = await this.http.del(`${this.base()}/${encodeURIComponent(String(id))}`);
6447
- return res.deleted ?? 0;
6648
+ if (!this.offline?.enabled) {
6649
+ const res = await this.http.del(this.rowUrl(id));
6650
+ return res.deleted ?? 0;
6651
+ }
6652
+ const hlc = await this.offline.nextHlc();
6653
+ await this.offline.deleteRow(this.name, String(id)); // optimistic
6654
+ try {
6655
+ const res = await this.http.del(this.rowUrl(id), { _hlc: hlc });
6656
+ return res.deleted ?? 0;
6657
+ }
6658
+ catch (err) {
6659
+ if (err instanceof HttpError)
6660
+ throw err;
6661
+ await this.offline.enqueue("delete", { table: this.name, id, hlc }, hlc, this.offline.newClientId());
6662
+ return 1; // optimistic
6663
+ }
6448
6664
  }
6449
6665
  }
6450
6666
  class DbNamespace {
@@ -6460,7 +6676,21 @@ class DbNamespace {
6460
6676
  * const todos = client.db.table<Todo>('todos');
6461
6677
  */
6462
6678
  table(name) {
6463
- return new DbTable(this.deps.http, name);
6679
+ return new DbTable(this.deps.http, name, this.deps.offline);
6680
+ }
6681
+ /** Replay one queued db mutation (called by the sync engine). */
6682
+ async replay(entry) {
6683
+ const a = entry.args;
6684
+ const base = `/api/db/${encodeURIComponent(a.table)}`;
6685
+ if (entry.method === "insert") {
6686
+ await this.deps.http.post(base, { values: a.values, _hlc: a._hlc });
6687
+ }
6688
+ else if (entry.method === "update") {
6689
+ await this.deps.http.patch(`${base}/${encodeURIComponent(String(a.id))}`, { values: a.values, _hlc: a._hlc });
6690
+ }
6691
+ else if (entry.method === "delete") {
6692
+ await this.deps.http.del(`${base}/${encodeURIComponent(String(a.id))}`, { _hlc: a.hlc });
6693
+ }
6464
6694
  }
6465
6695
  }
6466
6696
 
@@ -6588,6 +6818,8 @@ class StorageNamespace {
6588
6818
  baseUrl: this.deps.baseUrl,
6589
6819
  pathPrefix: "/api/shards",
6590
6820
  fetch: this.deps.http.fetch,
6821
+ cache: this.deps.shardCache,
6822
+ deferUpload: this.deps.deferShardUpload,
6591
6823
  });
6592
6824
  }
6593
6825
  return this._shards;
@@ -6825,6 +7057,9 @@ class Space {
6825
7057
  /** Per-instance event bus for fan-out events (isolated across spaces). */
6826
7058
  events = new EventTarget();
6827
7059
  rawWired = false;
7060
+ catchUpUnsub = null;
7061
+ /** Optional P2P swarm for this Space (block exchange among members). */
7062
+ peerNet = null;
6828
7063
  constructor(deps) {
6829
7064
  this.deps = deps;
6830
7065
  this.name = deps.name;
@@ -6873,14 +7108,45 @@ class Space {
6873
7108
  if (this.deps.keyring) {
6874
7109
  this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
6875
7110
  this.channel?.sendRaw({ name: this.deps.myId() });
7111
+ // Kick P2P peer discovery now the signaling channel is live
7112
+ // (re-runs on each reconnect to re-form the mesh).
7113
+ this.peerNet?.start();
6876
7114
  });
6877
7115
  this.wireFanout();
7116
+ // Register offline catch-up once: on each reconnect the sync
7117
+ // engine pages history forward to fill any gap we missed.
7118
+ if (this.deps.offline?.enabled && !this.catchUpUnsub) {
7119
+ this.catchUpUnsub = this.deps.offline.registerCatchUp(() => this.catchUp());
7120
+ }
6878
7121
  }
6879
7122
  }
6880
7123
  await this.channel.connect();
6881
7124
  }
6882
7125
  disconnect() {
6883
7126
  this.channel?.disconnect();
7127
+ this.catchUpUnsub?.();
7128
+ this.catchUpUnsub = null;
7129
+ this.peerNet?.close();
7130
+ this.peerNet = null;
7131
+ }
7132
+ /** Attach a P2P swarm so this Space's file reads/writes can use peers. */
7133
+ attachPeerNetwork(net) {
7134
+ this.peerNet = net;
7135
+ }
7136
+ /**
7137
+ * Broadcast an app payload directly to connected Space peers over P2P,
7138
+ * **bypassing the server** — for CRDT ops / high-frequency state (live
7139
+ * cursors, presence, collaborative edits) you don't want to push through the
7140
+ * relay. No-op when no P2P mesh is active (requires `client` `p2p`).
7141
+ * Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
7142
+ * is peer-direct and fragments large payloads.
7143
+ */
7144
+ gossipToPeers(data) {
7145
+ this.peerNet?.gossip(data);
7146
+ }
7147
+ /** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
7148
+ onPeerGossip(handler) {
7149
+ return this.peerNet?.onGossip(handler) ?? (() => { });
6884
7150
  }
6885
7151
  /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
6886
7152
  on(event, handler) {
@@ -6927,7 +7193,30 @@ class Space {
6927
7193
  * persisted `spaceMessage`. Requires a keyring.
6928
7194
  */
6929
7195
  async sendMessage(payload, opts = {}) {
6930
- this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
7196
+ const offline = this.deps.offline;
7197
+ if (!offline?.enabled) {
7198
+ this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
7199
+ return;
7200
+ }
7201
+ // Offline-aware path: stamp the message, cache it optimistically (so the
7202
+ // UI can show it instantly via cachedMessages), then send if connected
7203
+ // or durably queue it for replay on reconnect.
7204
+ const clientId = offline.newClientId();
7205
+ const hlc = await offline.nextHlc();
7206
+ const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
7207
+ const frame = { spaceMessage: packet };
7208
+ await offline.putMessage(this.id, {
7209
+ handle: provisionalSpaceKey(clientId),
7210
+ packet,
7211
+ op: "msg",
7212
+ clientId,
7213
+ hlc,
7214
+ pending: true,
7215
+ });
7216
+ if (this.isConnected())
7217
+ this.sendRaw(frame);
7218
+ else
7219
+ await offline.enqueueFrame(this.id, frame, clientId, hlc);
6931
7220
  }
6932
7221
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
6933
7222
  onMessage(handler) {
@@ -6945,7 +7234,22 @@ class Space {
6945
7234
  */
6946
7235
  async editMessage(handle, payload, opts = {}) {
6947
7236
  const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
6948
- this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
7237
+ const frame = { editSpaceMessage: { ts: handle, spaceMessage } };
7238
+ const offline = this.deps.offline;
7239
+ if (offline?.enabled) {
7240
+ await offline.putMessage(this.id, {
7241
+ handle: padSpaceHandle(handle),
7242
+ packet: spaceMessage,
7243
+ op: "edit",
7244
+ hlc: await offline.nextHlc(),
7245
+ });
7246
+ if (this.isConnected())
7247
+ this.sendRaw(frame);
7248
+ else
7249
+ await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
7250
+ return;
7251
+ }
7252
+ this.sendRaw(frame);
6949
7253
  }
6950
7254
  /**
6951
7255
  * Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
@@ -6953,7 +7257,45 @@ class Space {
6953
7257
  * original author. Receivers get an {@link onMessageDeleted} event.
6954
7258
  */
6955
7259
  async deleteMessage(handle) {
6956
- this.sendRaw({ deleteSpaceMessage: { ts: handle } });
7260
+ const frame = { deleteSpaceMessage: { ts: handle } };
7261
+ const offline = this.deps.offline;
7262
+ if (offline?.enabled) {
7263
+ await offline.putDeleted(this.id, handle);
7264
+ if (this.isConnected())
7265
+ this.sendRaw(frame);
7266
+ else
7267
+ await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
7268
+ return;
7269
+ }
7270
+ this.sendRaw(frame);
7271
+ }
7272
+ /**
7273
+ * Decrypt and return the locally-cached message log for this space, in
7274
+ * handle order (pending sends last). Works offline — the source is the
7275
+ * IndexedDB cache populated by live frames + {@link history}. Apps call this
7276
+ * on boot to paint instantly, then subscribe to {@link onMessage} for live
7277
+ * updates. Returns `[]` when offline support is off.
7278
+ */
7279
+ async cachedMessages() {
7280
+ const offline = this.deps.offline;
7281
+ if (!offline?.enabled || !this.cipher)
7282
+ return [];
7283
+ const entries = await offline.loadMessages(this.id);
7284
+ const out = [];
7285
+ for (const e of entries) {
7286
+ if (e.deleted || !e.packet)
7287
+ continue;
7288
+ // Already verified when first cached → skip re-verification (which
7289
+ // would also fail offline without a warm member directory).
7290
+ const decoded = await this.decodeFrame(e.packet, this.cipher,
7291
+ /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
7292
+ /*skipVerify*/ true);
7293
+ if (decoded) {
7294
+ decoded.pending = e.pending;
7295
+ out.push(decoded);
7296
+ }
7297
+ }
7298
+ return out;
6957
7299
  }
6958
7300
  /** Subscribe to in-place edits of persisted messages (new content at the same handle). */
6959
7301
  onMessageEdited(handler) {
@@ -6974,10 +7316,12 @@ class Space {
6974
7316
  * indicators, presence, or live cursors are built on top of this. No-op if
6975
7317
  * the space isn't connected (ephemeral signals aren't worth queuing).
6976
7318
  */
6977
- sendEphemeral(subject, data) {
7319
+ sendEphemeral(subject, data, to) {
6978
7320
  if (!this.isConnected())
6979
7321
  return;
6980
- this.sendRaw({ pub: { subject, data } });
7322
+ // `to` (a member id) lets the server unicast a *directed* ephemeral
7323
+ // instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
7324
+ this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
6981
7325
  }
6982
7326
  /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
6983
7327
  onEphemeral(handler) {
@@ -7014,8 +7358,21 @@ class Space {
7014
7358
  const messages = [];
7015
7359
  for (const raw of body.messages ?? []) {
7016
7360
  const decoded = await this.decodeFrame(raw, cipher);
7017
- if (decoded)
7018
- messages.push(decoded);
7361
+ if (!decoded)
7362
+ continue;
7363
+ messages.push(decoded);
7364
+ // Warm the offline cache with the inner sealed packet at its handle.
7365
+ if (this.deps.offline?.enabled) {
7366
+ try {
7367
+ const outer = JSON.parse(raw);
7368
+ if (typeof outer.spaceMessage === "string") {
7369
+ await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7370
+ }
7371
+ }
7372
+ catch {
7373
+ /* unparseable history row — skip caching, still surfaced above */
7374
+ }
7375
+ }
7019
7376
  }
7020
7377
  return { messages, nextCursor: body.nextCursor ?? null };
7021
7378
  }
@@ -7082,6 +7439,9 @@ class Space {
7082
7439
  baseUrl: this.deps.httpBaseUrl,
7083
7440
  pathPrefix: "/api/shards",
7084
7441
  fetch: this.deps.fetch,
7442
+ cache: this.deps.shardCache,
7443
+ deferUpload: this.deps.deferShardUpload,
7444
+ peers: this.peerNet?.exchange,
7085
7445
  });
7086
7446
  return new FileStorage({ shards });
7087
7447
  }
@@ -7100,9 +7460,12 @@ class Space {
7100
7460
  if (!f || typeof f !== "object")
7101
7461
  return;
7102
7462
  if (typeof f.spaceMessage === "string" && this.cipher) {
7103
- const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
7104
- if (decoded)
7463
+ const handle = Number(f.timestamp ?? 0);
7464
+ const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
7465
+ if (decoded) {
7105
7466
  this.emitEvent("message", decoded);
7467
+ await this.cacheFrame(f.spaceMessage, decoded, "msg");
7468
+ }
7106
7469
  return;
7107
7470
  }
7108
7471
  if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
@@ -7111,14 +7474,21 @@ class Space {
7111
7474
  const e = f.editSpaceMessage;
7112
7475
  if (typeof e.spaceMessage === "string") {
7113
7476
  const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
7114
- if (decoded)
7477
+ if (decoded) {
7115
7478
  this.emitEvent("message-edited", decoded);
7479
+ await this.cacheFrame(e.spaceMessage, decoded, "edit");
7480
+ }
7116
7481
  }
7117
7482
  return;
7118
7483
  }
7119
7484
  if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
7120
7485
  const d = f.deleteSpaceMessage;
7121
- this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
7486
+ const handle = Number(d.ts ?? 0);
7487
+ this.emitEvent("message-deleted", { handle });
7488
+ if (this.deps.offline?.enabled) {
7489
+ await this.deps.offline.putDeleted(this.id, handle);
7490
+ await this.deps.offline.observeHandle(this.id, handle);
7491
+ }
7122
7492
  return;
7123
7493
  }
7124
7494
  if (f.pub && typeof f.pub === "object") {
@@ -7154,12 +7524,17 @@ class Space {
7154
7524
  * serialized `Packet` string. Shared by send (a new message) and edit
7155
7525
  * (replacement content for an existing handle). Requires a keyring.
7156
7526
  */
7157
- async sealMessage(payload, channel, contentType) {
7527
+ async sealMessage(payload, channel, contentType, clientId) {
7158
7528
  const cipher = this.requireCipher(contentType);
7159
7529
  const message = new Message(payload);
7160
7530
  const headers = await cipher.seal(message.serialize());
7161
7531
  const source = this.deps.myId();
7162
7532
  const subject = channel ?? "default";
7533
+ // Cleartext dedupe hint — lets the sender match its optimistic entry to
7534
+ // the server echo. Not part of the signed canonical form (it carries no
7535
+ // authority); worst case a stale hint just shows a transient duplicate.
7536
+ if (clientId)
7537
+ headers.cid = clientId;
7163
7538
  // Sign with our identity ECDSA key so receivers can verify authorship
7164
7539
  // end-to-end (independent of the relay's `source` stamp).
7165
7540
  const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
@@ -7179,7 +7554,7 @@ class Space {
7179
7554
  * the enclosing frame); false means `input` is the full
7180
7555
  * `{spaceMessage, name, timestamp}` frame and the handle is read from it.
7181
7556
  */
7182
- async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
7557
+ async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0, skipVerify = false) {
7183
7558
  try {
7184
7559
  let packetJson = input;
7185
7560
  let handle = serverTs;
@@ -7197,8 +7572,9 @@ class Space {
7197
7572
  // by `source`'s identity ECDSA key (looked up in the member
7198
7573
  // directory). Drop anything unsigned, signed by an unknown member,
7199
7574
  // or whose signature doesn't match — this defeats both member
7200
- // impersonation and a relay rewriting `source`.
7201
- if (!(await this.verifySender(packet)))
7575
+ // impersonation and a relay rewriting `source`. Skipped only when
7576
+ // re-reading from the local cache (already verified before caching).
7577
+ if (!skipVerify && !(await this.verifySender(packet)))
7202
7578
  return null;
7203
7579
  const serialized = await cipher.open(packet.headers);
7204
7580
  if (serialized === null)
@@ -7210,6 +7586,7 @@ class Space {
7210
7586
  epoch: Number(packet.headers?.epoch ?? 0),
7211
7587
  contentType: packet.headers?.contentType,
7212
7588
  handle,
7589
+ clientId: packet.headers?.cid,
7213
7590
  message,
7214
7591
  };
7215
7592
  }
@@ -7252,6 +7629,62 @@ class Space {
7252
7629
  });
7253
7630
  return verifySpaceMessage(canonical, sig, key);
7254
7631
  }
7632
+ /** Persist a decoded live/history/edit frame to the offline cache. */
7633
+ async cacheFrame(packet, decoded, op) {
7634
+ const offline = this.deps.offline;
7635
+ if (!offline?.enabled)
7636
+ return;
7637
+ // An incoming real message supersedes any optimistic pending entry.
7638
+ if (decoded.clientId)
7639
+ await offline.dropPending(this.id, decoded.clientId);
7640
+ await offline.putMessage(this.id, {
7641
+ handle: padSpaceHandle(decoded.handle),
7642
+ packet,
7643
+ op,
7644
+ clientId: decoded.clientId,
7645
+ });
7646
+ await offline.observeHandle(this.id, decoded.handle);
7647
+ }
7648
+ /**
7649
+ * Inbound reconciliation run on reconnect: page the server's forward delta
7650
+ * (`?since=<lastSeenHandle>`) to the head, pulling exactly the messages that
7651
+ * landed while we were offline into the cache. Bounded so a long absence
7652
+ * can't page forever. Falls back gracefully if the endpoint is unavailable.
7653
+ */
7654
+ async catchUp() {
7655
+ const offline = this.deps.offline;
7656
+ if (!offline?.enabled || !this.cipher)
7657
+ return;
7658
+ const cursor = await offline.getCursor(this.id);
7659
+ let since = String(cursor?.lastSeenHandle ?? 0);
7660
+ // Warm the member directory once so historical senders' signatures verify.
7661
+ await this.deps.keyring?.refreshDirectory();
7662
+ for (let page = 0; page < 50; page++) {
7663
+ const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7664
+ `?since=${encodeURIComponent(since)}&limit=100`;
7665
+ const res = await this.deps.fetch(url);
7666
+ if (!res.ok)
7667
+ break;
7668
+ const body = (await res.json());
7669
+ for (const raw of body.messages ?? []) {
7670
+ const decoded = await this.decodeFrame(raw, this.cipher);
7671
+ if (!decoded)
7672
+ continue;
7673
+ try {
7674
+ const outer = JSON.parse(raw);
7675
+ if (typeof outer.spaceMessage === "string") {
7676
+ await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7677
+ }
7678
+ }
7679
+ catch {
7680
+ /* unparseable row — skip */
7681
+ }
7682
+ }
7683
+ if (!body.nextCursor)
7684
+ break;
7685
+ since = body.nextCursor;
7686
+ }
7687
+ }
7255
7688
  emitEvent(type, detail) {
7256
7689
  this.events.dispatchEvent(new CustomEvent(type, { detail }));
7257
7690
  }
@@ -7271,6 +7704,18 @@ class Space {
7271
7704
  function delay(ms) {
7272
7705
  return new Promise((r) => setTimeout(r, ms));
7273
7706
  }
7707
+ /**
7708
+ * Zero-pad a numeric server handle for use as a cache key, so string-ordered
7709
+ * keys sort in handle order. Must match the offline layer's `padHandle`.
7710
+ */
7711
+ function padSpaceHandle(handle) {
7712
+ return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
7713
+ }
7714
+ /** Cache key for an un-acked optimistic send. Must match the offline layer's
7715
+ * `provisionalHandle` (sorts after every real, zero-padded handle). */
7716
+ function provisionalSpaceKey(clientId) {
7717
+ return `~local:${clientId}`;
7718
+ }
7274
7719
 
7275
7720
  /**
7276
7721
  * `client.message` — realtime messaging over the shared-space primitive.
@@ -7766,455 +8211,2573 @@ class KeyringClient {
7766
8211
  }
7767
8212
 
7768
8213
  /**
7769
- * `client.space` — the fan-out group-encryption surface.
7770
- *
7771
- * const space = await client.space.createSpace({ historyPolicy: "static" });
7772
- * await space.sendMessage({ text: "hello" });
7773
- * space.onMessage(e => …);
7774
- *
7775
- * const joined = await client.space.joinSpace(space.id); // newcomer
7776
- *
7777
- * A space owns a client-generated keypair whose public key is its id, a
7778
- * symmetric group key distributed server-blind via the keyring, and a
7779
- * persisted message log. This namespace wires the {@link Space} handle to the
7780
- * HTTP {@link KeyringClient}, the member's identity keys (from {@link KeyStore},
7781
- * shared with the Double Ratchet path), and a PersonalSpace-backed key cache.
8214
+ * Message-log helpers — the conflict model for a Space's message history.
8215
+ *
8216
+ * Messages are the easy CRDT: the accelerator assigns every persisted message a
8217
+ * monotonic, zero-padded `handle` that already totally-orders the log, and the
8218
+ * sealed body is immutable. So the "log" is just a grow-only set keyed by
8219
+ * `handle`, and convergence is structural — no server change needed.
8220
+ *
8221
+ * The only subtleties this module encodes:
8222
+ * - **Optimistic sends** have no server handle yet. We key them by a
8223
+ * provisional handle that sorts *after* every real one, and tag them with
8224
+ * the sender's `clientId` so the real frame (carrying the same `clientId`)
8225
+ * can replace the placeholder instead of duplicating it.
8226
+ * - **Edits** are a per-message LWW register on the body (greater HLC wins).
8227
+ * - **Deletes** are a tombstone at the message's handle.
7782
8228
  */
7783
- /** Thrown by `joinChannel` when no channel with that name is registered. */
7784
- class ChannelNotFoundError extends Error {
7785
- channelName;
7786
- constructor(channelName) {
7787
- super(`Channel "${channelName}" does not exist. Create it first.`);
7788
- this.channelName = channelName;
7789
- this.name = "ChannelNotFoundError";
7790
- }
8229
+ /** IndexedDB key for a message within a space. */
8230
+ function messageKey(spaceId, handle) {
8231
+ return `${spaceId}|${handle}`;
7791
8232
  }
7792
- /** Thrown by `createChannel` when the name is already registered. */
7793
- class ChannelExistsError extends Error {
7794
- channelName;
7795
- constructor(channelName) {
7796
- super(`Channel "${channelName}" already exists.`);
7797
- this.channelName = channelName;
7798
- this.name = "ChannelExistsError";
8233
+ /**
8234
+ * A handle for an un-acked optimistic message. Prefixed with `"~"` (0x7E),
8235
+ * which sorts after every digit, so pending sends always appear at the tail of
8236
+ * the handle-ordered log until the real handle arrives.
8237
+ */
8238
+ function provisionalHandle(clientId) {
8239
+ return `~local:${clientId}`;
8240
+ }
8241
+ function isProvisional(handle) {
8242
+ return handle.startsWith("~local:");
8243
+ }
8244
+ /**
8245
+ * Merge two entries for the SAME handle. A delete always wins (tombstone); an
8246
+ * edit wins over the entry it supersedes when its HLC is greater; otherwise the
8247
+ * existing entry stands (a re-delivered `msg` is idempotent).
8248
+ */
8249
+ function mergeMessage(local, incoming) {
8250
+ if (!local)
8251
+ return incoming;
8252
+ if (incoming.op === "delete" || local.deleted) {
8253
+ return { ...local, op: "delete", packet: null, deleted: true };
8254
+ }
8255
+ if (incoming.op === "edit") {
8256
+ if (!local.hlc || (incoming.hlc && incoming.hlc > local.hlc)) {
8257
+ return { ...local, packet: incoming.packet, hlc: incoming.hlc, op: "edit" };
8258
+ }
8259
+ return local;
7799
8260
  }
8261
+ // incoming.op === "msg": keep the already-stored (de-pending) entry.
8262
+ return { ...local, pending: false };
7800
8263
  }
7801
- class SpaceNamespace {
7802
- deps;
7803
- constructor(deps) {
7804
- this.deps = deps;
8264
+
8265
+ /**
8266
+ * `SpaceCache` — the offline adapter a {@link ../spaces/Space} talks to. It
8267
+ * persists sealed message frames (ciphertext at rest), tracks per-space sync
8268
+ * cursors, and records offline sends in the durable queue. Kept as its own
8269
+ * object (rather than wiring the Space straight to the store + clock) so the
8270
+ * Space stays decoupled from IndexedDB and so the no-op path is a single
8271
+ * `enabled` check.
8272
+ *
8273
+ * Messages are the structural CRDT (see {@link ./crdt/MessageLog}): the server
8274
+ * assigns a monotonic `handle` that totally-orders the log, so the cache just
8275
+ * stores frames keyed by a zero-padded handle (real) or a provisional key
8276
+ * (pending sends, which sort to the tail until their echo arrives).
8277
+ */
8278
+ /** Zero-pad a numeric server handle so string keys sort in handle order. */
8279
+ function padHandle(handle) {
8280
+ return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
8281
+ }
8282
+ /**
8283
+ * The surface a {@link ../spaces/Space} uses. Declared structurally on the
8284
+ * Space side too (as `SpaceOfflineAdapter`) so the spaces module never imports
8285
+ * the offline module.
8286
+ */
8287
+ class SpaceCache {
8288
+ manager;
8289
+ store;
8290
+ constructor(manager) {
8291
+ this.manager = manager;
8292
+ this.store = manager.store;
8293
+ }
8294
+ get enabled() {
8295
+ return this.manager.enabled;
8296
+ }
8297
+ newClientId() {
8298
+ return this.manager.newClientId();
8299
+ }
8300
+ nextHlc() {
8301
+ return this.manager.nextHlc();
8302
+ }
8303
+ /** Persist a sealed message frame keyed by its (real or provisional) handle. */
8304
+ async putMessage(spaceId, entry) {
8305
+ await this.store.put("space-messages", messageKey(spaceId, entry.handle), entry);
8306
+ }
8307
+ /** Tombstone the message at `handle`. */
8308
+ async putDeleted(spaceId, handle) {
8309
+ const key = messageKey(spaceId, padHandle(handle));
8310
+ const existing = await this.store.get("space-messages", key);
8311
+ await this.store.put("space-messages", key, {
8312
+ handle: padHandle(handle),
8313
+ packet: null,
8314
+ op: "delete",
8315
+ deleted: true,
8316
+ clientId: existing?.clientId,
8317
+ });
7805
8318
  }
7806
- /**
7807
- * Create a new space: generate its keypair (public key = id), register
7808
- * metadata, mint the initial group key, and connect. Returns the open Space.
7809
- */
7810
- async createSpace(opts = {}) {
7811
- const historyPolicy = opts.historyPolicy ?? "static";
7812
- const identity = await generateSpaceIdentity();
7813
- // Register the space (server records pubkey + policy; the DO is lazy).
7814
- // For a private space the server also records the creator (this user) as
7815
- // its first member — so the metadata POST must carry the user session.
7816
- await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
7817
- const space = await this.build(identity.id, historyPolicy);
7818
- await space.create();
7819
- return space;
8319
+ /** Drop a pending (optimistic) entry once its real echo has been stored. */
8320
+ async dropPending(spaceId, clientId) {
8321
+ await this.store.delete("space-messages", messageKey(spaceId, provisionalHandle(clientId)));
7820
8322
  }
7821
- /** Join an existing space by id (its public key). Resolves once a key arrives. */
7822
- async joinSpace(spaceId, opts = {}) {
7823
- const policy = await this.fetchPolicy(spaceId);
7824
- const space = await this.build(spaceId, policy);
7825
- await space.join({ timeoutMs: opts.timeoutMs });
7826
- return space;
8323
+ /** All cached frames for a space, in handle order (pending ones last). */
8324
+ async loadMessages(spaceId) {
8325
+ const rows = await this.store.range("space-messages", `${spaceId}|`, `${spaceId}|￿`);
8326
+ return rows.map((r) => r.value);
7827
8327
  }
7828
- /** A handle to a space without auto-connecting (uses cached keys on demand). */
7829
- async get(spaceId, historyPolicy = "static") {
7830
- return this.build(spaceId, historyPolicy);
8328
+ async getCursor(spaceId) {
8329
+ return this.store.get("space-cursors", spaceId);
7831
8330
  }
7832
- // -------------------------------------------------------------------------
7833
- // Channels — named, app-scoped pointers to spaces. The name → space-id
7834
- // directory is app-public config (server-readable, scoped to this app via
7835
- // its key); the channel CONTENTS stay fan-out E2E-encrypted. Lets chat use
7836
- // human room names (`general`) instead of raw pubkey ids.
7837
- // -------------------------------------------------------------------------
7838
- /** List this app's channels (name → space-id) for the key's environment. */
7839
- async listChannels() {
7840
- const body = await this.deps.http.get("/api/app/channels");
7841
- return body.channels ?? [];
8331
+ /** Advance the last-seen handle (monotonic) and track the oldest fetched. */
8332
+ async observeHandle(spaceId, handle) {
8333
+ if (!handle)
8334
+ return;
8335
+ const cur = (await this.getCursor(spaceId)) ?? {
8336
+ lastSeenHandle: 0,
8337
+ oldestHandle: handle,
8338
+ fullyBackfilled: false,
8339
+ };
8340
+ const next = {
8341
+ lastSeenHandle: Math.max(cur.lastSeenHandle, handle),
8342
+ oldestHandle: cur.oldestHandle ? Math.min(cur.oldestHandle, handle) : handle,
8343
+ fullyBackfilled: cur.fullyBackfilled,
8344
+ };
8345
+ await this.store.put("space-cursors", spaceId, next);
8346
+ }
8347
+ /** Queue a raw space frame for replay when the connection returns. */
8348
+ async enqueueFrame(spaceId, frame, clientId, hlc) {
8349
+ await this.manager.enqueue({
8350
+ hlc,
8351
+ clientId,
8352
+ domain: "space",
8353
+ method: "sendRaw",
8354
+ args: { spaceId, frame },
8355
+ });
7842
8356
  }
7843
- /** Resolve a channel name to its space id, or null if it doesn't exist. */
7844
- async resolveChannel(name) {
8357
+ /** Register an inbound catch-up task (forward/back history paging). */
8358
+ registerCatchUp(task) {
8359
+ return this.manager.registerCatchUp(task);
8360
+ }
8361
+ }
8362
+
8363
+ /**
8364
+ * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
8365
+ * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
8366
+ * is uniquely safe: a cache hit is self-validating, immutable, and shareable
8367
+ * across files. The Cache API (rather than IndexedDB) is the right home for
8368
+ * large binary blobs — it streams, it's purpose-built for response bodies, and
8369
+ * it has its own eviction.
8370
+ *
8371
+ * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
8372
+ * than the real shard endpoint, so the cache is addressable by hash alone — the
8373
+ * sync engine can re-PUT a deferred shard knowing only its hash, without
8374
+ * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
8375
+ * exist only as cache keys.
8376
+ *
8377
+ * Every method degrades to a no-op / null where the Cache API is unavailable
8378
+ * (Node, Workers, insecure contexts), so callers don't need to branch.
8379
+ */
8380
+ const CACHE_NAME = "muhkoo-shards-v1";
8381
+ class ShardCache {
8382
+ static available() {
7845
8383
  try {
7846
- const body = await this.deps.http.get(`/api/app/channels/${encodeURIComponent(name)}`);
7847
- return body.spaceId ?? null;
8384
+ return typeof caches !== "undefined";
7848
8385
  }
7849
- catch (err) {
7850
- if (err instanceof HttpError && err.status === 404)
7851
- return null;
7852
- throw err;
8386
+ catch {
8387
+ return false;
7853
8388
  }
7854
8389
  }
7855
- /**
7856
- * Create a new channel: mint a fan-out space (the caller becomes its first
7857
- * key-holder) and register `name → spaceId` in the app directory. Throws
7858
- * {@link ChannelExistsError} if the name is already taken.
7859
- */
7860
- async createChannel(name, opts = {}) {
7861
- const space = await this.createSpace(opts);
7862
- // Register name → id first so a name clash doesn't leave an admitted keeper.
8390
+ key(hash) {
8391
+ return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
8392
+ }
8393
+ open() {
8394
+ return caches.open(CACHE_NAME);
8395
+ }
8396
+ /** Cached shard bytes, or `null` on miss / when unavailable. */
8397
+ async get(hash) {
8398
+ if (!ShardCache.available())
8399
+ return null;
7863
8400
  try {
7864
- await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
8401
+ const cache = await this.open();
8402
+ const res = await cache.match(this.key(hash));
8403
+ if (!res)
8404
+ return null;
8405
+ return new Uint8Array(await res.arrayBuffer());
7865
8406
  }
7866
- catch (err) {
7867
- if (err instanceof HttpError && err.status === 409) {
7868
- throw new ChannelExistsError(name);
7869
- }
7870
- throw err;
8407
+ catch {
8408
+ return null;
7871
8409
  }
7872
- // Admit the app keeper so the channel is joinable with no member online.
7873
- // Best-effort: a channel still works (members admit each other) without it.
8410
+ }
8411
+ /** Store shard bytes under their hash. Best-effort. */
8412
+ async put(hash, bytes) {
8413
+ if (!ShardCache.available())
8414
+ return;
7874
8415
  try {
7875
- const { pubkey } = await this.deps.http.get("/api/app/keeper");
7876
- if (pubkey && space.keyring)
7877
- await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
8416
+ const cache = await this.open();
8417
+ await cache.put(this.key(hash), new Response(bytes, {
8418
+ headers: { "content-type": "application/octet-stream" },
8419
+ }));
7878
8420
  }
7879
- catch (err) {
7880
- console.warn("[client.space] keeper admit failed (channel still usable):", err);
8421
+ catch {
8422
+ /* quota / unavailable — degrade to online-only for this shard */
7881
8423
  }
7882
- return space;
7883
8424
  }
7884
- /**
8425
+ /** Whether a shard is present in the cache. */
8426
+ async has(hash) {
8427
+ if (!ShardCache.available())
8428
+ return false;
8429
+ try {
8430
+ const cache = await this.open();
8431
+ return Boolean(await cache.match(this.key(hash)));
8432
+ }
8433
+ catch {
8434
+ return false;
8435
+ }
8436
+ }
8437
+ }
8438
+
8439
+ /**
8440
+ * `PeerExchange` — the thin façade the storage layer consults to try fetching a
8441
+ * block from peers before falling back to origin. It satisfies the structural
8442
+ * `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
8443
+ * declares (so storage never imports the p2p module — the dependency arrow stays
8444
+ * one-way, p2p → storage).
8445
+ *
8446
+ * It delegates to an {@link EngineHandle} — either an in-process {@link
8447
+ * ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
8448
+ * works whether the block engine runs on the main thread or off it.
8449
+ */
8450
+ class PeerExchange {
8451
+ engine;
8452
+ timeoutMs;
8453
+ constructor(engine, opts = {}) {
8454
+ this.engine = engine;
8455
+ this.timeoutMs = opts.timeoutMs ?? 2500;
8456
+ }
8457
+ getBlock(hash, opts) {
8458
+ return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
8459
+ }
8460
+ announce(hash) {
8461
+ this.engine.announce(hash);
8462
+ }
8463
+ }
8464
+
8465
+ /**
8466
+ * `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
8467
+ * relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
8468
+ * blind-relay `pub` frames stamped with the authenticated sender, so SDP
8469
+ * offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
8470
+ * to Space membership. The sender (`from`) is server-stamped and can't be
8471
+ * spoofed; the payload is opaque to the server.
8472
+ *
8473
+ * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
8474
+ * accelerator can optionally unicast peer-pair signaling instead of broadcasting
8475
+ * (a small, backward-compatible `pub` enhancement); the client filters by `to`
8476
+ * regardless.
8477
+ */
8478
+ const P2P_SUBJECT = "__p2p__";
8479
+ class SpaceSignaler {
8480
+ space;
8481
+ myId;
8482
+ constructor(space, myId) {
8483
+ this.space = space;
8484
+ this.myId = myId;
8485
+ }
8486
+ /** Send a signal to one peer (or `"*"`). */
8487
+ send(to, kind, data) {
8488
+ const env = { to, kind, data };
8489
+ // Ask the server to unicast for a directed target; broadcast for "*".
8490
+ // `to` also stays in the envelope so a broadcast-only server still works
8491
+ // (the client filters by it).
8492
+ this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
8493
+ }
8494
+ /** Announce/withdraw P2P availability to all Space members. */
8495
+ broadcast(kind, data) {
8496
+ this.send("*", kind, data);
8497
+ }
8498
+ /**
8499
+ * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
8500
+ * echoes. Returns an unsubscribe function.
8501
+ */
8502
+ onSignal(cb) {
8503
+ return this.space.onEphemeral((e) => {
8504
+ if (e.subject !== P2P_SUBJECT || e.from === this.myId)
8505
+ return;
8506
+ const env = e.data;
8507
+ if (!env || typeof env.kind !== "string")
8508
+ return;
8509
+ if (env.to !== this.myId && env.to !== "*")
8510
+ return;
8511
+ cb(e.from, env.kind, env.data);
8512
+ });
8513
+ }
8514
+ }
8515
+
8516
+ /**
8517
+ * `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
8518
+ * signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
8519
+ * (`RTCPeerConnection` is Window-only — not available in Workers), so the block
8520
+ * engine lives in a Worker and this only shuttles bytes.
8521
+ *
8522
+ * Connection setup uses a deterministic initiator (the peer with the smaller id
8523
+ * dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
8524
+ * TURN — an unreachable peer simply never connects and the caller falls back to
8525
+ * origin (P2P is best-effort).
8526
+ *
8527
+ * Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
8528
+ * each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
8529
+ * `[msgId u32][index u32][total u32]` and reassembled per peer.
8530
+ */
8531
+ const CHUNK = 16 * 1024;
8532
+ // [msgId u32][index u32][total u32][channel u32]
8533
+ const HEADER$1 = 16;
8534
+ /** Pause sends when a channel has this many bytes buffered (backpressure). */
8535
+ const MAX_BUFFERED = 4 * 1024 * 1024;
8536
+ const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
8537
+ class WebRtcTransport {
8538
+ signaler;
8539
+ myId;
8540
+ conns = new Map();
8541
+ msgCbs = new Set();
8542
+ peerCbs = new Set();
8543
+ offSignal;
8544
+ iceServers;
8545
+ maxPeers;
8546
+ nextMsgId = 1;
8547
+ constructor(signaler, myId, opts = {}) {
8548
+ this.signaler = signaler;
8549
+ this.myId = myId;
8550
+ this.iceServers = opts.iceServers ?? DEFAULT_ICE;
8551
+ this.maxPeers = opts.maxPeers ?? 8;
8552
+ this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
8553
+ }
8554
+ peers() {
8555
+ return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
8556
+ }
8557
+ /** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
8558
+ connectTo(peerId) {
8559
+ if (peerId === this.myId || this.conns.has(peerId))
8560
+ return;
8561
+ if (this.conns.size >= this.maxPeers)
8562
+ return; // mesh cap
8563
+ if (this.myId < peerId)
8564
+ void this.initiate(peerId); // smaller id dials
8565
+ }
8566
+ send(peer, data, channel = 0) {
8567
+ const dc = this.conns.get(peer)?.dc;
8568
+ if (dc?.readyState === "open")
8569
+ void this.sendFragmented(dc, data, channel);
8570
+ }
8571
+ broadcast(data, channel = 0) {
8572
+ for (const [, c] of this.conns) {
8573
+ if (c.dc?.readyState === "open")
8574
+ void this.sendFragmented(c.dc, data, channel);
8575
+ }
8576
+ }
8577
+ onMessage(cb) {
8578
+ this.msgCbs.add(cb);
8579
+ return () => this.msgCbs.delete(cb);
8580
+ }
8581
+ onPeerChange(cb) {
8582
+ this.peerCbs.add(cb);
8583
+ return () => this.peerCbs.delete(cb);
8584
+ }
8585
+ close() {
8586
+ this.offSignal();
8587
+ for (const [, c] of this.conns) {
8588
+ try {
8589
+ c.dc?.close();
8590
+ }
8591
+ catch { /* already closing */ }
8592
+ try {
8593
+ c.pc.close();
8594
+ }
8595
+ catch { /* already closing */ }
8596
+ }
8597
+ this.conns.clear();
8598
+ this.msgCbs.clear();
8599
+ this.peerCbs.clear();
8600
+ }
8601
+ // -- internals -----------------------------------------------------------
8602
+ newPc(peerId) {
8603
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
8604
+ const conn = { pc, reasm: new Map() };
8605
+ this.conns.set(peerId, conn);
8606
+ pc.onicecandidate = (e) => {
8607
+ if (e.candidate)
8608
+ this.signaler.send(peerId, "ice", e.candidate.toJSON());
8609
+ };
8610
+ pc.onconnectionstatechange = () => {
8611
+ if (["failed", "closed", "disconnected"].includes(pc.connectionState))
8612
+ this.drop(peerId);
8613
+ };
8614
+ pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
8615
+ return conn;
8616
+ }
8617
+ async initiate(peerId) {
8618
+ const conn = this.newPc(peerId);
8619
+ const dc = conn.pc.createDataChannel("blocks");
8620
+ this.bindChannel(peerId, conn, dc);
8621
+ const offer = await conn.pc.createOffer();
8622
+ await conn.pc.setLocalDescription(offer);
8623
+ this.signaler.send(peerId, "offer", offer);
8624
+ }
8625
+ bindChannel(peerId, conn, dc) {
8626
+ dc.binaryType = "arraybuffer";
8627
+ conn.dc = dc;
8628
+ dc.onopen = () => this.firePeerChange();
8629
+ dc.onclose = () => this.drop(peerId);
8630
+ dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
8631
+ }
8632
+ async onSignal(from, kind, data) {
8633
+ if (kind === "offer") {
8634
+ if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
8635
+ return; // mesh cap
8636
+ const conn = this.conns.get(from) ?? this.newPc(from);
8637
+ await conn.pc.setRemoteDescription(data);
8638
+ const answer = await conn.pc.createAnswer();
8639
+ await conn.pc.setLocalDescription(answer);
8640
+ this.signaler.send(from, "answer", answer);
8641
+ }
8642
+ else if (kind === "answer") {
8643
+ const conn = this.conns.get(from);
8644
+ if (conn)
8645
+ await conn.pc.setRemoteDescription(data);
8646
+ }
8647
+ else if (kind === "ice") {
8648
+ const conn = this.conns.get(from);
8649
+ if (conn && data) {
8650
+ try {
8651
+ await conn.pc.addIceCandidate(data);
8652
+ }
8653
+ catch { /* pre-SRD race */ }
8654
+ }
8655
+ }
8656
+ else if (kind === "bye") {
8657
+ this.drop(from);
8658
+ }
8659
+ }
8660
+ drop(peerId) {
8661
+ const conn = this.conns.get(peerId);
8662
+ if (!conn)
8663
+ return;
8664
+ this.conns.delete(peerId);
8665
+ try {
8666
+ conn.dc?.close();
8667
+ }
8668
+ catch { /* already closing */ }
8669
+ try {
8670
+ conn.pc.close();
8671
+ }
8672
+ catch { /* already closing */ }
8673
+ this.firePeerChange();
8674
+ }
8675
+ firePeerChange() {
8676
+ const peers = this.peers();
8677
+ for (const cb of this.peerCbs)
8678
+ cb(peers);
8679
+ }
8680
+ // -- fragmentation -------------------------------------------------------
8681
+ async sendFragmented(dc, data, channel) {
8682
+ const msgId = this.nextMsgId++ >>> 0;
8683
+ const total = Math.max(1, Math.ceil(data.length / CHUNK));
8684
+ for (let i = 0; i < total; i++) {
8685
+ const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
8686
+ const frame = new Uint8Array(HEADER$1 + slice.length);
8687
+ const dv = new DataView(frame.buffer);
8688
+ dv.setUint32(0, msgId);
8689
+ dv.setUint32(4, i);
8690
+ dv.setUint32(8, total);
8691
+ dv.setUint32(12, channel);
8692
+ frame.set(slice, HEADER$1);
8693
+ if (dc.bufferedAmount > MAX_BUFFERED)
8694
+ await this.drain(dc);
8695
+ dc.send(frame);
8696
+ }
8697
+ }
8698
+ drain(dc) {
8699
+ return new Promise((resolve) => {
8700
+ dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
8701
+ const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
8702
+ dc.addEventListener("bufferedamountlow", onLow);
8703
+ });
8704
+ }
8705
+ onChunk(peerId, conn, frame) {
8706
+ if (frame.length < HEADER$1)
8707
+ return;
8708
+ const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
8709
+ const msgId = dv.getUint32(0);
8710
+ const index = dv.getUint32(4);
8711
+ const total = dv.getUint32(8);
8712
+ const channel = dv.getUint32(12);
8713
+ const chunk = frame.slice(HEADER$1);
8714
+ let r = conn.reasm.get(msgId);
8715
+ if (!r) {
8716
+ r = { chunks: new Array(total), received: 0, total, size: 0, channel };
8717
+ conn.reasm.set(msgId, r);
8718
+ }
8719
+ if (r.chunks[index])
8720
+ return; // dup
8721
+ r.chunks[index] = chunk;
8722
+ r.received++;
8723
+ r.size += chunk.length;
8724
+ if (r.received < r.total)
8725
+ return;
8726
+ conn.reasm.delete(msgId);
8727
+ const out = new Uint8Array(r.size);
8728
+ let off = 0;
8729
+ for (const c of r.chunks) {
8730
+ if (!c)
8731
+ return; // gap — shouldn't happen on reliable ordered
8732
+ out.set(c, off);
8733
+ off += c.length;
8734
+ }
8735
+ for (const cb of this.msgCbs)
8736
+ cb(peerId, out, r.channel);
8737
+ }
8738
+ }
8739
+
8740
+ /**
8741
+ * `PeerTransport` — the pluggable wire under the block engine. It moves opaque
8742
+ * frames between Space peers; it knows nothing about blocks, hashes, or the
8743
+ * protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
8744
+ * a future libp2p/Helia transport can implement the same interface without the
8745
+ * block engine or `ShardClient` changing.
8746
+ *
8747
+ * Implementations are responsible for fragmenting/ reassembling frames larger
8748
+ * than the underlying channel's max message size (a 4 MiB shard won't fit in one
8749
+ * `RTCDataChannel.send`); the engine always deals in whole frames.
8750
+ */
8751
+ /**
8752
+ * Logical channels multiplexed over the one data link. `0` is block exchange
8753
+ * (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
8754
+ * so payloads aren't copied to prepend it.
8755
+ */
8756
+ const CHANNEL_BLOCK = 0;
8757
+ const CHANNEL_GOSSIP = 1;
8758
+
8759
+ /**
8760
+ * Bitswap-lite wire protocol for peer block exchange. Four frame types over a
8761
+ * {@link ../transport/PeerTransport} data channel:
8762
+ *
8763
+ * WANT(hash) — "does anyone have this block?" / "send it to me"
8764
+ * HAVE(hash) — "I hold this block" (announce / reply to a WANT)
8765
+ * BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
8766
+ * CANCEL(hash) — "stop, I got it elsewhere"
8767
+ *
8768
+ * Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
8769
+ *
8770
+ * byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
8771
+ * bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
8772
+ * bytes 33.. : payload (BLOCK only)
8773
+ *
8774
+ * Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
8775
+ * is the capability check (the receiver re-hashes the payload and drops a
8776
+ * mismatch). See {@link ./blockEngine}.
8777
+ */
8778
+ var FrameType;
8779
+ (function (FrameType) {
8780
+ FrameType[FrameType["WANT"] = 0] = "WANT";
8781
+ FrameType[FrameType["HAVE"] = 1] = "HAVE";
8782
+ FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
8783
+ FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
8784
+ })(FrameType || (FrameType = {}));
8785
+ const HASH_BYTES = 32;
8786
+ const HEADER = 1 + HASH_BYTES; // type + hash
8787
+ function hexToBytes(hex) {
8788
+ if (hex.length !== HASH_BYTES * 2) {
8789
+ throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
8790
+ }
8791
+ const out = new Uint8Array(HASH_BYTES);
8792
+ for (let i = 0; i < HASH_BYTES; i++)
8793
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
8794
+ return out;
8795
+ }
8796
+ const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
8797
+ function bytesToHex(bytes) {
8798
+ let s = "";
8799
+ for (let i = 0; i < bytes.length; i++)
8800
+ s += HEX[bytes[i]];
8801
+ return s;
8802
+ }
8803
+ function header(type, hash, extra = 0) {
8804
+ const buf = new Uint8Array(HEADER + extra);
8805
+ buf[0] = type;
8806
+ buf.set(hexToBytes(hash), 1);
8807
+ return { buf, offset: HEADER };
8808
+ }
8809
+ function encodeWant(hash) {
8810
+ return header(FrameType.WANT, hash).buf;
8811
+ }
8812
+ function encodeHave(hash) {
8813
+ return header(FrameType.HAVE, hash).buf;
8814
+ }
8815
+ function encodeCancel(hash) {
8816
+ return header(FrameType.CANCEL, hash).buf;
8817
+ }
8818
+ function encodeBlock(hash, payload) {
8819
+ const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
8820
+ buf.set(payload, offset);
8821
+ return buf;
8822
+ }
8823
+ function decode(frame) {
8824
+ if (frame.length < HEADER)
8825
+ throw new Error("protocol: frame too short");
8826
+ const type = frame[0];
8827
+ const hash = bytesToHex(frame.subarray(1, HEADER));
8828
+ if (type === FrameType.BLOCK) {
8829
+ // Copy the payload out so it doesn't alias a transferred/pooled buffer.
8830
+ return { type, hash, payload: frame.slice(HEADER) };
8831
+ }
8832
+ return { type, hash };
8833
+ }
8834
+
8835
+ var protocol = /*#__PURE__*/Object.freeze({
8836
+ __proto__: null,
8837
+ get FrameType () { return FrameType; },
8838
+ decode: decode,
8839
+ encodeBlock: encodeBlock,
8840
+ encodeCancel: encodeCancel,
8841
+ encodeHave: encodeHave,
8842
+ encodeWant: encodeWant
8843
+ });
8844
+
8845
+ /**
8846
+ * `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
8847
+ * only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
8848
+ * encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
8849
+ * `handleFrame` to a real transport — on the main thread or inside the Web
8850
+ * Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
8851
+ *
8852
+ * It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
8853
+ * one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
8854
+ * mismatch, so a malicious peer can't poison the store (the requester just
8855
+ * falls back to origin).
8856
+ */
8857
+ class BlockEngine {
8858
+ store;
8859
+ out;
8860
+ hash;
8861
+ wants = new Map();
8862
+ constructor(store, out, hash) {
8863
+ this.store = store;
8864
+ this.out = out;
8865
+ this.hash = hash;
8866
+ }
8867
+ /**
8868
+ * Ask the swarm for a block. Resolves with the verified bytes, or `null` on
8869
+ * timeout (caller then falls back to origin). Returns immediately if we
8870
+ * already hold it; concurrent wants for the same hash share one request.
8871
+ */
8872
+ want(hash, timeoutMs) {
8873
+ return new Promise((resolve) => {
8874
+ void this.store.get(hash).then((local) => {
8875
+ if (local)
8876
+ return resolve(local);
8877
+ const existing = this.wants.get(hash);
8878
+ if (existing) {
8879
+ existing.resolvers.push(resolve);
8880
+ return;
8881
+ }
8882
+ const timer = setTimeout(() => {
8883
+ const w = this.wants.get(hash);
8884
+ this.wants.delete(hash);
8885
+ this.out("*", encodeCancel(hash));
8886
+ w?.resolvers.forEach((r) => r(null));
8887
+ }, timeoutMs);
8888
+ this.wants.set(hash, { resolvers: [resolve], timer });
8889
+ this.out("*", encodeWant(hash));
8890
+ });
8891
+ });
8892
+ }
8893
+ /** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
8894
+ announce(hash) {
8895
+ this.out("*", encodeHave(hash));
8896
+ }
8897
+ /** Handle one inbound frame from `peer`. */
8898
+ async handleFrame(peer, frame) {
8899
+ let f;
8900
+ try {
8901
+ f = decode(frame);
8902
+ }
8903
+ catch {
8904
+ return; // malformed — ignore
8905
+ }
8906
+ switch (f.type) {
8907
+ case FrameType.WANT: {
8908
+ const bytes = await this.store.get(f.hash);
8909
+ if (bytes)
8910
+ this.out(peer, encodeBlock(f.hash, bytes));
8911
+ return;
8912
+ }
8913
+ case FrameType.HAVE: {
8914
+ // A peer announced a block we're still waiting for → pull from them.
8915
+ if (this.wants.has(f.hash))
8916
+ this.out(peer, encodeWant(f.hash));
8917
+ return;
8918
+ }
8919
+ case FrameType.BLOCK: {
8920
+ const pending = this.wants.get(f.hash);
8921
+ if (!pending || !f.payload)
8922
+ return; // unsolicited / empty — ignore
8923
+ // Integrity: a peer can't poison the store. Drop a mismatch and
8924
+ // keep waiting (the timeout will fall back to origin).
8925
+ const got = await this.hash(f.payload);
8926
+ if (got !== f.hash)
8927
+ return;
8928
+ await this.store.put(f.hash, f.payload);
8929
+ this.wants.delete(f.hash);
8930
+ clearTimeout(pending.timer);
8931
+ this.out("*", encodeCancel(f.hash)); // others can stop sending
8932
+ pending.resolvers.forEach((r) => r(f.payload));
8933
+ return;
8934
+ }
8935
+ case FrameType.CANCEL:
8936
+ // We serve WANTs synchronously, so there's nothing in flight to
8937
+ // cancel yet. Reserved for chunked-send cancellation later.
8938
+ return;
8939
+ }
8940
+ }
8941
+ /** Drop all pending waits (resolve null) — on teardown. */
8942
+ close() {
8943
+ for (const [, w] of this.wants) {
8944
+ clearTimeout(w.timer);
8945
+ w.resolvers.forEach((r) => r(null));
8946
+ }
8947
+ this.wants.clear();
8948
+ }
8949
+ }
8950
+
8951
+ /**
8952
+ * `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
8953
+ * abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
8954
+ * - {@link LocalEngineHost} runs it on the calling thread (tests, or a
8955
+ * fallback when the Web Worker can't be built by a consumer's bundler);
8956
+ * - the Worker proxy ({@link ./engineClient}) runs it off-thread.
8957
+ *
8958
+ * Either way the host exposes the same surface: drive `want`/`announce`, feed it
8959
+ * inbound frames via `handleFrame`, and receive its outbound frames via
8960
+ * `onOutbound` (which the network forwards to the transport).
8961
+ */
8962
+ /** Runs the engine in-process (main thread or test). */
8963
+ class LocalEngineHost {
8964
+ engine;
8965
+ cbs = new Set();
8966
+ constructor(store, hasher) {
8967
+ this.engine = new BlockEngine(store, (target, frame) => {
8968
+ for (const cb of this.cbs)
8969
+ cb(target, frame);
8970
+ }, hasher);
8971
+ }
8972
+ want(hash, timeoutMs) {
8973
+ return this.engine.want(hash, timeoutMs);
8974
+ }
8975
+ announce(hash) {
8976
+ this.engine.announce(hash);
8977
+ }
8978
+ handleFrame(peer, frame) {
8979
+ void this.engine.handleFrame(peer, frame);
8980
+ }
8981
+ onOutbound(cb) {
8982
+ this.cbs.add(cb);
8983
+ return () => this.cbs.delete(cb);
8984
+ }
8985
+ close() {
8986
+ this.engine.close();
8987
+ this.cbs.clear();
8988
+ }
8989
+ }
8990
+
8991
+ /**
8992
+ * `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
8993
+ * running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
8994
+ * protocol codec stay off the main thread). It speaks the small message protocol
8995
+ * in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
8996
+ * so {@link ../PeerNetwork} treats it identically to the in-process host.
8997
+ *
8998
+ * Block bytes cross the worker boundary as **transferable** ArrayBuffers
8999
+ * (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
9000
+ */
9001
+ class WorkerEngineHost {
9002
+ worker;
9003
+ wants = new Map();
9004
+ cbs = new Set();
9005
+ nextId = 1;
9006
+ constructor(worker) {
9007
+ this.worker = worker;
9008
+ this.worker.onmessage = (e) => {
9009
+ const m = e.data;
9010
+ if (m.t === "out") {
9011
+ const frame = new Uint8Array(m.data);
9012
+ for (const cb of this.cbs)
9013
+ cb(m.target, frame);
9014
+ }
9015
+ else if (m.t === "want-result") {
9016
+ const resolve = this.wants.get(m.id);
9017
+ if (resolve) {
9018
+ this.wants.delete(m.id);
9019
+ resolve(m.data ? new Uint8Array(m.data) : null);
9020
+ }
9021
+ }
9022
+ };
9023
+ }
9024
+ want(hash, timeoutMs) {
9025
+ return new Promise((resolve) => {
9026
+ const id = this.nextId++;
9027
+ this.wants.set(id, resolve);
9028
+ this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
9029
+ });
9030
+ }
9031
+ announce(hash) {
9032
+ this.worker.postMessage({ t: "announce", hash });
9033
+ }
9034
+ handleFrame(peer, frame) {
9035
+ // Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
9036
+ const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
9037
+ ? frame.buffer
9038
+ : frame.slice().buffer;
9039
+ this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
9040
+ }
9041
+ onOutbound(cb) {
9042
+ this.cbs.add(cb);
9043
+ return () => this.cbs.delete(cb);
9044
+ }
9045
+ close() {
9046
+ try {
9047
+ this.worker.postMessage({ t: "close" });
9048
+ }
9049
+ catch { /* already gone */ }
9050
+ this.worker.terminate();
9051
+ this.wants.forEach((r) => r(null));
9052
+ this.wants.clear();
9053
+ this.cbs.clear();
9054
+ }
9055
+ }
9056
+
9057
+ /**
9058
+ * `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
9059
+ * the main thread:
9060
+ *
9061
+ * transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
9062
+ * ▲ │
9063
+ * └────────────── signaling over the Space relay ─────────────┘
9064
+ *
9065
+ * It owns one {@link ./transport/WebRtcTransport} + one {@link
9066
+ * ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
9067
+ * engine's outbound frames back to the transport, runs peer discovery over the
9068
+ * Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
9069
+ * for `ShardClient` to consult. Scope is one Space (private swarm).
9070
+ *
9071
+ * `engineHost` defaults to in-process; pass `workerFactory` to run the block
9072
+ * engine off the main thread (the consumer's bundler resolves the worker URL).
9073
+ */
9074
+ /**
9075
+ * Build the engine host, preferring an off-thread Web Worker:
9076
+ * 1. an explicit `workerFactory` (most reliable in app bundlers), else
9077
+ * 2. the bundled worker resolved relative to this module (works where the
9078
+ * bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
9079
+ * 3. the in-process engine (always works; tests + non-worker runtimes).
9080
+ * Any worker construction failure degrades to in-process.
9081
+ */
9082
+ function buildEngineHost(opts) {
9083
+ try {
9084
+ const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
9085
+ if (worker)
9086
+ return new WorkerEngineHost(worker);
9087
+ }
9088
+ catch {
9089
+ /* no Worker global / bundler can't resolve it → in-process */
9090
+ }
9091
+ return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
9092
+ }
9093
+ /** Construct the bundled worker if the runtime + bundler support it, else null. */
9094
+ function defaultWorker() {
9095
+ try {
9096
+ if (typeof Worker === "undefined")
9097
+ return null;
9098
+ // The string is resolved by the consumer's bundler relative to the
9099
+ // built connect module (sibling of dist/browser/index.js).
9100
+ return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
9101
+ }
9102
+ catch {
9103
+ return null;
9104
+ }
9105
+ }
9106
+ class PeerNetwork {
9107
+ /** Pass this to a `ShardClient` as `peers`. */
9108
+ exchange;
9109
+ transport;
9110
+ host;
9111
+ signaler;
9112
+ offs = [];
9113
+ gossipCbs = new Set();
9114
+ closed = false;
9115
+ constructor(opts) {
9116
+ this.signaler = new SpaceSignaler(opts.space, opts.myId);
9117
+ this.transport =
9118
+ opts.transport ??
9119
+ new WebRtcTransport(this.signaler, opts.myId, {
9120
+ iceServers: opts.iceServers,
9121
+ maxPeers: opts.maxPeers,
9122
+ });
9123
+ this.host = buildEngineHost(opts);
9124
+ // Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
9125
+ this.offs.push(this.transport.onMessage((peer, data, channel) => {
9126
+ if (channel === CHANNEL_GOSSIP) {
9127
+ for (const cb of this.gossipCbs)
9128
+ cb(peer, data);
9129
+ }
9130
+ else {
9131
+ this.host.handleFrame(peer, data);
9132
+ }
9133
+ }));
9134
+ this.offs.push(this.host.onOutbound((target, frame) => target === "*"
9135
+ ? this.transport.broadcast(frame, CHANNEL_BLOCK)
9136
+ : this.transport.send(target, frame, CHANNEL_BLOCK)));
9137
+ // Discovery: a broadcast hello triggers a unicast hello back (so a
9138
+ // newcomer learns existing members); both sides connectTo (the smaller
9139
+ // id actually dials). Deterministic + idempotent.
9140
+ this.offs.push(this.signaler.onSignal((from, kind, data) => {
9141
+ if (kind !== "hello")
9142
+ return;
9143
+ this.transport.connectTo?.(from);
9144
+ if (data?.broadcast) {
9145
+ this.signaler.send(from, "hello", { broadcast: false });
9146
+ }
9147
+ }));
9148
+ this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
9149
+ }
9150
+ /**
9151
+ * Begin peer discovery — broadcast a hello so existing members dial us and
9152
+ * we learn them. Call once the signaling channel is live (the Space fires
9153
+ * this on CONNECTED); safe to call again on reconnect.
9154
+ */
9155
+ start() {
9156
+ if (this.closed)
9157
+ return;
9158
+ this.signaler.broadcast("hello", { broadcast: true });
9159
+ }
9160
+ /**
9161
+ * Broadcast an app payload to all connected peers over the gossip channel —
9162
+ * a direct, server-bypassing data path for CRDT ops / high-frequency state
9163
+ * (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
9164
+ */
9165
+ gossip(data) {
9166
+ this.transport.broadcast(data, CHANNEL_GOSSIP);
9167
+ }
9168
+ /** Subscribe to gossip from peers. Returns an unsubscribe function. */
9169
+ onGossip(cb) {
9170
+ this.gossipCbs.add(cb);
9171
+ return () => this.gossipCbs.delete(cb);
9172
+ }
9173
+ /** Connected peer count (open data channels). */
9174
+ peerCount() {
9175
+ return this.transport.peers().length;
9176
+ }
9177
+ close() {
9178
+ if (this.closed)
9179
+ return;
9180
+ this.closed = true;
9181
+ try {
9182
+ this.signaler.broadcast("bye");
9183
+ }
9184
+ catch { /* socket may be gone */ }
9185
+ for (const off of this.offs)
9186
+ off();
9187
+ this.transport.close();
9188
+ this.host.close();
9189
+ }
9190
+ }
9191
+
9192
+ /**
9193
+ * Whether this environment can do WebRTC peering. `RTCPeerConnection` is
9194
+ * Window-only (absent in Node/Workers/SSR), so this gates whether a
9195
+ * {@link ./PeerNetwork} can be built at all.
9196
+ */
9197
+ function isP2pCapable() {
9198
+ try {
9199
+ return typeof RTCPeerConnection !== "undefined";
9200
+ }
9201
+ catch {
9202
+ return false;
9203
+ }
9204
+ }
9205
+
9206
+ /**
9207
+ * `client.space` — the fan-out group-encryption surface.
9208
+ *
9209
+ * const space = await client.space.createSpace({ historyPolicy: "static" });
9210
+ * await space.sendMessage({ text: "hello" });
9211
+ * space.onMessage(e => …);
9212
+ *
9213
+ * const joined = await client.space.joinSpace(space.id); // newcomer
9214
+ *
9215
+ * A space owns a client-generated keypair whose public key is its id, a
9216
+ * symmetric group key distributed server-blind via the keyring, and a
9217
+ * persisted message log. This namespace wires the {@link Space} handle to the
9218
+ * HTTP {@link KeyringClient}, the member's identity keys (from {@link KeyStore},
9219
+ * shared with the Double Ratchet path), and a PersonalSpace-backed key cache.
9220
+ */
9221
+ /** Thrown by `joinChannel` when no channel with that name is registered. */
9222
+ class ChannelNotFoundError extends Error {
9223
+ channelName;
9224
+ constructor(channelName) {
9225
+ super(`Channel "${channelName}" does not exist. Create it first.`);
9226
+ this.channelName = channelName;
9227
+ this.name = "ChannelNotFoundError";
9228
+ }
9229
+ }
9230
+ /** Thrown by `createChannel` when the name is already registered. */
9231
+ class ChannelExistsError extends Error {
9232
+ channelName;
9233
+ constructor(channelName) {
9234
+ super(`Channel "${channelName}" already exists.`);
9235
+ this.channelName = channelName;
9236
+ this.name = "ChannelExistsError";
9237
+ }
9238
+ }
9239
+ class SpaceNamespace {
9240
+ deps;
9241
+ spaceCache;
9242
+ /** Spaces built this session, by id — used to route offline send replays. */
9243
+ openSpaces = new Map();
9244
+ constructor(deps) {
9245
+ this.deps = deps;
9246
+ if (deps.offline?.enabled) {
9247
+ this.spaceCache = new SpaceCache(deps.offline);
9248
+ // Replay queued space frames on reconnect: route each to its space
9249
+ // (reopening + connecting it if needed), then re-send the raw frame.
9250
+ deps.offline.registerReplayer("space", async (entry) => {
9251
+ const { spaceId, frame } = entry.args;
9252
+ let space = this.openSpaces.get(spaceId);
9253
+ if (!space)
9254
+ space = await this.get(spaceId);
9255
+ await space.connect();
9256
+ if (!space.isConnected())
9257
+ throw new Error(`space ${spaceId} not connected for replay`);
9258
+ space.sendRaw(frame);
9259
+ });
9260
+ }
9261
+ }
9262
+ /**
9263
+ * Create a new space: generate its keypair (public key = id), register
9264
+ * metadata, mint the initial group key, and connect. Returns the open Space.
9265
+ */
9266
+ async createSpace(opts = {}) {
9267
+ const historyPolicy = opts.historyPolicy ?? "static";
9268
+ const identity = await generateSpaceIdentity();
9269
+ // Register the space (server records pubkey + policy; the DO is lazy).
9270
+ // For a private space the server also records the creator (this user) as
9271
+ // its first member — so the metadata POST must carry the user session.
9272
+ await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
9273
+ const space = await this.build(identity.id, historyPolicy);
9274
+ await space.create();
9275
+ return space;
9276
+ }
9277
+ /** Join an existing space by id (its public key). Resolves once a key arrives. */
9278
+ async joinSpace(spaceId, opts = {}) {
9279
+ const policy = await this.fetchPolicy(spaceId);
9280
+ const space = await this.build(spaceId, policy);
9281
+ await space.join({ timeoutMs: opts.timeoutMs });
9282
+ return space;
9283
+ }
9284
+ /** A handle to a space without auto-connecting (uses cached keys on demand). */
9285
+ async get(spaceId, historyPolicy = "static") {
9286
+ return this.build(spaceId, historyPolicy);
9287
+ }
9288
+ // -------------------------------------------------------------------------
9289
+ // Channels — named, app-scoped pointers to spaces. The name → space-id
9290
+ // directory is app-public config (server-readable, scoped to this app via
9291
+ // its key); the channel CONTENTS stay fan-out E2E-encrypted. Lets chat use
9292
+ // human room names (`general`) instead of raw pubkey ids.
9293
+ // -------------------------------------------------------------------------
9294
+ /** List this app's channels (name → space-id) for the key's environment. */
9295
+ async listChannels() {
9296
+ const body = await this.deps.http.get("/api/app/channels");
9297
+ return body.channels ?? [];
9298
+ }
9299
+ /** Resolve a channel name to its space id, or null if it doesn't exist. */
9300
+ async resolveChannel(name) {
9301
+ try {
9302
+ const body = await this.deps.http.get(`/api/app/channels/${encodeURIComponent(name)}`);
9303
+ return body.spaceId ?? null;
9304
+ }
9305
+ catch (err) {
9306
+ if (err instanceof HttpError && err.status === 404)
9307
+ return null;
9308
+ throw err;
9309
+ }
9310
+ }
9311
+ /**
9312
+ * Create a new channel: mint a fan-out space (the caller becomes its first
9313
+ * key-holder) and register `name → spaceId` in the app directory. Throws
9314
+ * {@link ChannelExistsError} if the name is already taken.
9315
+ */
9316
+ async createChannel(name, opts = {}) {
9317
+ const space = await this.createSpace(opts);
9318
+ // Register name → id first so a name clash doesn't leave an admitted keeper.
9319
+ try {
9320
+ await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
9321
+ }
9322
+ catch (err) {
9323
+ if (err instanceof HttpError && err.status === 409) {
9324
+ throw new ChannelExistsError(name);
9325
+ }
9326
+ throw err;
9327
+ }
9328
+ // Admit the app keeper so the channel is joinable with no member online.
9329
+ // Best-effort: a channel still works (members admit each other) without it.
9330
+ try {
9331
+ const { pubkey } = await this.deps.http.get("/api/app/keeper");
9332
+ if (pubkey && space.keyring)
9333
+ await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
9334
+ }
9335
+ catch (err) {
9336
+ console.warn("[client.space] keeper admit failed (channel still usable):", err);
9337
+ }
9338
+ return space;
9339
+ }
9340
+ /**
7885
9341
  * Join an existing channel by name. Throws {@link ChannelNotFoundError} if
7886
9342
  * no channel with that name is registered (use {@link createChannel} first).
7887
9343
  */
7888
- async joinChannel(name, opts = {}) {
7889
- const spaceId = await this.resolveChannel(name);
7890
- if (!spaceId)
7891
- throw new ChannelNotFoundError(name);
7892
- return this.joinSpace(spaceId, opts);
9344
+ async joinChannel(name, opts = {}) {
9345
+ const spaceId = await this.resolveChannel(name);
9346
+ if (!spaceId)
9347
+ throw new ChannelNotFoundError(name);
9348
+ return this.joinSpace(spaceId, opts);
9349
+ }
9350
+ // -------------------------------------------------------------------------
9351
+ // Invite links — shareable, keeper-gated invitations to a space. The token
9352
+ // is a capability: anyone signed in who redeems it is admitted by the
9353
+ // keeper. Mint/list/revoke require the caller to be the space's creator or
9354
+ // an existing member.
9355
+ // -------------------------------------------------------------------------
9356
+ /** Mint a shareable invite link for a space. `opts.expiresInSec` / `maxUses`
9357
+ * bound it (0/undefined = unlimited); `opts.role` ("viewer"|"editor") is the
9358
+ * access granted to redeemers (default viewer). Returns the link. */
9359
+ async createInviteLink(spaceId, opts = {}) {
9360
+ return this.keyringFor(spaceId).createInviteLink(opts);
9361
+ }
9362
+ /** Set a member's role on a space ("viewer" | "editor"). Owner-only. */
9363
+ async setMemberRole(spaceId, username, role) {
9364
+ await this.keyringFor(spaceId).setMemberRole(username, role);
9365
+ }
9366
+ /** Roster with roles (owner/members only). */
9367
+ async members(spaceId) {
9368
+ return this.keyringFor(spaceId).members();
9369
+ }
9370
+ /** List a space's active invite links (creator/members only). */
9371
+ async listInviteLinks(spaceId) {
9372
+ return this.keyringFor(spaceId).listInviteLinks();
9373
+ }
9374
+ /** Revoke an invite link by token. */
9375
+ async revokeInviteLink(spaceId, token) {
9376
+ await this.keyringFor(spaceId).revokeInviteLink(token);
9377
+ }
9378
+ /**
9379
+ * Join a space using an invite link: redeem the token (the keeper admits
9380
+ * this member), then connect and resolve once the group key arrives.
9381
+ * Returns the open {@link Space}.
9382
+ */
9383
+ async joinByInvite(spaceId, token, opts = {}) {
9384
+ const memberId = this.myId();
9385
+ const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
9386
+ await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
9387
+ return this.joinSpace(spaceId, opts);
9388
+ }
9389
+ /** The current member roster of a space (member id → identity public keys). */
9390
+ async roster(spaceId) {
9391
+ return this.keyringFor(spaceId).fetchRoster();
9392
+ }
9393
+ keyringFor(spaceId) {
9394
+ return new KeyringClient({
9395
+ spaceId,
9396
+ httpBaseUrl: this.deps.http.baseUrl,
9397
+ fetch: this.deps.http.fetch,
9398
+ });
9399
+ }
9400
+ // -------------------------------------------------------------------------
9401
+ // Internals
9402
+ // -------------------------------------------------------------------------
9403
+ async build(spaceId, historyPolicy) {
9404
+ const memberId = this.myId();
9405
+ const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
9406
+ const transport = new KeyringClient({
9407
+ spaceId,
9408
+ httpBaseUrl: this.deps.http.baseUrl,
9409
+ fetch: this.deps.http.fetch,
9410
+ });
9411
+ const keyring = new SpaceKeyring({
9412
+ spaceId,
9413
+ memberId,
9414
+ identityEcdhPub: ecdhPub,
9415
+ identityEcdsaPub: ecdsaPub,
9416
+ ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
9417
+ transport,
9418
+ cache: this.deps.cache,
9419
+ historyPolicy,
9420
+ });
9421
+ const space = new Space({
9422
+ name: spaceId,
9423
+ wsBaseUrl: this.deps.wsBaseUrl,
9424
+ httpBaseUrl: this.deps.http.baseUrl,
9425
+ fetch: this.deps.http.fetch,
9426
+ myId: () => memberId,
9427
+ fetchTicket: () => this.fetchTicket(),
9428
+ keyring,
9429
+ historyPolicy,
9430
+ offline: this.spaceCache,
9431
+ shardCache: this.deps.offline?.fileCache,
9432
+ deferShardUpload: this.deps.offline
9433
+ ? (hash) => this.deps.offline.deferShardUpload(hash)
9434
+ : undefined,
9435
+ });
9436
+ this.openSpaces.set(spaceId, space);
9437
+ // Attach a private P2P swarm scoped to this Space (block exchange among
9438
+ // members; signaled over the Space relay). Discovery starts on connect.
9439
+ if (this.deps.p2p?.enabled && isP2pCapable()) {
9440
+ space.attachPeerNetwork(new PeerNetwork({
9441
+ space,
9442
+ myId: memberId,
9443
+ workerFactory: this.deps.p2p.workerFactory,
9444
+ iceServers: this.deps.p2p.iceServers,
9445
+ maxPeers: this.deps.p2p.maxPeers,
9446
+ }));
9447
+ }
9448
+ return space;
9449
+ }
9450
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
9451
+ async ensureIdentity(memberId) {
9452
+ const store = KeyStore.getInstance();
9453
+ if (!store.getKeyPair(memberId)) {
9454
+ await store.generateOwnKeyPair(memberId);
9455
+ }
9456
+ const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
9457
+ const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
9458
+ return { ecdhPub, ecdsaPub };
9459
+ }
9460
+ async fetchPolicy(spaceId) {
9461
+ const meta = await new KeyringClient({
9462
+ spaceId,
9463
+ httpBaseUrl: this.deps.http.baseUrl,
9464
+ fetch: this.deps.http.fetch,
9465
+ }).fetchMetadata();
9466
+ return meta?.historyPolicy ?? "static";
9467
+ }
9468
+ async fetchTicket() {
9469
+ try {
9470
+ const res = await this.deps.http.post("/api/ws-ticket", {});
9471
+ return res?.ticket ?? null;
9472
+ }
9473
+ catch {
9474
+ return null;
9475
+ }
9476
+ }
9477
+ myId() {
9478
+ const id = this.deps.session.username;
9479
+ if (!id)
9480
+ throw new Error("client.space: not signed in — call client.auth.zk.login() first.");
9481
+ return id;
9482
+ }
9483
+ }
9484
+
9485
+ /**
9486
+ * `client.agents` — manage an app's **Programmable Agents** (server-side
9487
+ * "virtual users" backed by Workers AI).
9488
+ *
9489
+ * const { config } = await client.agents.create(appId, {
9490
+ * handle: '@assistant', displayName: 'Assistant',
9491
+ * systemPrompt: 'You are a helpful teammate in this Space.',
9492
+ * triggers: [{ type: 'mention' }],
9493
+ * });
9494
+ * await client.agents.enable(appId, config.agentId, spaceId); // per-Space opt-in
9495
+ *
9496
+ * These are MANAGEMENT calls — the agent itself runs on the accelerator. Only an
9497
+ * app's owner or an editor of its shared Space may create/update/enable an agent
9498
+ * (viewers can read). Authorization rides the user session token; non-owner
9499
+ * editors pass their management Space via `opts.space`.
9500
+ *
9501
+ * An agent is "prompts + persona": a system prompt, a model, named prompt
9502
+ * `skills`, and `triggers` that decide when it speaks — plus optional `tools`
9503
+ * that let it act on the app (query/write its database, call its functions,
9504
+ * resolve its channels) via a server-side function-calling loop. See
9505
+ * {@link AgentToolsConfig}. Pair with the `@MuhkooAgent`/`@MuhkooSpace`/
9506
+ * `@MuhkooDB`/`@MuhkooFunction` decorators + `ejectAgentPrompt`/`ejectAgentTools`
9507
+ * to generate `systemPrompt` and `tools` straight from your app's surface.
9508
+ */
9509
+ class AgentsNamespace {
9510
+ deps;
9511
+ constructor(deps) {
9512
+ this.deps = deps;
9513
+ }
9514
+ /** List the app's agents. */
9515
+ async list(appId, opts = {}) {
9516
+ const res = await this.deps.http.get(this.path(appId, "", opts));
9517
+ return res.agents ?? [];
9518
+ }
9519
+ /** Read one agent (config + identity keys). */
9520
+ async get(appId, agentId, opts = {}) {
9521
+ return this.deps.http.get(this.path(appId, `/${encodeURIComponent(agentId)}`, opts));
9522
+ }
9523
+ /** Create an agent (owner/editor only). */
9524
+ async create(appId, input, opts = {}) {
9525
+ return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
9526
+ }
9527
+ /** Update an agent's editor-settable fields (owner/editor only). */
9528
+ async update(appId, agentId, patch, opts = {}) {
9529
+ return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { ...patch, space: opts.space });
9530
+ }
9531
+ /** Delete an agent (owner/editor only). */
9532
+ async delete(appId, agentId, opts = {}) {
9533
+ return this.deps.http.del(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { space: opts.space });
9534
+ }
9535
+ /** Enable the agent on a Space (per-Space opt-in). */
9536
+ async enable(appId, agentId, spaceId, opts = {}) {
9537
+ return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
9538
+ }
9539
+ /** Disable the agent on a Space. */
9540
+ async disable(appId, agentId, spaceId, opts = {}) {
9541
+ return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
9542
+ }
9543
+ path(appId, suffix, opts) {
9544
+ const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
9545
+ return `/api/apps/${encodeURIComponent(appId)}/agents${suffix}${q}`;
9546
+ }
9547
+ }
9548
+
9549
+ /**
9550
+ * `client.functions` — manage an app's **serverless functions** (developer-
9551
+ * authored code that runs on the accelerator).
9552
+ *
9553
+ * const { config } = await client.functions.deploy(appId, {
9554
+ * name: 'hello', displayName: 'Hello',
9555
+ * code: `export default { fetch: () => new Response('hi') }`,
9556
+ * triggers: { http: { enabled: true } },
9557
+ * });
9558
+ * // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
9559
+ * await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
9560
+ * const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
9561
+ *
9562
+ * These are MANAGEMENT calls — the function itself runs on the accelerator
9563
+ * (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
9564
+ * an editor of its shared Space may deploy/update/enable (viewers read).
9565
+ * Authorization rides the user session token; non-owner editors pass their
9566
+ * management Space via `opts.space`.
9567
+ *
9568
+ * v1 is "compute-only": a function is an untrusted single-module ES worker
9569
+ * (`export default { fetch }`) with NO platform bindings — it reaches the
9570
+ * platform as an external client (the end-user session is passed through). Two
9571
+ * triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
9572
+ * like a Programmable Agent).
9573
+ */
9574
+ /** The hosted platform's HTTP-function zone. */
9575
+ const DEFAULT_FN_HOST_SUFFIX = "fns.muhkoo.dev";
9576
+ /** Bodies `fetch` accepts natively — everything else is JSON-encoded. */
9577
+ function isRawBody(body) {
9578
+ return (typeof body === "string" ||
9579
+ body instanceof FormData ||
9580
+ body instanceof Blob ||
9581
+ body instanceof ArrayBuffer ||
9582
+ ArrayBuffer.isView(body) ||
9583
+ body instanceof URLSearchParams ||
9584
+ (typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
9585
+ }
9586
+ class FunctionsNamespace {
9587
+ deps;
9588
+ constructor(deps) {
9589
+ this.deps = deps;
9590
+ }
9591
+ /** List the app's functions. */
9592
+ async list(appId, opts = {}) {
9593
+ const res = await this.deps.http.get(this.path(appId, "", opts));
9594
+ return res.functions ?? [];
9595
+ }
9596
+ /** Read one function's config. */
9597
+ async get(appId, functionId, opts = {}) {
9598
+ return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}`, opts));
9599
+ }
9600
+ /** Read a function's decrypted source (owner/editor; for the editor UI). */
9601
+ async code(appId, functionId, opts = {}) {
9602
+ return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}/code`, opts));
9603
+ }
9604
+ /** Deploy (create) a function (owner/editor only). */
9605
+ async deploy(appId, input, opts = {}) {
9606
+ return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
9607
+ }
9608
+ /** Update a function's editor-settable fields and/or redeploy its `code`. */
9609
+ async update(appId, functionId, patch, opts = {}) {
9610
+ return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { ...patch, space: opts.space });
9611
+ }
9612
+ /** Delete a function (owner/editor only). */
9613
+ async delete(appId, functionId, opts = {}) {
9614
+ return this.deps.http.del(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { space: opts.space });
9615
+ }
9616
+ /** Enable the function on a Space (per-Space opt-in for the `space` trigger). */
9617
+ async enable(appId, functionId, spaceId, opts = {}) {
9618
+ return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
9619
+ }
9620
+ /** Disable the function on a Space. */
9621
+ async disable(appId, functionId, spaceId, opts = {}) {
9622
+ return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
9623
+ }
9624
+ /**
9625
+ * Invoke an HTTP-triggered function and parse its JSON response.
9626
+ *
9627
+ * const out = await client.functions.invoke({ name: 'hello', slug: 'myapp' });
9628
+ * const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
9629
+ * const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
9630
+ *
9631
+ * The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
9632
+ * user is signed in, `X-Muhkoo-Session`) — the platform passes the session
9633
+ * token through to the function, which can verify it server-side via
9634
+ * `POST /api/auth/verify` to establish the caller's identity.
9635
+ *
9636
+ * Non-2xx responses throw {@link HttpError}. For functions that return
9637
+ * something other than JSON, use {@link invokeRaw}.
9638
+ */
9639
+ async invoke(target, opts = {}) {
9640
+ const res = await this.invokeRaw(target, opts);
9641
+ let parsed = null;
9642
+ try {
9643
+ parsed = await res.json();
9644
+ }
9645
+ catch {
9646
+ // Non-JSON body — fall through to the status-only error below.
9647
+ }
9648
+ if (!res.ok) {
9649
+ const msg = parsed && typeof parsed === "object" && "error" in parsed
9650
+ ? String(parsed.error)
9651
+ : `${res.status} ${res.statusText}`;
9652
+ throw new HttpError(msg, res.status, parsed);
9653
+ }
9654
+ return parsed;
9655
+ }
9656
+ /** Invoke an HTTP-triggered function and return the raw `Response`. */
9657
+ async invokeRaw(target, opts = {}) {
9658
+ const init = {
9659
+ method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
9660
+ };
9661
+ const headers = new Headers(opts.headers);
9662
+ if (opts.body !== undefined) {
9663
+ if (isRawBody(opts.body)) {
9664
+ init.body = opts.body;
9665
+ }
9666
+ else {
9667
+ init.body = JSON.stringify(opts.body);
9668
+ if (!headers.has("Content-Type"))
9669
+ headers.set("Content-Type", "application/json");
9670
+ }
9671
+ }
9672
+ init.headers = headers;
9673
+ return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
9674
+ }
9675
+ /** Resolve an invoke target to the function's absolute URL. */
9676
+ invokeUrl(target, path = "") {
9677
+ const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
9678
+ let base;
9679
+ if (typeof target === "string") {
9680
+ base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
9681
+ }
9682
+ else {
9683
+ base = `https://${target.name}--${target.slug}.${suffix}`;
9684
+ }
9685
+ if (!path)
9686
+ return base;
9687
+ return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
9688
+ }
9689
+ path(appId, suffix, opts) {
9690
+ const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
9691
+ return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
9692
+ }
9693
+ }
9694
+
9695
+ /**
9696
+ * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
9697
+ * lexicographically-sortable string so the whole offline layer (and the
9698
+ * accelerator) can compare causality with one plain string comparison.
9699
+ *
9700
+ * An HLC fuses a physical wall-clock millisecond with a logical `counter`
9701
+ * (breaks ties inside the same millisecond and lets the clock advance past a
9702
+ * remote event without the wall clock moving) and a stable `nodeId` (a final,
9703
+ * deterministic tiebreak so two devices never produce an "equal but different"
9704
+ * stamp). See {@link ./HLC}.
9705
+ *
9706
+ * The packed form is `"{wall}:{counter}:{nodeId}"` with `wall` and `counter`
9707
+ * zero-padded to a FIXED width. Fixed width is what makes lexicographic order
9708
+ * equal to numeric order — `"0000000000123:..."` < `"0000000000124:..."`. The
9709
+ * server only ever compares these strings; it never has to parse them.
9710
+ */
9711
+ /** Zero-padded width of the wall-clock field. 15 digits holds ms timestamps
9712
+ * past the year 5000, so `Date.now()` (currently 13 digits) never overflows. */
9713
+ const WALL_DIGITS = 15;
9714
+ /** Zero-padded width of the logical counter. 5 digits allows 100k events in a
9715
+ * single wall-clock millisecond before the clock is forced forward — far more
9716
+ * than any real burst. */
9717
+ const COUNTER_DIGITS = 5;
9718
+ /** The smallest possible stamp — used as the "nothing stored yet" sentinel so a
9719
+ * first real write always wins (`compareHlc(real, ZERO_HLC) > 0`). */
9720
+ const ZERO_HLC = pack(0, 0, "");
9721
+ function padNum(n, width) {
9722
+ const s = Math.max(0, Math.floor(n)).toString();
9723
+ return s.length >= width ? s : "0".repeat(width - s.length) + s;
9724
+ }
9725
+ /** Pack the three components into the sortable string form. */
9726
+ function pack(wall, counter, nodeId) {
9727
+ return `${padNum(wall, WALL_DIGITS)}:${padNum(counter, COUNTER_DIGITS)}:${nodeId}`;
9728
+ }
9729
+ /** Parse a packed stamp back into its components. Tolerates the empty/zero
9730
+ * sentinel. Throws on a malformed string so corruption surfaces loudly. */
9731
+ function unpack(stamp) {
9732
+ const idx1 = stamp.indexOf(":");
9733
+ const idx2 = stamp.indexOf(":", idx1 + 1);
9734
+ if (idx1 < 0 || idx2 < 0) {
9735
+ throw new Error(`malformed HLC timestamp: ${JSON.stringify(stamp)}`);
9736
+ }
9737
+ return {
9738
+ wall: Number(stamp.slice(0, idx1)),
9739
+ counter: Number(stamp.slice(idx1 + 1, idx2)),
9740
+ nodeId: stamp.slice(idx2 + 1),
9741
+ };
9742
+ }
9743
+ /**
9744
+ * Total order over packed HLC strings: negative if `a < b`, positive if
9745
+ * `a > b`, `0` only when they are byte-identical. Because the fields are
9746
+ * fixed-width and ordered (wall, counter, nodeId), a single string comparison
9747
+ * already yields causal order — this helper just normalizes the result to the
9748
+ * `-1 | 0 | 1` shape callers expect.
9749
+ */
9750
+ function compareHlc(a, b) {
9751
+ if (a === b)
9752
+ return 0;
9753
+ return a < b ? -1 : 1;
9754
+ }
9755
+ /** Convenience: is `a` strictly newer than `b`? */
9756
+ function isNewer(a, b) {
9757
+ return compareHlc(a, b) > 0;
9758
+ }
9759
+
9760
+ /**
9761
+ * `HLC` — a Hybrid Logical Clock. Hands out monotonically increasing
9762
+ * {@link HlcTimestamp} stamps that (a) track real time closely enough to be
9763
+ * human-meaningful, yet (b) never go backwards, even across a reload or when a
9764
+ * peer's clock is ahead of ours.
9765
+ *
9766
+ * Two operations, both the textbook HLC algorithm (Kulkarni et al.):
9767
+ * - `now()` — stamp a local event.
9768
+ * - `update(remote)` — observe a remote stamp (e.g. arriving on the realtime
9769
+ * change feed) and advance our clock past it, so any event we generate
9770
+ * afterwards is causally `> remote`.
9771
+ *
9772
+ * State is just `{ wall, counter }`; `nodeId` is fixed for the install. The
9773
+ * owner ({@link ../OfflineManager}) seeds the clock from persisted state on
9774
+ * boot and persists `getState()` after each tick so monotonicity survives page
9775
+ * reloads — without that, a reload could mint a stamp that loses to one written
9776
+ * seconds earlier.
9777
+ */
9778
+ class HLC {
9779
+ wall;
9780
+ counter;
9781
+ nodeId;
9782
+ physical;
9783
+ constructor(nodeId, opts = {}) {
9784
+ this.nodeId = nodeId;
9785
+ this.physical = opts.physical ?? (() => Date.now());
9786
+ this.wall = opts.state?.wall ?? 0;
9787
+ this.counter = opts.state?.counter ?? 0;
9788
+ }
9789
+ /** Stamp a locally-generated event. Advances the clock. */
9790
+ now() {
9791
+ const pt = this.physical();
9792
+ const prevWall = this.wall;
9793
+ this.wall = Math.max(prevWall, pt);
9794
+ this.counter = this.wall === prevWall ? this.counter + 1 : 0;
9795
+ return this.current();
9796
+ }
9797
+ /**
9798
+ * Merge in an observed remote stamp and advance our clock strictly past it.
9799
+ * Call this for every inbound HLC (change-feed frames, history rows) so the
9800
+ * next `now()` is causally after anything we've seen.
9801
+ */
9802
+ update(remote) {
9803
+ const pt = this.physical();
9804
+ const prevWall = this.wall;
9805
+ const newWall = Math.max(prevWall, remote.wall, pt);
9806
+ if (newWall === prevWall && newWall === remote.wall) {
9807
+ this.counter = Math.max(this.counter, remote.counter) + 1;
9808
+ }
9809
+ else if (newWall === prevWall) {
9810
+ this.counter = this.counter + 1;
9811
+ }
9812
+ else if (newWall === remote.wall) {
9813
+ this.counter = remote.counter + 1;
9814
+ }
9815
+ else {
9816
+ this.counter = 0;
9817
+ }
9818
+ this.wall = newWall;
9819
+ return this.current();
7893
9820
  }
7894
- // -------------------------------------------------------------------------
7895
- // Invite links — shareable, keeper-gated invitations to a space. The token
7896
- // is a capability: anyone signed in who redeems it is admitted by the
7897
- // keeper. Mint/list/revoke require the caller to be the space's creator or
7898
- // an existing member.
7899
- // -------------------------------------------------------------------------
7900
- /** Mint a shareable invite link for a space. `opts.expiresInSec` / `maxUses`
7901
- * bound it (0/undefined = unlimited); `opts.role` ("viewer"|"editor") is the
7902
- * access granted to redeemers (default viewer). Returns the link. */
7903
- async createInviteLink(spaceId, opts = {}) {
7904
- return this.keyringFor(spaceId).createInviteLink(opts);
9821
+ /** The current stamp without advancing the clock. */
9822
+ current() {
9823
+ return pack(this.wall, this.counter, this.nodeId);
7905
9824
  }
7906
- /** Set a member's role on a space ("viewer" | "editor"). Owner-only. */
7907
- async setMemberRole(spaceId, username, role) {
7908
- await this.keyringFor(spaceId).setMemberRole(username, role);
9825
+ /** Serializable `{ wall, counter }` for persistence. */
9826
+ getState() {
9827
+ return { wall: this.wall, counter: this.counter };
7909
9828
  }
7910
- /** Roster with roles (owner/members only). */
7911
- async members(spaceId) {
7912
- return this.keyringFor(spaceId).members();
9829
+ }
9830
+
9831
+ /**
9832
+ * `ConnectivityManager` — the single source of truth for "are we online", which
9833
+ * the offline layer needs to decide between a network call and a cache read, and
9834
+ * to know when to flush the durable outbound queue.
9835
+ *
9836
+ * `navigator.onLine` alone lies often (captive portals, VPN flaps, a laptop
9837
+ * that's "connected" to a network with no route), so we fuse three signals:
9838
+ * 1. the browser `online`/`offline` events (coarse but free),
9839
+ * 2. real fetch outcomes reported by {@link ../core/HttpClient} and the shard
9840
+ * client — a thrown `TypeError`/abort is the most reliable "offline" tell,
9841
+ * and a 2xx is the most reliable "online" tell,
9842
+ * 3. the sync engine, which flips us to `syncing` while it drains the queue.
9843
+ *
9844
+ * The offline→online edge fires `onReconnect` (wired to the sync engine) so
9845
+ * queued writes replay automatically. Transitions are debounced to avoid
9846
+ * flapping the UI on a brief blip. State changes also emit on {@link EventCore}
9847
+ * (`ONLINE`/`OFFLINE`/`SYNCING`) so apps can render a status indicator.
9848
+ */
9849
+ class ConnectivityManager {
9850
+ state;
9851
+ listeners = new Set();
9852
+ onReconnect;
9853
+ debounceMs;
9854
+ offlineTimer = null;
9855
+ started = false;
9856
+ handleOnline = () => this.markOnline();
9857
+ handleOffline = () => this.scheduleOffline();
9858
+ constructor(opts = {}) {
9859
+ this.onReconnect = opts.onReconnect;
9860
+ this.debounceMs = opts.debounceMs ?? 1500;
9861
+ this.state =
9862
+ typeof navigator !== "undefined" && navigator.onLine === false ? "offline" : "online";
9863
+ }
9864
+ /** Attach to the browser online/offline events. Safe to call in Node (no-op). */
9865
+ start() {
9866
+ if (this.started || typeof window === "undefined")
9867
+ return;
9868
+ window.addEventListener("online", this.handleOnline);
9869
+ window.addEventListener("offline", this.handleOffline);
9870
+ this.started = true;
7913
9871
  }
7914
- /** List a space's active invite links (creator/members only). */
7915
- async listInviteLinks(spaceId) {
7916
- return this.keyringFor(spaceId).listInviteLinks();
9872
+ stop() {
9873
+ if (!this.started || typeof window === "undefined")
9874
+ return;
9875
+ window.removeEventListener("online", this.handleOnline);
9876
+ window.removeEventListener("offline", this.handleOffline);
9877
+ if (this.offlineTimer)
9878
+ clearTimeout(this.offlineTimer);
9879
+ this.started = false;
9880
+ }
9881
+ get current() {
9882
+ return this.state;
9883
+ }
9884
+ get isOnline() {
9885
+ return this.state !== "offline";
9886
+ }
9887
+ /** Subscribe to state changes. Returns an unsubscribe function. */
9888
+ onChange(cb) {
9889
+ this.listeners.add(cb);
9890
+ return () => this.listeners.delete(cb);
9891
+ }
9892
+ /** Report a failed network attempt (thrown fetch / abort). */
9893
+ reportFetchFailure() {
9894
+ this.scheduleOffline();
9895
+ }
9896
+ /** Report a successful network round-trip — strongest "we're online" signal. */
9897
+ reportFetchSuccess() {
9898
+ this.markOnline();
9899
+ }
9900
+ /** The sync engine entering its drain phase. */
9901
+ markSyncing() {
9902
+ if (this.offlineTimer) {
9903
+ clearTimeout(this.offlineTimer);
9904
+ this.offlineTimer = null;
9905
+ }
9906
+ this.set("syncing");
9907
+ }
9908
+ /** The sync engine finished — settle back to online. */
9909
+ markSynced() {
9910
+ this.set("online");
9911
+ }
9912
+ markOnline() {
9913
+ if (this.offlineTimer) {
9914
+ clearTimeout(this.offlineTimer);
9915
+ this.offlineTimer = null;
9916
+ }
9917
+ // The sync engine owns the syncing→online transition (via markSynced).
9918
+ // A request succeeding mid-drain must not cut the syncing state short.
9919
+ if (this.state === "syncing")
9920
+ return;
9921
+ const wasOffline = this.state === "offline";
9922
+ this.set("online");
9923
+ if (wasOffline) {
9924
+ try {
9925
+ this.onReconnect?.();
9926
+ }
9927
+ catch {
9928
+ /* a reconnect handler must not break connectivity tracking */
9929
+ }
9930
+ }
7917
9931
  }
7918
- /** Revoke an invite link by token. */
7919
- async revokeInviteLink(spaceId, token) {
7920
- await this.keyringFor(spaceId).revokeInviteLink(token);
9932
+ scheduleOffline() {
9933
+ if (this.state === "offline" || this.offlineTimer)
9934
+ return;
9935
+ this.offlineTimer = setTimeout(() => {
9936
+ this.offlineTimer = null;
9937
+ this.set("offline");
9938
+ }, this.debounceMs);
9939
+ }
9940
+ set(state) {
9941
+ if (state === this.state)
9942
+ return;
9943
+ this.state = state;
9944
+ for (const cb of this.listeners) {
9945
+ try {
9946
+ cb(state);
9947
+ }
9948
+ catch {
9949
+ /* a bad listener mustn't break the others */
9950
+ }
9951
+ }
9952
+ const event = state === "online"
9953
+ ? EventCoreEvents.ONLINE
9954
+ : state === "offline"
9955
+ ? EventCoreEvents.OFFLINE
9956
+ : EventCoreEvents.SYNCING;
9957
+ EventCore.emit(event, { state });
9958
+ }
9959
+ }
9960
+
9961
+ /**
9962
+ * `OutboundQueue` — the durable record of writes made while offline (or that
9963
+ * failed mid-flight) plus the machinery to replay them on reconnect. This is
9964
+ * what makes offline writes *survive a reload*: unlike the in-memory buffer in
9965
+ * {@link ../transport/WSTransport}, every entry lives in IndexedDB until it has
9966
+ * been acknowledged by the server.
9967
+ *
9968
+ * Each domain registers a **replayer** — a function that re-applies one queued
9969
+ * mutation by re-invoking the real namespace method (with an `isReplay` flag so
9970
+ * it doesn't re-enqueue). `drain()` replays entries in HLC order:
9971
+ * - success → remove the entry;
9972
+ * - permanent failure (4xx) → drop it and surface a `SYNC_ERROR` (a write the
9973
+ * server will never accept must not wedge the queue forever);
9974
+ * - transient/network failure → stop draining and leave the rest for the next
9975
+ * reconnect.
9976
+ *
9977
+ * Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
9978
+ * by `clientId`, file PUTs are content-addressed), so re-running a partially
9979
+ * drained queue is safe.
9980
+ */
9981
+ class OutboundQueue {
9982
+ store;
9983
+ replayers = new Map();
9984
+ draining = null;
9985
+ constructor(store) {
9986
+ this.store = store;
9987
+ }
9988
+ /** A domain registers how to replay its mutations. */
9989
+ register(domain, replayer) {
9990
+ this.replayers.set(domain, replayer);
9991
+ }
9992
+ /** Record a mutation for later replay; resolves with its assigned seq. */
9993
+ enqueue(entry) {
9994
+ return this.store.enqueue(entry);
9995
+ }
9996
+ /** Number of writes still awaiting sync. */
9997
+ async pending() {
9998
+ return (await this.store.queued()).length;
7921
9999
  }
7922
10000
  /**
7923
- * Join a space using an invite link: redeem the token (the keeper admits
7924
- * this member), then connect and resolve once the group key arrives.
7925
- * Returns the open {@link Space}.
10001
+ * Replay queued mutations in HLC order. Coalesces concurrent calls into a
10002
+ * single in-flight drain. Stops on the first transient failure, leaving the
10003
+ * remaining entries for the next attempt.
7926
10004
  */
7927
- async joinByInvite(spaceId, token, opts = {}) {
7928
- const memberId = this.myId();
7929
- const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
7930
- await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
7931
- return this.joinSpace(spaceId, opts);
10005
+ drain() {
10006
+ if (this.draining)
10007
+ return this.draining;
10008
+ this.draining = this.runDrain().finally(() => {
10009
+ this.draining = null;
10010
+ });
10011
+ return this.draining;
10012
+ }
10013
+ async runDrain() {
10014
+ const entries = await this.store.queued();
10015
+ for (const entry of entries) {
10016
+ const replayer = this.replayers.get(entry.domain);
10017
+ if (!replayer) {
10018
+ // No handler registered (namespace not wired) — drop it rather
10019
+ // than block the queue; nothing can ever replay it.
10020
+ await this.store.dequeue(entry.seq);
10021
+ continue;
10022
+ }
10023
+ try {
10024
+ await replayer(entry);
10025
+ await this.store.dequeue(entry.seq);
10026
+ EventCore.emit(EventCoreEvents.SYNC_PROGRESS, {
10027
+ domain: entry.domain,
10028
+ method: entry.method,
10029
+ });
10030
+ }
10031
+ catch (err) {
10032
+ if (isPermanent(err)) {
10033
+ await this.store.dequeue(entry.seq);
10034
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
10035
+ domain: entry.domain,
10036
+ method: entry.method,
10037
+ error: String(err?.message ?? err),
10038
+ dropped: true,
10039
+ });
10040
+ continue;
10041
+ }
10042
+ // Transient (offline again, 5xx) — keep the entry, stop here.
10043
+ throw err;
10044
+ }
10045
+ }
7932
10046
  }
7933
- /** The current member roster of a space (member id → identity public keys). */
7934
- async roster(spaceId) {
7935
- return this.keyringFor(spaceId).fetchRoster();
10047
+ }
10048
+ /** A 4xx is the server permanently refusing the write; anything else is worth
10049
+ * retrying on the next reconnect. */
10050
+ function isPermanent(err) {
10051
+ return err instanceof HttpError && err.status >= 400 && err.status < 500;
10052
+ }
10053
+
10054
+ /**
10055
+ * `SyncEngine` — orchestrates catch-up when the client comes back online. It's
10056
+ * the thing wired to the {@link ConnectivityManager}'s offline→online edge.
10057
+ *
10058
+ * One `run()` does, in order:
10059
+ * 1. **Guard** — only proceed when the session is authenticated and unlocked
10060
+ * (replays may need to re-seal/re-encrypt, and there's no point pushing
10061
+ * writes for a logged-out user).
10062
+ * 2. **Drain** the durable {@link OutboundQueue} — push every offline write.
10063
+ * 3. **Catch-up tasks** — domain-registered pulls that reconcile *inbound*
10064
+ * state we missed while offline (e.g. a Space paging history forward to its
10065
+ * last-seen handle). Registered by the namespaces.
10066
+ *
10067
+ * Concurrent triggers coalesce into a single in-flight run (a reconnect often
10068
+ * fires several signals at once), mirroring `Client.recoverInFlight`.
10069
+ */
10070
+ class SyncEngine {
10071
+ deps;
10072
+ catchUpTasks = new Set();
10073
+ running = null;
10074
+ constructor(deps) {
10075
+ this.deps = deps;
7936
10076
  }
7937
- keyringFor(spaceId) {
7938
- return new KeyringClient({
7939
- spaceId,
7940
- httpBaseUrl: this.deps.http.baseUrl,
7941
- fetch: this.deps.http.fetch,
10077
+ /** Register an inbound reconciliation task run after the queue drains. */
10078
+ registerCatchUp(task) {
10079
+ this.catchUpTasks.add(task);
10080
+ return () => this.catchUpTasks.delete(task);
10081
+ }
10082
+ /** Drain + catch up. Concurrent calls share one in-flight run. */
10083
+ run() {
10084
+ if (this.running)
10085
+ return this.running;
10086
+ this.running = this.execute().finally(() => {
10087
+ this.running = null;
7942
10088
  });
10089
+ return this.running;
7943
10090
  }
7944
- // -------------------------------------------------------------------------
7945
- // Internals
7946
- // -------------------------------------------------------------------------
7947
- async build(spaceId, historyPolicy) {
7948
- const memberId = this.myId();
7949
- const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
7950
- const transport = new KeyringClient({
7951
- spaceId,
7952
- httpBaseUrl: this.deps.http.baseUrl,
7953
- fetch: this.deps.http.fetch,
10091
+ async execute() {
10092
+ if (!this.deps.canSync())
10093
+ return;
10094
+ this.deps.connectivity.markSyncing();
10095
+ try {
10096
+ await this.deps.queue.drain();
10097
+ for (const task of this.catchUpTasks) {
10098
+ try {
10099
+ await task();
10100
+ }
10101
+ catch {
10102
+ // One domain's catch-up failing shouldn't abort the others
10103
+ // or wedge the sync; it'll retry on the next reconnect.
10104
+ }
10105
+ }
10106
+ EventCore.emit(EventCoreEvents.SYNC_COMPLETE, {});
10107
+ }
10108
+ finally {
10109
+ this.deps.connectivity.markSynced();
10110
+ }
10111
+ }
10112
+ }
10113
+
10114
+ /**
10115
+ * Errors specific to the offline layer.
10116
+ */
10117
+ /**
10118
+ * Thrown when an offline read/write needs the user's identity (to decrypt or
10119
+ * re-encrypt cached data) but the client is locked — typically right after a
10120
+ * reload, where only the session token was restored and `unlock()` hasn't run
10121
+ * yet. Distinct from the generic "not signed in" identity error so apps can
10122
+ * specifically prompt the user to unlock to see their offline data.
10123
+ */
10124
+ class OfflineLockedError extends Error {
10125
+ constructor(message = "Unlock required to access encrypted offline data. Call client.auth.zk.unlock(password).") {
10126
+ super(message);
10127
+ this.name = "OfflineLockedError";
10128
+ }
10129
+ }
10130
+
10131
+ /**
10132
+ * `client.offline.snapshot` — a small, encrypted, app-state cache. Apps stash
10133
+ * whatever they need to repaint instantly on a cold offline boot: the last open
10134
+ * Space, a draft message, scroll position, the current route. It's deliberately
10135
+ * separate from `client.kv`: snapshots are **local-only by default** (instant,
10136
+ * never metered, never leave the device) and hold view state rather than domain
10137
+ * data.
10138
+ *
10139
+ * await client.offline.snapshot.save('ui', { route, openSpaceId, draft })
10140
+ * const ui = await client.offline.snapshot.load<UiState>('ui')
10141
+ *
10142
+ * Values are encrypted at rest with the same identity-derived {@link StorageCipher}
10143
+ * as `client.kv`, so a snapshot is unreadable until the user unlocks — `load`
10144
+ * returns `null` when locked or absent, and `save` throws {@link OfflineLockedError}.
10145
+ * Conflict model is a single-writer LWW register (the latest save wins).
10146
+ */
10147
+ class SnapshotNamespace {
10148
+ deps;
10149
+ cipher = null;
10150
+ cipherIdentity = null;
10151
+ constructor(deps) {
10152
+ this.deps = deps;
10153
+ }
10154
+ /** Persist `state` under `name` (encrypted). No-op when offline support is off. */
10155
+ async save(name, state) {
10156
+ if (!this.deps.store.enabled)
10157
+ return;
10158
+ const envelope = await this.requireCipher().encrypt(state);
10159
+ const record = {
10160
+ envelope,
10161
+ hlc: await this.deps.nextHlc(),
10162
+ updatedAt: Date.now(),
10163
+ };
10164
+ await this.deps.store.put("snapshots", this.key(name), record);
10165
+ }
10166
+ /** Load and decrypt the snapshot `name`, or `null` if absent/locked. */
10167
+ async load(name) {
10168
+ if (!this.deps.store.enabled)
10169
+ return null;
10170
+ const record = await this.deps.store.get("snapshots", this.key(name));
10171
+ if (!record)
10172
+ return null;
10173
+ if (!this.deps.session.isUnlocked)
10174
+ return null;
10175
+ return this.requireCipher().decrypt(record.envelope);
10176
+ }
10177
+ /** Remove the snapshot `name`. */
10178
+ async delete(name) {
10179
+ if (!this.deps.store.enabled)
10180
+ return;
10181
+ await this.deps.store.delete("snapshots", this.key(name));
10182
+ }
10183
+ /** Names of all snapshots for the current user. */
10184
+ async list() {
10185
+ if (!this.deps.store.enabled)
10186
+ return [];
10187
+ const prefix = `${this.commitment()}|`;
10188
+ const rows = await this.deps.store.prefix("snapshots", prefix);
10189
+ return rows.map((r) => r.key.slice(prefix.length));
10190
+ }
10191
+ key(name) {
10192
+ return `${this.commitment()}|${name}`;
10193
+ }
10194
+ commitment() {
10195
+ // Snapshots are scoped per user; before sign-in they share an "anon"
10196
+ // bucket so pre-auth UI state (e.g. a chosen theme) still persists.
10197
+ return this.deps.session.commitment ?? "anon";
10198
+ }
10199
+ requireCipher() {
10200
+ if (!this.deps.session.isUnlocked)
10201
+ throw new OfflineLockedError();
10202
+ const identity = this.deps.session.requireIdentity();
10203
+ if (this.cipher && this.cipherIdentity === identity)
10204
+ return this.cipher;
10205
+ this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
10206
+ this.cipherIdentity = identity;
10207
+ return this.cipher;
10208
+ }
10209
+ }
10210
+
10211
+ /**
10212
+ * IndexedDB schema for the offline layer — the single source of truth for the
10213
+ * database name, version, and object stores. Bump {@link DB_VERSION} whenever
10214
+ * the store set changes; {@link ./idb}'s `onupgradeneeded` creates any store
10215
+ * that doesn't exist yet (additive migrations only — never drop
10216
+ * `outbound-queue`, it holds un-synced writes).
10217
+ *
10218
+ * One database per origin. Records are partitioned *by key* (most keys are
10219
+ * prefixed with the user's `commitment`) rather than by separate databases, so
10220
+ * a single connection serves every signed-in user on the origin and prefix
10221
+ * scans isolate a user's data.
10222
+ */
10223
+ const DB_NAME = "muhkoo.offline";
10224
+ const DB_VERSION = 1;
10225
+ const STORES = [
10226
+ { name: "kv-cache" },
10227
+ { name: "db-row-cache" },
10228
+ { name: "db-query-cache" },
10229
+ { name: "space-messages" },
10230
+ { name: "space-cursors" },
10231
+ { name: "outbound-queue", autoIncrement: true, keyPath: "seq" },
10232
+ { name: "snapshots" },
10233
+ { name: "hlc-state" },
10234
+ { name: "meta" },
10235
+ ];
10236
+ /** Fixed keys in the `meta` store. */
10237
+ const META_NODE_ID = "nodeId";
10238
+
10239
+ /**
10240
+ * `OfflineManager` — the object `client.offline` points at, and the single
10241
+ * owner of everything the offline layer needs at runtime: the {@link
10242
+ * OfflineStore}, the {@link HLC} (Hybrid Logical Clock), the {@link
10243
+ * ConnectivityManager}, the durable {@link OutboundQueue}, the {@link
10244
+ * SyncEngine}, and the {@link SnapshotNamespace}.
10245
+ *
10246
+ * The {@link ../core/Client} constructs one of these (always — it's a cheap
10247
+ * no-op when offline support is off) and hands the namespaces the small set of
10248
+ * primitives they need to participate: `nextHlc()` to stamp a write,
10249
+ * `observeHlc()` to fold in a remote stamp, `enqueue()` to record an offline
10250
+ * mutation, and `registerReplayer()`/`registerCatchUp()` to define how their
10251
+ * domain re-syncs on reconnect. Keeping that wiring here means the namespaces
10252
+ * stay decoupled from IndexedDB and the clock.
10253
+ */
10254
+ const HLC_STATE_KEY = "global";
10255
+ class OfflineManager {
10256
+ /** True in browsers with IndexedDB; false when running over a NoopStore. */
10257
+ enabled;
10258
+ store;
10259
+ connectivity;
10260
+ queue;
10261
+ sync;
10262
+ /** `client.offline.snapshot` — encrypted app-state cache. */
10263
+ snapshot;
10264
+ /** Cache-API store for file-shard bytes (browser only). */
10265
+ fileCache;
10266
+ hlc = null;
10267
+ nodeId = "";
10268
+ bootPromise;
10269
+ persistTimer = null;
10270
+ constructor(deps) {
10271
+ this.enabled = deps.enabled;
10272
+ this.store = deps.store;
10273
+ this.queue = new OutboundQueue(deps.store);
10274
+ this.connectivity = new ConnectivityManager({
10275
+ onReconnect: () => void this.sync.run(),
7954
10276
  });
7955
- const keyring = new SpaceKeyring({
7956
- spaceId,
7957
- memberId,
7958
- identityEcdhPub: ecdhPub,
7959
- identityEcdsaPub: ecdsaPub,
7960
- ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
7961
- transport,
7962
- cache: this.deps.cache,
7963
- historyPolicy,
10277
+ this.sync = new SyncEngine({
10278
+ queue: this.queue,
10279
+ connectivity: this.connectivity,
10280
+ // Replays may need to re-seal/re-encrypt, and there's no point
10281
+ // pushing a logged-out user's writes — gate on a usable session.
10282
+ canSync: () => deps.session.isAuthenticated && deps.session.isUnlocked,
7964
10283
  });
7965
- return new Space({
7966
- name: spaceId,
7967
- wsBaseUrl: this.deps.wsBaseUrl,
7968
- httpBaseUrl: this.deps.http.baseUrl,
7969
- fetch: this.deps.http.fetch,
7970
- myId: () => memberId,
7971
- fetchTicket: () => this.fetchTicket(),
7972
- keyring,
7973
- historyPolicy,
10284
+ this.snapshot = new SnapshotNamespace({
10285
+ store: deps.store,
10286
+ session: deps.session,
10287
+ nextHlc: () => this.nextHlc(),
7974
10288
  });
10289
+ if (this.enabled && ShardCache.available())
10290
+ this.fileCache = new ShardCache();
10291
+ this.bootPromise = this.boot();
10292
+ if (this.enabled)
10293
+ this.connectivity.start();
7975
10294
  }
7976
- /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
7977
- async ensureIdentity(memberId) {
7978
- const store = KeyStore.getInstance();
7979
- if (!store.getKeyPair(memberId)) {
7980
- await store.generateOwnKeyPair(memberId);
10295
+ /**
10296
+ * Queue a shard upload that couldn't reach the network. The bytes are
10297
+ * already cached (keyed by `hash`), so only the hash is recorded; the file
10298
+ * replayer re-PUTs from the cache on reconnect.
10299
+ */
10300
+ async deferShardUpload(hash) {
10301
+ await this.enqueue({
10302
+ hlc: await this.nextHlc(),
10303
+ clientId: this.newClientId(),
10304
+ domain: "file",
10305
+ method: "putShard",
10306
+ args: { hash },
10307
+ });
10308
+ }
10309
+ /** Resolve once the clock + node id are loaded. */
10310
+ ready() {
10311
+ return this.bootPromise;
10312
+ }
10313
+ /** Current connectivity state (`online` | `offline` | `syncing`). */
10314
+ get status() {
10315
+ return this.connectivity.current;
10316
+ }
10317
+ /** Subscribe to connectivity changes. Returns an unsubscribe function. */
10318
+ onStatusChange(cb) {
10319
+ return this.connectivity.onChange(cb);
10320
+ }
10321
+ /** Stamp a locally-generated write with a fresh, monotonic HLC. */
10322
+ async nextHlc() {
10323
+ await this.bootPromise;
10324
+ const stamp = this.hlc.now();
10325
+ this.schedulePersist();
10326
+ return stamp;
10327
+ }
10328
+ /** Fold an observed remote HLC into the clock (call for inbound stamps). */
10329
+ async observeHlc(remote) {
10330
+ await this.bootPromise;
10331
+ this.hlc.update(unpack(remote));
10332
+ this.schedulePersist();
10333
+ }
10334
+ /** A fresh client-generated id (idempotent replay / message dedupe). */
10335
+ newClientId() {
10336
+ return generateId();
10337
+ }
10338
+ /** Record an offline mutation for replay on reconnect. */
10339
+ enqueue(entry) {
10340
+ return this.queue.enqueue(entry);
10341
+ }
10342
+ /** A domain registers how to replay its queued mutations. */
10343
+ registerReplayer(domain, replayer) {
10344
+ this.queue.register(domain, replayer);
10345
+ }
10346
+ /** A domain registers an inbound catch-up task (run after the queue drains). */
10347
+ registerCatchUp(task) {
10348
+ return this.sync.registerCatchUp(task);
10349
+ }
10350
+ /** Surface a network failure to connectivity tracking. */
10351
+ reportFetchFailure() {
10352
+ this.connectivity.reportFetchFailure();
10353
+ }
10354
+ /** Surface a successful round-trip to connectivity tracking. */
10355
+ reportFetchSuccess() {
10356
+ this.connectivity.reportFetchSuccess();
10357
+ }
10358
+ /** Tear down listeners (tests / hot-reload). */
10359
+ dispose() {
10360
+ this.connectivity.stop();
10361
+ if (this.persistTimer)
10362
+ clearTimeout(this.persistTimer);
10363
+ }
10364
+ async boot() {
10365
+ if (!this.enabled) {
10366
+ // In-memory clock only; nothing is persisted over a NoopStore.
10367
+ this.nodeId = generateId();
10368
+ this.hlc = new HLC(this.nodeId);
10369
+ return;
7981
10370
  }
7982
- const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
7983
- const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
7984
- return { ecdhPub, ecdsaPub };
10371
+ let nodeId = await this.store.get("meta", META_NODE_ID);
10372
+ if (!nodeId) {
10373
+ nodeId = generateId();
10374
+ await this.store.put("meta", META_NODE_ID, nodeId);
10375
+ }
10376
+ this.nodeId = nodeId;
10377
+ const state = await this.store.get("hlc-state", HLC_STATE_KEY);
10378
+ this.hlc = new HLC(nodeId, { state });
10379
+ }
10380
+ /** Coalesce HLC-state writes — a burst of stamps persists once. */
10381
+ schedulePersist() {
10382
+ if (!this.enabled || this.persistTimer)
10383
+ return;
10384
+ this.persistTimer = setTimeout(() => {
10385
+ this.persistTimer = null;
10386
+ if (this.hlc)
10387
+ void this.store.put("hlc-state", HLC_STATE_KEY, this.hlc.getState());
10388
+ }, 200);
10389
+ }
10390
+ }
10391
+
10392
+ /**
10393
+ * Environment probe deciding whether the offline layer turns itself on. Offline
10394
+ * support is **on by default in browsers** and a no-op everywhere else, so this
10395
+ * is the gate the {@link ../core/Client} consults to pick {@link
10396
+ * ./store/IndexedDbStore} vs {@link ./store/NoopStore}.
10397
+ *
10398
+ * We require all three web APIs the layer leans on — `indexedDB` (structured
10399
+ * cache + durable queue), the Cache API (file-shard bytes), and a `window`
10400
+ * (so we're in a real document with online/offline events) — and we *touch*
10401
+ * them inside a try/catch, because some sandboxed or private-mode contexts
10402
+ * expose the globals but throw on use (mirrors `defaultSessionStore`).
10403
+ */
10404
+ function isOfflineCapable() {
10405
+ try {
10406
+ return (typeof window !== "undefined" &&
10407
+ typeof indexedDB !== "undefined" &&
10408
+ typeof caches !== "undefined");
10409
+ }
10410
+ catch {
10411
+ return false;
10412
+ }
10413
+ }
10414
+
10415
+ /**
10416
+ * A ~thin promise wrapper over the raw IndexedDB API — just enough surface for
10417
+ * {@link ./IndexedDbStore}: open-with-migration, get/put/delete, prefix and
10418
+ * range cursor scans, autoincrement append, and clear. We hand-roll this rather
10419
+ * than depend on `idb`/`dexie` because the access patterns are simple and
10420
+ * `@muhkoo/connect` keeps its runtime dependency surface minimal.
10421
+ *
10422
+ * Everything here assumes a browser (or a `fake-indexeddb` test shim) — callers
10423
+ * gate construction behind {@link ../detect}.
10424
+ */
10425
+ function req(request) {
10426
+ return new Promise((resolve, reject) => {
10427
+ request.onsuccess = () => resolve(request.result);
10428
+ request.onerror = () => reject(request.error);
10429
+ });
10430
+ }
10431
+ /** Open (and migrate) the offline database. Creates any missing object store. */
10432
+ function openDatabase() {
10433
+ return new Promise((resolve, reject) => {
10434
+ const open = indexedDB.open(DB_NAME, DB_VERSION);
10435
+ open.onupgradeneeded = () => {
10436
+ const db = open.result;
10437
+ for (const def of STORES) {
10438
+ if (db.objectStoreNames.contains(def.name))
10439
+ continue;
10440
+ db.createObjectStore(def.name, def.autoIncrement ? { keyPath: def.keyPath, autoIncrement: true } : undefined);
10441
+ }
10442
+ };
10443
+ open.onsuccess = () => resolve(open.result);
10444
+ open.onerror = () => reject(open.error);
10445
+ // A blocked upgrade means another tab holds an older version open; let
10446
+ // it surface as an error rather than hang forever.
10447
+ open.onblocked = () => reject(new Error("offline DB upgrade blocked by another tab"));
10448
+ });
10449
+ }
10450
+ /** Wraps an open connection with the typed operations the store needs. */
10451
+ class IdbConnection {
10452
+ db;
10453
+ constructor(db) {
10454
+ this.db = db;
10455
+ }
10456
+ tx(store, mode) {
10457
+ return this.db.transaction(store, mode).objectStore(store);
10458
+ }
10459
+ get(store, key) {
10460
+ return req(this.tx(store, "readonly").get(key)).then((v) => v ?? null);
10461
+ }
10462
+ async put(store, key, value) {
10463
+ await req(this.tx(store, "readwrite").put(value, key));
10464
+ }
10465
+ async delete(store, key) {
10466
+ await req(this.tx(store, "readwrite").delete(key));
10467
+ }
10468
+ async clear(store) {
10469
+ await req(this.tx(store, "readwrite").clear());
10470
+ }
10471
+ /** Append to an autoincrement store; resolves with the generated key. */
10472
+ async append(store, value) {
10473
+ const key = await req(this.tx(store, "readwrite").add(value));
10474
+ return key;
10475
+ }
10476
+ /** All entries whose string key falls in `[lower, upper)`, in key order. */
10477
+ range(store, lower, upper) {
10478
+ return this.scan(store, IDBKeyRange.bound(lower, upper, false, true));
10479
+ }
10480
+ /** All entries whose key starts with `prefix`, in key order. */
10481
+ prefix(store, prefix) {
10482
+ // "￿" is the largest BMP code unit, so `[prefix, prefix+￿)`
10483
+ // bounds every key that begins with `prefix`.
10484
+ return this.scan(store, IDBKeyRange.bound(prefix, prefix + "￿", false, true));
10485
+ }
10486
+ /** Every entry in the store, in key order. */
10487
+ all(store) {
10488
+ return this.scan(store, null);
10489
+ }
10490
+ scan(store, range) {
10491
+ return new Promise((resolve, reject) => {
10492
+ const out = [];
10493
+ const cursorReq = this.tx(store, "readonly").openCursor(range ?? undefined);
10494
+ cursorReq.onsuccess = () => {
10495
+ const cursor = cursorReq.result;
10496
+ if (!cursor)
10497
+ return resolve(out);
10498
+ out.push({ key: String(cursor.key), value: cursor.value });
10499
+ cursor.continue();
10500
+ };
10501
+ cursorReq.onerror = () => reject(cursorReq.error);
10502
+ });
10503
+ }
10504
+ }
10505
+
10506
+ /**
10507
+ * `IndexedDbStore` — the browser {@link OfflineStore}, backed by one IndexedDB
10508
+ * database per origin (see {@link ./schema}). It opens lazily on first use and
10509
+ * memoizes the connection. The queue methods sit on the autoincrement
10510
+ * `outbound-queue` store and return mutations in HLC order for the sync engine.
10511
+ *
10512
+ * Construct only where `indexedDB` exists — {@link ../detect.isOfflineCapable}
10513
+ * gates this, and the {@link ../../core/Client} falls back to {@link ./NoopStore}
10514
+ * everywhere else.
10515
+ */
10516
+ class IndexedDbStore {
10517
+ enabled = true;
10518
+ conn = null;
10519
+ opening = null;
10520
+ connect() {
10521
+ if (this.conn)
10522
+ return Promise.resolve(this.conn);
10523
+ if (!this.opening) {
10524
+ this.opening = openDatabase()
10525
+ .then((db) => {
10526
+ this.conn = new IdbConnection(db);
10527
+ return this.conn;
10528
+ })
10529
+ .finally(() => {
10530
+ this.opening = null;
10531
+ });
10532
+ }
10533
+ return this.opening;
10534
+ }
10535
+ async ready() {
10536
+ await this.connect();
10537
+ }
10538
+ async get(store, key) {
10539
+ return (await this.connect()).get(store, key);
10540
+ }
10541
+ async put(store, key, value) {
10542
+ await (await this.connect()).put(store, key, value);
10543
+ }
10544
+ async delete(store, key) {
10545
+ await (await this.connect()).delete(store, key);
10546
+ }
10547
+ async prefix(store, prefix) {
10548
+ return (await this.connect()).prefix(store, prefix);
10549
+ }
10550
+ async range(store, lower, upper) {
10551
+ return (await this.connect()).range(store, lower, upper);
10552
+ }
10553
+ async all(store) {
10554
+ return (await this.connect()).all(store);
10555
+ }
10556
+ async clear(store) {
10557
+ await (await this.connect()).clear(store);
10558
+ }
10559
+ async enqueue(entry) {
10560
+ // keyPath autoincrement writes the generated `seq` back into the record.
10561
+ return (await this.connect()).append("outbound-queue", entry);
10562
+ }
10563
+ async dequeue(seq) {
10564
+ await (await this.connect()).delete("outbound-queue", seq);
10565
+ }
10566
+ async queued() {
10567
+ const rows = await (await this.connect()).all("outbound-queue");
10568
+ return rows.map((r) => r.value).sort((a, b) => compareHlc(a.hlc, b.hlc));
10569
+ }
10570
+ }
10571
+
10572
+ /**
10573
+ * `NoopStore` — the {@link OfflineStore} used wherever offline support is off
10574
+ * (Node, Workers, SSR, or when a consumer disables it). Every read returns
10575
+ * empty and every write is dropped, so the namespace hooks that call into it
10576
+ * behave exactly as they did before the offline layer existed. `enabled` is
10577
+ * `false` so hot paths can skip the call entirely.
10578
+ */
10579
+ class NoopStore {
10580
+ enabled = false;
10581
+ async ready() { }
10582
+ async get(_store, _key) {
10583
+ return null;
7985
10584
  }
7986
- async fetchPolicy(spaceId) {
7987
- const meta = await new KeyringClient({
7988
- spaceId,
7989
- httpBaseUrl: this.deps.http.baseUrl,
7990
- fetch: this.deps.http.fetch,
7991
- }).fetchMetadata();
7992
- return meta?.historyPolicy ?? "static";
10585
+ async put(_store, _key, _value) { }
10586
+ async delete(_store, _key) { }
10587
+ async prefix(_store, _prefix) {
10588
+ return [];
7993
10589
  }
7994
- async fetchTicket() {
7995
- try {
7996
- const res = await this.deps.http.post("/api/ws-ticket", {});
7997
- return res?.ticket ?? null;
7998
- }
7999
- catch {
8000
- return null;
8001
- }
10590
+ async range(_store, _lower, _upper) {
10591
+ return [];
8002
10592
  }
8003
- myId() {
8004
- const id = this.deps.session.username;
8005
- if (!id)
8006
- throw new Error("client.space: not signed in — call client.auth.zk.login() first.");
8007
- return id;
10593
+ async all(_store) {
10594
+ return [];
10595
+ }
10596
+ async clear(_store) { }
10597
+ async enqueue(_entry) {
10598
+ return -1;
10599
+ }
10600
+ async dequeue(_seq) { }
10601
+ async queued() {
10602
+ return [];
8008
10603
  }
8009
10604
  }
8010
10605
 
8011
10606
  /**
8012
- * `client.agents` — manage an app's **Programmable Agents** (server-side
8013
- * "virtual users" backed by Workers AI).
8014
- *
8015
- * const { config } = await client.agents.create(appId, {
8016
- * handle: '@assistant', displayName: 'Assistant',
8017
- * systemPrompt: 'You are a helpful teammate in this Space.',
8018
- * triggers: [{ type: 'mention' }],
8019
- * });
8020
- * await client.agents.enable(appId, config.agentId, spaceId); // per-Space opt-in
8021
- *
8022
- * These are MANAGEMENT calls — the agent itself runs on the accelerator. Only an
8023
- * app's owner or an editor of its shared Space may create/update/enable an agent
8024
- * (viewers can read). Authorization rides the user session token; non-owner
8025
- * editors pass their management Space via `opts.space`.
8026
- *
8027
- * An agent is "prompts + persona": a system prompt, a model, named prompt
8028
- * `skills`, and `triggers` that decide when it speaks — plus optional `tools`
8029
- * that let it act on the app (query/write its database, call its functions,
8030
- * resolve its channels) via a server-side function-calling loop. See
8031
- * {@link AgentToolsConfig}. Pair with the `@MuhkooAgent`/`@MuhkooSpace`/
8032
- * `@MuhkooDB`/`@MuhkooFunction` decorators + `ejectAgentPrompt`/`ejectAgentTools`
8033
- * to generate `systemPrompt` and `tools` straight from your app's surface.
10607
+ * `LWWRegister` — a last-write-wins register CRDT, the conflict model for a
10608
+ * `kv` value and for an app-state snapshot. A register holds a single value
10609
+ * stamped with the {@link HlcTimestamp} of the write that produced it. Two
10610
+ * concurrent writes converge to the one with the greater HLC; the loser is
10611
+ * discarded. A delete is just a write that sets `deleted` (a tombstone), so a
10612
+ * stale write that arrives after a newer delete can't resurrect the value.
10613
+ *
10614
+ * Because the HLC total-orders every write deterministically (down to a nodeId
10615
+ * tiebreak), every replica that has seen the same set of writes lands on the
10616
+ * same value — that's the convergence guarantee.
8034
10617
  */
8035
- class AgentsNamespace {
8036
- deps;
8037
- constructor(deps) {
8038
- this.deps = deps;
10618
+ /** An empty register that loses to any real write. */
10619
+ function emptyRegister() {
10620
+ return { value: null, hlc: ZERO_HLC, deleted: true };
10621
+ }
10622
+ /**
10623
+ * Merge two register states, returning the winner (greater HLC). Pure: neither
10624
+ * input is mutated. Used identically on the inbound change feed and on
10625
+ * sync-replay reconciliation so there's exactly one definition of "who wins".
10626
+ */
10627
+ function mergeRegister(a, b) {
10628
+ return compareHlc(a.hlc, b.hlc) >= 0 ? a : b;
10629
+ }
10630
+ /** Build the register produced by a `set`. */
10631
+ function writeRegister(value, hlc) {
10632
+ return { value, hlc, deleted: false };
10633
+ }
10634
+ /** Build the register produced by a `delete` (a tombstone at `hlc`). */
10635
+ function deleteRegister(hlc) {
10636
+ return { value: null, hlc, deleted: true };
10637
+ }
10638
+
10639
+ /**
10640
+ * `KvCache` — the offline adapter for `client.kv`. Stores each value's
10641
+ * ciphertext envelope (never plaintext) under `${commitment}|${collection}/${id}`
10642
+ * as an LWW-Register stamped with an HLC, so reads work offline and concurrent
10643
+ * writes converge (see {@link ./crdt/LWWRegister}). Deletes are causal
10644
+ * tombstones — a stale write can't resurrect a key a newer delete removed.
10645
+ *
10646
+ * True cross-device convergence also needs the accelerator to honor the HLC
10647
+ * (see the server's `kvmeta` sidecar); this adapter embeds the stamp now and
10648
+ * degrades gracefully (last-writer-by-arrival) until that ships.
10649
+ */
10650
+ class KvCache {
10651
+ manager;
10652
+ store;
10653
+ constructor(manager) {
10654
+ this.manager = manager;
10655
+ this.store = manager.store;
8039
10656
  }
8040
- /** List the app's agents. */
8041
- async list(appId, opts = {}) {
8042
- const res = await this.deps.http.get(this.path(appId, "", opts));
8043
- return res.agents ?? [];
10657
+ get enabled() {
10658
+ return this.manager.enabled;
8044
10659
  }
8045
- /** Read one agent (config + identity keys). */
8046
- async get(appId, agentId, opts = {}) {
8047
- return this.deps.http.get(this.path(appId, `/${encodeURIComponent(agentId)}`, opts));
10660
+ nextHlc() {
10661
+ return this.manager.nextHlc();
8048
10662
  }
8049
- /** Create an agent (owner/editor only). */
8050
- async create(appId, input, opts = {}) {
8051
- return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
10663
+ newClientId() {
10664
+ return this.manager.newClientId();
8052
10665
  }
8053
- /** Update an agent's editor-settable fields (owner/editor only). */
8054
- async update(appId, agentId, patch, opts = {}) {
8055
- return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { ...patch, space: opts.space });
10666
+ key(commitment, fullKey) {
10667
+ return `${commitment}|${fullKey}`;
8056
10668
  }
8057
- /** Delete an agent (owner/editor only). */
8058
- async delete(appId, agentId, opts = {}) {
8059
- return this.deps.http.del(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { space: opts.space });
10669
+ /** Read the cached register for `collection/id`, or null when absent. */
10670
+ read(commitment, fullKey) {
10671
+ return this.store.get("kv-cache", this.key(commitment, fullKey));
8060
10672
  }
8061
- /** Enable the agent on a Space (per-Space opt-in). */
8062
- async enable(appId, agentId, spaceId, opts = {}) {
8063
- return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
10673
+ /** Optimistically record a local `set` (ciphertext envelope + HLC). */
10674
+ async writeLocal(commitment, fullKey, envelope, hlc) {
10675
+ await this.store.put("kv-cache", this.key(commitment, fullKey), writeRegister(envelope, hlc));
8064
10676
  }
8065
- /** Disable the agent on a Space. */
8066
- async disable(appId, agentId, spaceId, opts = {}) {
8067
- return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
10677
+ /** Optimistically record a local `delete` (tombstone at HLC). */
10678
+ async deleteLocal(commitment, fullKey, hlc) {
10679
+ await this.store.put("kv-cache", this.key(commitment, fullKey), deleteRegister(hlc));
8068
10680
  }
8069
- path(appId, suffix, opts) {
8070
- const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
8071
- return `/api/apps/${encodeURIComponent(appId)}/agents${suffix}${q}`;
10681
+ /**
10682
+ * Merge an authoritative value (from a GET refresh or a realtime frame) into
10683
+ * the cache by HLC. When the server doesn't supply an HLC yet, treat it as
10684
+ * "now-ish" by stamping our clock so a server value still beats stale local
10685
+ * state but a fresh local write wins.
10686
+ */
10687
+ async mergeRemote(commitment, fullKey, envelope, op, hlc) {
10688
+ const k = this.key(commitment, fullKey);
10689
+ const incoming = op === "delete"
10690
+ ? deleteRegister(hlc ?? (await this.nextHlc()))
10691
+ : writeRegister(envelope, hlc ?? (await this.nextHlc()));
10692
+ const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
10693
+ await this.store.put("kv-cache", k, mergeRegister(local, incoming));
10694
+ }
10695
+ /** Non-tombstoned full keys for a commitment (used for offline `list`). */
10696
+ async liveKeys(commitment) {
10697
+ const prefix = `${commitment}|`;
10698
+ const rows = await this.store.prefix("kv-cache", prefix);
10699
+ return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
10700
+ }
10701
+ /** Queue a kv mutation for replay on reconnect. */
10702
+ async enqueue(method, args, hlc, clientId) {
10703
+ await this.manager.enqueue({ hlc, clientId, domain: "kv", method, args });
8072
10704
  }
8073
10705
  }
8074
10706
 
8075
10707
  /**
8076
- * `client.functions` — manage an app's **serverless functions** (developer-
8077
- * authored code that runs on the accelerator).
8078
- *
8079
- * const { config } = await client.functions.deploy(appId, {
8080
- * name: 'hello', displayName: 'Hello',
8081
- * code: `export default { fetch: () => new Response('hi') }`,
8082
- * triggers: { http: { enabled: true } },
8083
- * });
8084
- * // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
8085
- * await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
8086
- * const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
8087
- *
8088
- * These are MANAGEMENT calls — the function itself runs on the accelerator
8089
- * (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
8090
- * an editor of its shared Space may deploy/update/enable (viewers read).
8091
- * Authorization rides the user session token; non-owner editors pass their
8092
- * management Space via `opts.space`.
8093
- *
8094
- * v1 is "compute-only": a function is an untrusted single-module ES worker
8095
- * (`export default { fetch }`) with NO platform bindings — it reaches the
8096
- * platform as an external client (the end-user session is passed through). Two
8097
- * triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
8098
- * like a Programmable Agent).
10708
+ * `DbCache` — the offline adapter for `client.db`. Unlike kv, db rows are
10709
+ * server-plaintext, so they're cached as-is. The SDK doesn't know a table's
10710
+ * primary-key column, so it caches schema-agnostically:
10711
+ * - `get(id)` results keyed by the requested id (`${table}|row|${id}`),
10712
+ * - `query(q)` result pages keyed by a stable hash of the query.
10713
+ *
10714
+ * Writes carry per-column HLC stamps (`_hlc`) so the accelerator's per-column
10715
+ * LWW merge can converge concurrent edits; offline writes are queued and
10716
+ * replayed. Optimistic row patches keep `get` fresh between sync cycles.
8099
10717
  */
8100
- /** The hosted platform's HTTP-function zone. */
8101
- const DEFAULT_FN_HOST_SUFFIX = "fns.muhkoo.dev";
8102
- /** Bodies `fetch` accepts natively — everything else is JSON-encoded. */
8103
- function isRawBody(body) {
8104
- return (typeof body === "string" ||
8105
- body instanceof FormData ||
8106
- body instanceof Blob ||
8107
- body instanceof ArrayBuffer ||
8108
- ArrayBuffer.isView(body) ||
8109
- body instanceof URLSearchParams ||
8110
- (typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
8111
- }
8112
- class FunctionsNamespace {
8113
- deps;
8114
- constructor(deps) {
8115
- this.deps = deps;
10718
+ /** Stable JSON for query-cache keys (sorted object keys → deterministic). */
10719
+ function stableStringify(value) {
10720
+ if (value === null || typeof value !== "object")
10721
+ return JSON.stringify(value);
10722
+ if (Array.isArray(value))
10723
+ return `[${value.map(stableStringify).join(",")}]`;
10724
+ const obj = value;
10725
+ return `{${Object.keys(obj)
10726
+ .sort()
10727
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
10728
+ .join(",")}}`;
10729
+ }
10730
+ class DbCache {
10731
+ manager;
10732
+ store;
10733
+ constructor(manager) {
10734
+ this.manager = manager;
10735
+ this.store = manager.store;
8116
10736
  }
8117
- /** List the app's functions. */
8118
- async list(appId, opts = {}) {
8119
- const res = await this.deps.http.get(this.path(appId, "", opts));
8120
- return res.functions ?? [];
10737
+ get enabled() {
10738
+ return this.manager.enabled;
8121
10739
  }
8122
- /** Read one function's config. */
8123
- async get(appId, functionId, opts = {}) {
8124
- return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}`, opts));
10740
+ nextHlc() {
10741
+ return this.manager.nextHlc();
8125
10742
  }
8126
- /** Read a function's decrypted source (owner/editor; for the editor UI). */
8127
- async code(appId, functionId, opts = {}) {
8128
- return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}/code`, opts));
10743
+ newClientId() {
10744
+ return this.manager.newClientId();
8129
10745
  }
8130
- /** Deploy (create) a function (owner/editor only). */
8131
- async deploy(appId, input, opts = {}) {
8132
- return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
10746
+ /** Build the `{ [col]: hlc }` map for a write (one stamp across its columns). */
10747
+ columnStamps(values, hlc) {
10748
+ const out = {};
10749
+ for (const col of Object.keys(values))
10750
+ out[col] = hlc;
10751
+ return out;
8133
10752
  }
8134
- /** Update a function's editor-settable fields and/or redeploy its `code`. */
8135
- async update(appId, functionId, patch, opts = {}) {
8136
- return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { ...patch, space: opts.space });
10753
+ rowKey(table, id) {
10754
+ return `${table}|row|${id}`;
8137
10755
  }
8138
- /** Delete a function (owner/editor only). */
8139
- async delete(appId, functionId, opts = {}) {
8140
- return this.deps.http.del(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { space: opts.space });
10756
+ queryKey(table, q) {
10757
+ return `${table}|query|${stableStringify(q)}`;
8141
10758
  }
8142
- /** Enable the function on a Space (per-Space opt-in for the `space` trigger). */
8143
- async enable(appId, functionId, spaceId, opts = {}) {
8144
- return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
10759
+ async readRow(table, id) {
10760
+ return this.store.get("db-row-cache", this.rowKey(table, id));
8145
10761
  }
8146
- /** Disable the function on a Space. */
8147
- async disable(appId, functionId, spaceId, opts = {}) {
8148
- return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
10762
+ async writeRow(table, id, row) {
10763
+ await this.store.put("db-row-cache", this.rowKey(table, id), row);
8149
10764
  }
8150
- /**
8151
- * Invoke an HTTP-triggered function and parse its JSON response.
8152
- *
8153
- * const out = await client.functions.invoke({ name: 'hello', slug: 'myapp' });
8154
- * const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
8155
- * const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
8156
- *
8157
- * The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
8158
- * user is signed in, `X-Muhkoo-Session`) — the platform passes the session
8159
- * token through to the function, which can verify it server-side via
8160
- * `POST /api/auth/verify` to establish the caller's identity.
8161
- *
8162
- * Non-2xx responses throw {@link HttpError}. For functions that return
8163
- * something other than JSON, use {@link invokeRaw}.
8164
- */
8165
- async invoke(target, opts = {}) {
8166
- const res = await this.invokeRaw(target, opts);
8167
- let parsed = null;
8168
- try {
8169
- parsed = await res.json();
8170
- }
8171
- catch {
8172
- // Non-JSON body — fall through to the status-only error below.
8173
- }
8174
- if (!res.ok) {
8175
- const msg = parsed && typeof parsed === "object" && "error" in parsed
8176
- ? String(parsed.error)
8177
- : `${res.status} ${res.statusText}`;
8178
- throw new HttpError(msg, res.status, parsed);
8179
- }
8180
- return parsed;
10765
+ /** Shallow-merge a partial update into a cached row (optimistic). */
10766
+ async patchRow(table, id, values) {
10767
+ const existing = (await this.readRow(table, id)) ?? {};
10768
+ await this.writeRow(table, id, { ...existing, ...values });
8181
10769
  }
8182
- /** Invoke an HTTP-triggered function and return the raw `Response`. */
8183
- async invokeRaw(target, opts = {}) {
8184
- const init = {
8185
- method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
8186
- };
8187
- const headers = new Headers(opts.headers);
8188
- if (opts.body !== undefined) {
8189
- if (isRawBody(opts.body)) {
8190
- init.body = opts.body;
8191
- }
8192
- else {
8193
- init.body = JSON.stringify(opts.body);
8194
- if (!headers.has("Content-Type"))
8195
- headers.set("Content-Type", "application/json");
8196
- }
8197
- }
8198
- init.headers = headers;
8199
- return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
10770
+ async deleteRow(table, id) {
10771
+ await this.store.delete("db-row-cache", this.rowKey(table, id));
8200
10772
  }
8201
- /** Resolve an invoke target to the function's absolute URL. */
8202
- invokeUrl(target, path = "") {
8203
- const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
8204
- let base;
8205
- if (typeof target === "string") {
8206
- base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
8207
- }
8208
- else {
8209
- base = `https://${target.name}--${target.slug}.${suffix}`;
8210
- }
8211
- if (!path)
8212
- return base;
8213
- return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
10773
+ async readQuery(table, q) {
10774
+ return this.store.get("db-query-cache", this.queryKey(table, q));
8214
10775
  }
8215
- path(appId, suffix, opts) {
8216
- const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
8217
- return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
10776
+ async writeQuery(table, q, page) {
10777
+ await this.store.put("db-query-cache", this.queryKey(table, q), page);
10778
+ }
10779
+ async enqueue(method, args, hlc, clientId) {
10780
+ await this.manager.enqueue({ hlc, clientId, domain: "db", method, args });
8218
10781
  }
8219
10782
  }
8220
10783
 
@@ -8270,6 +10833,8 @@ class Client {
8270
10833
  agents;
8271
10834
  /** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
8272
10835
  functions;
10836
+ /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
10837
+ offline;
8273
10838
  session;
8274
10839
  http;
8275
10840
  /** Subscribers notified when a session expires and can't be recovered. */
@@ -8289,11 +10854,40 @@ class Client {
8289
10854
  }
8290
10855
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8291
10856
  this.session = new SessionState(options.sessionStore ?? defaultSessionStore());
10857
+ // Offline layer. On by default in browsers, a no-op (NoopStore) in Node,
10858
+ // Workers, and SSR — so existing consumers are unaffected unless they're
10859
+ // in a browser that can actually cache.
10860
+ const offlineEnabled = options.offline?.enabled ?? isOfflineCapable();
10861
+ const offlineStore = offlineEnabled
10862
+ ? (options.offline?.store ?? new IndexedDbStore())
10863
+ : new NoopStore();
10864
+ this.offline = new OfflineManager({
10865
+ store: offlineStore,
10866
+ session: this.session,
10867
+ enabled: offlineEnabled,
10868
+ });
10869
+ // Wrap fetch so every accelerator round-trip feeds the connectivity
10870
+ // detector: a thrown fetch is the strongest "offline" signal, a 2xx the
10871
+ // strongest "online" one. Only when offline support is active.
10872
+ const baseFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
10873
+ const reportingFetch = offlineEnabled && baseFetch
10874
+ ? async (input, init) => {
10875
+ try {
10876
+ const res = await baseFetch(input, init);
10877
+ this.offline.reportFetchSuccess();
10878
+ return res;
10879
+ }
10880
+ catch (err) {
10881
+ this.offline.reportFetchFailure();
10882
+ throw err;
10883
+ }
10884
+ }
10885
+ : options.fetch;
8292
10886
  this.http = new HttpClient({
8293
10887
  baseUrl: this.baseUrl,
8294
10888
  apiKey: options.apiKey,
8295
10889
  getSessionToken: () => this.session.token,
8296
- fetch: options.fetch,
10890
+ fetch: reportingFetch,
8297
10891
  // Self-heal stale sessions: on a 401, try a silent re-auth (only
8298
10892
  // works while unlocked). If it can't, `recoverSession` fires the
8299
10893
  // session-expired event so the app can redirect to login.
@@ -8304,9 +10898,24 @@ class Client {
8304
10898
  const wsBaseUrl = toWsBase(this.baseUrl);
8305
10899
  const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
8306
10900
  this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
8307
- this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
8308
- this.db = new DbNamespace({ http: this.http });
8309
- this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
10901
+ const deferShardUpload = offlineEnabled
10902
+ ? (hash) => this.offline.deferShardUpload(hash)
10903
+ : undefined;
10904
+ const kvCache = offlineEnabled ? new KvCache(this.offline) : undefined;
10905
+ this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl, offline: kvCache });
10906
+ if (offlineEnabled)
10907
+ this.offline.registerReplayer("kv", (e) => this.kv.replay(e));
10908
+ const dbCache = offlineEnabled ? new DbCache(this.offline) : undefined;
10909
+ this.db = new DbNamespace({ http: this.http, offline: dbCache });
10910
+ if (offlineEnabled)
10911
+ this.offline.registerReplayer("db", (e) => this.db.replay(e));
10912
+ this.storage = new StorageNamespace({
10913
+ http: this.http,
10914
+ baseUrl: this.baseUrl,
10915
+ kv: this.kv,
10916
+ shardCache: this.offline.fileCache,
10917
+ deferShardUpload,
10918
+ });
8310
10919
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
8311
10920
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
8312
10921
  // by client.kv), so a returning member hydrates without a fresh keyring
@@ -8320,9 +10929,29 @@ class Client {
8320
10929
  session: this.session,
8321
10930
  wsBaseUrl,
8322
10931
  cache: spaceKeyCache,
10932
+ offline: this.offline,
10933
+ p2p: options.p2p,
8323
10934
  });
8324
10935
  this.agents = new AgentsNamespace({ http: this.http });
8325
10936
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
10937
+ // Replay deferred shard uploads on reconnect. The bytes live in the
10938
+ // Cache API keyed by hash; re-PUT them through a (defer-free) shard
10939
+ // client so a failure surfaces as transient and the entry stays queued.
10940
+ if (offlineEnabled && this.offline.fileCache) {
10941
+ const replayShards = new ShardClient({
10942
+ baseUrl: this.baseUrl,
10943
+ pathPrefix: "/api/shards",
10944
+ fetch: this.http.fetch,
10945
+ cache: this.offline.fileCache,
10946
+ });
10947
+ this.offline.registerReplayer("file", async (entry) => {
10948
+ const { hash } = entry.args;
10949
+ const bytes = await this.offline.fileCache.get(hash);
10950
+ if (!bytes)
10951
+ return; // evicted from cache — nothing to replay
10952
+ await replayShards.putShard(hash, bytes);
10953
+ });
10954
+ }
8326
10955
  }
8327
10956
  /** The currently signed-in user, or `null`. */
8328
10957
  get user() {
@@ -8547,6 +11176,116 @@ function ejectAgentTools(appClass, opts) {
8547
11176
  };
8548
11177
  }
8549
11178
 
11179
+ /**
11180
+ * `LWWMap` — a map of independent last-write-wins registers, one per column.
11181
+ * This is the conflict model for a `db` row: two devices that edit *different*
11182
+ * columns of the same row while offline both keep their edits (no lost update),
11183
+ * and two that edit the *same* column converge to the greater-HLC write. A row
11184
+ * delete is a causal tombstone (see {@link ./ORSet}); a later write to any
11185
+ * column resurrects the row with just the surviving columns.
11186
+ *
11187
+ * This mirrors, on the client, exactly what the accelerator's `_muhkoo_crdt`
11188
+ * sidecar does server-side — so a row materializes identically whether it was
11189
+ * merged locally (offline) or by the server on replay.
11190
+ */
11191
+ function emptyMap() {
11192
+ return { fields: {}, columnHlc: {}, tombstone: null };
11193
+ }
11194
+ /** Apply a local column write (insert/update) at the given per-column HLCs. */
11195
+ function writeColumns(map, values, hlc) {
11196
+ const next = {
11197
+ fields: { ...map.fields },
11198
+ columnHlc: { ...map.columnHlc },
11199
+ tombstone: map.tombstone,
11200
+ };
11201
+ for (const [col, value] of Object.entries(values)) {
11202
+ const prev = next.columnHlc[col];
11203
+ if (!prev || compareHlc(hlc, prev) > 0) {
11204
+ next.fields[col] = value;
11205
+ next.columnHlc[col] = hlc;
11206
+ }
11207
+ }
11208
+ return next;
11209
+ }
11210
+ /** Apply a row delete at `hlc` (causal tombstone). */
11211
+ function tombstoneRow(map, hlc) {
11212
+ const tombstone = !map.tombstone || compareHlc(hlc, map.tombstone) > 0 ? hlc : map.tombstone;
11213
+ return { fields: { ...map.fields }, columnHlc: { ...map.columnHlc }, tombstone };
11214
+ }
11215
+ /** Merge two row states column-by-column. Pure; neither input is mutated. */
11216
+ function mergeMap(a, b) {
11217
+ const cols = new Set([...Object.keys(a.columnHlc), ...Object.keys(b.columnHlc)]);
11218
+ const out = emptyMap();
11219
+ for (const col of cols) {
11220
+ const ah = a.columnHlc[col];
11221
+ const bh = b.columnHlc[col];
11222
+ const aWins = ah && (!bh || compareHlc(ah, bh) >= 0);
11223
+ const winner = aWins ? a : b;
11224
+ out.fields[col] = winner.fields[col];
11225
+ out.columnHlc[col] = winner.columnHlc[col];
11226
+ }
11227
+ out.tombstone =
11228
+ a.tombstone && b.tombstone
11229
+ ? compareHlc(a.tombstone, b.tombstone) >= 0
11230
+ ? a.tombstone
11231
+ : b.tombstone
11232
+ : (a.tombstone ?? b.tombstone);
11233
+ return out;
11234
+ }
11235
+ /**
11236
+ * Materialize the visible row from merged state, or `null` when the row is
11237
+ * deleted. A column is visible only when its write is causally newer than the
11238
+ * tombstone — so a delete hides every prior column, but a post-delete write to
11239
+ * a column brings the row back with just that column populated.
11240
+ */
11241
+ function materializeRow(map) {
11242
+ const visible = {};
11243
+ let any = false;
11244
+ for (const [col, hlc] of Object.entries(map.columnHlc)) {
11245
+ if (!map.tombstone || compareHlc(hlc, map.tombstone) > 0) {
11246
+ visible[col] = map.fields[col];
11247
+ any = true;
11248
+ }
11249
+ }
11250
+ return any ? visible : null;
11251
+ }
11252
+
11253
+ /**
11254
+ * Tombstone helpers shared by the LWW CRDTs. We don't need a full
11255
+ * observed-remove set — every domain that deletes (kv keys, db rows) does so
11256
+ * by stamping a delete with an HLC and keeping that stamp around. The only
11257
+ * rule that matters is the causal one:
11258
+ *
11259
+ * a delete at HLC `d` hides the value, but a *later* write at HLC `w > d`
11260
+ * resurrects it; a *stale* write at `w <= d` is dropped.
11261
+ *
11262
+ * Keeping the delete HLC (rather than physically forgetting the key) is what
11263
+ * prevents a delayed offline write from "un-deleting" something a peer already
11264
+ * removed. Callers GC tombstones older than any possible offline window.
11265
+ */
11266
+ /**
11267
+ * Does a write at `writeHlc` win against a tombstone at `tombstoneHlc`?
11268
+ * `true` ⇒ the write applies (resurrecting a deleted item if needed); `false`
11269
+ * ⇒ the tombstone still holds and the write is dropped. A missing tombstone
11270
+ * (`null`) means there's nothing suppressing the write.
11271
+ */
11272
+ function writeBeatsTombstone(writeHlc, tombstoneHlc) {
11273
+ if (!tombstoneHlc)
11274
+ return true;
11275
+ return compareHlc(writeHlc, tombstoneHlc) > 0;
11276
+ }
11277
+ /**
11278
+ * Does a delete at `deleteHlc` win against the latest write at `writeHlc`?
11279
+ * `true` ⇒ the item is now tombstoned. Ties go to the delete (remove-wins) so
11280
+ * that a delete and a write stamped in the same instant resolve deterministically
11281
+ * toward removal.
11282
+ */
11283
+ function deleteBeatsWrite(deleteHlc, writeHlc) {
11284
+ if (!writeHlc)
11285
+ return true;
11286
+ return compareHlc(deleteHlc, writeHlc) >= 0;
11287
+ }
11288
+
8550
11289
  /**
8551
11290
  * PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
8552
11291
  *
@@ -8613,5 +11352,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
8613
11352
  default: loadRsWasm
8614
11353
  });
8615
11354
 
8616
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VaultUnavailableError, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, toField, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
11355
+ export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
8617
11356
  //# sourceMappingURL=index.js.map