@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 +5 -1
- package/dist/bindings/zustand.d.ts +13 -1
- package/dist/bindings/zustand.js +163 -80
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +44 -6
- package/dist/index.js +144 -80
- package/dist/index.js.map +2 -2
- package/dist/sync.d.ts +22 -0
- package/dist/types.d.ts +13 -7
- package/package.json +3 -3
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
|
-
- **
|
|
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 {
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -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
|
-
/**
|
|
537
|
-
|
|
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
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|