@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.22

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.
Files changed (53) hide show
  1. package/README.md +44 -0
  2. package/dist/_crypto_helpers.d.ts +4 -0
  3. package/dist/append-log.d.ts +228 -0
  4. package/dist/append-log.js +267 -0
  5. package/dist/bindings/legend.d.ts +23 -0
  6. package/dist/bindings/legend.js +32 -0
  7. package/dist/bindings/legend.js.map +2 -2
  8. package/dist/bindings/zustand.d.ts +72 -1
  9. package/dist/bindings/zustand.js +427 -63
  10. package/dist/bindings/zustand.js.map +3 -3
  11. package/dist/cap-mint.d.ts +20 -0
  12. package/dist/cap-mint.js +12 -0
  13. package/dist/cap-mint.js.map +7 -0
  14. package/dist/client.d.ts +128 -5
  15. package/dist/client.js +316 -37
  16. package/dist/config.d.ts +9 -0
  17. package/dist/directory.d.ts +9 -0
  18. package/dist/directory.js +24 -0
  19. package/dist/directory.js.map +7 -0
  20. package/dist/identity.d.ts +4 -82
  21. package/dist/identity.js +2 -354
  22. package/dist/identity.js.map +4 -4
  23. package/dist/index.d.ts +9 -5
  24. package/dist/index.js +578 -60
  25. package/dist/index.js.map +4 -4
  26. package/dist/keyring.d.ts +6 -0
  27. package/dist/keyring.js +26 -0
  28. package/dist/keyring.js.map +7 -0
  29. package/dist/logger.d.ts +3 -0
  30. package/dist/mobile-lifecycle.d.ts +28 -1
  31. package/dist/mobile-lifecycle.js +41 -2
  32. package/dist/mutate.d.ts +39 -0
  33. package/dist/pairing.d.ts +6 -0
  34. package/dist/pairing.js +26 -0
  35. package/dist/pairing.js.map +7 -0
  36. package/dist/polling.js +2 -2
  37. package/dist/recipients.d.ts +6 -0
  38. package/dist/recipients.js +16 -0
  39. package/dist/recipients.js.map +7 -0
  40. package/dist/sync.d.ts +28 -0
  41. package/dist/sync.js +68 -14
  42. package/dist/types.d.ts +62 -0
  43. package/package.json +2 -2
  44. package/dist/append.d.ts +0 -50
  45. package/dist/bindings/broadcast.d.ts +0 -19
  46. package/dist/bindings/broadcast.js +0 -65
  47. package/dist/bindings/react.d.ts +0 -12
  48. package/dist/bindings/react.js +0 -25
  49. package/dist/crypto.js +0 -49
  50. package/dist/entitlements.js +0 -41
  51. package/dist/group-crypto.d.ts +0 -111
  52. package/dist/group-crypto.js +0 -205
  53. package/dist/group-crypto.js.map +0 -7
@@ -230,6 +230,20 @@ import { useEffect, useRef, useState, useCallback } from "react";
230
230
 
231
231
  // src/client.ts
232
232
  import {
233
+ AUTHOR_PUBKEY_FIELD,
234
+ AUTHOR_SIGNATURE_FIELD,
235
+ DATA_FIELD,
236
+ TS_FIELD,
237
+ BASE_HASH_FIELD,
238
+ PUSH_PATH_PREFIX,
239
+ HEADER_AUTHORIZATION,
240
+ HEADER_SIG,
241
+ HEADER_TS,
242
+ HEADER_NONCE,
243
+ HEADER_PUB,
244
+ HEADER_CONTENT_TYPE,
245
+ HEADER_ACCEPT,
246
+ signAppendAuthor,
233
247
  signRequest,
234
248
  stableStringify
235
249
  } from "@drakkar.software/starfish-protocol";
@@ -252,6 +266,16 @@ var StarfishHttpError = class extends Error {
252
266
 
253
267
  // src/client.ts
254
268
  var APPEND_DEFAULT_FIELD = "items";
269
+ function pullCacheKey(pathAndQuery) {
270
+ const q = pathAndQuery.indexOf("?");
271
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
272
+ }
273
+ function pullWasFromCache(result) {
274
+ return result.fromCache === true;
275
+ }
276
+ function stripPushPrefix(path) {
277
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
278
+ }
255
279
  function encodeCapAuth(cap) {
256
280
  const json = stableStringify(cap);
257
281
  if (typeof btoa === "function") {
@@ -263,8 +287,11 @@ function encodeCapAuth(cap) {
263
287
  }
264
288
  var StarfishClient = class {
265
289
  baseUrl;
290
+ namespace;
266
291
  capProvider;
267
292
  fetch;
293
+ cache;
294
+ cacheMaxAgeMs;
268
295
  /**
269
296
  * Installed client-side plugins. Currently stored as inert data; no
270
297
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -272,10 +299,22 @@ var StarfishClient = class {
272
299
  plugins;
273
300
  constructor(options) {
274
301
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
302
+ this.namespace = options.namespace || void 0;
275
303
  this.capProvider = options.capProvider;
276
304
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
305
+ this.cache = options.cache;
306
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
277
307
  this.plugins = options.plugins ? [...options.plugins] : [];
278
308
  }
309
+ /**
310
+ * Mark a `PullResult` as having been served from the offline read-through
311
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
312
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
313
+ */
314
+ tagFromCache(result) {
315
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
316
+ return result;
317
+ }
279
318
  /**
280
319
  * Resolve the host portion of the URL the client will send to. The host
281
320
  * is folded into the signed canonical input as the `h` field so the
@@ -295,6 +334,20 @@ var StarfishClient = class {
295
334
  return "";
296
335
  }
297
336
  }
337
+ /**
338
+ * Rewrite a request path for the configured namespace. A no-op when no
339
+ * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
340
+ * (the `/v1` protocol-version segment is part of the namespaced route, matching
341
+ * the Python client and the server's namespace mount).
342
+ *
343
+ * Applied to the path used for BOTH the signature and the URL so the canonical
344
+ * path the client signs equals the path the server reconstructs from the URL.
345
+ * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
346
+ * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
347
+ */
348
+ applyNamespace(path) {
349
+ return this.namespace ? `/v1/${this.namespace}${path}` : path;
350
+ }
298
351
  /**
299
352
  * Build auth headers for a request. When a `capProvider` is set, signs the
300
353
  * request with the device's Ed25519 private key and returns the v3 header
@@ -306,28 +359,52 @@ var StarfishClient = class {
306
359
  * The host bound into the signature is derived from `baseUrl` once per call.
307
360
  */
308
361
  async buildAuthHeaders(method, pathAndQuery, body) {
309
- if (this.capProvider) {
310
- const { cap, devEdPrivHex, pubHex } = await this.capProvider.getCap();
311
- const req = {
312
- method,
313
- pathAndQuery,
314
- body,
315
- host: this.signingHost()
316
- };
317
- const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
318
- const headers = {
319
- Authorization: `Cap ${encodeCapAuth(cap)}`,
320
- "X-Starfish-Sig": sig,
321
- "X-Starfish-Ts": String(ts),
322
- "X-Starfish-Nonce": nonce
323
- };
324
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
325
- return headers;
326
- }
327
- return {};
362
+ if (!this.capProvider) return {};
363
+ const capCtx = await this.capProvider.getCap();
364
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
365
+ }
366
+ /**
367
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
368
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
369
+ * reuse it for BOTH the author signature (over the element data) and the
370
+ * request signature (over the body), without redeeming the cap twice — a
371
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
372
+ * presenter` bind the server checks.
373
+ */
374
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
375
+ const { cap, devEdPrivHex, pubHex } = capCtx;
376
+ const req = {
377
+ method,
378
+ pathAndQuery,
379
+ body,
380
+ host: this.signingHost()
381
+ };
382
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
383
+ const headers = {
384
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
385
+ [HEADER_SIG]: sig,
386
+ [HEADER_TS]: String(ts),
387
+ [HEADER_NONCE]: nonce
388
+ };
389
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
390
+ return headers;
391
+ }
392
+ /**
393
+ * Resolve the author public key to attach to a signed append: the redeemer's
394
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
395
+ * device/member cap. This is the SAME key that signs the request, so a server
396
+ * enforcing author proof can bind the stored element to its writer. Returns
397
+ * undefined only for a (malformed) cap with neither — the append then goes
398
+ * unsigned and a server requiring signatures rejects it.
399
+ */
400
+ appendAuthorKey(capCtx) {
401
+ const { cap, pubHex } = capCtx;
402
+ const authorPubHex = pubHex ?? cap.sub;
403
+ if (authorPubHex === void 0) return null;
404
+ return { authorPubHex };
328
405
  }
329
406
  async pull(path, checkpointOrOptions) {
330
- let pathAndQuery = path;
407
+ let pathAndQuery = this.applyNamespace(path);
331
408
  let appendField;
332
409
  if (typeof checkpointOrOptions === "number") {
333
410
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -344,23 +421,43 @@ var StarfishClient = class {
344
421
  }
345
422
  } else {
346
423
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
424
+ if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
425
+ throw new Error("full cannot be combined with since, limit, or last");
426
+ }
347
427
  if (opts.since != null) {
348
428
  if (opts.since < 0) throw new Error("since must be non-negative");
349
429
  params.set("checkpoint", String(opts.since));
350
430
  }
431
+ if (opts.limit != null) {
432
+ if (opts.limit < 0) throw new Error("limit must be non-negative");
433
+ params.set("limit", String(opts.limit));
434
+ }
351
435
  if (opts.last != null) {
352
436
  if (opts.last < 0) throw new Error("last must be non-negative");
353
437
  params.set("last", String(opts.last));
354
438
  }
439
+ if (opts.full) {
440
+ params.set("full", "true");
441
+ }
355
442
  }
356
443
  if (params.size > 0) pathAndQuery += `?${params.toString()}`;
357
444
  }
358
445
  const url = `${this.baseUrl}${pathAndQuery}`;
359
446
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
360
- const res = await this.fetch(url, {
361
- method: "GET",
362
- headers: { Accept: "application/json", ...authHeaders }
363
- });
447
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
448
+ let res;
449
+ try {
450
+ res = await this.fetch(url, {
451
+ method: "GET",
452
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
453
+ });
454
+ } catch (err) {
455
+ if (cacheKey) {
456
+ const cached = await this.readCache(cacheKey);
457
+ if (cached) return cached;
458
+ }
459
+ throw err;
460
+ }
364
461
  if (!res.ok) {
365
462
  throw new StarfishHttpError(res.status, await res.text());
366
463
  }
@@ -369,29 +466,118 @@ var StarfishClient = class {
369
466
  const list = result.data?.[appendField];
370
467
  return Array.isArray(list) ? list : [];
371
468
  }
469
+ if (cacheKey) {
470
+ const snapshot = {
471
+ data: result.data,
472
+ hash: result.hash,
473
+ timestamp: result.timestamp,
474
+ cachedAt: Date.now()
475
+ };
476
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
477
+ });
478
+ }
372
479
  return result;
373
480
  }
481
+ /**
482
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
483
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
484
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
485
+ * or null when no cache is configured / there's no entry. Namespacing matches
486
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
487
+ */
488
+ async peekCache(path) {
489
+ if (!this.cache) return null;
490
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
491
+ }
492
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
493
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
494
+ * must not break a pull, just miss). */
495
+ async readCache(cacheKey) {
496
+ try {
497
+ const raw = await this.cache.get(cacheKey);
498
+ if (!raw) return null;
499
+ const parsed = JSON.parse(raw);
500
+ if (!parsed || typeof parsed.hash !== "string") return null;
501
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
502
+ return null;
503
+ }
504
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
509
+ /**
510
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
511
+ * the list of distinct collection names; `opts.params` supplies, per collection,
512
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
513
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
514
+ * The server auto-fills the `{identity}` param from the authenticated caller for
515
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
516
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
517
+ * in request order. Honors the configured namespace.
518
+ *
519
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
520
+ *
521
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
522
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
523
+ */
524
+ async batchPull(collections, opts = {}) {
525
+ const search = new URLSearchParams();
526
+ search.set("collections", collections.join(","));
527
+ if (opts.params && Object.keys(opts.params).length > 0) {
528
+ search.set("params", JSON.stringify(opts.params));
529
+ }
530
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
531
+ const url = `${this.baseUrl}${pathAndQuery}`;
532
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
533
+ const res = await this.fetch(url, {
534
+ method: "GET",
535
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
536
+ });
537
+ if (!res.ok) {
538
+ throw new StarfishHttpError(res.status, await res.text());
539
+ }
540
+ return await res.json();
541
+ }
542
+ /**
543
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
544
+ * collection in a single round-trip: pass the per-document param-sets and get
545
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
546
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
547
+ * issues no request and returns `[]`.
548
+ */
549
+ async batchPullMany(collection, paramsList) {
550
+ if (paramsList.length === 0) return [];
551
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
552
+ return res.collections[collection] ?? [];
553
+ }
374
554
  /**
375
555
  * Push synced data to the server.
376
556
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
377
557
  * @param data - The full document data to push
378
558
  * @param baseHash - Hash of the document this push is based on (null for first push)
379
559
  *
380
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
381
- * and are produced by `SyncManager` when a `signer` is configured.
560
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
561
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
562
+ * top-level body siblings of `data`, where the server verifies it.
382
563
  * @throws {ConflictError} if the server detects a hash mismatch (409)
383
564
  */
384
- async push(path, data, baseHash) {
565
+ async push(path, data, baseHash, author) {
385
566
  const body = JSON.stringify({
386
- data,
387
- baseHash
567
+ [DATA_FIELD]: data,
568
+ [BASE_HASH_FIELD]: baseHash,
569
+ ...author && {
570
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
571
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
572
+ }
388
573
  });
389
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
390
- const res = await this.fetch(`${this.baseUrl}${path}`, {
574
+ const sendPath = this.applyNamespace(path);
575
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
576
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
391
577
  method: "POST",
392
578
  headers: {
393
- "Content-Type": "application/json",
394
- Accept: "application/json",
579
+ [HEADER_CONTENT_TYPE]: "application/json",
580
+ [HEADER_ACCEPT]: "application/json",
395
581
  ...authHeaders
396
582
  },
397
583
  body
@@ -418,19 +604,37 @@ var StarfishClient = class {
418
604
  * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
419
605
  * non-negative integer strictly greater than the latest stored element's ts
420
606
  * (else the server responds 409). Omit to let the server assign one.
421
- * @throws {StarfishHttpError} on a non-2xx response (e.g. 409 for a
422
- * non-monotonic timestamp).
607
+ * @throws {StarfishHttpError} on a non-2xx response e.g. 409
608
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
609
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
610
+ * cap is reached (partition by a path parameter for higher volume).
423
611
  */
424
612
  async append(path, data, opts = {}) {
425
- const bodyObj = { data };
426
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
613
+ const sendPath = this.applyNamespace(path);
614
+ const bodyObj = { [DATA_FIELD]: data };
615
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
616
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
617
+ if (capCtx) {
618
+ const authorKey = this.appendAuthorKey(capCtx);
619
+ if (authorKey) {
620
+ const documentKey = stripPushPrefix(path);
621
+ const { authorPubkey, authorSignature } = signAppendAuthor(
622
+ documentKey,
623
+ data,
624
+ authorKey.authorPubHex,
625
+ capCtx.devEdPrivHex
626
+ );
627
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
628
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
629
+ }
630
+ }
427
631
  const body = JSON.stringify(bodyObj);
428
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
429
- const res = await this.fetch(`${this.baseUrl}${path}`, {
632
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
633
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
430
634
  method: "POST",
431
635
  headers: {
432
- "Content-Type": "application/json",
433
- Accept: "application/json",
636
+ [HEADER_CONTENT_TYPE]: "application/json",
637
+ [HEADER_ACCEPT]: "application/json",
434
638
  ...authHeaders
435
639
  },
436
640
  body
@@ -445,16 +649,17 @@ var StarfishClient = class {
445
649
  * Returns raw bytes with the content hash from the ETag header.
446
650
  */
447
651
  async pullBlob(path) {
448
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
449
- const res = await this.fetch(`${this.baseUrl}${path}`, {
652
+ const sendPath = this.applyNamespace(path);
653
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
654
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
450
655
  method: "GET",
451
- headers: { Accept: "*/*", ...authHeaders }
656
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
452
657
  });
453
658
  if (!res.ok) {
454
659
  throw new StarfishHttpError(res.status, await res.text());
455
660
  }
456
661
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
457
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
662
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
458
663
  const data = await res.arrayBuffer();
459
664
  return { data, hash: etag, contentType };
460
665
  }
@@ -463,12 +668,13 @@ var StarfishClient = class {
463
668
  * Binary collections use last-write-wins (no conflict detection).
464
669
  */
465
670
  async pushBlob(path, data, contentType) {
466
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
467
- const res = await this.fetch(`${this.baseUrl}${path}`, {
671
+ const sendPath = this.applyNamespace(path);
672
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
673
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
468
674
  method: "POST",
469
675
  headers: {
470
- "Content-Type": contentType,
471
- Accept: "application/json",
676
+ [HEADER_CONTENT_TYPE]: contentType,
677
+ [HEADER_ACCEPT]: "application/json",
472
678
  ...authHeaders
473
679
  },
474
680
  body: data
@@ -481,7 +687,13 @@ var StarfishClient = class {
481
687
  };
482
688
 
483
689
  // src/sync.ts
484
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
690
+ import {
691
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
692
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
693
+ deepMerge,
694
+ docAuthorCanonicalInput,
695
+ getBase64
696
+ } from "@drakkar.software/starfish-protocol";
485
697
 
486
698
  // src/validate.ts
487
699
  var ValidationError = class extends Error {
@@ -514,6 +726,7 @@ var SyncManager = class {
514
726
  lastCheckpoint = 0;
515
727
  localData = {};
516
728
  aborted = false;
729
+ lastFromCache = false;
517
730
  constructor(options) {
518
731
  this.client = options.client;
519
732
  this.pullPath = options.pullPath;
@@ -535,6 +748,18 @@ var SyncManager = class {
535
748
  getData() {
536
749
  return { ...this.localData };
537
750
  }
751
+ /**
752
+ * Merge a remote snapshot with local (optimistic) data using this manager's
753
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
754
+ * {@link pull} overwrites the store's data with the server snapshot, which
755
+ * would drop un-pushed local writes (they live only in the store, never in
756
+ * `localData` until a push succeeds). The zustand binding calls this on pull
757
+ * while the store is dirty so those writes survive. `local` wins by the same
758
+ * rules as a push conflict.
759
+ */
760
+ resolve(local, remote) {
761
+ return this.onConflict(local, remote);
762
+ }
538
763
  getHash() {
539
764
  return this.lastHash;
540
765
  }
@@ -542,6 +767,40 @@ var SyncManager = class {
542
767
  setHash(hash) {
543
768
  this.lastHash = hash;
544
769
  }
770
+ /**
771
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
772
+ * from the client's offline read-through cache rather than a live server
773
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
774
+ * offline indicator without treating a cache hit as "reachable". Reset to
775
+ * false by the next successful network pull.
776
+ */
777
+ getLastPullFromCache() {
778
+ return this.lastFromCache;
779
+ }
780
+ /**
781
+ * Cache-first paint: seed `localData` from the client's read-through cache
782
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
783
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
784
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
785
+ * the initial live {@link pull}, which then supersedes the seeded snapshot.
786
+ * Requires the client to have been built with a `cache`.
787
+ */
788
+ async seedFromCache() {
789
+ if (this.aborted) return false;
790
+ const cached = await this.client.peekCache(this.pullPath);
791
+ if (!cached) return false;
792
+ let data;
793
+ try {
794
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
795
+ } catch {
796
+ return false;
797
+ }
798
+ if (this.aborted) return false;
799
+ this.localData = data;
800
+ this.lastHash = cached.hash;
801
+ this.lastFromCache = true;
802
+ return true;
803
+ }
545
804
  getCheckpoint() {
546
805
  return this.lastCheckpoint;
547
806
  }
@@ -552,6 +811,7 @@ var SyncManager = class {
552
811
  try {
553
812
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
554
813
  if (this.aborted) throw new AbortError();
814
+ this.lastFromCache = pullWasFromCache(result);
555
815
  if (this.encryptor) {
556
816
  const decrypted = await this.encryptor.decrypt(result.data);
557
817
  if (this.aborted) throw new AbortError();
@@ -586,23 +846,24 @@ var SyncManager = class {
586
846
  try {
587
847
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
588
848
  if (this.aborted) throw new AbortError();
589
- let payload = sealed;
849
+ let author;
590
850
  if (this.signer) {
591
851
  const { devEdPubHex, sign } = await this.signer.getSigner();
592
852
  if (this.aborted) throw new AbortError();
593
- const canonical = stableStringify2(sealed);
853
+ const documentKey = stripPushPrefix(this.pushPath);
854
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
594
855
  const sigBytes = await sign(new TextEncoder().encode(canonical));
595
856
  if (this.aborted) throw new AbortError();
596
- payload = {
597
- ...sealed,
598
- authorPubkey: devEdPubHex,
599
- authorSignature: getBase64().encode(sigBytes)
857
+ author = {
858
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
859
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
600
860
  };
601
861
  }
602
862
  const result = await this.client.push(
603
863
  this.pushPath,
604
- payload,
605
- this.lastHash
864
+ sealed,
865
+ this.lastHash,
866
+ author
606
867
  );
607
868
  if (this.aborted) throw new AbortError();
608
869
  this.lastHash = result.hash;
@@ -724,12 +985,25 @@ function createStarfishStore(options) {
724
985
  dirty: false,
725
986
  error: null,
726
987
  hash: null,
988
+ stale: false,
989
+ seed: async () => {
990
+ try {
991
+ const seeded = await syncManager.seedFromCache();
992
+ if (!seeded) return;
993
+ if (get().dirty || Object.keys(get().data).length > 0) return;
994
+ set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
995
+ } catch {
996
+ }
997
+ },
727
998
  pull: async () => {
728
999
  set({ syncing: true, error: null }, false, "pull/start");
729
1000
  try {
730
1001
  await syncManager.pull();
731
- const newData = syncManager.getData();
732
- set({ data: newData, syncing: false, hash: syncManager.getHash() }, false, "pull/success");
1002
+ const remote = syncManager.getData();
1003
+ const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
1004
+ set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
1005
+ if (get().online && get().dirty) get().flush().catch(() => {
1006
+ });
733
1007
  options.onRemoteUpdate?.(newData);
734
1008
  } catch (err) {
735
1009
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
@@ -753,7 +1027,7 @@ function createStarfishStore(options) {
753
1027
  set({ syncing: true, error: null }, false, "flush/start");
754
1028
  try {
755
1029
  await syncManager.push(get().data);
756
- set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash() }, false, "flush/success");
1030
+ set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
757
1031
  } catch (err) {
758
1032
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
759
1033
  }
@@ -876,8 +1150,11 @@ function useSyncInit(config) {
876
1150
  }
877
1151
  const client = new StarfishClient({
878
1152
  baseUrl: config.serverUrl,
1153
+ namespace: config.namespace,
879
1154
  capProvider: config.capProvider,
880
- fetch: config.fetch
1155
+ fetch: config.fetch,
1156
+ cache: config.cache,
1157
+ cacheMaxAgeMs: config.cacheMaxAgeMs
881
1158
  });
882
1159
  const syncManager = new SyncManager({
883
1160
  client,
@@ -905,7 +1182,9 @@ function useSyncInit(config) {
905
1182
  }
906
1183
  });
907
1184
  setStore(newStore);
908
- newStore.getState().pull().catch(() => {
1185
+ newStore.getState().seed().finally(() => {
1186
+ newStore.getState().pull().catch(() => {
1187
+ });
909
1188
  });
910
1189
  return () => {
911
1190
  setStore(null);
@@ -919,16 +1198,101 @@ function useSyncInit(config) {
919
1198
  ]);
920
1199
  return store;
921
1200
  }
1201
+ function createStarfishLog(options) {
1202
+ const { cursor } = options;
1203
+ const storeCreator = (rawSet, get) => {
1204
+ const set = rawSet;
1205
+ return {
1206
+ // Seed from the cursor so a warm-started cursor's items show immediately.
1207
+ items: cursor.getItems(),
1208
+ loading: false,
1209
+ online: true,
1210
+ error: null,
1211
+ checkpoint: cursor.getCheckpoint(),
1212
+ pull: async () => {
1213
+ if (get().loading) return [];
1214
+ set({ loading: true, error: null }, false, "log/pull/start");
1215
+ try {
1216
+ const batch = await cursor.pull();
1217
+ set(
1218
+ { items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
1219
+ false,
1220
+ "log/pull/success"
1221
+ );
1222
+ return batch;
1223
+ } catch (err) {
1224
+ set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
1225
+ return [];
1226
+ }
1227
+ },
1228
+ setOnline: (online) => {
1229
+ set({ online }, false, "log/setOnline");
1230
+ }
1231
+ };
1232
+ };
1233
+ const withSelector = subscribeWithSelector(storeCreator);
1234
+ return createStore()(
1235
+ options.devtools ? options.devtools(withSelector) : withSelector
1236
+ );
1237
+ }
1238
+ function deriveLogStatus(state) {
1239
+ if (!state.online) return "offline";
1240
+ if (state.error) return "error";
1241
+ if (state.loading) return "loading";
1242
+ return "idle";
1243
+ }
1244
+ function useStarfishLog(store) {
1245
+ return useStore(store);
1246
+ }
1247
+ function useStarfishLogItems(store, selector) {
1248
+ return useStore(
1249
+ store,
1250
+ (state) => selector ? selector(state.items) : state.items
1251
+ );
1252
+ }
1253
+ function useLogStatus(store) {
1254
+ return useStore(store, deriveLogStatus);
1255
+ }
1256
+ function subscribeLogStatus(store, callback) {
1257
+ let prev = deriveLogStatus(store.getState());
1258
+ callback(prev);
1259
+ return store.subscribe((state) => {
1260
+ const next = deriveLogStatus(state);
1261
+ if (next !== prev) {
1262
+ prev = next;
1263
+ callback(next);
1264
+ }
1265
+ });
1266
+ }
1267
+ function useLogConnectivity(store) {
1268
+ useEffect(() => {
1269
+ const handleOnline = () => store.getState().setOnline(true);
1270
+ const handleOffline = () => store.getState().setOnline(false);
1271
+ window.addEventListener("online", handleOnline);
1272
+ window.addEventListener("offline", handleOffline);
1273
+ return () => {
1274
+ window.removeEventListener("online", handleOnline);
1275
+ window.removeEventListener("offline", handleOffline);
1276
+ };
1277
+ }, [store]);
1278
+ }
922
1279
  export {
923
1280
  aggregateSyncStatus,
1281
+ createStarfishLog,
924
1282
  createStarfishStore,
1283
+ deriveLogStatus,
925
1284
  deriveSyncStatus,
1285
+ subscribeLogStatus,
926
1286
  subscribeSyncStatus,
927
1287
  useConnectivity,
928
1288
  useCrossTabSync,
929
1289
  useLastSynced,
1290
+ useLogConnectivity,
1291
+ useLogStatus,
930
1292
  useStarfish,
931
1293
  useStarfishData,
1294
+ useStarfishLog,
1295
+ useStarfishLogItems,
932
1296
  useSyncInit,
933
1297
  useSyncStatus
934
1298
  };