@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/README.md +17 -0
- package/dist/bindings/zustand.d.ts +27 -1
- package/dist/bindings/zustand.js +142 -9
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +27 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +121 -4
- package/dist/index.js.map +2 -2
- package/dist/sync.d.ts +28 -0
- package/dist/types.d.ts +43 -0
- package/package.json +2 -2
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
|
|
193
|
-
|
|
194
|
-
|
|
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,
|