@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.50

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 (47) hide show
  1. package/README.md +59 -0
  2. package/dist/append-log.d.ts +228 -0
  3. package/dist/append-log.js +267 -0
  4. package/dist/background-sync.js +29 -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/suspense.js +49 -0
  9. package/dist/bindings/zustand.d.ts +167 -2
  10. package/dist/bindings/zustand.js +941 -82
  11. package/dist/bindings/zustand.js.map +4 -4
  12. package/dist/blob-seal.d.ts +123 -0
  13. package/dist/client.d.ts +270 -5
  14. package/dist/client.js +391 -0
  15. package/dist/config.d.ts +9 -0
  16. package/dist/config.js +18 -0
  17. package/dist/debounced-sync.js +120 -0
  18. package/dist/dedup.js +35 -0
  19. package/dist/events.d.ts +150 -0
  20. package/dist/events.js +116 -0
  21. package/dist/events.js.map +7 -0
  22. package/dist/export.js +115 -0
  23. package/dist/fetch.d.ts +40 -0
  24. package/dist/fetch.js +51 -14
  25. package/dist/fetch.js.map +2 -2
  26. package/dist/history.js +61 -0
  27. package/dist/index.d.ts +16 -7
  28. package/dist/index.js +1029 -94
  29. package/dist/index.js.map +4 -4
  30. package/dist/kv-cache.d.ts +63 -0
  31. package/dist/logger.d.ts +3 -0
  32. package/dist/logger.js +80 -0
  33. package/dist/migrate.js +38 -0
  34. package/dist/mobile-lifecycle.d.ts +28 -1
  35. package/dist/mobile-lifecycle.js +94 -0
  36. package/dist/multi-store.js +92 -0
  37. package/dist/mutate.d.ts +39 -0
  38. package/dist/polling.js +52 -0
  39. package/dist/resolvers.js +223 -0
  40. package/dist/service-worker.js +55 -0
  41. package/dist/storage/indexeddb.js +59 -0
  42. package/dist/sync.d.ts +83 -0
  43. package/dist/sync.js +181 -0
  44. package/dist/types.d.ts +106 -11
  45. package/dist/types.js +18 -0
  46. package/dist/validate.js +28 -0
  47. package/package.json +12 -3
@@ -230,15 +230,31 @@ import { useEffect, useRef, useState, useCallback } from "react";
230
230
 
231
231
  // src/client.ts
232
232
  import {
233
- DEFAULT_ALG,
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
+ PARQUET_MIME_TYPE as PARQUET_MIME_TYPE_VALUE,
247
+ PARQUET_MIME_TYPES as PARQUET_MIME_TYPES_VALUE,
248
+ signAppendAuthor,
234
249
  signRequest,
235
250
  stableStringify
236
251
  } from "@drakkar.software/starfish-protocol";
237
252
 
238
253
  // src/types.ts
239
254
  var ConflictError = class extends Error {
240
- constructor() {
255
+ constructor(currentHash = "") {
241
256
  super("hash_mismatch");
257
+ this.currentHash = currentHash;
242
258
  this.name = "ConflictError";
243
259
  }
244
260
  };
@@ -250,9 +266,59 @@ var StarfishHttpError = class extends Error {
250
266
  this.name = "StarfishHttpError";
251
267
  }
252
268
  };
269
+ var AppendHttpError = class extends Error {
270
+ constructor(status, message) {
271
+ super(message);
272
+ this.status = status;
273
+ this.name = "AppendHttpError";
274
+ }
275
+ };
276
+
277
+ // src/fetch.ts
278
+ function parseRetryAfterMs(header, opts) {
279
+ const { fallbackMs, maxMs } = opts;
280
+ const trimmed = header?.trim();
281
+ if (trimmed) {
282
+ const seconds = Number(trimmed);
283
+ if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
284
+ const date = Date.parse(trimmed);
285
+ if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
286
+ }
287
+ return Math.min(fallbackMs, maxMs);
288
+ }
289
+ function classifyError(err) {
290
+ if (err instanceof Response || err && typeof err === "object" && "status" in err) {
291
+ const status = err.status;
292
+ if (typeof status !== "number" || isNaN(status)) return "unknown";
293
+ if (status === 0) return "network";
294
+ if (status === 401 || status === 403) return "auth";
295
+ if (status === 409) return "conflict";
296
+ if (status === 429) return "rate-limited";
297
+ if (status >= 500) return "server";
298
+ if (status >= 400) return "client";
299
+ }
300
+ if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
301
+ return "unknown";
302
+ }
253
303
 
254
304
  // src/client.ts
255
305
  var APPEND_DEFAULT_FIELD = "items";
306
+ var MAX_REVALIDATE_ATTEMPTS = 5;
307
+ var REVALIDATE_INITIAL_DELAY_MS = 1e3;
308
+ var REVALIDATE_MAX_DELAY_MS = 3e4;
309
+ function sleep(ms) {
310
+ return new Promise((resolve) => setTimeout(resolve, ms));
311
+ }
312
+ function pullCacheKey(pathAndQuery) {
313
+ const q = pathAndQuery.indexOf("?");
314
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
315
+ }
316
+ function pullWasFromCache(result) {
317
+ return result.fromCache === true;
318
+ }
319
+ function stripPushPrefix(path) {
320
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
321
+ }
256
322
  function encodeCapAuth(cap) {
257
323
  const json = stableStringify(cap);
258
324
  if (typeof btoa === "function") {
@@ -267,6 +333,17 @@ var StarfishClient = class {
267
333
  namespace;
268
334
  capProvider;
269
335
  fetch;
336
+ cache;
337
+ cacheMaxAgeMs;
338
+ cacheFallbackStatuses;
339
+ onRevalidated;
340
+ revalidating = /* @__PURE__ */ new Set();
341
+ /**
342
+ * In-memory mirror of the latest document timestamp written to each cache
343
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
344
+ * can guard against stale overwrites without an extra async cache read.
345
+ */
346
+ latestCacheTimestamp = /* @__PURE__ */ new Map();
270
347
  /**
271
348
  * Installed client-side plugins. Currently stored as inert data; no
272
349
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -277,8 +354,21 @@ var StarfishClient = class {
277
354
  this.namespace = options.namespace || void 0;
278
355
  this.capProvider = options.capProvider;
279
356
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
357
+ this.cache = options.cache;
358
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
359
+ this.cacheFallbackStatuses = options.cacheFallbackStatuses;
360
+ this.onRevalidated = options.onRevalidated;
280
361
  this.plugins = options.plugins ? [...options.plugins] : [];
281
362
  }
363
+ /**
364
+ * Mark a `PullResult` as having been served from the offline read-through
365
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
366
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
367
+ */
368
+ tagFromCache(result) {
369
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
370
+ return result;
371
+ }
282
372
  /**
283
373
  * Resolve the host portion of the URL the client will send to. The host
284
374
  * is folded into the signed canonical input as the `h` field so the
@@ -323,38 +413,59 @@ var StarfishClient = class {
323
413
  * The host bound into the signature is derived from `baseUrl` once per call.
324
414
  */
325
415
  async buildAuthHeaders(method, pathAndQuery, body) {
326
- if (this.capProvider) {
327
- const { cap, devEdPrivHex, pubHex, presenterAlg } = await this.capProvider.getCap();
328
- const req = {
329
- method,
330
- pathAndQuery,
331
- body,
332
- host: this.signingHost()
333
- };
334
- const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
335
- const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
336
- alg: signAlg
337
- });
338
- const headers = {
339
- Authorization: `Cap ${encodeCapAuth(cap)}`,
340
- "X-Starfish-Sig": sig,
341
- "X-Starfish-Ts": String(ts),
342
- "X-Starfish-Nonce": nonce,
343
- "X-Starfish-Alg": alg
344
- };
345
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
346
- return headers;
347
- }
348
- return {};
416
+ if (!this.capProvider) return {};
417
+ const capCtx = await this.capProvider.getCap();
418
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
419
+ }
420
+ /**
421
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
422
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
423
+ * reuse it for BOTH the author signature (over the element data) and the
424
+ * request signature (over the body), without redeeming the cap twice a
425
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
426
+ * presenter` bind the server checks.
427
+ */
428
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
429
+ const { cap, devEdPrivHex, pubHex } = capCtx;
430
+ const req = {
431
+ method,
432
+ pathAndQuery,
433
+ body,
434
+ host: this.signingHost()
435
+ };
436
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
437
+ const headers = {
438
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
439
+ [HEADER_SIG]: sig,
440
+ [HEADER_TS]: String(ts),
441
+ [HEADER_NONCE]: nonce
442
+ };
443
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
444
+ return headers;
445
+ }
446
+ /**
447
+ * Resolve the author public key to attach to a signed append: the redeemer's
448
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
449
+ * device/member cap. This is the SAME key that signs the request, so a server
450
+ * enforcing author proof can bind the stored element to its writer. Returns
451
+ * undefined only for a (malformed) cap with neither — the append then goes
452
+ * unsigned and a server requiring signatures rejects it.
453
+ */
454
+ appendAuthorKey(capCtx) {
455
+ const { cap, pubHex } = capCtx;
456
+ const authorPubHex = pubHex ?? cap.sub;
457
+ if (authorPubHex === void 0) return null;
458
+ return { authorPubHex };
349
459
  }
350
460
  async pull(path, checkpointOrOptions) {
351
461
  let pathAndQuery = this.applyNamespace(path);
352
462
  let appendField;
463
+ let swr = false;
353
464
  if (typeof checkpointOrOptions === "number") {
354
465
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
355
466
  } else if (checkpointOrOptions != null) {
356
467
  const opts = checkpointOrOptions;
357
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
468
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
358
469
  const params = new URLSearchParams();
359
470
  if (isPullOptions) {
360
471
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -363,68 +474,342 @@ var StarfishClient = class {
363
474
  if (opts.withKeyring) {
364
475
  params.set("withKeyring", "1");
365
476
  }
477
+ swr = opts.staleWhileRevalidate === true;
366
478
  } else {
367
479
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
480
+ if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
481
+ throw new Error("full cannot be combined with since, limit, or last");
482
+ }
368
483
  if (opts.since != null) {
369
484
  if (opts.since < 0) throw new Error("since must be non-negative");
370
485
  params.set("checkpoint", String(opts.since));
371
486
  }
487
+ if (opts.limit != null) {
488
+ if (opts.limit < 0) throw new Error("limit must be non-negative");
489
+ params.set("limit", String(opts.limit));
490
+ }
372
491
  if (opts.last != null) {
373
492
  if (opts.last < 0) throw new Error("last must be non-negative");
374
493
  params.set("last", String(opts.last));
375
494
  }
495
+ if (opts.full) {
496
+ params.set("full", "true");
497
+ }
376
498
  }
377
499
  if (params.size > 0) pathAndQuery += `?${params.toString()}`;
378
500
  }
379
501
  const url = `${this.baseUrl}${pathAndQuery}`;
380
502
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
381
- const res = await this.fetch(url, {
382
- method: "GET",
383
- headers: { Accept: "application/json", ...authHeaders }
384
- });
503
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
504
+ if (swr && cacheKey) {
505
+ const cached = await this.readCache(cacheKey);
506
+ if (cached) {
507
+ this.scheduleRevalidate(
508
+ cacheKey,
509
+ pathAndQuery,
510
+ null,
511
+ /* immediate */
512
+ true
513
+ );
514
+ return cached;
515
+ }
516
+ }
517
+ let res;
518
+ try {
519
+ res = await this.fetch(url, {
520
+ method: "GET",
521
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
522
+ cache: "no-store"
523
+ });
524
+ } catch (err) {
525
+ if (cacheKey) {
526
+ const cached = await this.readCache(cacheKey);
527
+ if (cached) return cached;
528
+ }
529
+ throw err;
530
+ }
385
531
  if (!res.ok) {
386
- throw new StarfishHttpError(res.status, await res.text());
532
+ const status = res.status;
533
+ if (cacheKey && this.cacheFallbackStatuses?.includes(status)) {
534
+ const retryAfterHeader = res.headers.get("Retry-After");
535
+ this.scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader);
536
+ const cached = await this.readCache(cacheKey);
537
+ if (cached) {
538
+ void res.body?.cancel();
539
+ return cached;
540
+ }
541
+ }
542
+ throw new StarfishHttpError(status, await res.text());
387
543
  }
388
544
  const result = await res.json();
389
545
  if (appendField !== void 0) {
390
546
  const list = result.data?.[appendField];
391
547
  return Array.isArray(list) ? list : [];
392
548
  }
549
+ if (cacheKey) this.writeCache(cacheKey, result);
393
550
  return result;
394
551
  }
552
+ /**
553
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
554
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
555
+ */
556
+ writeCache(cacheKey, result) {
557
+ if (!this.cache) return;
558
+ if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
559
+ this.latestCacheTimestamp.set(cacheKey, result.timestamp);
560
+ }
561
+ const snapshot = {
562
+ data: result.data,
563
+ hash: result.hash,
564
+ timestamp: result.timestamp,
565
+ cachedAt: Date.now()
566
+ };
567
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
568
+ });
569
+ }
570
+ /** Build the URL + auth headers for one revalidation GET. Shared between
571
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
572
+ async revalidateFetch(pathAndQuery) {
573
+ const url = `${this.baseUrl}${pathAndQuery}`;
574
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
575
+ return this.fetch(url, {
576
+ method: "GET",
577
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
578
+ });
579
+ }
580
+ /**
581
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
582
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
583
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
584
+ * read path (`immediate: true` — no initial delay on the first attempt). The
585
+ * `revalidating` set deduplicates across both triggers so a concurrent
586
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
587
+ */
588
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
589
+ if (this.revalidating.has(cacheKey)) return;
590
+ this.revalidating.add(cacheKey);
591
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
592
+ this.revalidating.delete(cacheKey);
593
+ });
594
+ }
595
+ /**
596
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
597
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
598
+ *
599
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
600
+ * When `immediate` is true the first attempt fires without any initial delay
601
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
602
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
603
+ */
604
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
605
+ let retryAfterHeader = firstRetryAfter;
606
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
607
+ for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
608
+ if (!immediate || attempt > 0) {
609
+ const delay = parseRetryAfterMs(retryAfterHeader, {
610
+ fallbackMs: Math.min(
611
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
612
+ REVALIDATE_MAX_DELAY_MS
613
+ ),
614
+ maxMs: REVALIDATE_MAX_DELAY_MS
615
+ });
616
+ await sleep(delay);
617
+ }
618
+ try {
619
+ const res = await this.revalidateFetch(pathAndQuery);
620
+ if (res.ok) {
621
+ const result = await res.json();
622
+ const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
623
+ if (result.timestamp >= latestTs) {
624
+ this.writeCache(cacheKey, result);
625
+ this.onRevalidated?.(pathAndQuery, result);
626
+ }
627
+ return;
628
+ }
629
+ if (!fallbackSet?.has(res.status)) {
630
+ return;
631
+ }
632
+ retryAfterHeader = res.headers.get("Retry-After");
633
+ } catch {
634
+ retryAfterHeader = null;
635
+ }
636
+ }
637
+ }
638
+ /**
639
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
640
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
641
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
642
+ * or null when no cache is configured / there's no entry. Namespacing matches
643
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
644
+ */
645
+ async peekCache(path) {
646
+ if (!this.cache) return null;
647
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
648
+ }
649
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
650
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
651
+ * must not break a pull, just miss). */
652
+ async readCache(cacheKey) {
653
+ try {
654
+ const raw = await this.cache.get(cacheKey);
655
+ if (!raw) return null;
656
+ const parsed = JSON.parse(raw);
657
+ if (!parsed || typeof parsed.hash !== "string") return null;
658
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
659
+ return null;
660
+ }
661
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
666
+ /**
667
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
668
+ * the list of distinct collection names; `opts.params` supplies, per collection,
669
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
670
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
671
+ * The server auto-fills the `{identity}` param from the authenticated caller for
672
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
673
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
674
+ * in request order. Honors the configured namespace.
675
+ *
676
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
677
+ *
678
+ * Pass `appendParams` per entry for append-only bounded-tail reads (see {@link batchPullManyAppend}).
679
+ */
680
+ async batchPull(collections, opts = {}) {
681
+ const search = new URLSearchParams();
682
+ search.set("collections", collections.join(","));
683
+ if (opts.params && Object.keys(opts.params).length > 0) {
684
+ search.set("params", JSON.stringify(opts.params));
685
+ }
686
+ if (opts.appendParams && Object.keys(opts.appendParams).length > 0) {
687
+ for (const [col, optsArr] of Object.entries(opts.appendParams)) {
688
+ for (const ap of optsArr) {
689
+ if (ap.full) {
690
+ throw new Error(
691
+ `batchPull: appendParams["${col}"] contains full:true \u2014 full is not supported in batch pull`
692
+ );
693
+ }
694
+ if (ap.since != null && (!Number.isInteger(ap.since) || ap.since < 0)) {
695
+ throw new Error(`batchPull: appendParams["${col}"].since must be a non-negative integer`);
696
+ }
697
+ if (ap.last != null && (!Number.isInteger(ap.last) || ap.last < 0)) {
698
+ throw new Error(`batchPull: appendParams["${col}"].last must be a non-negative integer`);
699
+ }
700
+ if (ap.limit != null && (!Number.isInteger(ap.limit) || ap.limit < 0)) {
701
+ throw new Error(`batchPull: appendParams["${col}"].limit must be a non-negative integer`);
702
+ }
703
+ }
704
+ }
705
+ search.set("appendParams", JSON.stringify(opts.appendParams));
706
+ }
707
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
708
+ const url = `${this.baseUrl}${pathAndQuery}`;
709
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
710
+ const res = await this.fetch(url, {
711
+ method: "GET",
712
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
713
+ });
714
+ if (!res.ok) {
715
+ throw new StarfishHttpError(res.status, await res.text());
716
+ }
717
+ return await res.json();
718
+ }
719
+ /**
720
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
721
+ * collection in a single round-trip: pass the per-document param-sets and get
722
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
723
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
724
+ * issues no request and returns `[]`.
725
+ */
726
+ async batchPullMany(collection, paramsList) {
727
+ if (paramsList.length === 0) return [];
728
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
729
+ return res.collections[collection] ?? [];
730
+ }
731
+ /**
732
+ * Convenience over {@link batchPull} for reading append-only bounded tails from
733
+ * MANY entries of ONE collection in a single round-trip.
734
+ *
735
+ * Each request in `requests` carries optional `params` (path params) and
736
+ * `options` (append bounds: `since`/`last`/`limit`/`appendField`). An empty
737
+ * `requests` issues no request and returns `[]`.
738
+ *
739
+ * Returns an array aligned to `requests` by index. Each element is either:
740
+ * - the filtered array `T[]` extracted from `entry.data[appendField]`, or
741
+ * - `{ error: string }` if the server returned a per-entry error.
742
+ *
743
+ * The `appendField` used for extraction defaults to `"items"` and can be
744
+ * overridden per request via `options.appendField`.
745
+ *
746
+ * The `appendField` option is client-side only (used for result extraction, not sent to the server).
747
+ * It must match the collection's server-configured append field and defaults to `"items"`.
748
+ *
749
+ * Note: `full: true` is not supported in batch and is rejected client-side
750
+ * before the request is sent.
751
+ */
752
+ async batchPullManyAppend(collection, requests) {
753
+ if (requests.length === 0) return [];
754
+ const paramsList = requests.map((r) => r.params ?? {});
755
+ const appendParamsList = requests.map(({ options: { appendField: _af, ...wireOpts } }) => wireOpts);
756
+ const res = await this.batchPull([collection], {
757
+ params: { [collection]: paramsList },
758
+ appendParams: { [collection]: appendParamsList }
759
+ });
760
+ const entries = res.collections[collection] ?? [];
761
+ return entries.map((entry, i) => {
762
+ if (entry.error) return { error: entry.error };
763
+ const appendField = requests[i]?.options.appendField ?? APPEND_DEFAULT_FIELD;
764
+ const data = entry.data;
765
+ const items = data?.[appendField];
766
+ return Array.isArray(items) ? items : [];
767
+ });
768
+ }
395
769
  /**
396
770
  * Push synced data to the server.
397
771
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
398
772
  * @param data - The full document data to push
399
773
  * @param baseHash - Hash of the document this push is based on (null for first push)
400
774
  *
401
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
402
- * and are produced by `SyncManager` when a `signer` is configured.
775
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
776
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
777
+ * top-level body siblings of `data`, where the server verifies it.
403
778
  * @throws {ConflictError} if the server detects a hash mismatch (409)
404
779
  */
405
- async push(path, data, baseHash) {
780
+ async push(path, data, baseHash, author) {
406
781
  const body = JSON.stringify({
407
- data,
408
- baseHash
782
+ [DATA_FIELD]: data,
783
+ [BASE_HASH_FIELD]: baseHash,
784
+ ...author && {
785
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
786
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
787
+ }
409
788
  });
410
789
  const sendPath = this.applyNamespace(path);
411
790
  const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
412
791
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
413
792
  method: "POST",
414
793
  headers: {
415
- "Content-Type": "application/json",
416
- Accept: "application/json",
794
+ [HEADER_CONTENT_TYPE]: "application/json",
795
+ [HEADER_ACCEPT]: "application/json",
417
796
  ...authHeaders
418
797
  },
419
798
  body
420
799
  });
421
800
  if (res.status === 409) {
422
- throw new ConflictError();
801
+ const conflict = await res.json().catch(() => null);
802
+ throw new ConflictError(conflict?.currentHash ?? "");
423
803
  }
424
804
  if (!res.ok) {
425
805
  throw new StarfishHttpError(res.status, await res.text());
426
806
  }
427
- return res.json();
807
+ const result = await res.json();
808
+ if (this.cache) {
809
+ const pullPath = sendPath.replace("/push/", "/pull/");
810
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
811
+ }
812
+ return result;
428
813
  }
429
814
  /**
430
815
  * Append an element to an appendOnly (`by_timestamp`) collection.
@@ -440,20 +825,37 @@ var StarfishClient = class {
440
825
  * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
441
826
  * non-negative integer strictly greater than the latest stored element's ts
442
827
  * (else the server responds 409). Omit to let the server assign one.
443
- * @throws {StarfishHttpError} on a non-2xx response (e.g. 409 for a
444
- * non-monotonic timestamp).
828
+ * @throws {StarfishHttpError} on a non-2xx response e.g. 409
829
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
830
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
831
+ * cap is reached (partition by a path parameter for higher volume).
445
832
  */
446
833
  async append(path, data, opts = {}) {
447
- const bodyObj = { data };
448
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
449
- const body = JSON.stringify(bodyObj);
450
834
  const sendPath = this.applyNamespace(path);
451
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
835
+ const bodyObj = { [DATA_FIELD]: data };
836
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
837
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
838
+ if (capCtx) {
839
+ const authorKey = this.appendAuthorKey(capCtx);
840
+ if (authorKey) {
841
+ const documentKey = stripPushPrefix(path);
842
+ const { authorPubkey, authorSignature } = signAppendAuthor(
843
+ documentKey,
844
+ data,
845
+ authorKey.authorPubHex,
846
+ capCtx.devEdPrivHex
847
+ );
848
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
849
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
850
+ }
851
+ }
852
+ const body = JSON.stringify(bodyObj);
853
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
452
854
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
453
855
  method: "POST",
454
856
  headers: {
455
- "Content-Type": "application/json",
456
- Accept: "application/json",
857
+ [HEADER_CONTENT_TYPE]: "application/json",
858
+ [HEADER_ACCEPT]: "application/json",
457
859
  ...authHeaders
458
860
  },
459
861
  body
@@ -463,6 +865,62 @@ var StarfishClient = class {
463
865
  }
464
866
  return res.json();
465
867
  }
868
+ /**
869
+ * Append one element to a **public-write** append-only collection with an
870
+ * Ed25519 author proof but **no cap `Authorization` header**.
871
+ *
872
+ * Unlike {@link append}, which always attaches a cap-signed `Authorization`
873
+ * header from the configured `capProvider`, this method signs only the
874
+ * append-author proof (binding the element to the writer's Ed25519 key) and
875
+ * sends the request without authentication headers. This is required for
876
+ * collections with `writeRoles: ["public"]` — the server's cap-scope check
877
+ * would reject a request carrying a cap whose scope does not cover the path.
878
+ *
879
+ * Typical use-case: writing a sealed invitation to another user's
880
+ * public-write inbox collection without needing a cap scoped to the
881
+ * recipient's namespace. The author proof is optional on the server side
882
+ * (`requireAuthorSignature: false` for a public inbox), but signing anyway
883
+ * binds the stored element to the sender's Ed25519 key for verification in
884
+ * the receive path.
885
+ *
886
+ * The element is sent as `{ data, authorPubkey, authorSignature }`.
887
+ *
888
+ * @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
889
+ * @param element The JSON element to append.
890
+ * @param signer The sender's Ed25519 keypair (signs the author proof).
891
+ *
892
+ * @throws {AppendHttpError} on a non-2xx response.
893
+ */
894
+ async appendAnonymous(path, element, signer) {
895
+ const sendPath = this.applyNamespace(path);
896
+ const documentKey = stripPushPrefix(path);
897
+ const { authorPubkey, authorSignature } = signAppendAuthor(
898
+ documentKey,
899
+ element,
900
+ signer.edPubHex,
901
+ signer.edPrivHex
902
+ );
903
+ const body = JSON.stringify({
904
+ [DATA_FIELD]: element,
905
+ [AUTHOR_PUBKEY_FIELD]: authorPubkey,
906
+ [AUTHOR_SIGNATURE_FIELD]: authorSignature
907
+ });
908
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
909
+ method: "POST",
910
+ headers: {
911
+ [HEADER_CONTENT_TYPE]: "application/json",
912
+ [HEADER_ACCEPT]: "application/json"
913
+ },
914
+ body
915
+ });
916
+ if (!res.ok) {
917
+ const detail = await res.text().catch(() => "");
918
+ throw new AppendHttpError(
919
+ res.status,
920
+ `anonymous append failed: HTTP ${res.status} ${detail}`.trim()
921
+ );
922
+ }
923
+ }
466
924
  /**
467
925
  * Pull binary data from a blob collection.
468
926
  * Returns raw bytes with the content hash from the ETag header.
@@ -472,13 +930,13 @@ var StarfishClient = class {
472
930
  const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
473
931
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
474
932
  method: "GET",
475
- headers: { Accept: "*/*", ...authHeaders }
933
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
476
934
  });
477
935
  if (!res.ok) {
478
936
  throw new StarfishHttpError(res.status, await res.text());
479
937
  }
480
938
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
481
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
939
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
482
940
  const data = await res.arrayBuffer();
483
941
  return { data, hash: etag, contentType };
484
942
  }
@@ -492,8 +950,8 @@ var StarfishClient = class {
492
950
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
493
951
  method: "POST",
494
952
  headers: {
495
- "Content-Type": contentType,
496
- Accept: "application/json",
953
+ [HEADER_CONTENT_TYPE]: contentType,
954
+ [HEADER_ACCEPT]: "application/json",
497
955
  ...authHeaders
498
956
  },
499
957
  body: data
@@ -503,10 +961,56 @@ var StarfishClient = class {
503
961
  }
504
962
  return res.json();
505
963
  }
964
+ /**
965
+ * Push an Apache Parquet file to a Parquet collection.
966
+ *
967
+ * Thin wrapper over {@link pushBlob} that fixes `Content-Type` to
968
+ * `application/vnd.apache.parquet` so the S3 object is tagged correctly
969
+ * for DuckDB and CDN consumption.
970
+ *
971
+ * @example
972
+ * ```ts
973
+ * const parquetBytes = await generateParquet(rows)
974
+ * const result = await client.pushParquet("/push/analytics/alice/q1.parquet", parquetBytes)
975
+ * console.log("stored hash:", result.hash)
976
+ * ```
977
+ */
978
+ async pushParquet(path, data) {
979
+ return this.pushBlob(path, data, PARQUET_MIME_TYPE_VALUE);
980
+ }
981
+ /**
982
+ * Pull an Apache Parquet file from a Parquet collection.
983
+ *
984
+ * Thin wrapper over {@link pullBlob} for API symmetry with
985
+ * {@link pushParquet}.
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * const result = await client.pullParquet("/pull/analytics/alice/q1.parquet")
990
+ * // result.data → ArrayBuffer
991
+ * // result.contentType → "application/vnd.apache.parquet"
992
+ * ```
993
+ */
994
+ async pullParquet(path) {
995
+ const result = await this.pullBlob(path);
996
+ if (!PARQUET_MIME_TYPES_VALUE.includes(result.contentType)) {
997
+ throw new StarfishHttpError(
998
+ 415,
999
+ `Expected a Parquet content-type, got: ${result.contentType}`
1000
+ );
1001
+ }
1002
+ return result;
1003
+ }
506
1004
  };
507
1005
 
508
1006
  // src/sync.ts
509
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
1007
+ import {
1008
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
1009
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
1010
+ deepMerge,
1011
+ docAuthorCanonicalInput,
1012
+ getBase64
1013
+ } from "@drakkar.software/starfish-protocol";
510
1014
 
511
1015
  // src/validate.ts
512
1016
  var ValidationError = class extends Error {
@@ -539,6 +1043,9 @@ var SyncManager = class {
539
1043
  lastCheckpoint = 0;
540
1044
  localData = {};
541
1045
  aborted = false;
1046
+ lastFromCache = false;
1047
+ /** True once {@link seedFromCache} has successfully seeded localData from the cache. */
1048
+ seeded = false;
542
1049
  constructor(options) {
543
1050
  this.client = options.client;
544
1051
  this.pullPath = options.pullPath;
@@ -560,6 +1067,36 @@ var SyncManager = class {
560
1067
  getData() {
561
1068
  return { ...this.localData };
562
1069
  }
1070
+ /**
1071
+ * Returns true when `pull()` / `ingest()` should merge against the current
1072
+ * `localData` rather than replace it wholesale.
1073
+ *
1074
+ * Two situations establish a merge baseline:
1075
+ * - A successful prior pull/ingest advanced `lastCheckpoint` beyond 0 (the
1076
+ * normal steady-state case, unchanged since alpha.36).
1077
+ * - A cache seed painted `localData` via {@link seedFromCache} AND the store
1078
+ * uses a custom conflict resolver (i.e. NOT the default `deepMerge`). For a
1079
+ * union/custom resolver the seeded snapshot is a real baseline that must not
1080
+ * be clobbered by a short first live response (a cache-fallback on 429/5xx
1081
+ * or a momentarily-short concurrent server snapshot). For the default
1082
+ * `deepMerge` resolver we keep the pre-fix wholesale-replace behaviour so
1083
+ * non-union stores are byte-identical to alpha.36.
1084
+ */
1085
+ hasMergeBaseline() {
1086
+ return this.lastCheckpoint > 0 || this.seeded && this.onConflict !== deepMerge;
1087
+ }
1088
+ /**
1089
+ * Merge a remote snapshot with local (optimistic) data using this manager's
1090
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
1091
+ * {@link pull} overwrites the store's data with the server snapshot, which
1092
+ * would drop un-pushed local writes (they live only in the store, never in
1093
+ * `localData` until a push succeeds). The zustand binding calls this on pull
1094
+ * while the store is dirty so those writes survive. `local` wins by the same
1095
+ * rules as a push conflict.
1096
+ */
1097
+ resolve(local, remote) {
1098
+ return this.onConflict(local, remote);
1099
+ }
563
1100
  getHash() {
564
1101
  return this.lastHash;
565
1102
  }
@@ -567,9 +1104,95 @@ var SyncManager = class {
567
1104
  setHash(hash) {
568
1105
  this.lastHash = hash;
569
1106
  }
1107
+ /**
1108
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
1109
+ * from the client's offline read-through cache rather than a live server
1110
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
1111
+ * offline indicator without treating a cache hit as "reachable". Reset to
1112
+ * false by the next successful network pull.
1113
+ */
1114
+ getLastPullFromCache() {
1115
+ return this.lastFromCache;
1116
+ }
1117
+ /**
1118
+ * Cache-first paint: seed `localData` from the client's read-through cache
1119
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
1120
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
1121
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
1122
+ * the initial live {@link pull}.
1123
+ *
1124
+ * `lastCheckpoint` is intentionally left at 0 so the first live pull sends a
1125
+ * full (re)sync request to the server, not a delta. However, for stores with
1126
+ * a custom conflict resolver (e.g. `createUnionMerge`) the seeded snapshot is
1127
+ * treated as a merge baseline: {@link hasMergeBaseline} returns true, so the
1128
+ * first pull/ingest merges against the seed rather than replacing it wholesale.
1129
+ * This closes the bootstrap window where a short first-pull response (a cache-
1130
+ * fallback on 429/5xx or a momentarily-short concurrent snapshot) would
1131
+ * silently drop items the resolver was configured to preserve. For the default
1132
+ * `deepMerge` resolver the first pull still takes the snapshot wholesale —
1133
+ * behaviour is byte-identical to alpha.36.
1134
+ *
1135
+ * Requires the client to have been built with a `cache`.
1136
+ */
1137
+ async seedFromCache() {
1138
+ if (this.aborted) return false;
1139
+ const cached = await this.client.peekCache(this.pullPath);
1140
+ if (!cached) return false;
1141
+ let data;
1142
+ try {
1143
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
1144
+ } catch {
1145
+ return false;
1146
+ }
1147
+ if (this.aborted) return false;
1148
+ this.localData = data;
1149
+ this.lastHash = cached.hash;
1150
+ this.seeded = true;
1151
+ this.lastFromCache = true;
1152
+ return true;
1153
+ }
570
1154
  getCheckpoint() {
571
1155
  return this.lastCheckpoint;
572
1156
  }
1157
+ /**
1158
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
1159
+ * firing a network request. Used by the zustand binding's `mergeResult`
1160
+ * action to absorb a background revalidation result (delivered via
1161
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
1162
+ *
1163
+ * Like {@link pull}, `ingest` conflict-merges the snapshot against the
1164
+ * established baseline via `this.onConflict` when a merge baseline exists
1165
+ * ({@link hasMergeBaseline}) — so a union-merge store does not lose array
1166
+ * items when a revalidation result (e.g. a stale cache-fallback on 429/5xx)
1167
+ * is a shorter snapshot. The baseline is established by either a prior
1168
+ * pull/ingest that advanced `lastCheckpoint`, or by a successful
1169
+ * {@link seedFromCache} for a store with a custom resolver. The first ingest
1170
+ * without such a baseline takes the snapshot wholesale (default `deepMerge`
1171
+ * stores are byte-identical to alpha.36). Sets `lastFromCache = false` (a
1172
+ * revalidation is a live response) so the binding can clear its `stale` flag.
1173
+ *
1174
+ * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
1175
+ * time the revalidation request was sent and the time it resolves, the
1176
+ * result is from an older document version. Ingesting it would clobber the
1177
+ * user's just-saved edit and reset `lastHash` to a stale server hash
1178
+ * (causing a spurious 409 on the next push). We silently drop the result in
1179
+ * that case — the store's post-push state is already correct.
1180
+ */
1181
+ async ingest(result) {
1182
+ if (this.aborted) return;
1183
+ if (result.timestamp < this.lastCheckpoint) return;
1184
+ let incoming;
1185
+ if (this.encryptor) {
1186
+ incoming = await this.encryptor.decrypt(result.data);
1187
+ if (this.aborted) return;
1188
+ } else {
1189
+ incoming = result.data;
1190
+ }
1191
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
1192
+ this.lastHash = result.hash;
1193
+ this.lastCheckpoint = result.timestamp;
1194
+ this.lastFromCache = false;
1195
+ }
573
1196
  async pull() {
574
1197
  if (this.aborted) throw new AbortError();
575
1198
  this.logger?.pullStart(this.loggerName);
@@ -577,17 +1200,16 @@ var SyncManager = class {
577
1200
  try {
578
1201
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
579
1202
  if (this.aborted) throw new AbortError();
1203
+ this.lastFromCache = pullWasFromCache(result);
1204
+ let incoming;
580
1205
  if (this.encryptor) {
581
- const decrypted = await this.encryptor.decrypt(result.data);
1206
+ incoming = await this.encryptor.decrypt(result.data);
582
1207
  if (this.aborted) throw new AbortError();
583
- this.localData = decrypted;
584
- result.data = decrypted;
585
- } else if (this.lastCheckpoint > 0) {
586
- this.localData = deepMerge(this.localData, result.data);
587
- result.data = this.localData;
588
1208
  } else {
589
- this.localData = result.data;
1209
+ incoming = result.data;
590
1210
  }
1211
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
1212
+ result.data = this.localData;
591
1213
  this.lastHash = result.hash;
592
1214
  this.lastCheckpoint = result.timestamp;
593
1215
  this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
@@ -611,23 +1233,24 @@ var SyncManager = class {
611
1233
  try {
612
1234
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
613
1235
  if (this.aborted) throw new AbortError();
614
- let payload = sealed;
1236
+ let author;
615
1237
  if (this.signer) {
616
1238
  const { devEdPubHex, sign } = await this.signer.getSigner();
617
1239
  if (this.aborted) throw new AbortError();
618
- const canonical = stableStringify2(sealed);
1240
+ const documentKey = stripPushPrefix(this.pushPath);
1241
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
619
1242
  const sigBytes = await sign(new TextEncoder().encode(canonical));
620
1243
  if (this.aborted) throw new AbortError();
621
- payload = {
622
- ...sealed,
623
- authorPubkey: devEdPubHex,
624
- authorSignature: getBase64().encode(sigBytes)
1244
+ author = {
1245
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
1246
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
625
1247
  };
626
1248
  }
627
1249
  const result = await this.client.push(
628
1250
  this.pushPath,
629
- payload,
630
- this.lastHash
1251
+ sealed,
1252
+ this.lastHash,
1253
+ author
631
1254
  );
632
1255
  if (this.aborted) throw new AbortError();
633
1256
  this.lastHash = result.hash;
@@ -742,6 +1365,38 @@ function createStarfishStore(options) {
742
1365
  const { name, syncManager, storage } = options;
743
1366
  const storeCreator = (rawSet, get) => {
744
1367
  const set = rawSet;
1368
+ let retryTimer;
1369
+ let retryAttempt = 0;
1370
+ const scheduleFlushRetry = () => {
1371
+ const retryOpts = options.flushRetry;
1372
+ if (!retryOpts) return;
1373
+ const maxRetries = retryOpts.maxRetries ?? 5;
1374
+ if (retryAttempt >= maxRetries) return;
1375
+ const initialMs = retryOpts.initialDelayMs ?? 500;
1376
+ const maxMs = retryOpts.maxDelayMs ?? 3e4;
1377
+ const delayMs = Math.min(initialMs * Math.pow(2, retryAttempt), maxMs) + Math.random() * 100;
1378
+ retryAttempt++;
1379
+ clearTimeout(retryTimer);
1380
+ retryTimer = setTimeout(() => {
1381
+ if (get().dirty && get().online && !get().syncing) {
1382
+ get().flush().catch(() => {
1383
+ });
1384
+ }
1385
+ }, delayMs);
1386
+ };
1387
+ const cancelFlushRetry = () => {
1388
+ clearTimeout(retryTimer);
1389
+ retryTimer = void 0;
1390
+ retryAttempt = 0;
1391
+ };
1392
+ const commitRemote = (label) => {
1393
+ const remote = syncManager.getData();
1394
+ const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
1395
+ set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, label);
1396
+ if (get().online && get().dirty) get().flush().catch(() => {
1397
+ });
1398
+ options.onRemoteUpdate?.(newData);
1399
+ };
745
1400
  return {
746
1401
  data: {},
747
1402
  syncing: false,
@@ -749,20 +1404,38 @@ function createStarfishStore(options) {
749
1404
  dirty: false,
750
1405
  error: null,
751
1406
  hash: null,
1407
+ stale: false,
1408
+ seed: async () => {
1409
+ try {
1410
+ const seeded = await syncManager.seedFromCache();
1411
+ if (!seeded) return;
1412
+ if (get().dirty || Object.keys(get().data).length > 0) return;
1413
+ set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
1414
+ } catch {
1415
+ }
1416
+ },
752
1417
  pull: async () => {
753
- set({ syncing: true, error: null }, false, "pull/start");
1418
+ set(get().stale ? { error: null } : { syncing: true, error: null }, false, "pull/start");
754
1419
  try {
755
1420
  await syncManager.pull();
756
- const newData = syncManager.getData();
757
- set({ data: newData, syncing: false, hash: syncManager.getHash() }, false, "pull/success");
758
- options.onRemoteUpdate?.(newData);
1421
+ commitRemote("pull/success");
759
1422
  } catch (err) {
1423
+ if (classifyError(err) === "network") {
1424
+ set({ syncing: false, stale: true }, false, "pull/offline");
1425
+ return;
1426
+ }
760
1427
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
761
1428
  }
762
1429
  },
1430
+ mergeResult: async (result) => {
1431
+ await syncManager.ingest(result);
1432
+ commitRemote("merge/success");
1433
+ },
763
1434
  set: (modifier) => {
764
1435
  try {
765
1436
  const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
1437
+ retryAttempt = 0;
1438
+ clearTimeout(retryTimer);
766
1439
  set({ data: next, dirty: true, error: null }, false, "set");
767
1440
  if (get().online) get().flush().catch(() => {
768
1441
  });
@@ -778,15 +1451,22 @@ function createStarfishStore(options) {
778
1451
  set({ syncing: true, error: null }, false, "flush/start");
779
1452
  try {
780
1453
  await syncManager.push(get().data);
781
- set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash() }, false, "flush/success");
1454
+ cancelFlushRetry();
1455
+ set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
782
1456
  } catch (err) {
1457
+ const isAbort = err instanceof Error && (err.name === "AbortError" || typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "AbortError");
783
1458
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
1459
+ if (!isAbort) scheduleFlushRetry();
784
1460
  }
785
1461
  },
786
1462
  setOnline: (online) => {
787
1463
  set({ online }, false, "setOnline");
788
- if (online && get().dirty) get().flush().catch(() => {
789
- });
1464
+ if (online && get().dirty) {
1465
+ get().flush().catch(() => {
1466
+ });
1467
+ } else if (!online) {
1468
+ cancelFlushRetry();
1469
+ }
790
1470
  }
791
1471
  };
792
1472
  };
@@ -824,6 +1504,9 @@ function aggregateSyncStatus(statuses) {
824
1504
  function useStarfish(store) {
825
1505
  return useStore(store);
826
1506
  }
1507
+ function useStarfishState(store, selector) {
1508
+ return useStore(store, selector);
1509
+ }
827
1510
  function useStarfishData(store, selector) {
828
1511
  return useStore(
829
1512
  store,
@@ -884,7 +1567,7 @@ function useLastSynced(store) {
884
1567
  }, [store, computeLabel]);
885
1568
  useEffect(() => {
886
1569
  const timer = setInterval(() => {
887
- setLabel(computeLabel());
1570
+ if (!document.hidden) setLabel(computeLabel());
888
1571
  }, 5e3);
889
1572
  return () => clearInterval(timer);
890
1573
  }, [computeLabel]);
@@ -895,14 +1578,24 @@ function useSyncInit(config) {
895
1578
  const onDataRef = useRef(config?.onData);
896
1579
  onDataRef.current = config?.onData;
897
1580
  useEffect(() => {
898
- if (!config) {
899
- setStore(null);
900
- return;
901
- }
1581
+ if (!config) return;
902
1582
  const client = new StarfishClient({
903
1583
  baseUrl: config.serverUrl,
1584
+ namespace: config.namespace,
904
1585
  capProvider: config.capProvider,
905
- fetch: config.fetch
1586
+ fetch: config.fetch,
1587
+ cache: config.cache,
1588
+ cacheMaxAgeMs: config.cacheMaxAgeMs,
1589
+ cacheFallbackStatuses: config.cacheFallbackStatuses,
1590
+ // Auto-merge: when a background revalidation delivers a fresh snapshot,
1591
+ // push it into the store so the UI heals without waiting for the next pull.
1592
+ // newStore is referenced by closure — safe because onRevalidated only fires
1593
+ // asynchronously, well after the store is created below.
1594
+ onRevalidated: (path, result) => {
1595
+ newStore.getState().mergeResult(result).catch(() => {
1596
+ });
1597
+ config.onRevalidated?.(path, result);
1598
+ }
906
1599
  });
907
1600
  const syncManager = new SyncManager({
908
1601
  client,
@@ -930,7 +1623,9 @@ function useSyncInit(config) {
930
1623
  }
931
1624
  });
932
1625
  setStore(newStore);
933
- newStore.getState().pull().catch(() => {
1626
+ newStore.getState().seed().finally(() => {
1627
+ newStore.getState().pull().catch(() => {
1628
+ });
934
1629
  });
935
1630
  return () => {
936
1631
  setStore(null);
@@ -942,18 +1637,182 @@ function useSyncInit(config) {
942
1637
  config?.encryptor,
943
1638
  config?.storeName
944
1639
  ]);
1640
+ return config ? store : null;
1641
+ }
1642
+ var _syncStoreRegistry = /* @__PURE__ */ new Map();
1643
+ function acquireSyncStore(config) {
1644
+ const existing = _syncStoreRegistry.get(config.storeName);
1645
+ if (existing) {
1646
+ existing.refCount += 1;
1647
+ return existing.store;
1648
+ }
1649
+ const client = new StarfishClient({
1650
+ baseUrl: config.serverUrl,
1651
+ namespace: config.namespace,
1652
+ capProvider: config.capProvider,
1653
+ fetch: config.fetch,
1654
+ cache: config.cache,
1655
+ cacheMaxAgeMs: config.cacheMaxAgeMs,
1656
+ cacheFallbackStatuses: config.cacheFallbackStatuses,
1657
+ // Auto-merge: push fresh revalidated snapshots into the store.
1658
+ // store is referenced by closure — safe because onRevalidated only fires
1659
+ // asynchronously, well after the store is created below.
1660
+ onRevalidated: (path, result) => {
1661
+ store.getState().mergeResult(result).catch(() => {
1662
+ });
1663
+ config.onRevalidated?.(path, result);
1664
+ }
1665
+ });
1666
+ const syncManager = new SyncManager({
1667
+ client,
1668
+ pullPath: config.pullPath,
1669
+ pushPath: config.pushPath,
1670
+ encryptor: config.encryptor,
1671
+ onConflict: config.onConflict,
1672
+ logger: config.logger,
1673
+ validate: config.validate
1674
+ });
1675
+ const store = createStarfishStore({
1676
+ name: config.storeName,
1677
+ syncManager,
1678
+ storage: config.storage
1679
+ // No onRemoteUpdate: consumers subscribe via store.subscribe() — see module comment.
1680
+ });
1681
+ const entry = { store, refCount: 1 };
1682
+ _syncStoreRegistry.set(config.storeName, entry);
1683
+ store.getState().seed().finally(() => {
1684
+ if (_syncStoreRegistry.get(config.storeName) === entry) {
1685
+ store.getState().pull().catch(() => {
1686
+ });
1687
+ }
1688
+ });
945
1689
  return store;
946
1690
  }
1691
+ function releaseSyncStore(storeName) {
1692
+ const entry = _syncStoreRegistry.get(storeName);
1693
+ if (!entry) return;
1694
+ entry.refCount -= 1;
1695
+ if (entry.refCount <= 0) _syncStoreRegistry.delete(storeName);
1696
+ }
1697
+ function clearSyncStoreRegistry() {
1698
+ _syncStoreRegistry.clear();
1699
+ }
1700
+ function useSharedSyncStore(config) {
1701
+ const [store, setStore] = useState(null);
1702
+ const storeName = config?.storeName ?? null;
1703
+ const configRef = useRef(config);
1704
+ configRef.current = config;
1705
+ useEffect(() => {
1706
+ if (!storeName) return;
1707
+ const acquired = acquireSyncStore(configRef.current);
1708
+ setStore(acquired);
1709
+ return () => {
1710
+ releaseSyncStore(storeName);
1711
+ setStore(null);
1712
+ };
1713
+ }, [storeName]);
1714
+ return storeName ? store : null;
1715
+ }
1716
+ function createStarfishLog(options) {
1717
+ const { cursor } = options;
1718
+ const storeCreator = (rawSet, get) => {
1719
+ const set = rawSet;
1720
+ return {
1721
+ // Seed from the cursor so a warm-started cursor's items show immediately.
1722
+ items: cursor.getItems(),
1723
+ loading: false,
1724
+ online: true,
1725
+ error: null,
1726
+ checkpoint: cursor.getCheckpoint(),
1727
+ pull: async () => {
1728
+ if (get().loading) return [];
1729
+ set({ loading: true, error: null }, false, "log/pull/start");
1730
+ try {
1731
+ const batch = await cursor.pull();
1732
+ set(
1733
+ { items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
1734
+ false,
1735
+ "log/pull/success"
1736
+ );
1737
+ return batch;
1738
+ } catch (err) {
1739
+ set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
1740
+ return [];
1741
+ }
1742
+ },
1743
+ setOnline: (online) => {
1744
+ set({ online }, false, "log/setOnline");
1745
+ }
1746
+ };
1747
+ };
1748
+ const withSelector = subscribeWithSelector(storeCreator);
1749
+ return createStore()(
1750
+ options.devtools ? options.devtools(withSelector) : withSelector
1751
+ );
1752
+ }
1753
+ function deriveLogStatus(state) {
1754
+ if (!state.online) return "offline";
1755
+ if (state.error) return "error";
1756
+ if (state.loading) return "loading";
1757
+ return "idle";
1758
+ }
1759
+ function useStarfishLog(store) {
1760
+ return useStore(store);
1761
+ }
1762
+ function useStarfishLogItems(store, selector) {
1763
+ return useStore(
1764
+ store,
1765
+ (state) => selector ? selector(state.items) : state.items
1766
+ );
1767
+ }
1768
+ function useLogStatus(store) {
1769
+ return useStore(store, deriveLogStatus);
1770
+ }
1771
+ function subscribeLogStatus(store, callback) {
1772
+ let prev = deriveLogStatus(store.getState());
1773
+ callback(prev);
1774
+ return store.subscribe((state) => {
1775
+ const next = deriveLogStatus(state);
1776
+ if (next !== prev) {
1777
+ prev = next;
1778
+ callback(next);
1779
+ }
1780
+ });
1781
+ }
1782
+ function useLogConnectivity(store) {
1783
+ useEffect(() => {
1784
+ const handleOnline = () => store.getState().setOnline(true);
1785
+ const handleOffline = () => store.getState().setOnline(false);
1786
+ window.addEventListener("online", handleOnline);
1787
+ window.addEventListener("offline", handleOffline);
1788
+ return () => {
1789
+ window.removeEventListener("online", handleOnline);
1790
+ window.removeEventListener("offline", handleOffline);
1791
+ };
1792
+ }, [store]);
1793
+ }
947
1794
  export {
1795
+ acquireSyncStore,
948
1796
  aggregateSyncStatus,
1797
+ clearSyncStoreRegistry,
1798
+ createStarfishLog,
949
1799
  createStarfishStore,
1800
+ deriveLogStatus,
950
1801
  deriveSyncStatus,
1802
+ releaseSyncStore,
1803
+ subscribeLogStatus,
951
1804
  subscribeSyncStatus,
952
1805
  useConnectivity,
953
1806
  useCrossTabSync,
954
1807
  useLastSynced,
1808
+ useLogConnectivity,
1809
+ useLogStatus,
1810
+ useSharedSyncStore,
955
1811
  useStarfish,
956
1812
  useStarfishData,
1813
+ useStarfishLog,
1814
+ useStarfishLogItems,
1815
+ useStarfishState,
957
1816
  useSyncInit,
958
1817
  useSyncStatus
959
1818
  };