@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/dist/client.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
2
2
  import { type AppendAuthor } from "@drakkar.software/starfish-protocol";
3
3
  import type { StarfishClientOptions } from "./types.js";
4
+ /**
5
+ * Whether a {@link PullResult} was served from the offline read-through cache
6
+ * (the transport was unreachable) rather than a live server response. Used by
7
+ * {@link SyncManager} to surface a `stale` flag to the UI without treating a
8
+ * cache hit as proof the server is reachable.
9
+ */
10
+ export declare function pullWasFromCache(result: PullResult): boolean;
4
11
  /** The storage `documentKey` for a push `path`: the path with the `/push/`
5
12
  * action prefix stripped (the namespace lives only in the URL). The author
6
13
  * signature binds to this key. */
@@ -76,12 +83,20 @@ export declare class StarfishClient {
76
83
  private readonly namespace?;
77
84
  private readonly capProvider?;
78
85
  private readonly fetch;
86
+ private readonly cache?;
87
+ private readonly cacheMaxAgeMs?;
79
88
  /**
80
89
  * Installed client-side plugins. Currently stored as inert data; no
81
90
  * hooks fire yet. Extensions can inspect this list if needed.
82
91
  */
83
92
  readonly plugins: ReadonlyArray<import("./types.js").ClientPlugin>;
84
93
  constructor(options: StarfishClientOptions);
94
+ /**
95
+ * Mark a `PullResult` as having been served from the offline read-through
96
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
97
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
98
+ */
99
+ private tagFromCache;
85
100
  /**
86
101
  * Resolve the host portion of the URL the client will send to. The host
87
102
  * is folded into the signed canonical input as the `h` field so the
@@ -142,6 +157,18 @@ export declare class StarfishClient {
142
157
  pull(path: string, options: PullOptions): Promise<PullResult>;
143
158
  /** Pull an append-only collection. Extracts and returns `data[appendField]` as `T[]`. */
144
159
  pull<T = unknown>(path: string, options: AppendPullOptions): Promise<T[]>;
160
+ /**
161
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
162
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
163
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
164
+ * or null when no cache is configured / there's no entry. Namespacing matches
165
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
166
+ */
167
+ peekCache(path: string): Promise<PullResult | null>;
168
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
169
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
170
+ * must not break a pull, just miss). */
171
+ private readCache;
145
172
  /**
146
173
  * Pull several documents in one round-trip via `/batch/pull`. `collections` is
147
174
  * the list of distinct collection names; `opts.params` supplies, per collection,
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;
@@ -470,6 +539,40 @@ var SyncManager = class {
470
539
  setHash(hash) {
471
540
  this.lastHash = hash;
472
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
+ }
473
576
  getCheckpoint() {
474
577
  return this.lastCheckpoint;
475
578
  }
@@ -480,6 +583,7 @@ var SyncManager = class {
480
583
  try {
481
584
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
482
585
  if (this.aborted) throw new AbortError();
586
+ this.lastFromCache = pullWasFromCache(result);
483
587
  if (this.encryptor) {
484
588
  const decrypted = await this.encryptor.decrypt(result.data);
485
589
  if (this.aborted) throw new AbortError();
@@ -1671,6 +1775,7 @@ export {
1671
1775
  isServiceWorkerSupported,
1672
1776
  noopSyncLogger,
1673
1777
  pruneTombstones,
1778
+ pullWasFromCache,
1674
1779
  registerBackgroundSync,
1675
1780
  registerServiceWorker,
1676
1781
  revocationListCanonicalSigningInput,