@drakkar.software/starfish-client 3.0.0-alpha.31 → 3.0.0-alpha.36

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 {
@@ -335,6 +335,12 @@ var StarfishClient = class {
335
335
  cacheFallbackStatuses;
336
336
  onRevalidated;
337
337
  revalidating = /* @__PURE__ */ new Set();
338
+ /**
339
+ * In-memory mirror of the latest document timestamp written to each cache
340
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
341
+ * can guard against stale overwrites without an extra async cache read.
342
+ */
343
+ latestCacheTimestamp = /* @__PURE__ */ new Map();
338
344
  /**
339
345
  * Installed client-side plugins. Currently stored as inert data; no
340
346
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -451,11 +457,12 @@ var StarfishClient = class {
451
457
  async pull(path, checkpointOrOptions) {
452
458
  let pathAndQuery = this.applyNamespace(path);
453
459
  let appendField;
460
+ let swr = false;
454
461
  if (typeof checkpointOrOptions === "number") {
455
462
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
456
463
  } else if (checkpointOrOptions != null) {
457
464
  const opts = checkpointOrOptions;
458
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
465
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
459
466
  const params = new URLSearchParams();
460
467
  if (isPullOptions) {
461
468
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -464,6 +471,7 @@ var StarfishClient = class {
464
471
  if (opts.withKeyring) {
465
472
  params.set("withKeyring", "1");
466
473
  }
474
+ swr = opts.staleWhileRevalidate === true;
467
475
  } else {
468
476
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
469
477
  if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
@@ -490,6 +498,19 @@ var StarfishClient = class {
490
498
  const url = `${this.baseUrl}${pathAndQuery}`;
491
499
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
492
500
  const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
501
+ if (swr && cacheKey) {
502
+ const cached = await this.readCache(cacheKey);
503
+ if (cached) {
504
+ this.scheduleRevalidate(
505
+ cacheKey,
506
+ pathAndQuery,
507
+ null,
508
+ /* immediate */
509
+ true
510
+ );
511
+ return cached;
512
+ }
513
+ }
493
514
  let res;
494
515
  try {
495
516
  res = await this.fetch(url, {
@@ -521,67 +542,87 @@ var StarfishClient = class {
521
542
  const list = result.data?.[appendField];
522
543
  return Array.isArray(list) ? list : [];
523
544
  }
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
- }
545
+ if (cacheKey) this.writeCache(cacheKey, result);
534
546
  return result;
535
547
  }
536
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
537
- scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
548
+ /**
549
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
550
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
551
+ */
552
+ writeCache(cacheKey, result) {
553
+ if (!this.cache) return;
554
+ if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
555
+ this.latestCacheTimestamp.set(cacheKey, result.timestamp);
556
+ }
557
+ const snapshot = {
558
+ data: result.data,
559
+ hash: result.hash,
560
+ timestamp: result.timestamp,
561
+ cachedAt: Date.now()
562
+ };
563
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
564
+ });
565
+ }
566
+ /** Build the URL + auth headers for one revalidation GET. Shared between
567
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
568
+ async revalidateFetch(pathAndQuery) {
569
+ const url = `${this.baseUrl}${pathAndQuery}`;
570
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
571
+ return this.fetch(url, {
572
+ method: "GET",
573
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
574
+ });
575
+ }
576
+ /**
577
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
578
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
579
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
580
+ * read path (`immediate: true` — no initial delay on the first attempt). The
581
+ * `revalidating` set deduplicates across both triggers so a concurrent
582
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
583
+ */
584
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
538
585
  if (this.revalidating.has(cacheKey)) return;
539
586
  this.revalidating.add(cacheKey);
540
- void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
587
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
541
588
  this.revalidating.delete(cacheKey);
542
589
  });
543
590
  }
544
591
  /**
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).
592
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
593
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
594
+ *
595
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
596
+ * When `immediate` is true the first attempt fires without any initial delay
597
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
598
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
550
599
  */
551
- async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
600
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
552
601
  let retryAfterHeader = firstRetryAfter;
602
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
553
603
  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 }
604
+ if (!immediate || attempt > 0) {
605
+ const delay = parseRetryAfterMs(retryAfterHeader, {
606
+ fallbackMs: Math.min(
607
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
608
+ REVALIDATE_MAX_DELAY_MS
609
+ ),
610
+ maxMs: REVALIDATE_MAX_DELAY_MS
568
611
  });
612
+ await sleep(delay);
613
+ }
614
+ try {
615
+ const res = await this.revalidateFetch(pathAndQuery);
569
616
  if (res.ok) {
570
617
  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
- });
618
+ const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
619
+ if (result.timestamp >= latestTs) {
620
+ this.writeCache(cacheKey, result);
621
+ this.onRevalidated?.(pathAndQuery, result);
580
622
  }
581
- this.onRevalidated?.(pathAndQuery, result);
582
623
  return;
583
624
  }
584
- if (!this.cacheFallbackStatuses?.includes(res.status)) {
625
+ if (!fallbackSet?.has(res.status)) {
585
626
  return;
586
627
  }
587
628
  retryAfterHeader = res.headers.get("Retry-After");
@@ -703,15 +744,7 @@ var StarfishClient = class {
703
744
  const result = await res.json();
704
745
  if (this.cache) {
705
746
  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
- });
747
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
715
748
  }
716
749
  return result;
717
750
  }
@@ -985,6 +1018,42 @@ var SyncManager = class {
985
1018
  getCheckpoint() {
986
1019
  return this.lastCheckpoint;
987
1020
  }
1021
+ /**
1022
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
1023
+ * firing a network request. Used by the zustand binding's `mergeResult`
1024
+ * action to absorb a background revalidation result (delivered via
1025
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
1026
+ *
1027
+ * Like {@link pull}, `ingest` conflict-merges the snapshot against the
1028
+ * established baseline via `this.onConflict` when a checkpoint exists — so a
1029
+ * union-merge store does not lose array items when a revalidation result
1030
+ * (e.g. a stale cache-fallback on 429/5xx) is a shorter snapshot. The first
1031
+ * ingest (no checkpoint yet) takes the snapshot wholesale. Sets
1032
+ * `lastFromCache = false` (a revalidation is a live response) so the binding
1033
+ * can clear its `stale` flag.
1034
+ *
1035
+ * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
1036
+ * time the revalidation request was sent and the time it resolves, the
1037
+ * result is from an older document version. Ingesting it would clobber the
1038
+ * user's just-saved edit and reset `lastHash` to a stale server hash
1039
+ * (causing a spurious 409 on the next push). We silently drop the result in
1040
+ * that case — the store's post-push state is already correct.
1041
+ */
1042
+ async ingest(result) {
1043
+ if (this.aborted) return;
1044
+ if (result.timestamp < this.lastCheckpoint) return;
1045
+ let incoming;
1046
+ if (this.encryptor) {
1047
+ incoming = await this.encryptor.decrypt(result.data);
1048
+ if (this.aborted) return;
1049
+ } else {
1050
+ incoming = result.data;
1051
+ }
1052
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
1053
+ this.lastHash = result.hash;
1054
+ this.lastCheckpoint = result.timestamp;
1055
+ this.lastFromCache = false;
1056
+ }
988
1057
  async pull() {
989
1058
  if (this.aborted) throw new AbortError();
990
1059
  this.logger?.pullStart(this.loggerName);
@@ -993,17 +1062,15 @@ var SyncManager = class {
993
1062
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
994
1063
  if (this.aborted) throw new AbortError();
995
1064
  this.lastFromCache = pullWasFromCache(result);
1065
+ let incoming;
996
1066
  if (this.encryptor) {
997
- const decrypted = await this.encryptor.decrypt(result.data);
1067
+ incoming = await this.encryptor.decrypt(result.data);
998
1068
  if (this.aborted) throw new AbortError();
999
- this.localData = decrypted;
1000
- result.data = decrypted;
1001
- } else if (this.lastCheckpoint > 0) {
1002
- this.localData = deepMerge(this.localData, result.data);
1003
- result.data = this.localData;
1004
1069
  } else {
1005
- this.localData = result.data;
1070
+ incoming = result.data;
1006
1071
  }
1072
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
1073
+ result.data = this.localData;
1007
1074
  this.lastHash = result.hash;
1008
1075
  this.lastCheckpoint = result.timestamp;
1009
1076
  this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
@@ -1183,6 +1250,14 @@ function createStarfishStore(options) {
1183
1250
  retryTimer = void 0;
1184
1251
  retryAttempt = 0;
1185
1252
  };
1253
+ const commitRemote = (label) => {
1254
+ const remote = syncManager.getData();
1255
+ const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
1256
+ set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, label);
1257
+ if (get().online && get().dirty) get().flush().catch(() => {
1258
+ });
1259
+ options.onRemoteUpdate?.(newData);
1260
+ };
1186
1261
  return {
1187
1262
  data: {},
1188
1263
  syncing: false,
@@ -1201,15 +1276,10 @@ function createStarfishStore(options) {
1201
1276
  }
1202
1277
  },
1203
1278
  pull: async () => {
1204
- set({ syncing: true, error: null }, false, "pull/start");
1279
+ set(get().stale ? { error: null } : { syncing: true, error: null }, false, "pull/start");
1205
1280
  try {
1206
1281
  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);
1282
+ commitRemote("pull/success");
1213
1283
  } catch (err) {
1214
1284
  if (classifyError(err) === "network") {
1215
1285
  set({ syncing: false, stale: true }, false, "pull/offline");
@@ -1218,6 +1288,10 @@ function createStarfishStore(options) {
1218
1288
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
1219
1289
  }
1220
1290
  },
1291
+ mergeResult: async (result) => {
1292
+ await syncManager.ingest(result);
1293
+ commitRemote("merge/success");
1294
+ },
1221
1295
  set: (modifier) => {
1222
1296
  try {
1223
1297
  const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
@@ -1362,10 +1436,7 @@ function useSyncInit(config) {
1362
1436
  const onDataRef = useRef(config?.onData);
1363
1437
  onDataRef.current = config?.onData;
1364
1438
  useEffect(() => {
1365
- if (!config) {
1366
- setStore(null);
1367
- return;
1368
- }
1439
+ if (!config) return;
1369
1440
  const client = new StarfishClient({
1370
1441
  baseUrl: config.serverUrl,
1371
1442
  namespace: config.namespace,
@@ -1374,7 +1445,15 @@ function useSyncInit(config) {
1374
1445
  cache: config.cache,
1375
1446
  cacheMaxAgeMs: config.cacheMaxAgeMs,
1376
1447
  cacheFallbackStatuses: config.cacheFallbackStatuses,
1377
- onRevalidated: config.onRevalidated
1448
+ // Auto-merge: when a background revalidation delivers a fresh snapshot,
1449
+ // push it into the store so the UI heals without waiting for the next pull.
1450
+ // newStore is referenced by closure — safe because onRevalidated only fires
1451
+ // asynchronously, well after the store is created below.
1452
+ onRevalidated: (path, result) => {
1453
+ newStore.getState().mergeResult(result).catch(() => {
1454
+ });
1455
+ config.onRevalidated?.(path, result);
1456
+ }
1378
1457
  });
1379
1458
  const syncManager = new SyncManager({
1380
1459
  client,
@@ -1416,7 +1495,7 @@ function useSyncInit(config) {
1416
1495
  config?.encryptor,
1417
1496
  config?.storeName
1418
1497
  ]);
1419
- return store;
1498
+ return config ? store : null;
1420
1499
  }
1421
1500
  var _syncStoreRegistry = /* @__PURE__ */ new Map();
1422
1501
  function acquireSyncStore(config) {
@@ -1433,7 +1512,14 @@ function acquireSyncStore(config) {
1433
1512
  cache: config.cache,
1434
1513
  cacheMaxAgeMs: config.cacheMaxAgeMs,
1435
1514
  cacheFallbackStatuses: config.cacheFallbackStatuses,
1436
- onRevalidated: config.onRevalidated
1515
+ // Auto-merge: push fresh revalidated snapshots into the store.
1516
+ // store is referenced by closure — safe because onRevalidated only fires
1517
+ // asynchronously, well after the store is created below.
1518
+ onRevalidated: (path, result) => {
1519
+ store.getState().mergeResult(result).catch(() => {
1520
+ });
1521
+ config.onRevalidated?.(path, result);
1522
+ }
1437
1523
  });
1438
1524
  const syncManager = new SyncManager({
1439
1525
  client,
@@ -1475,10 +1561,7 @@ function useSharedSyncStore(config) {
1475
1561
  const configRef = useRef(config);
1476
1562
  configRef.current = config;
1477
1563
  useEffect(() => {
1478
- if (!storeName) {
1479
- setStore(null);
1480
- return;
1481
- }
1564
+ if (!storeName) return;
1482
1565
  const acquired = acquireSyncStore(configRef.current);
1483
1566
  setStore(acquired);
1484
1567
  return () => {
@@ -1486,7 +1569,7 @@ function useSharedSyncStore(config) {
1486
1569
  setStore(null);
1487
1570
  };
1488
1571
  }, [storeName]);
1489
- return store;
1572
+ return storeName ? store : null;
1490
1573
  }
1491
1574
  function createStarfishLog(options) {
1492
1575
  const { cursor } = options;