@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/README.md +5 -1
- package/dist/bindings/zustand.d.ts +13 -1
- package/dist/bindings/zustand.js +134 -73
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +38 -6
- package/dist/index.js +115 -73
- package/dist/index.js.map +2 -2
- package/dist/sync.d.ts +12 -0
- package/dist/types.d.ts +13 -7
- package/package.json +3 -3
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
|
-
/**
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
-
/**
|
|
312
|
-
|
|
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
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
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
|
}
|