@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/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
|
|
@@ -205,6 +220,8 @@ new SyncManager({
|
|
|
205
220
|
|
|
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.
|
|
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.
|
|
208
225
|
|
|
209
226
|
## `AppendLogCursor`
|
|
210
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;
|
|
@@ -669,6 +738,18 @@ var SyncManager = class {
|
|
|
669
738
|
getData() {
|
|
670
739
|
return { ...this.localData };
|
|
671
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Merge a remote snapshot with local (optimistic) data using this manager's
|
|
743
|
+
* conflict resolver — the same resolver the push-conflict path uses. A plain
|
|
744
|
+
* {@link pull} overwrites the store's data with the server snapshot, which
|
|
745
|
+
* would drop un-pushed local writes (they live only in the store, never in
|
|
746
|
+
* `localData` until a push succeeds). The zustand binding calls this on pull
|
|
747
|
+
* while the store is dirty so those writes survive. `local` wins by the same
|
|
748
|
+
* rules as a push conflict.
|
|
749
|
+
*/
|
|
750
|
+
resolve(local, remote) {
|
|
751
|
+
return this.onConflict(local, remote);
|
|
752
|
+
}
|
|
672
753
|
getHash() {
|
|
673
754
|
return this.lastHash;
|
|
674
755
|
}
|
|
@@ -676,6 +757,40 @@ var SyncManager = class {
|
|
|
676
757
|
setHash(hash) {
|
|
677
758
|
this.lastHash = hash;
|
|
678
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
|
+
}
|
|
679
794
|
getCheckpoint() {
|
|
680
795
|
return this.lastCheckpoint;
|
|
681
796
|
}
|
|
@@ -686,6 +801,7 @@ var SyncManager = class {
|
|
|
686
801
|
try {
|
|
687
802
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
688
803
|
if (this.aborted) throw new AbortError();
|
|
804
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
689
805
|
if (this.encryptor) {
|
|
690
806
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
691
807
|
if (this.aborted) throw new AbortError();
|
|
@@ -859,12 +975,25 @@ function createStarfishStore(options) {
|
|
|
859
975
|
dirty: false,
|
|
860
976
|
error: null,
|
|
861
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
|
+
},
|
|
862
988
|
pull: async () => {
|
|
863
989
|
set({ syncing: true, error: null }, false, "pull/start");
|
|
864
990
|
try {
|
|
865
991
|
await syncManager.pull();
|
|
866
|
-
const
|
|
867
|
-
|
|
992
|
+
const remote = syncManager.getData();
|
|
993
|
+
const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
|
|
994
|
+
set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
|
|
995
|
+
if (get().online && get().dirty) get().flush().catch(() => {
|
|
996
|
+
});
|
|
868
997
|
options.onRemoteUpdate?.(newData);
|
|
869
998
|
} catch (err) {
|
|
870
999
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
@@ -888,7 +1017,7 @@ function createStarfishStore(options) {
|
|
|
888
1017
|
set({ syncing: true, error: null }, false, "flush/start");
|
|
889
1018
|
try {
|
|
890
1019
|
await syncManager.push(get().data);
|
|
891
|
-
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");
|
|
892
1021
|
} catch (err) {
|
|
893
1022
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
894
1023
|
}
|
|
@@ -1013,7 +1142,9 @@ function useSyncInit(config) {
|
|
|
1013
1142
|
baseUrl: config.serverUrl,
|
|
1014
1143
|
namespace: config.namespace,
|
|
1015
1144
|
capProvider: config.capProvider,
|
|
1016
|
-
fetch: config.fetch
|
|
1145
|
+
fetch: config.fetch,
|
|
1146
|
+
cache: config.cache,
|
|
1147
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs
|
|
1017
1148
|
});
|
|
1018
1149
|
const syncManager = new SyncManager({
|
|
1019
1150
|
client,
|
|
@@ -1041,7 +1172,9 @@ function useSyncInit(config) {
|
|
|
1041
1172
|
}
|
|
1042
1173
|
});
|
|
1043
1174
|
setStore(newStore);
|
|
1044
|
-
newStore.getState().
|
|
1175
|
+
newStore.getState().seed().finally(() => {
|
|
1176
|
+
newStore.getState().pull().catch(() => {
|
|
1177
|
+
});
|
|
1045
1178
|
});
|
|
1046
1179
|
return () => {
|
|
1047
1180
|
setStore(null);
|