@drakkar.software/starfish-client 3.0.0-alpha.30 → 3.0.0-alpha.35

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.
package/README.md CHANGED
@@ -211,7 +211,8 @@ Pass a `cache` (a `PullCache`: `{ get(k): Promise<string|null>; set(k, v): Promi
211
211
  - **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
212
212
  - **Offline fallback (without a persist-backed store):** a pull that fails because the **transport** is unreachable returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
213
213
  - **Real HTTP errors propagate:** 404/403 are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning. 429 and 5xx can optionally be caught via `cacheFallbackStatuses` (see below).
214
- - **Stale-while-revalidate:** set `cacheFallbackStatuses: [429, 500, 502, 503, 504]` to make transient server failures serve the last-synced snapshot immediately and retry in the background (honoring `Retry-After`). When the live response arrives, the cache is updated and `onRevalidated` fires. No snapshot → the error propagates as usual. Do NOT include 403/404 — they are genuine answers, not transient failures.
214
+ - **Error-triggered stale-while-revalidate:** set `cacheFallbackStatuses: [429, 500, 502, 503, 504]` to make transient server failures serve the last-synced snapshot immediately and retry in the background (honoring `Retry-After`). When the live response arrives, the cache is updated and `onRevalidated` fires. No snapshot → the error propagates as usual. Do NOT include 403/404 — they are genuine answers, not transient failures.
215
+ - **Proactive stale-while-revalidate (`staleWhileRevalidate` pull option):** pass `{ staleWhileRevalidate: true }` to `client.pull(path, { staleWhileRevalidate: true })` to serve the cached snapshot immediately and revalidate in the background on every read (not just on errors). On a cache hit the cached result is returned at once (tagged via `pullWasFromCache`) and the background fetch fires immediately (no initial delay). On a miss it falls through to a normal network-first pull. `onRevalidated` fires on success with the fresh `PullResult`. Both SWR paths share the same dedup loop — a concurrent error-triggered loop and an SWR-on-read loop for the same document collapse to one.
215
216
  - **`cacheMaxAgeMs`:** an entry older than this is treated as a miss (both cache-first paint and offline fallback); omit for entries that never expire (recommended for offline-first, where any last-synced data beats none).
216
217
  - **Ciphertext-at-rest by construction:** the cache stores the raw server payload, which for E2E (`delegated`) collections is the sealed ciphertext the server holds — never the decrypted form. Decryption happens in memory on read (see `SyncManager.seedFromCache`).
217
218
  - **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
@@ -233,6 +234,9 @@ new SyncManager({
233
234
  - `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
234
235
  - `onConflict` resolves write conflicts on push *and* reconciles a pull against un-pushed local writes. On a zustand-bound store, a `pull()` while the store is `dirty` merges the fetched snapshot with the local data through this resolver (rather than overwriting it), so an optimistic write isn't lost when a pull races a `set()`. Use a union/CRDT-style resolver (`createUnionMerge`) for append-style collections so both the local and remote writes survive; `SyncManager.resolve(local, remote)` exposes the same merge for callers that need it.
235
236
  - **Offline-first:** Zustand stores backed by `storage` are offline-first without any client `cache` — a transport failure during `pull()` preserves the persisted data and sets `stale: true` on the store. When a client `cache` is also configured, `seedFromCache()` additionally populates `localData` from the ciphertext cache without a network round-trip (decrypting in memory for E2E collections); `getLastPullFromCache()` reports whether the latest `pull()`/seed came from that cache. On a zustand store, the `stale` flag tracks both sources: it is set by an offline `pull()` (no cache needed) and by `seed()` (cache-first paint before the initial live pull).
237
+ - **`SyncManager.ingest(result: PullResult)`** applies an externally-delivered `PullResult` to the manager's state (decrypting for E2E, updating `localData`/`lastHash`/`lastCheckpoint`, clearing `lastFromCache`) without a network call. Used by the zustand binding's `mergeResult` action to absorb background revalidation results.
238
+
239
+ **Auto-merge on revalidation:** when `cacheFallbackStatuses` or `staleWhileRevalidate` triggers a background revalidation, `useSyncInit` and `acquireSyncStore` automatically push the fresh `PullResult` into the bound store via `mergeResult` — the store repaints without waiting for the next explicit `pull()`. The consumer's own `onRevalidated` callback is still called after the store update. The `mergeResult` store action is also available for manual use when a caller holds a fresh `PullResult` it wants to push into the store without an extra round-trip.
236
240
 
237
241
  ## `AppendLogCursor`
238
242
 
@@ -1,7 +1,7 @@
1
1
  import { type StoreApi } from "zustand/vanilla";
2
2
  import { type StateStorage } from "zustand/middleware";
3
3
  import type { DevtoolsOptions } from "zustand/middleware";
4
- import type { Encryptor } from "@drakkar.software/starfish-protocol";
4
+ import type { Encryptor, PullResult } from "@drakkar.software/starfish-protocol";
5
5
  import { SyncManager } from "../sync.js";
6
6
  import { AppendLogCursor, type AppendElement } from "../append-log.js";
7
7
  import type { StarfishClientOptions, StarfishCapProvider, ConflictResolver, PullCache } from "../types.js";
@@ -39,6 +39,18 @@ export interface StarfishActions {
39
39
  * pull; the live pull then supersedes the seeded snapshot.
40
40
  */
41
41
  seed: () => Promise<void>;
42
+ /**
43
+ * Apply a freshly-fetched `PullResult` to the store WITHOUT firing a network
44
+ * request. Decrypts in memory for E2E collections, conflict-merges against
45
+ * any local optimistic writes (same logic as a live pull), and clears `stale`.
46
+ *
47
+ * Primarily called automatically by the binding when
48
+ * {@link StarfishClientOptions.onRevalidated} fires (background revalidation
49
+ * delivered a fresh snapshot after a 429/5xx hit or an SWR-on-read). Also
50
+ * available for manual use when a caller holds a fresh `PullResult` it wants
51
+ * to push into the store without a second network round-trip.
52
+ */
53
+ mergeResult: (result: PullResult) => Promise<void>;
42
54
  }
43
55
  export type StarfishStore = StarfishState & StarfishActions;
44
56
  export interface CreateStarfishStoreOptions {
@@ -451,11 +451,12 @@ var StarfishClient = class {
451
451
  async pull(path, checkpointOrOptions) {
452
452
  let pathAndQuery = this.applyNamespace(path);
453
453
  let appendField;
454
+ let swr = false;
454
455
  if (typeof checkpointOrOptions === "number") {
455
456
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
456
457
  } else if (checkpointOrOptions != null) {
457
458
  const opts = checkpointOrOptions;
458
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
459
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
459
460
  const params = new URLSearchParams();
460
461
  if (isPullOptions) {
461
462
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -464,6 +465,7 @@ var StarfishClient = class {
464
465
  if (opts.withKeyring) {
465
466
  params.set("withKeyring", "1");
466
467
  }
468
+ swr = opts.staleWhileRevalidate === true;
467
469
  } else {
468
470
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
469
471
  if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
@@ -490,6 +492,19 @@ var StarfishClient = class {
490
492
  const url = `${this.baseUrl}${pathAndQuery}`;
491
493
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
492
494
  const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
495
+ if (swr && cacheKey) {
496
+ const cached = await this.readCache(cacheKey);
497
+ if (cached) {
498
+ this.scheduleRevalidate(
499
+ cacheKey,
500
+ pathAndQuery,
501
+ null,
502
+ /* immediate */
503
+ true
504
+ );
505
+ return cached;
506
+ }
507
+ }
493
508
  let res;
494
509
  try {
495
510
  res = await this.fetch(url, {
@@ -521,67 +536,81 @@ var StarfishClient = class {
521
536
  const list = result.data?.[appendField];
522
537
  return Array.isArray(list) ? list : [];
523
538
  }
524
- if (cacheKey) {
525
- const snapshot = {
526
- data: result.data,
527
- hash: result.hash,
528
- timestamp: result.timestamp,
529
- cachedAt: Date.now()
530
- };
531
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
532
- });
533
- }
539
+ if (cacheKey) this.writeCache(cacheKey, result);
534
540
  return result;
535
541
  }
536
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
537
- scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
542
+ /**
543
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
544
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
545
+ */
546
+ writeCache(cacheKey, result) {
547
+ if (!this.cache) return;
548
+ const snapshot = {
549
+ data: result.data,
550
+ hash: result.hash,
551
+ timestamp: result.timestamp,
552
+ cachedAt: Date.now()
553
+ };
554
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
555
+ });
556
+ }
557
+ /** Build the URL + auth headers for one revalidation GET. Shared between
558
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
559
+ async revalidateFetch(pathAndQuery) {
560
+ const url = `${this.baseUrl}${pathAndQuery}`;
561
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
562
+ return this.fetch(url, {
563
+ method: "GET",
564
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
565
+ });
566
+ }
567
+ /**
568
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
569
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
570
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
571
+ * read path (`immediate: true` — no initial delay on the first attempt). The
572
+ * `revalidating` set deduplicates across both triggers so a concurrent
573
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
574
+ */
575
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
538
576
  if (this.revalidating.has(cacheKey)) return;
539
577
  this.revalidating.add(cacheKey);
540
- void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
578
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
541
579
  this.revalidating.delete(cacheKey);
542
580
  });
543
581
  }
544
582
  /**
545
- * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
546
- * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
547
- * times. On a live 2xx response the fresh snapshot is written through to the
548
- * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
549
- * status (e.g. 404/403 the server gave a genuine answer).
583
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
584
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
585
+ *
586
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
587
+ * When `immediate` is true the first attempt fires without any initial delay
588
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
589
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
550
590
  */
551
- async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
591
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
552
592
  let retryAfterHeader = firstRetryAfter;
593
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
553
594
  for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
554
- const delay = parseRetryAfterMs(retryAfterHeader, {
555
- fallbackMs: Math.min(
556
- REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
557
- REVALIDATE_MAX_DELAY_MS
558
- ),
559
- maxMs: REVALIDATE_MAX_DELAY_MS
560
- });
561
- await sleep(delay);
562
- try {
563
- const url = `${this.baseUrl}${pathAndQuery}`;
564
- const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
565
- const res = await this.fetch(url, {
566
- method: "GET",
567
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
595
+ if (!immediate || attempt > 0) {
596
+ const delay = parseRetryAfterMs(retryAfterHeader, {
597
+ fallbackMs: Math.min(
598
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
599
+ REVALIDATE_MAX_DELAY_MS
600
+ ),
601
+ maxMs: REVALIDATE_MAX_DELAY_MS
568
602
  });
603
+ await sleep(delay);
604
+ }
605
+ try {
606
+ const res = await this.revalidateFetch(pathAndQuery);
569
607
  if (res.ok) {
570
608
  const result = await res.json();
571
- if (this.cache) {
572
- const snapshot = {
573
- data: result.data,
574
- hash: result.hash,
575
- timestamp: result.timestamp,
576
- cachedAt: Date.now()
577
- };
578
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
579
- });
580
- }
609
+ this.writeCache(cacheKey, result);
581
610
  this.onRevalidated?.(pathAndQuery, result);
582
611
  return;
583
612
  }
584
- if (!this.cacheFallbackStatuses?.includes(res.status)) {
613
+ if (!fallbackSet?.has(res.status)) {
585
614
  return;
586
615
  }
587
616
  retryAfterHeader = res.headers.get("Retry-After");
@@ -703,15 +732,7 @@ var StarfishClient = class {
703
732
  const result = await res.json();
704
733
  if (this.cache) {
705
734
  const pullPath = sendPath.replace("/push/", "/pull/");
706
- const cacheKey = pullCacheKey(pullPath);
707
- const snapshot = {
708
- data,
709
- hash: result.hash,
710
- timestamp: result.timestamp,
711
- cachedAt: Date.now()
712
- };
713
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
714
- });
735
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
715
736
  }
716
737
  return result;
717
738
  }
@@ -985,6 +1006,30 @@ var SyncManager = class {
985
1006
  getCheckpoint() {
986
1007
  return this.lastCheckpoint;
987
1008
  }
1009
+ /**
1010
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
1011
+ * firing a network request. Used by the zustand binding's `mergeResult`
1012
+ * action to absorb a background revalidation result (delivered via
1013
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
1014
+ *
1015
+ * Unlike {@link pull}, `ingest` never does a deep-merge with the previous
1016
+ * checkpoint — the revalidated result is always a full fresh snapshot. It
1017
+ * sets `lastFromCache = false` (a revalidation is a live response) so the
1018
+ * binding can clear its `stale` flag.
1019
+ */
1020
+ async ingest(result) {
1021
+ if (this.aborted) return;
1022
+ if (this.encryptor) {
1023
+ const decrypted = await this.encryptor.decrypt(result.data);
1024
+ if (this.aborted) return;
1025
+ this.localData = decrypted;
1026
+ } else {
1027
+ this.localData = result.data;
1028
+ }
1029
+ this.lastHash = result.hash;
1030
+ this.lastCheckpoint = result.timestamp;
1031
+ this.lastFromCache = false;
1032
+ }
988
1033
  async pull() {
989
1034
  if (this.aborted) throw new AbortError();
990
1035
  this.logger?.pullStart(this.loggerName);
@@ -1183,6 +1228,14 @@ function createStarfishStore(options) {
1183
1228
  retryTimer = void 0;
1184
1229
  retryAttempt = 0;
1185
1230
  };
1231
+ const commitRemote = (label) => {
1232
+ const remote = syncManager.getData();
1233
+ const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
1234
+ set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, label);
1235
+ if (get().online && get().dirty) get().flush().catch(() => {
1236
+ });
1237
+ options.onRemoteUpdate?.(newData);
1238
+ };
1186
1239
  return {
1187
1240
  data: {},
1188
1241
  syncing: false,
@@ -1201,15 +1254,10 @@ function createStarfishStore(options) {
1201
1254
  }
1202
1255
  },
1203
1256
  pull: async () => {
1204
- set({ syncing: true, error: null }, false, "pull/start");
1257
+ set(get().stale ? { error: null } : { syncing: true, error: null }, false, "pull/start");
1205
1258
  try {
1206
1259
  await syncManager.pull();
1207
- const remote = syncManager.getData();
1208
- const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
1209
- set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
1210
- if (get().online && get().dirty) get().flush().catch(() => {
1211
- });
1212
- options.onRemoteUpdate?.(newData);
1260
+ commitRemote("pull/success");
1213
1261
  } catch (err) {
1214
1262
  if (classifyError(err) === "network") {
1215
1263
  set({ syncing: false, stale: true }, false, "pull/offline");
@@ -1218,6 +1266,10 @@ function createStarfishStore(options) {
1218
1266
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
1219
1267
  }
1220
1268
  },
1269
+ mergeResult: async (result) => {
1270
+ await syncManager.ingest(result);
1271
+ commitRemote("merge/success");
1272
+ },
1221
1273
  set: (modifier) => {
1222
1274
  try {
1223
1275
  const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
@@ -1362,10 +1414,7 @@ function useSyncInit(config) {
1362
1414
  const onDataRef = useRef(config?.onData);
1363
1415
  onDataRef.current = config?.onData;
1364
1416
  useEffect(() => {
1365
- if (!config) {
1366
- setStore(null);
1367
- return;
1368
- }
1417
+ if (!config) return;
1369
1418
  const client = new StarfishClient({
1370
1419
  baseUrl: config.serverUrl,
1371
1420
  namespace: config.namespace,
@@ -1374,7 +1423,15 @@ function useSyncInit(config) {
1374
1423
  cache: config.cache,
1375
1424
  cacheMaxAgeMs: config.cacheMaxAgeMs,
1376
1425
  cacheFallbackStatuses: config.cacheFallbackStatuses,
1377
- onRevalidated: config.onRevalidated
1426
+ // Auto-merge: when a background revalidation delivers a fresh snapshot,
1427
+ // push it into the store so the UI heals without waiting for the next pull.
1428
+ // newStore is referenced by closure — safe because onRevalidated only fires
1429
+ // asynchronously, well after the store is created below.
1430
+ onRevalidated: (path, result) => {
1431
+ newStore.getState().mergeResult(result).catch(() => {
1432
+ });
1433
+ config.onRevalidated?.(path, result);
1434
+ }
1378
1435
  });
1379
1436
  const syncManager = new SyncManager({
1380
1437
  client,
@@ -1416,7 +1473,7 @@ function useSyncInit(config) {
1416
1473
  config?.encryptor,
1417
1474
  config?.storeName
1418
1475
  ]);
1419
- return store;
1476
+ return config ? store : null;
1420
1477
  }
1421
1478
  var _syncStoreRegistry = /* @__PURE__ */ new Map();
1422
1479
  function acquireSyncStore(config) {
@@ -1433,7 +1490,14 @@ function acquireSyncStore(config) {
1433
1490
  cache: config.cache,
1434
1491
  cacheMaxAgeMs: config.cacheMaxAgeMs,
1435
1492
  cacheFallbackStatuses: config.cacheFallbackStatuses,
1436
- onRevalidated: config.onRevalidated
1493
+ // Auto-merge: push fresh revalidated snapshots into the store.
1494
+ // store is referenced by closure — safe because onRevalidated only fires
1495
+ // asynchronously, well after the store is created below.
1496
+ onRevalidated: (path, result) => {
1497
+ store.getState().mergeResult(result).catch(() => {
1498
+ });
1499
+ config.onRevalidated?.(path, result);
1500
+ }
1437
1501
  });
1438
1502
  const syncManager = new SyncManager({
1439
1503
  client,
@@ -1475,10 +1539,7 @@ function useSharedSyncStore(config) {
1475
1539
  const configRef = useRef(config);
1476
1540
  configRef.current = config;
1477
1541
  useEffect(() => {
1478
- if (!storeName) {
1479
- setStore(null);
1480
- return;
1481
- }
1542
+ if (!storeName) return;
1482
1543
  const acquired = acquireSyncStore(configRef.current);
1483
1544
  setStore(acquired);
1484
1545
  return () => {
@@ -1486,7 +1547,7 @@ function useSharedSyncStore(config) {
1486
1547
  setStore(null);
1487
1548
  };
1488
1549
  }, [storeName]);
1489
- return store;
1550
+ return storeName ? store : null;
1490
1551
  }
1491
1552
  function createStarfishLog(options) {
1492
1553
  const { cursor } = options;