@drakkar.software/starfish-client 3.0.0-alpha.14 → 3.0.0-alpha.16

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
@@ -185,6 +185,8 @@ new StarfishClient({
185
185
  baseUrl: "https://api.example.com/v1",
186
186
  capProvider, // v3 — signs every request. Replaces v2 `auth`/`authProvider`.
187
187
  fetch, // optional custom fetch
188
+ cache, // optional offline-first read-through cache — see below
189
+ cacheMaxAgeMs, // optional TTL (ms) for cache entries
188
190
  })
189
191
  ```
190
192
 
@@ -192,6 +194,19 @@ new StarfishClient({
192
194
 
193
195
  Omit `capProvider` for unauthenticated public reads.
194
196
 
197
+ ### Offline-first read cache
198
+
199
+ Pass a `cache` (a `PullCache`: `{ get(k): Promise<string|null>; set(k, v): Promise<void> }`, host-backed by `localStorage`/`AsyncStorage`/etc.) to make every structured `pull()` offline-capable:
200
+
201
+ - **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
202
+ - **Offline fallback:** a pull that fails because the **transport** is unreachable (`fetch` rejects — offline/DNS/timeout) returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
203
+ - **Real HTTP errors propagate:** 404/403/5xx are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning.
204
+ - **`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).
205
+ - **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`).
206
+ - **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
207
+
208
+ Append collections are not cached here; they own warm-start persistence via `AppendLogCursor` (`persistEncrypted`).
209
+
195
210
  ## `SyncManager`
196
211
 
197
212
  ```ts
@@ -206,6 +221,7 @@ new SyncManager({
206
221
  - `signer.getSigner()` returns `{ devEdPubHex, sign(payload) }`. When set, every push attaches `authorPubkey = cap.sub` and `authorSignature = base64(Ed25519(payload))` over the encrypted payload (without the author fields).
207
222
  - `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
208
223
  - `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.
224
+ - **Offline-first (with a client `cache`):** `seedFromCache()` populates `localData` from the client's read-through cache without a network round-trip, decrypting in memory for E2E collections — the merge-doc counterpart to `AppendLogCursor.getDecryptedItems()`. `getLastPullFromCache()` reports whether the latest `pull()`/seed came from cache. On a zustand store these power cache-first paint: `useSyncInit` seeds before the initial pull, and the store exposes a `stale` flag (`seed()` action + `state.stale`) so the UI can show an "offline / showing last-synced data" indicator that clears on the next live pull.
209
225
 
210
226
  ## `AppendLogCursor`
211
227
 
@@ -4,7 +4,7 @@ import type { DevtoolsOptions } from "zustand/middleware";
4
4
  import type { Encryptor } from "@drakkar.software/starfish-protocol";
5
5
  import { SyncManager } from "../sync.js";
6
6
  import { AppendLogCursor, type AppendElement } from "../append-log.js";
7
- import type { StarfishCapProvider, ConflictResolver } from "../types.js";
7
+ import type { StarfishCapProvider, ConflictResolver, PullCache } from "../types.js";
8
8
  import type { SyncLogger } from "../logger.js";
9
9
  import type { Validator } from "../validate.js";
10
10
  export interface StarfishState {
@@ -15,6 +15,14 @@ export interface StarfishState {
15
15
  error: string | null;
16
16
  /** Last-known server hash, persisted alongside `data`/`dirty`. Restored into the bound SyncManager on hydration. */
17
17
  hash: string | null;
18
+ /**
19
+ * True when the currently-shown `data` came from the offline read-through
20
+ * cache (a cache-first {@link StarfishActions.seed} or a {@link StarfishActions.pull}
21
+ * the client served from cache because the transport was unreachable) rather
22
+ * than a live server response. A successful live pull/flush clears it. Use it
23
+ * to drive an "offline / showing last-synced data" indicator.
24
+ */
25
+ stale: boolean;
18
26
  }
19
27
  export interface StarfishActions {
20
28
  pull: () => Promise<void>;
@@ -23,6 +31,14 @@ export interface StarfishActions {
23
31
  restore: (data: Record<string, unknown>) => void;
24
32
  flush: () => Promise<void>;
25
33
  setOnline: (online: boolean) => void;
34
+ /**
35
+ * Cache-first paint: populate `data` from the client's offline read-through
36
+ * cache (decrypting in memory for E2E collections) without touching the
37
+ * network. A no-op when the client has no cache configured or there's no
38
+ * (unexpired) entry. {@link useSyncInit} calls this once before the initial
39
+ * pull; the live pull then supersedes the seeded snapshot.
40
+ */
41
+ seed: () => Promise<void>;
26
42
  }
27
43
  export type StarfishStore = StarfishState & StarfishActions;
28
44
  export interface CreateStarfishStoreOptions {
@@ -123,6 +139,16 @@ export interface SyncInitConfig {
123
139
  storeName?: string;
124
140
  storage?: StateStorage | false;
125
141
  fetch?: typeof globalThis.fetch;
142
+ /**
143
+ * Offline-first read-through cache for the underlying {@link StarfishClient}
144
+ * (see {@link StarfishClientOptions.cache}). When set, the store seeds from the
145
+ * last-synced ciphertext on creation (cache-first paint, decrypted in memory)
146
+ * and the live pull falls back to it when the transport is unreachable; the
147
+ * store's `stale` flag reflects whether the shown data is from cache.
148
+ */
149
+ cache?: PullCache;
150
+ /** Max age (ms) for {@link cache} entries; see {@link StarfishClientOptions.cacheMaxAgeMs}. */
151
+ cacheMaxAgeMs?: number;
126
152
  logger?: SyncLogger;
127
153
  validate?: Validator;
128
154
  }
@@ -266,6 +266,13 @@ var StarfishHttpError = class extends Error {
266
266
 
267
267
  // src/client.ts
268
268
  var APPEND_DEFAULT_FIELD = "items";
269
+ function pullCacheKey(pathAndQuery) {
270
+ const q = pathAndQuery.indexOf("?");
271
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
272
+ }
273
+ function pullWasFromCache(result) {
274
+ return result.fromCache === true;
275
+ }
269
276
  function stripPushPrefix(path) {
270
277
  return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
271
278
  }
@@ -283,6 +290,8 @@ var StarfishClient = class {
283
290
  namespace;
284
291
  capProvider;
285
292
  fetch;
293
+ cache;
294
+ cacheMaxAgeMs;
286
295
  /**
287
296
  * Installed client-side plugins. Currently stored as inert data; no
288
297
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -293,8 +302,19 @@ var StarfishClient = class {
293
302
  this.namespace = options.namespace || void 0;
294
303
  this.capProvider = options.capProvider;
295
304
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
305
+ this.cache = options.cache;
306
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
296
307
  this.plugins = options.plugins ? [...options.plugins] : [];
297
308
  }
309
+ /**
310
+ * Mark a `PullResult` as having been served from the offline read-through
311
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
312
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
313
+ */
314
+ tagFromCache(result) {
315
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
316
+ return result;
317
+ }
298
318
  /**
299
319
  * Resolve the host portion of the URL the client will send to. The host
300
320
  * is folded into the signed canonical input as the `h` field so the
@@ -414,10 +434,20 @@ var StarfishClient = class {
414
434
  }
415
435
  const url = `${this.baseUrl}${pathAndQuery}`;
416
436
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
417
- const res = await this.fetch(url, {
418
- method: "GET",
419
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
420
- });
437
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
438
+ let res;
439
+ try {
440
+ res = await this.fetch(url, {
441
+ method: "GET",
442
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
443
+ });
444
+ } catch (err) {
445
+ if (cacheKey) {
446
+ const cached = await this.readCache(cacheKey);
447
+ if (cached) return cached;
448
+ }
449
+ throw err;
450
+ }
421
451
  if (!res.ok) {
422
452
  throw new StarfishHttpError(res.status, await res.text());
423
453
  }
@@ -426,8 +456,46 @@ var StarfishClient = class {
426
456
  const list = result.data?.[appendField];
427
457
  return Array.isArray(list) ? list : [];
428
458
  }
459
+ if (cacheKey) {
460
+ const snapshot = {
461
+ data: result.data,
462
+ hash: result.hash,
463
+ timestamp: result.timestamp,
464
+ cachedAt: Date.now()
465
+ };
466
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
467
+ });
468
+ }
429
469
  return result;
430
470
  }
471
+ /**
472
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
473
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
474
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
475
+ * or null when no cache is configured / there's no entry. Namespacing matches
476
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
477
+ */
478
+ async peekCache(path) {
479
+ if (!this.cache) return null;
480
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
481
+ }
482
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
483
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
484
+ * must not break a pull, just miss). */
485
+ async readCache(cacheKey) {
486
+ try {
487
+ const raw = await this.cache.get(cacheKey);
488
+ if (!raw) return null;
489
+ const parsed = JSON.parse(raw);
490
+ if (!parsed || typeof parsed.hash !== "string") return null;
491
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
492
+ return null;
493
+ }
494
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
431
499
  /**
432
500
  * Pull several documents in one round-trip via `/batch/pull`. `collections` is
433
501
  * the list of distinct collection names; `opts.params` supplies, per collection,
@@ -648,6 +716,7 @@ var SyncManager = class {
648
716
  lastCheckpoint = 0;
649
717
  localData = {};
650
718
  aborted = false;
719
+ lastFromCache = false;
651
720
  constructor(options) {
652
721
  this.client = options.client;
653
722
  this.pullPath = options.pullPath;
@@ -688,6 +757,40 @@ var SyncManager = class {
688
757
  setHash(hash) {
689
758
  this.lastHash = hash;
690
759
  }
760
+ /**
761
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
762
+ * from the client's offline read-through cache rather than a live server
763
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
764
+ * offline indicator without treating a cache hit as "reachable". Reset to
765
+ * false by the next successful network pull.
766
+ */
767
+ getLastPullFromCache() {
768
+ return this.lastFromCache;
769
+ }
770
+ /**
771
+ * Cache-first paint: seed `localData` from the client's read-through cache
772
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
773
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
774
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
775
+ * the initial live {@link pull}, which then supersedes the seeded snapshot.
776
+ * Requires the client to have been built with a `cache`.
777
+ */
778
+ async seedFromCache() {
779
+ if (this.aborted) return false;
780
+ const cached = await this.client.peekCache(this.pullPath);
781
+ if (!cached) return false;
782
+ let data;
783
+ try {
784
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
785
+ } catch {
786
+ return false;
787
+ }
788
+ if (this.aborted) return false;
789
+ this.localData = data;
790
+ this.lastHash = cached.hash;
791
+ this.lastFromCache = true;
792
+ return true;
793
+ }
691
794
  getCheckpoint() {
692
795
  return this.lastCheckpoint;
693
796
  }
@@ -698,6 +801,7 @@ var SyncManager = class {
698
801
  try {
699
802
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
700
803
  if (this.aborted) throw new AbortError();
804
+ this.lastFromCache = pullWasFromCache(result);
701
805
  if (this.encryptor) {
702
806
  const decrypted = await this.encryptor.decrypt(result.data);
703
807
  if (this.aborted) throw new AbortError();
@@ -871,13 +975,23 @@ function createStarfishStore(options) {
871
975
  dirty: false,
872
976
  error: null,
873
977
  hash: null,
978
+ stale: false,
979
+ seed: async () => {
980
+ try {
981
+ const seeded = await syncManager.seedFromCache();
982
+ if (!seeded) return;
983
+ if (get().dirty || Object.keys(get().data).length > 0) return;
984
+ set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
985
+ } catch {
986
+ }
987
+ },
874
988
  pull: async () => {
875
989
  set({ syncing: true, error: null }, false, "pull/start");
876
990
  try {
877
991
  await syncManager.pull();
878
992
  const remote = syncManager.getData();
879
993
  const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
880
- set({ data: newData, syncing: false, hash: syncManager.getHash() }, false, "pull/success");
994
+ set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
881
995
  if (get().online && get().dirty) get().flush().catch(() => {
882
996
  });
883
997
  options.onRemoteUpdate?.(newData);
@@ -903,7 +1017,7 @@ function createStarfishStore(options) {
903
1017
  set({ syncing: true, error: null }, false, "flush/start");
904
1018
  try {
905
1019
  await syncManager.push(get().data);
906
- set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash() }, false, "flush/success");
1020
+ set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
907
1021
  } catch (err) {
908
1022
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
909
1023
  }
@@ -1028,7 +1142,9 @@ function useSyncInit(config) {
1028
1142
  baseUrl: config.serverUrl,
1029
1143
  namespace: config.namespace,
1030
1144
  capProvider: config.capProvider,
1031
- fetch: config.fetch
1145
+ fetch: config.fetch,
1146
+ cache: config.cache,
1147
+ cacheMaxAgeMs: config.cacheMaxAgeMs
1032
1148
  });
1033
1149
  const syncManager = new SyncManager({
1034
1150
  client,
@@ -1056,7 +1172,9 @@ function useSyncInit(config) {
1056
1172
  }
1057
1173
  });
1058
1174
  setStore(newStore);
1059
- newStore.getState().pull().catch(() => {
1175
+ newStore.getState().seed().finally(() => {
1176
+ newStore.getState().pull().catch(() => {
1177
+ });
1060
1178
  });
1061
1179
  return () => {
1062
1180
  setStore(null);