@drakkar.software/starfish-client 3.0.0-alpha.13 → 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/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { stableStringify, computeHash } from "@drakkar.software/starfish-protoco
4
4
  export { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
5
5
  export type { RevocationList, RevocationEntry, RevokedSubject, BuildRevocationListOpts, } from "@drakkar.software/starfish-protocol";
6
6
  export type { PullResult, PushSuccess, PullKeyringProjection } from "@drakkar.software/starfish-protocol";
7
- export { StarfishClient } from "./client.js";
7
+ export { StarfishClient, pullWasFromCache } from "./client.js";
8
8
  export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions, BatchPullOptions, BatchPullResult, BatchPullEntry, } from "./client.js";
9
9
  export { SyncManager, AbortError } from "./sync.js";
10
10
  export type { SyncManagerOptions, SyncSigner } from "./sync.js";
@@ -13,7 +13,7 @@ export type { AppendLogCursorOptions, AppendElement, AuthorVerifier, ElementErro
13
13
  export { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
14
14
  export type { Encryptor } from "@drakkar.software/starfish-protocol";
15
15
  export { ConflictError, StarfishHttpError, } from "./types.js";
16
- export type { StarfishClientOptions, StarfishCapProvider, ConflictResolver, ClientPlugin, } from "./types.js";
16
+ export type { StarfishClientOptions, StarfishCapProvider, PullCache, ConflictResolver, ClientPlugin, } from "./types.js";
17
17
  export { consoleSyncLogger, noopSyncLogger, createMetricsCollector } from "./logger.js";
18
18
  export type { SyncLogger, SyncMetrics, MetricsCollector } from "./logger.js";
19
19
  export { createMigrator } from "./migrate.js";
package/dist/index.js CHANGED
@@ -41,6 +41,13 @@ var StarfishHttpError = class extends Error {
41
41
 
42
42
  // src/client.ts
43
43
  var APPEND_DEFAULT_FIELD = "items";
44
+ function pullCacheKey(pathAndQuery) {
45
+ const q = pathAndQuery.indexOf("?");
46
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
47
+ }
48
+ function pullWasFromCache(result) {
49
+ return result.fromCache === true;
50
+ }
44
51
  function stripPushPrefix(path) {
45
52
  return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
46
53
  }
@@ -58,6 +65,8 @@ var StarfishClient = class {
58
65
  namespace;
59
66
  capProvider;
60
67
  fetch;
68
+ cache;
69
+ cacheMaxAgeMs;
61
70
  /**
62
71
  * Installed client-side plugins. Currently stored as inert data; no
63
72
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -68,8 +77,19 @@ var StarfishClient = class {
68
77
  this.namespace = options.namespace || void 0;
69
78
  this.capProvider = options.capProvider;
70
79
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
80
+ this.cache = options.cache;
81
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
71
82
  this.plugins = options.plugins ? [...options.plugins] : [];
72
83
  }
84
+ /**
85
+ * Mark a `PullResult` as having been served from the offline read-through
86
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
87
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
88
+ */
89
+ tagFromCache(result) {
90
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
91
+ return result;
92
+ }
73
93
  /**
74
94
  * Resolve the host portion of the URL the client will send to. The host
75
95
  * is folded into the signed canonical input as the `h` field so the
@@ -189,10 +209,20 @@ var StarfishClient = class {
189
209
  }
190
210
  const url = `${this.baseUrl}${pathAndQuery}`;
191
211
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
192
- const res = await this.fetch(url, {
193
- method: "GET",
194
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
195
- });
212
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
213
+ let res;
214
+ try {
215
+ res = await this.fetch(url, {
216
+ method: "GET",
217
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
218
+ });
219
+ } catch (err) {
220
+ if (cacheKey) {
221
+ const cached = await this.readCache(cacheKey);
222
+ if (cached) return cached;
223
+ }
224
+ throw err;
225
+ }
196
226
  if (!res.ok) {
197
227
  throw new StarfishHttpError(res.status, await res.text());
198
228
  }
@@ -201,8 +231,46 @@ var StarfishClient = class {
201
231
  const list = result.data?.[appendField];
202
232
  return Array.isArray(list) ? list : [];
203
233
  }
234
+ if (cacheKey) {
235
+ const snapshot = {
236
+ data: result.data,
237
+ hash: result.hash,
238
+ timestamp: result.timestamp,
239
+ cachedAt: Date.now()
240
+ };
241
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
242
+ });
243
+ }
204
244
  return result;
205
245
  }
246
+ /**
247
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
248
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
249
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
250
+ * or null when no cache is configured / there's no entry. Namespacing matches
251
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
252
+ */
253
+ async peekCache(path) {
254
+ if (!this.cache) return null;
255
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
256
+ }
257
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
258
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
259
+ * must not break a pull, just miss). */
260
+ async readCache(cacheKey) {
261
+ try {
262
+ const raw = await this.cache.get(cacheKey);
263
+ if (!raw) return null;
264
+ const parsed = JSON.parse(raw);
265
+ if (!parsed || typeof parsed.hash !== "string") return null;
266
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
267
+ return null;
268
+ }
269
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
206
274
  /**
207
275
  * Pull several documents in one round-trip via `/batch/pull`. `collections` is
208
276
  * the list of distinct collection names; `opts.params` supplies, per collection,
@@ -430,6 +498,7 @@ var SyncManager = class {
430
498
  lastCheckpoint = 0;
431
499
  localData = {};
432
500
  aborted = false;
501
+ lastFromCache = false;
433
502
  constructor(options) {
434
503
  this.client = options.client;
435
504
  this.pullPath = options.pullPath;
@@ -451,6 +520,18 @@ var SyncManager = class {
451
520
  getData() {
452
521
  return { ...this.localData };
453
522
  }
523
+ /**
524
+ * Merge a remote snapshot with local (optimistic) data using this manager's
525
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
526
+ * {@link pull} overwrites the store's data with the server snapshot, which
527
+ * would drop un-pushed local writes (they live only in the store, never in
528
+ * `localData` until a push succeeds). The zustand binding calls this on pull
529
+ * while the store is dirty so those writes survive. `local` wins by the same
530
+ * rules as a push conflict.
531
+ */
532
+ resolve(local, remote) {
533
+ return this.onConflict(local, remote);
534
+ }
454
535
  getHash() {
455
536
  return this.lastHash;
456
537
  }
@@ -458,6 +539,40 @@ var SyncManager = class {
458
539
  setHash(hash) {
459
540
  this.lastHash = hash;
460
541
  }
542
+ /**
543
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
544
+ * from the client's offline read-through cache rather than a live server
545
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
546
+ * offline indicator without treating a cache hit as "reachable". Reset to
547
+ * false by the next successful network pull.
548
+ */
549
+ getLastPullFromCache() {
550
+ return this.lastFromCache;
551
+ }
552
+ /**
553
+ * Cache-first paint: seed `localData` from the client's read-through cache
554
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
555
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
556
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
557
+ * the initial live {@link pull}, which then supersedes the seeded snapshot.
558
+ * Requires the client to have been built with a `cache`.
559
+ */
560
+ async seedFromCache() {
561
+ if (this.aborted) return false;
562
+ const cached = await this.client.peekCache(this.pullPath);
563
+ if (!cached) return false;
564
+ let data;
565
+ try {
566
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
567
+ } catch {
568
+ return false;
569
+ }
570
+ if (this.aborted) return false;
571
+ this.localData = data;
572
+ this.lastHash = cached.hash;
573
+ this.lastFromCache = true;
574
+ return true;
575
+ }
461
576
  getCheckpoint() {
462
577
  return this.lastCheckpoint;
463
578
  }
@@ -468,6 +583,7 @@ var SyncManager = class {
468
583
  try {
469
584
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
470
585
  if (this.aborted) throw new AbortError();
586
+ this.lastFromCache = pullWasFromCache(result);
471
587
  if (this.encryptor) {
472
588
  const decrypted = await this.encryptor.decrypt(result.data);
473
589
  if (this.aborted) throw new AbortError();
@@ -1659,6 +1775,7 @@ export {
1659
1775
  isServiceWorkerSupported,
1660
1776
  noopSyncLogger,
1661
1777
  pruneTombstones,
1778
+ pullWasFromCache,
1662
1779
  registerBackgroundSync,
1663
1780
  registerServiceWorker,
1664
1781
  revocationListCanonicalSigningInput,