@drakkar.software/starfish-client 3.0.0-alpha.30 → 3.0.0-alpha.35

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
@@ -54,6 +54,21 @@ export interface PullOptions {
54
54
  checkpoint?: number;
55
55
  /** Include the sibling `_keyring` document in the response. Defaults to false. */
56
56
  withKeyring?: boolean;
57
+ /**
58
+ * Serve the last-synced cached snapshot immediately (tagged via
59
+ * {@link pullWasFromCache}) and revalidate in the background. Requires a
60
+ * {@link StarfishClientOptions.cache} to be configured; without one the option
61
+ * is a no-op and the pull goes to the network as usual.
62
+ *
63
+ * On a cache hit: returns the stale snapshot at once, kicks a background fetch,
64
+ * and on success writes the fresh snapshot to cache and fires
65
+ * {@link StarfishClientOptions.onRevalidated}. Uses the same dedup set as the
66
+ * {@link StarfishClientOptions.cacheFallbackStatuses} revalidation path — a
67
+ * concurrent error-triggered loop for the same document is not duplicated.
68
+ *
69
+ * On a cache miss: falls through to the normal network-first pull unchanged.
70
+ */
71
+ staleWhileRevalidate?: boolean;
57
72
  }
58
73
  /** Per-collection result in a {@link BatchPullResult}: either the pulled
59
74
  * document (`data`/`hash`/`timestamp`) or a per-collection `error` string. */
@@ -167,14 +182,31 @@ export declare class StarfishClient {
167
182
  pull(path: string, options: PullOptions): Promise<PullResult>;
168
183
  /** Pull an append-only collection. Extracts and returns `data[appendField]` as `T[]`. */
169
184
  pull<T = unknown>(path: string, options: AppendPullOptions): Promise<T[]>;
170
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
185
+ /**
186
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
187
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
188
+ */
189
+ private writeCache;
190
+ /** Build the URL + auth headers for one revalidation GET. Shared between
191
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
192
+ private revalidateFetch;
193
+ /**
194
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
195
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
196
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
197
+ * read path (`immediate: true` — no initial delay on the first attempt). The
198
+ * `revalidating` set deduplicates across both triggers so a concurrent
199
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
200
+ */
171
201
  private scheduleRevalidate;
172
202
  /**
173
- * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
174
- * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
175
- * times. On a live 2xx response the fresh snapshot is written through to the
176
- * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
177
- * status (e.g. 404/403 the server gave a genuine answer).
203
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
204
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
205
+ *
206
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
207
+ * When `immediate` is true the first attempt fires without any initial delay
208
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
209
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
178
210
  */
179
211
  private revalidateLoop;
180
212
  /**
package/dist/index.js CHANGED
@@ -226,11 +226,12 @@ var StarfishClient = class {
226
226
  async pull(path, checkpointOrOptions) {
227
227
  let pathAndQuery = this.applyNamespace(path);
228
228
  let appendField;
229
+ let swr = false;
229
230
  if (typeof checkpointOrOptions === "number") {
230
231
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
231
232
  } else if (checkpointOrOptions != null) {
232
233
  const opts = checkpointOrOptions;
233
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
234
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
234
235
  const params = new URLSearchParams();
235
236
  if (isPullOptions) {
236
237
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -239,6 +240,7 @@ var StarfishClient = class {
239
240
  if (opts.withKeyring) {
240
241
  params.set("withKeyring", "1");
241
242
  }
243
+ swr = opts.staleWhileRevalidate === true;
242
244
  } else {
243
245
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
244
246
  if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
@@ -265,6 +267,19 @@ var StarfishClient = class {
265
267
  const url = `${this.baseUrl}${pathAndQuery}`;
266
268
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
267
269
  const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
270
+ if (swr && cacheKey) {
271
+ const cached = await this.readCache(cacheKey);
272
+ if (cached) {
273
+ this.scheduleRevalidate(
274
+ cacheKey,
275
+ pathAndQuery,
276
+ null,
277
+ /* immediate */
278
+ true
279
+ );
280
+ return cached;
281
+ }
282
+ }
268
283
  let res;
269
284
  try {
270
285
  res = await this.fetch(url, {
@@ -296,67 +311,81 @@ var StarfishClient = class {
296
311
  const list = result.data?.[appendField];
297
312
  return Array.isArray(list) ? list : [];
298
313
  }
299
- if (cacheKey) {
300
- const snapshot = {
301
- data: result.data,
302
- hash: result.hash,
303
- timestamp: result.timestamp,
304
- cachedAt: Date.now()
305
- };
306
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
307
- });
308
- }
314
+ if (cacheKey) this.writeCache(cacheKey, result);
309
315
  return result;
310
316
  }
311
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
312
- scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
317
+ /**
318
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
319
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
320
+ */
321
+ writeCache(cacheKey, result) {
322
+ if (!this.cache) return;
323
+ const snapshot = {
324
+ data: result.data,
325
+ hash: result.hash,
326
+ timestamp: result.timestamp,
327
+ cachedAt: Date.now()
328
+ };
329
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
330
+ });
331
+ }
332
+ /** Build the URL + auth headers for one revalidation GET. Shared between
333
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
334
+ async revalidateFetch(pathAndQuery) {
335
+ const url = `${this.baseUrl}${pathAndQuery}`;
336
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
337
+ return this.fetch(url, {
338
+ method: "GET",
339
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
340
+ });
341
+ }
342
+ /**
343
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
344
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
345
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
346
+ * read path (`immediate: true` — no initial delay on the first attempt). The
347
+ * `revalidating` set deduplicates across both triggers so a concurrent
348
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
349
+ */
350
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
313
351
  if (this.revalidating.has(cacheKey)) return;
314
352
  this.revalidating.add(cacheKey);
315
- void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
353
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
316
354
  this.revalidating.delete(cacheKey);
317
355
  });
318
356
  }
319
357
  /**
320
- * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
321
- * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
322
- * times. On a live 2xx response the fresh snapshot is written through to the
323
- * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
324
- * status (e.g. 404/403 the server gave a genuine answer).
358
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
359
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
360
+ *
361
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
362
+ * When `immediate` is true the first attempt fires without any initial delay
363
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
364
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
325
365
  */
326
- async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
366
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
327
367
  let retryAfterHeader = firstRetryAfter;
368
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
328
369
  for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
329
- const delay = parseRetryAfterMs(retryAfterHeader, {
330
- fallbackMs: Math.min(
331
- REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
332
- REVALIDATE_MAX_DELAY_MS
333
- ),
334
- maxMs: REVALIDATE_MAX_DELAY_MS
335
- });
336
- await sleep(delay);
337
- try {
338
- const url = `${this.baseUrl}${pathAndQuery}`;
339
- const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
340
- const res = await this.fetch(url, {
341
- method: "GET",
342
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
370
+ if (!immediate || attempt > 0) {
371
+ const delay = parseRetryAfterMs(retryAfterHeader, {
372
+ fallbackMs: Math.min(
373
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
374
+ REVALIDATE_MAX_DELAY_MS
375
+ ),
376
+ maxMs: REVALIDATE_MAX_DELAY_MS
343
377
  });
378
+ await sleep(delay);
379
+ }
380
+ try {
381
+ const res = await this.revalidateFetch(pathAndQuery);
344
382
  if (res.ok) {
345
383
  const result = await res.json();
346
- if (this.cache) {
347
- const snapshot = {
348
- data: result.data,
349
- hash: result.hash,
350
- timestamp: result.timestamp,
351
- cachedAt: Date.now()
352
- };
353
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
354
- });
355
- }
384
+ this.writeCache(cacheKey, result);
356
385
  this.onRevalidated?.(pathAndQuery, result);
357
386
  return;
358
387
  }
359
- if (!this.cacheFallbackStatuses?.includes(res.status)) {
388
+ if (!fallbackSet?.has(res.status)) {
360
389
  return;
361
390
  }
362
391
  retryAfterHeader = res.headers.get("Retry-After");
@@ -478,15 +507,7 @@ var StarfishClient = class {
478
507
  const result = await res.json();
479
508
  if (this.cache) {
480
509
  const pullPath = sendPath.replace("/push/", "/pull/");
481
- const cacheKey = pullCacheKey(pullPath);
482
- const snapshot = {
483
- data,
484
- hash: result.hash,
485
- timestamp: result.timestamp,
486
- cachedAt: Date.now()
487
- };
488
- void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
489
- });
510
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
490
511
  }
491
512
  return result;
492
513
  }
@@ -767,6 +788,30 @@ var SyncManager = class {
767
788
  getCheckpoint() {
768
789
  return this.lastCheckpoint;
769
790
  }
791
+ /**
792
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
793
+ * firing a network request. Used by the zustand binding's `mergeResult`
794
+ * action to absorb a background revalidation result (delivered via
795
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
796
+ *
797
+ * Unlike {@link pull}, `ingest` never does a deep-merge with the previous
798
+ * checkpoint — the revalidated result is always a full fresh snapshot. It
799
+ * sets `lastFromCache = false` (a revalidation is a live response) so the
800
+ * binding can clear its `stale` flag.
801
+ */
802
+ async ingest(result) {
803
+ if (this.aborted) return;
804
+ if (this.encryptor) {
805
+ const decrypted = await this.encryptor.decrypt(result.data);
806
+ if (this.aborted) return;
807
+ this.localData = decrypted;
808
+ } else {
809
+ this.localData = result.data;
810
+ }
811
+ this.lastHash = result.hash;
812
+ this.lastCheckpoint = result.timestamp;
813
+ this.lastFromCache = false;
814
+ }
770
815
  async pull() {
771
816
  if (this.aborted) throw new AbortError();
772
817
  this.logger?.pullStart(this.loggerName);
@@ -971,20 +1016,22 @@ var AppendLogCursor = class {
971
1016
  const raw = await this.client.pull(this.pullPath, opts);
972
1017
  const batch = [];
973
1018
  const stored = [];
974
- let maxTs = since;
975
1019
  let skipped = 0;
976
- for (const el of raw) {
977
- if (since > 0 && el.ts <= since) continue;
978
- if (el.ts > maxTs) maxTs = el.ts;
979
- let decrypted = null;
1020
+ const eligible = raw.filter((el) => !(since > 0 && el.ts <= since));
1021
+ let maxTs = since;
1022
+ for (const el of eligible) if (el.ts > maxTs) maxTs = el.ts;
1023
+ const decryptResults = await Promise.all(eligible.map(async (el) => {
980
1024
  try {
981
1025
  this.verifyOne(el);
982
1026
  const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
983
- decrypted = withAuthor(el.ts, data, el);
1027
+ return { el, decrypted: withAuthor(el.ts, data, el) };
984
1028
  } catch (err) {
985
1029
  if (this.onElementError !== "skip") throw err;
986
1030
  skipped++;
1031
+ return { el, decrypted: null };
987
1032
  }
1033
+ }));
1034
+ for (const { el, decrypted } of decryptResults) {
988
1035
  if (this.persistEncrypted) {
989
1036
  stored.push(withAuthor(el.ts, el.data, el));
990
1037
  } else if (decrypted) {
@@ -1041,17 +1088,17 @@ var AppendLogCursor = class {
1041
1088
  async getDecryptedItems() {
1042
1089
  const snapshot = [...this.items];
1043
1090
  if (!this.encryptor || !this.persistEncrypted) return snapshot;
1044
- const out = [];
1045
- for (const el of snapshot) {
1091
+ const results = await Promise.all(snapshot.map(async (el) => {
1046
1092
  try {
1047
1093
  this.verifyOne(el);
1048
1094
  const data = await this.encryptor.decrypt(el.data);
1049
- out.push(withAuthor(el.ts, data, el));
1095
+ return withAuthor(el.ts, data, el);
1050
1096
  } catch (err) {
1051
1097
  if (this.onElementError !== "skip") throw err;
1098
+ return null;
1052
1099
  }
1053
- }
1054
- return out;
1100
+ }));
1101
+ return results.filter((el) => el !== null);
1055
1102
  }
1056
1103
  /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
1057
1104
  * when nothing has been pulled or seeded. */
@@ -1197,8 +1244,7 @@ function shallowEqual(a, b) {
1197
1244
  if (typeof a !== "object") return false;
1198
1245
  if (Array.isArray(a) !== Array.isArray(b)) return false;
1199
1246
  if (Array.isArray(a) && Array.isArray(b)) {
1200
- if (a.length !== b.length) return false;
1201
- return a.every((v, i) => shallowEqual(v, b[i]));
1247
+ return a.length === b.length && a.every((v, i) => shallowEqual(v, b[i]));
1202
1248
  }
1203
1249
  const aObj = a;
1204
1250
  const bObj = b;
@@ -1740,12 +1786,8 @@ async function unregisterServiceWorkers() {
1740
1786
  if (!isServiceWorkerSupported()) return false;
1741
1787
  try {
1742
1788
  const registrations = await navigator.serviceWorker.getRegistrations();
1743
- let unregistered = false;
1744
- for (const registration of registrations) {
1745
- const result = await registration.unregister();
1746
- if (result) unregistered = true;
1747
- }
1748
- return unregistered;
1789
+ const results = await Promise.all(registrations.map((r) => r.unregister()));
1790
+ return results.some(Boolean);
1749
1791
  } catch {
1750
1792
  return false;
1751
1793
  }