@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/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. */
@@ -95,6 +110,12 @@ export declare class StarfishClient {
95
110
  private readonly cacheFallbackStatuses?;
96
111
  private readonly onRevalidated?;
97
112
  private readonly revalidating;
113
+ /**
114
+ * In-memory mirror of the latest document timestamp written to each cache
115
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
116
+ * can guard against stale overwrites without an extra async cache read.
117
+ */
118
+ private readonly latestCacheTimestamp;
98
119
  /**
99
120
  * Installed client-side plugins. Currently stored as inert data; no
100
121
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -167,14 +188,31 @@ export declare class StarfishClient {
167
188
  pull(path: string, options: PullOptions): Promise<PullResult>;
168
189
  /** Pull an append-only collection. Extracts and returns `data[appendField]` as `T[]`. */
169
190
  pull<T = unknown>(path: string, options: AppendPullOptions): Promise<T[]>;
170
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
191
+ /**
192
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
193
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
194
+ */
195
+ private writeCache;
196
+ /** Build the URL + auth headers for one revalidation GET. Shared between
197
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
198
+ private revalidateFetch;
199
+ /**
200
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
201
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
202
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
203
+ * read path (`immediate: true` — no initial delay on the first attempt). The
204
+ * `revalidating` set deduplicates across both triggers so a concurrent
205
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
206
+ */
171
207
  private scheduleRevalidate;
172
208
  /**
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).
209
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
210
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
211
+ *
212
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
213
+ * When `immediate` is true the first attempt fires without any initial delay
214
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
215
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
178
216
  */
179
217
  private revalidateLoop;
180
218
  /**
package/dist/index.js CHANGED
@@ -110,6 +110,12 @@ var StarfishClient = class {
110
110
  cacheFallbackStatuses;
111
111
  onRevalidated;
112
112
  revalidating = /* @__PURE__ */ new Set();
113
+ /**
114
+ * In-memory mirror of the latest document timestamp written to each cache
115
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
116
+ * can guard against stale overwrites without an extra async cache read.
117
+ */
118
+ latestCacheTimestamp = /* @__PURE__ */ new Map();
113
119
  /**
114
120
  * Installed client-side plugins. Currently stored as inert data; no
115
121
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -226,11 +232,12 @@ var StarfishClient = class {
226
232
  async pull(path, checkpointOrOptions) {
227
233
  let pathAndQuery = this.applyNamespace(path);
228
234
  let appendField;
235
+ let swr = false;
229
236
  if (typeof checkpointOrOptions === "number") {
230
237
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
231
238
  } else if (checkpointOrOptions != null) {
232
239
  const opts = checkpointOrOptions;
233
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
240
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
234
241
  const params = new URLSearchParams();
235
242
  if (isPullOptions) {
236
243
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -239,6 +246,7 @@ var StarfishClient = class {
239
246
  if (opts.withKeyring) {
240
247
  params.set("withKeyring", "1");
241
248
  }
249
+ swr = opts.staleWhileRevalidate === true;
242
250
  } else {
243
251
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
244
252
  if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
@@ -265,6 +273,19 @@ var StarfishClient = class {
265
273
  const url = `${this.baseUrl}${pathAndQuery}`;
266
274
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
267
275
  const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
276
+ if (swr && cacheKey) {
277
+ const cached = await this.readCache(cacheKey);
278
+ if (cached) {
279
+ this.scheduleRevalidate(
280
+ cacheKey,
281
+ pathAndQuery,
282
+ null,
283
+ /* immediate */
284
+ true
285
+ );
286
+ return cached;
287
+ }
288
+ }
268
289
  let res;
269
290
  try {
270
291
  res = await this.fetch(url, {
@@ -296,67 +317,87 @@ var StarfishClient = class {
296
317
  const list = result.data?.[appendField];
297
318
  return Array.isArray(list) ? list : [];
298
319
  }
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
- }
320
+ if (cacheKey) this.writeCache(cacheKey, result);
309
321
  return result;
310
322
  }
311
- /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
312
- scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
323
+ /**
324
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
325
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
326
+ */
327
+ writeCache(cacheKey, result) {
328
+ if (!this.cache) return;
329
+ if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
330
+ this.latestCacheTimestamp.set(cacheKey, result.timestamp);
331
+ }
332
+ const snapshot = {
333
+ data: result.data,
334
+ hash: result.hash,
335
+ timestamp: result.timestamp,
336
+ cachedAt: Date.now()
337
+ };
338
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
339
+ });
340
+ }
341
+ /** Build the URL + auth headers for one revalidation GET. Shared between
342
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
343
+ async revalidateFetch(pathAndQuery) {
344
+ const url = `${this.baseUrl}${pathAndQuery}`;
345
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
346
+ return this.fetch(url, {
347
+ method: "GET",
348
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
349
+ });
350
+ }
351
+ /**
352
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
353
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
354
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
355
+ * read path (`immediate: true` — no initial delay on the first attempt). The
356
+ * `revalidating` set deduplicates across both triggers so a concurrent
357
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
358
+ */
359
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
313
360
  if (this.revalidating.has(cacheKey)) return;
314
361
  this.revalidating.add(cacheKey);
315
- void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
362
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
316
363
  this.revalidating.delete(cacheKey);
317
364
  });
318
365
  }
319
366
  /**
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).
367
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
368
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
369
+ *
370
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
371
+ * When `immediate` is true the first attempt fires without any initial delay
372
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
373
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
325
374
  */
326
- async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
375
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
327
376
  let retryAfterHeader = firstRetryAfter;
377
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
328
378
  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 }
379
+ if (!immediate || attempt > 0) {
380
+ const delay = parseRetryAfterMs(retryAfterHeader, {
381
+ fallbackMs: Math.min(
382
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
383
+ REVALIDATE_MAX_DELAY_MS
384
+ ),
385
+ maxMs: REVALIDATE_MAX_DELAY_MS
343
386
  });
387
+ await sleep(delay);
388
+ }
389
+ try {
390
+ const res = await this.revalidateFetch(pathAndQuery);
344
391
  if (res.ok) {
345
392
  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
- });
393
+ const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
394
+ if (result.timestamp >= latestTs) {
395
+ this.writeCache(cacheKey, result);
396
+ this.onRevalidated?.(pathAndQuery, result);
355
397
  }
356
- this.onRevalidated?.(pathAndQuery, result);
357
398
  return;
358
399
  }
359
- if (!this.cacheFallbackStatuses?.includes(res.status)) {
400
+ if (!fallbackSet?.has(res.status)) {
360
401
  return;
361
402
  }
362
403
  retryAfterHeader = res.headers.get("Retry-After");
@@ -478,15 +519,7 @@ var StarfishClient = class {
478
519
  const result = await res.json();
479
520
  if (this.cache) {
480
521
  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
- });
522
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
490
523
  }
491
524
  return result;
492
525
  }
@@ -767,6 +800,42 @@ var SyncManager = class {
767
800
  getCheckpoint() {
768
801
  return this.lastCheckpoint;
769
802
  }
803
+ /**
804
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
805
+ * firing a network request. Used by the zustand binding's `mergeResult`
806
+ * action to absorb a background revalidation result (delivered via
807
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
808
+ *
809
+ * Like {@link pull}, `ingest` conflict-merges the snapshot against the
810
+ * established baseline via `this.onConflict` when a checkpoint exists — so a
811
+ * union-merge store does not lose array items when a revalidation result
812
+ * (e.g. a stale cache-fallback on 429/5xx) is a shorter snapshot. The first
813
+ * ingest (no checkpoint yet) takes the snapshot wholesale. Sets
814
+ * `lastFromCache = false` (a revalidation is a live response) so the binding
815
+ * can clear its `stale` flag.
816
+ *
817
+ * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
818
+ * time the revalidation request was sent and the time it resolves, the
819
+ * result is from an older document version. Ingesting it would clobber the
820
+ * user's just-saved edit and reset `lastHash` to a stale server hash
821
+ * (causing a spurious 409 on the next push). We silently drop the result in
822
+ * that case — the store's post-push state is already correct.
823
+ */
824
+ async ingest(result) {
825
+ if (this.aborted) return;
826
+ if (result.timestamp < this.lastCheckpoint) return;
827
+ let incoming;
828
+ if (this.encryptor) {
829
+ incoming = await this.encryptor.decrypt(result.data);
830
+ if (this.aborted) return;
831
+ } else {
832
+ incoming = result.data;
833
+ }
834
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
835
+ this.lastHash = result.hash;
836
+ this.lastCheckpoint = result.timestamp;
837
+ this.lastFromCache = false;
838
+ }
770
839
  async pull() {
771
840
  if (this.aborted) throw new AbortError();
772
841
  this.logger?.pullStart(this.loggerName);
@@ -775,17 +844,15 @@ var SyncManager = class {
775
844
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
776
845
  if (this.aborted) throw new AbortError();
777
846
  this.lastFromCache = pullWasFromCache(result);
847
+ let incoming;
778
848
  if (this.encryptor) {
779
- const decrypted = await this.encryptor.decrypt(result.data);
849
+ incoming = await this.encryptor.decrypt(result.data);
780
850
  if (this.aborted) throw new AbortError();
781
- this.localData = decrypted;
782
- result.data = decrypted;
783
- } else if (this.lastCheckpoint > 0) {
784
- this.localData = deepMerge(this.localData, result.data);
785
- result.data = this.localData;
786
851
  } else {
787
- this.localData = result.data;
852
+ incoming = result.data;
788
853
  }
854
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
855
+ result.data = this.localData;
789
856
  this.lastHash = result.hash;
790
857
  this.lastCheckpoint = result.timestamp;
791
858
  this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
@@ -971,20 +1038,22 @@ var AppendLogCursor = class {
971
1038
  const raw = await this.client.pull(this.pullPath, opts);
972
1039
  const batch = [];
973
1040
  const stored = [];
974
- let maxTs = since;
975
1041
  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;
1042
+ const eligible = raw.filter((el) => !(since > 0 && el.ts <= since));
1043
+ let maxTs = since;
1044
+ for (const el of eligible) if (el.ts > maxTs) maxTs = el.ts;
1045
+ const decryptResults = await Promise.all(eligible.map(async (el) => {
980
1046
  try {
981
1047
  this.verifyOne(el);
982
1048
  const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
983
- decrypted = withAuthor(el.ts, data, el);
1049
+ return { el, decrypted: withAuthor(el.ts, data, el) };
984
1050
  } catch (err) {
985
1051
  if (this.onElementError !== "skip") throw err;
986
1052
  skipped++;
1053
+ return { el, decrypted: null };
987
1054
  }
1055
+ }));
1056
+ for (const { el, decrypted } of decryptResults) {
988
1057
  if (this.persistEncrypted) {
989
1058
  stored.push(withAuthor(el.ts, el.data, el));
990
1059
  } else if (decrypted) {
@@ -1041,17 +1110,17 @@ var AppendLogCursor = class {
1041
1110
  async getDecryptedItems() {
1042
1111
  const snapshot = [...this.items];
1043
1112
  if (!this.encryptor || !this.persistEncrypted) return snapshot;
1044
- const out = [];
1045
- for (const el of snapshot) {
1113
+ const results = await Promise.all(snapshot.map(async (el) => {
1046
1114
  try {
1047
1115
  this.verifyOne(el);
1048
1116
  const data = await this.encryptor.decrypt(el.data);
1049
- out.push(withAuthor(el.ts, data, el));
1117
+ return withAuthor(el.ts, data, el);
1050
1118
  } catch (err) {
1051
1119
  if (this.onElementError !== "skip") throw err;
1120
+ return null;
1052
1121
  }
1053
- }
1054
- return out;
1122
+ }));
1123
+ return results.filter((el) => el !== null);
1055
1124
  }
1056
1125
  /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
1057
1126
  * when nothing has been pulled or seeded. */
@@ -1197,8 +1266,7 @@ function shallowEqual(a, b) {
1197
1266
  if (typeof a !== "object") return false;
1198
1267
  if (Array.isArray(a) !== Array.isArray(b)) return false;
1199
1268
  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]));
1269
+ return a.length === b.length && a.every((v, i) => shallowEqual(v, b[i]));
1202
1270
  }
1203
1271
  const aObj = a;
1204
1272
  const bObj = b;
@@ -1740,12 +1808,8 @@ async function unregisterServiceWorkers() {
1740
1808
  if (!isServiceWorkerSupported()) return false;
1741
1809
  try {
1742
1810
  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;
1811
+ const results = await Promise.all(registrations.map((r) => r.unregister()));
1812
+ return results.some(Boolean);
1749
1813
  } catch {
1750
1814
  return false;
1751
1815
  }