@drakkar.software/starfish-client 3.0.0-alpha.4 → 3.0.0-alpha.40

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