@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/README.md +16 -0
- package/dist/bindings/zustand.d.ts +27 -1
- package/dist/bindings/zustand.js +126 -8
- 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 +109 -4
- package/dist/index.js.map +2 -2
- package/dist/sync.d.ts +18 -0
- package/dist/types.d.ts +43 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -185,6 +185,8 @@ new StarfishClient({
|
|
|
185
185
|
baseUrl: "https://api.example.com/v1",
|
|
186
186
|
capProvider, // v3 — signs every request. Replaces v2 `auth`/`authProvider`.
|
|
187
187
|
fetch, // optional custom fetch
|
|
188
|
+
cache, // optional offline-first read-through cache — see below
|
|
189
|
+
cacheMaxAgeMs, // optional TTL (ms) for cache entries
|
|
188
190
|
})
|
|
189
191
|
```
|
|
190
192
|
|
|
@@ -192,6 +194,19 @@ new StarfishClient({
|
|
|
192
194
|
|
|
193
195
|
Omit `capProvider` for unauthenticated public reads.
|
|
194
196
|
|
|
197
|
+
### Offline-first read cache
|
|
198
|
+
|
|
199
|
+
Pass a `cache` (a `PullCache`: `{ get(k): Promise<string|null>; set(k, v): Promise<void> }`, host-backed by `localStorage`/`AsyncStorage`/etc.) to make every structured `pull()` offline-capable:
|
|
200
|
+
|
|
201
|
+
- **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
|
|
202
|
+
- **Offline fallback:** a pull that fails because the **transport** is unreachable (`fetch` rejects — offline/DNS/timeout) returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
|
|
203
|
+
- **Real HTTP errors propagate:** 404/403/5xx are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning.
|
|
204
|
+
- **`cacheMaxAgeMs`:** an entry older than this is treated as a miss (both cache-first paint and offline fallback); omit for entries that never expire (recommended for offline-first, where any last-synced data beats none).
|
|
205
|
+
- **Ciphertext-at-rest by construction:** the cache stores the raw server payload, which for E2E (`delegated`) collections is the sealed ciphertext the server holds — never the decrypted form. Decryption happens in memory on read (see `SyncManager.seedFromCache`).
|
|
206
|
+
- **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
|
|
207
|
+
|
|
208
|
+
Append collections are not cached here; they own warm-start persistence via `AppendLogCursor` (`persistEncrypted`).
|
|
209
|
+
|
|
195
210
|
## `SyncManager`
|
|
196
211
|
|
|
197
212
|
```ts
|
|
@@ -206,6 +221,7 @@ new SyncManager({
|
|
|
206
221
|
- `signer.getSigner()` returns `{ devEdPubHex, sign(payload) }`. When set, every push attaches `authorPubkey = cap.sub` and `authorSignature = base64(Ed25519(payload))` over the encrypted payload (without the author fields).
|
|
207
222
|
- `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
|
|
208
223
|
- `onConflict` resolves write conflicts on push *and* reconciles a pull against un-pushed local writes. On a zustand-bound store, a `pull()` while the store is `dirty` merges the fetched snapshot with the local data through this resolver (rather than overwriting it), so an optimistic write isn't lost when a pull races a `set()`. Use a union/CRDT-style resolver (`createUnionMerge`) for append-style collections so both the local and remote writes survive; `SyncManager.resolve(local, remote)` exposes the same merge for callers that need it.
|
|
224
|
+
- **Offline-first (with a client `cache`):** `seedFromCache()` populates `localData` from the client's read-through cache without a network round-trip, decrypting in memory for E2E collections — the merge-doc counterpart to `AppendLogCursor.getDecryptedItems()`. `getLastPullFromCache()` reports whether the latest `pull()`/seed came from cache. On a zustand store these power cache-first paint: `useSyncInit` seeds before the initial pull, and the store exposes a `stale` flag (`seed()` action + `state.stale`) so the UI can show an "offline / showing last-synced data" indicator that clears on the next live pull.
|
|
209
225
|
|
|
210
226
|
## `AppendLogCursor`
|
|
211
227
|
|
|
@@ -4,7 +4,7 @@ import type { DevtoolsOptions } from "zustand/middleware";
|
|
|
4
4
|
import type { Encryptor } from "@drakkar.software/starfish-protocol";
|
|
5
5
|
import { SyncManager } from "../sync.js";
|
|
6
6
|
import { AppendLogCursor, type AppendElement } from "../append-log.js";
|
|
7
|
-
import type { StarfishCapProvider, ConflictResolver } from "../types.js";
|
|
7
|
+
import type { StarfishCapProvider, ConflictResolver, PullCache } from "../types.js";
|
|
8
8
|
import type { SyncLogger } from "../logger.js";
|
|
9
9
|
import type { Validator } from "../validate.js";
|
|
10
10
|
export interface StarfishState {
|
|
@@ -15,6 +15,14 @@ export interface StarfishState {
|
|
|
15
15
|
error: string | null;
|
|
16
16
|
/** Last-known server hash, persisted alongside `data`/`dirty`. Restored into the bound SyncManager on hydration. */
|
|
17
17
|
hash: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* True when the currently-shown `data` came from the offline read-through
|
|
20
|
+
* cache (a cache-first {@link StarfishActions.seed} or a {@link StarfishActions.pull}
|
|
21
|
+
* the client served from cache because the transport was unreachable) rather
|
|
22
|
+
* than a live server response. A successful live pull/flush clears it. Use it
|
|
23
|
+
* to drive an "offline / showing last-synced data" indicator.
|
|
24
|
+
*/
|
|
25
|
+
stale: boolean;
|
|
18
26
|
}
|
|
19
27
|
export interface StarfishActions {
|
|
20
28
|
pull: () => Promise<void>;
|
|
@@ -23,6 +31,14 @@ export interface StarfishActions {
|
|
|
23
31
|
restore: (data: Record<string, unknown>) => void;
|
|
24
32
|
flush: () => Promise<void>;
|
|
25
33
|
setOnline: (online: boolean) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Cache-first paint: populate `data` from the client's offline read-through
|
|
36
|
+
* cache (decrypting in memory for E2E collections) without touching the
|
|
37
|
+
* network. A no-op when the client has no cache configured or there's no
|
|
38
|
+
* (unexpired) entry. {@link useSyncInit} calls this once before the initial
|
|
39
|
+
* pull; the live pull then supersedes the seeded snapshot.
|
|
40
|
+
*/
|
|
41
|
+
seed: () => Promise<void>;
|
|
26
42
|
}
|
|
27
43
|
export type StarfishStore = StarfishState & StarfishActions;
|
|
28
44
|
export interface CreateStarfishStoreOptions {
|
|
@@ -123,6 +139,16 @@ export interface SyncInitConfig {
|
|
|
123
139
|
storeName?: string;
|
|
124
140
|
storage?: StateStorage | false;
|
|
125
141
|
fetch?: typeof globalThis.fetch;
|
|
142
|
+
/**
|
|
143
|
+
* Offline-first read-through cache for the underlying {@link StarfishClient}
|
|
144
|
+
* (see {@link StarfishClientOptions.cache}). When set, the store seeds from the
|
|
145
|
+
* last-synced ciphertext on creation (cache-first paint, decrypted in memory)
|
|
146
|
+
* and the live pull falls back to it when the transport is unreachable; the
|
|
147
|
+
* store's `stale` flag reflects whether the shown data is from cache.
|
|
148
|
+
*/
|
|
149
|
+
cache?: PullCache;
|
|
150
|
+
/** Max age (ms) for {@link cache} entries; see {@link StarfishClientOptions.cacheMaxAgeMs}. */
|
|
151
|
+
cacheMaxAgeMs?: number;
|
|
126
152
|
logger?: SyncLogger;
|
|
127
153
|
validate?: Validator;
|
|
128
154
|
}
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -266,6 +266,13 @@ var StarfishHttpError = class extends Error {
|
|
|
266
266
|
|
|
267
267
|
// src/client.ts
|
|
268
268
|
var APPEND_DEFAULT_FIELD = "items";
|
|
269
|
+
function pullCacheKey(pathAndQuery) {
|
|
270
|
+
const q = pathAndQuery.indexOf("?");
|
|
271
|
+
return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
272
|
+
}
|
|
273
|
+
function pullWasFromCache(result) {
|
|
274
|
+
return result.fromCache === true;
|
|
275
|
+
}
|
|
269
276
|
function stripPushPrefix(path) {
|
|
270
277
|
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
271
278
|
}
|
|
@@ -283,6 +290,8 @@ var StarfishClient = class {
|
|
|
283
290
|
namespace;
|
|
284
291
|
capProvider;
|
|
285
292
|
fetch;
|
|
293
|
+
cache;
|
|
294
|
+
cacheMaxAgeMs;
|
|
286
295
|
/**
|
|
287
296
|
* Installed client-side plugins. Currently stored as inert data; no
|
|
288
297
|
* hooks fire yet. Extensions can inspect this list if needed.
|
|
@@ -293,8 +302,19 @@ var StarfishClient = class {
|
|
|
293
302
|
this.namespace = options.namespace || void 0;
|
|
294
303
|
this.capProvider = options.capProvider;
|
|
295
304
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
305
|
+
this.cache = options.cache;
|
|
306
|
+
this.cacheMaxAgeMs = options.cacheMaxAgeMs;
|
|
296
307
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
297
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Mark a `PullResult` as having been served from the offline read-through
|
|
311
|
+
* cache (transport was unreachable). Non-enumerable so it doesn't leak into
|
|
312
|
+
* JSON / equality / re-caching; read via {@link pullWasFromCache}.
|
|
313
|
+
*/
|
|
314
|
+
tagFromCache(result) {
|
|
315
|
+
Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
298
318
|
/**
|
|
299
319
|
* Resolve the host portion of the URL the client will send to. The host
|
|
300
320
|
* is folded into the signed canonical input as the `h` field so the
|
|
@@ -414,10 +434,20 @@ var StarfishClient = class {
|
|
|
414
434
|
}
|
|
415
435
|
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
416
436
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
437
|
+
const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
|
|
438
|
+
let res;
|
|
439
|
+
try {
|
|
440
|
+
res = await this.fetch(url, {
|
|
441
|
+
method: "GET",
|
|
442
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
443
|
+
});
|
|
444
|
+
} catch (err) {
|
|
445
|
+
if (cacheKey) {
|
|
446
|
+
const cached = await this.readCache(cacheKey);
|
|
447
|
+
if (cached) return cached;
|
|
448
|
+
}
|
|
449
|
+
throw err;
|
|
450
|
+
}
|
|
421
451
|
if (!res.ok) {
|
|
422
452
|
throw new StarfishHttpError(res.status, await res.text());
|
|
423
453
|
}
|
|
@@ -426,8 +456,46 @@ var StarfishClient = class {
|
|
|
426
456
|
const list = result.data?.[appendField];
|
|
427
457
|
return Array.isArray(list) ? list : [];
|
|
428
458
|
}
|
|
459
|
+
if (cacheKey) {
|
|
460
|
+
const snapshot = {
|
|
461
|
+
data: result.data,
|
|
462
|
+
hash: result.hash,
|
|
463
|
+
timestamp: result.timestamp,
|
|
464
|
+
cachedAt: Date.now()
|
|
465
|
+
};
|
|
466
|
+
void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
|
|
467
|
+
});
|
|
468
|
+
}
|
|
429
469
|
return result;
|
|
430
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Read the cached snapshot for a document `path` WITHOUT hitting the network —
|
|
473
|
+
* the basis for cache-first paint (seed the UI from the last-synced snapshot,
|
|
474
|
+
* then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
|
|
475
|
+
* or null when no cache is configured / there's no entry. Namespacing matches
|
|
476
|
+
* {@link pull}, so the key lines up with whatever `pull` wrote.
|
|
477
|
+
*/
|
|
478
|
+
async peekCache(path) {
|
|
479
|
+
if (!this.cache) return null;
|
|
480
|
+
return this.readCache(pullCacheKey(this.applyNamespace(path)));
|
|
481
|
+
}
|
|
482
|
+
/** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
|
|
483
|
+
* null on a miss or an unparseable blob (never throws — a corrupt cache entry
|
|
484
|
+
* must not break a pull, just miss). */
|
|
485
|
+
async readCache(cacheKey) {
|
|
486
|
+
try {
|
|
487
|
+
const raw = await this.cache.get(cacheKey);
|
|
488
|
+
if (!raw) return null;
|
|
489
|
+
const parsed = JSON.parse(raw);
|
|
490
|
+
if (!parsed || typeof parsed.hash !== "string") return null;
|
|
491
|
+
if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
431
499
|
/**
|
|
432
500
|
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
433
501
|
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
@@ -648,6 +716,7 @@ var SyncManager = class {
|
|
|
648
716
|
lastCheckpoint = 0;
|
|
649
717
|
localData = {};
|
|
650
718
|
aborted = false;
|
|
719
|
+
lastFromCache = false;
|
|
651
720
|
constructor(options) {
|
|
652
721
|
this.client = options.client;
|
|
653
722
|
this.pullPath = options.pullPath;
|
|
@@ -688,6 +757,40 @@ var SyncManager = class {
|
|
|
688
757
|
setHash(hash) {
|
|
689
758
|
this.lastHash = hash;
|
|
690
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
762
|
+
* from the client's offline read-through cache rather than a live server
|
|
763
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
764
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
765
|
+
* false by the next successful network pull.
|
|
766
|
+
*/
|
|
767
|
+
getLastPullFromCache() {
|
|
768
|
+
return this.lastFromCache;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
772
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
773
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
774
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
775
|
+
* the initial live {@link pull}, which then supersedes the seeded snapshot.
|
|
776
|
+
* Requires the client to have been built with a `cache`.
|
|
777
|
+
*/
|
|
778
|
+
async seedFromCache() {
|
|
779
|
+
if (this.aborted) return false;
|
|
780
|
+
const cached = await this.client.peekCache(this.pullPath);
|
|
781
|
+
if (!cached) return false;
|
|
782
|
+
let data;
|
|
783
|
+
try {
|
|
784
|
+
data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
|
|
785
|
+
} catch {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
if (this.aborted) return false;
|
|
789
|
+
this.localData = data;
|
|
790
|
+
this.lastHash = cached.hash;
|
|
791
|
+
this.lastFromCache = true;
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
691
794
|
getCheckpoint() {
|
|
692
795
|
return this.lastCheckpoint;
|
|
693
796
|
}
|
|
@@ -698,6 +801,7 @@ var SyncManager = class {
|
|
|
698
801
|
try {
|
|
699
802
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
700
803
|
if (this.aborted) throw new AbortError();
|
|
804
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
701
805
|
if (this.encryptor) {
|
|
702
806
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
703
807
|
if (this.aborted) throw new AbortError();
|
|
@@ -871,13 +975,23 @@ function createStarfishStore(options) {
|
|
|
871
975
|
dirty: false,
|
|
872
976
|
error: null,
|
|
873
977
|
hash: null,
|
|
978
|
+
stale: false,
|
|
979
|
+
seed: async () => {
|
|
980
|
+
try {
|
|
981
|
+
const seeded = await syncManager.seedFromCache();
|
|
982
|
+
if (!seeded) return;
|
|
983
|
+
if (get().dirty || Object.keys(get().data).length > 0) return;
|
|
984
|
+
set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
|
|
985
|
+
} catch {
|
|
986
|
+
}
|
|
987
|
+
},
|
|
874
988
|
pull: async () => {
|
|
875
989
|
set({ syncing: true, error: null }, false, "pull/start");
|
|
876
990
|
try {
|
|
877
991
|
await syncManager.pull();
|
|
878
992
|
const remote = syncManager.getData();
|
|
879
993
|
const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
|
|
880
|
-
set({ data: newData, syncing: false, hash: syncManager.getHash() }, false, "pull/success");
|
|
994
|
+
set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
|
|
881
995
|
if (get().online && get().dirty) get().flush().catch(() => {
|
|
882
996
|
});
|
|
883
997
|
options.onRemoteUpdate?.(newData);
|
|
@@ -903,7 +1017,7 @@ function createStarfishStore(options) {
|
|
|
903
1017
|
set({ syncing: true, error: null }, false, "flush/start");
|
|
904
1018
|
try {
|
|
905
1019
|
await syncManager.push(get().data);
|
|
906
|
-
set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash() }, false, "flush/success");
|
|
1020
|
+
set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
|
|
907
1021
|
} catch (err) {
|
|
908
1022
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
909
1023
|
}
|
|
@@ -1028,7 +1142,9 @@ function useSyncInit(config) {
|
|
|
1028
1142
|
baseUrl: config.serverUrl,
|
|
1029
1143
|
namespace: config.namespace,
|
|
1030
1144
|
capProvider: config.capProvider,
|
|
1031
|
-
fetch: config.fetch
|
|
1145
|
+
fetch: config.fetch,
|
|
1146
|
+
cache: config.cache,
|
|
1147
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs
|
|
1032
1148
|
});
|
|
1033
1149
|
const syncManager = new SyncManager({
|
|
1034
1150
|
client,
|
|
@@ -1056,7 +1172,9 @@ function useSyncInit(config) {
|
|
|
1056
1172
|
}
|
|
1057
1173
|
});
|
|
1058
1174
|
setStore(newStore);
|
|
1059
|
-
newStore.getState().
|
|
1175
|
+
newStore.getState().seed().finally(() => {
|
|
1176
|
+
newStore.getState().pull().catch(() => {
|
|
1177
|
+
});
|
|
1060
1178
|
});
|
|
1061
1179
|
return () => {
|
|
1062
1180
|
setStore(null);
|