@drakkar.software/starfish-client 3.0.0-alpha.28 → 3.0.0-alpha.30
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 +11 -3
- package/dist/bindings/zustand.d.ts +67 -1
- package/dist/bindings/zustand.js +158 -1
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +30 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/fetch.d.ts +27 -0
- package/dist/fetch.js +32 -0
- package/dist/fetch.js.map +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +105 -0
- package/dist/index.js.map +3 -3
- package/dist/kv-cache.d.ts +63 -0
- package/dist/types.d.ts +14 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -198,10 +198,18 @@ Omit `capProvider` for unauthenticated public reads.
|
|
|
198
198
|
|
|
199
199
|
### Offline-first read cache
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
**Persist-backed Zustand stores are offline-first for reads without a client `cache`.** When
|
|
202
|
+
`createStarfishStore` is used with a `storage`, the persisted `starfish-{name}` entry rehydrates
|
|
203
|
+
`data` on cold start. If the first `pull()` then fails because the transport is unreachable (offline /
|
|
204
|
+
DNS / timeout), `pull()` preserves the already-shown data and sets `stale: true` — no error, no empty
|
|
205
|
+
screen. HTTP errors (4xx/5xx), aborts, and decrypt failures still set `error` as usual.
|
|
206
|
+
|
|
207
|
+
The client `cache` (`PullCache`) remains available for additional capabilities:
|
|
208
|
+
|
|
209
|
+
Pass a `cache` (a `PullCache`: `{ get(k): Promise<string|null>; set(k, v): Promise<void> }`, host-backed by `localStorage`/`AsyncStorage`/etc.) to the client for:
|
|
202
210
|
|
|
203
211
|
- **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
|
|
204
|
-
- **Offline fallback:** a pull that fails because the **transport** is unreachable
|
|
212
|
+
- **Offline fallback (without a persist-backed store):** a pull that fails because the **transport** is unreachable returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
|
|
205
213
|
- **Real HTTP errors propagate:** 404/403 are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning. 429 and 5xx can optionally be caught via `cacheFallbackStatuses` (see below).
|
|
206
214
|
- **Stale-while-revalidate:** set `cacheFallbackStatuses: [429, 500, 502, 503, 504]` to make transient server failures serve the last-synced snapshot immediately and retry in the background (honoring `Retry-After`). When the live response arrives, the cache is updated and `onRevalidated` fires. No snapshot → the error propagates as usual. Do NOT include 403/404 — they are genuine answers, not transient failures.
|
|
207
215
|
- **`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).
|
|
@@ -224,7 +232,7 @@ new SyncManager({
|
|
|
224
232
|
- `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).
|
|
225
233
|
- `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
|
|
226
234
|
- `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.
|
|
227
|
-
- **Offline-first (
|
|
235
|
+
- **Offline-first:** Zustand stores backed by `storage` are offline-first without any client `cache` — a transport failure during `pull()` preserves the persisted data and sets `stale: true` on the store. When a client `cache` is also configured, `seedFromCache()` additionally populates `localData` from the ciphertext cache without a network round-trip (decrypting in memory for E2E collections); `getLastPullFromCache()` reports whether the latest `pull()`/seed came from that cache. On a zustand store, the `stale` flag tracks both sources: it is set by an offline `pull()` (no cache needed) and by `seed()` (cache-first paint before the initial live pull).
|
|
228
236
|
|
|
229
237
|
## `AppendLogCursor`
|
|
230
238
|
|
|
@@ -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, PullCache } from "../types.js";
|
|
7
|
+
import type { StarfishClientOptions, 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 {
|
|
@@ -163,6 +163,19 @@ export interface SyncInitConfig {
|
|
|
163
163
|
cache?: PullCache;
|
|
164
164
|
/** Max age (ms) for {@link cache} entries; see {@link StarfishClientOptions.cacheMaxAgeMs}. */
|
|
165
165
|
cacheMaxAgeMs?: number;
|
|
166
|
+
/**
|
|
167
|
+
* HTTP status codes for which pulls fall back to the last-cached snapshot rather than
|
|
168
|
+
* throwing — stale-while-revalidate for transient server failures.
|
|
169
|
+
* See {@link StarfishClientOptions.cacheFallbackStatuses}.
|
|
170
|
+
* Recommended set for offline-first apps: `[429, 500, 502, 503, 504]`.
|
|
171
|
+
*/
|
|
172
|
+
cacheFallbackStatuses?: number[];
|
|
173
|
+
/**
|
|
174
|
+
* Called after a background revalidation following a {@link cacheFallbackStatuses} hit:
|
|
175
|
+
* the server returned a live response and the fresh snapshot has been written through.
|
|
176
|
+
* See {@link StarfishClientOptions.onRevalidated}.
|
|
177
|
+
*/
|
|
178
|
+
onRevalidated?: StarfishClientOptions["onRevalidated"];
|
|
166
179
|
logger?: SyncLogger;
|
|
167
180
|
validate?: Validator;
|
|
168
181
|
}
|
|
@@ -176,6 +189,59 @@ export interface SyncInitConfig {
|
|
|
176
189
|
* Pass `null` to disable sync (returns `null`).
|
|
177
190
|
*/
|
|
178
191
|
export declare function useSyncInit(config: SyncInitConfig | null): StoreApi<StarfishStore> | null;
|
|
192
|
+
/**
|
|
193
|
+
* Config for a shared sync store — identical to {@link SyncInitConfig} EXCEPT:
|
|
194
|
+
* - `onData` is omitted: it is not safe to fan out a single `onRemoteUpdate`
|
|
195
|
+
* callback to multiple independent subscribers. Consumers should instead
|
|
196
|
+
* subscribe to the returned store via `store.subscribe(...)`.
|
|
197
|
+
* - `storeName` is REQUIRED: it is the registry key, not an optional label.
|
|
198
|
+
*/
|
|
199
|
+
export type SharedSyncConfig = Omit<SyncInitConfig, "onData" | "storeName"> & {
|
|
200
|
+
/** Registry key AND store persistence label. Required; there is no default. */
|
|
201
|
+
storeName: string;
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Return (or create) the shared zustand store for `config.storeName`.
|
|
205
|
+
*
|
|
206
|
+
* On the **first** acquire, constructs `StarfishClient` → `SyncManager` → store
|
|
207
|
+
* (forwarding all config fields, including `cacheFallbackStatuses` and `onRevalidated`
|
|
208
|
+
* for native stale-while-revalidate), then fires `seed().finally(pull())`. On every
|
|
209
|
+
* subsequent acquire of the same `storeName`, the existing store is returned — **no**
|
|
210
|
+
* new pull fires.
|
|
211
|
+
*
|
|
212
|
+
* Always pair with {@link releaseSyncStore}. Call {@link clearSyncStoreRegistry}
|
|
213
|
+
* on account switch / sign-out.
|
|
214
|
+
*/
|
|
215
|
+
export declare function acquireSyncStore(config: SharedSyncConfig): StoreApi<StarfishStore>;
|
|
216
|
+
/**
|
|
217
|
+
* Release a previously acquired store. Decrements the refCount; on 0 the entry is
|
|
218
|
+
* evicted — the store, client, and sync manager are dropped and GC'd (mirrors
|
|
219
|
+
* `useSyncInit`'s own teardown, which simply drops the local store reference).
|
|
220
|
+
*/
|
|
221
|
+
export declare function releaseSyncStore(storeName: string): void;
|
|
222
|
+
/**
|
|
223
|
+
* Clear all registry entries.
|
|
224
|
+
*
|
|
225
|
+
* Call on account switch or sign-out alongside any other per-session cache clears.
|
|
226
|
+
* An identity guard inside {@link acquireSyncStore} prevents any in-flight pull from
|
|
227
|
+
* firing against the old session's cap after this is called.
|
|
228
|
+
*/
|
|
229
|
+
export declare function clearSyncStoreRegistry(): void;
|
|
230
|
+
/**
|
|
231
|
+
* React hook that returns (or creates) the shared zustand store for
|
|
232
|
+
* `config.storeName` — a drop-in replacement for {@link useSyncInit} when the
|
|
233
|
+
* same logical document is consumed from multiple components.
|
|
234
|
+
*
|
|
235
|
+
* **Key design decision — effect deps include only `storeName`:** config identity
|
|
236
|
+
* churn (fresh `capProvider`/`encryptor` refs per render) is intentionally ignored.
|
|
237
|
+
* For a given `(user, space)` the cap and keyring are functionally equivalent across
|
|
238
|
+
* refs, and no `onData` fan-out is needed, so the shared store never needs to rebuild
|
|
239
|
+
* on churn. The `configRef` pattern ensures the latest config values are captured at
|
|
240
|
+
* acquire-time without re-running the effect.
|
|
241
|
+
*
|
|
242
|
+
* Pass `null` to disable sync (returns `null`).
|
|
243
|
+
*/
|
|
244
|
+
export declare function useSharedSyncStore(config: SharedSyncConfig | null): StoreApi<StarfishStore> | null;
|
|
179
245
|
export interface StarfishLogState {
|
|
180
246
|
/** The full accumulated log, newest appended last. */
|
|
181
247
|
items: AppendElement[];
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -263,6 +263,13 @@ var StarfishHttpError = class extends Error {
|
|
|
263
263
|
this.name = "StarfishHttpError";
|
|
264
264
|
}
|
|
265
265
|
};
|
|
266
|
+
var AppendHttpError = class extends Error {
|
|
267
|
+
constructor(status, message) {
|
|
268
|
+
super(message);
|
|
269
|
+
this.status = status;
|
|
270
|
+
this.name = "AppendHttpError";
|
|
271
|
+
}
|
|
272
|
+
};
|
|
266
273
|
|
|
267
274
|
// src/fetch.ts
|
|
268
275
|
function parseRetryAfterMs(header, opts) {
|
|
@@ -276,6 +283,20 @@ function parseRetryAfterMs(header, opts) {
|
|
|
276
283
|
}
|
|
277
284
|
return Math.min(fallbackMs, maxMs);
|
|
278
285
|
}
|
|
286
|
+
function classifyError(err) {
|
|
287
|
+
if (err instanceof Response || err && typeof err === "object" && "status" in err) {
|
|
288
|
+
const status = err.status;
|
|
289
|
+
if (typeof status !== "number" || isNaN(status)) return "unknown";
|
|
290
|
+
if (status === 0) return "network";
|
|
291
|
+
if (status === 401 || status === 403) return "auth";
|
|
292
|
+
if (status === 409) return "conflict";
|
|
293
|
+
if (status === 429) return "rate-limited";
|
|
294
|
+
if (status >= 500) return "server";
|
|
295
|
+
if (status >= 400) return "client";
|
|
296
|
+
}
|
|
297
|
+
if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
|
|
298
|
+
return "unknown";
|
|
299
|
+
}
|
|
279
300
|
|
|
280
301
|
// src/client.ts
|
|
281
302
|
var APPEND_DEFAULT_FIELD = "items";
|
|
@@ -748,6 +769,62 @@ var StarfishClient = class {
|
|
|
748
769
|
}
|
|
749
770
|
return res.json();
|
|
750
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Append one element to a **public-write** append-only collection with an
|
|
774
|
+
* Ed25519 author proof but **no cap `Authorization` header**.
|
|
775
|
+
*
|
|
776
|
+
* Unlike {@link append}, which always attaches a cap-signed `Authorization`
|
|
777
|
+
* header from the configured `capProvider`, this method signs only the
|
|
778
|
+
* append-author proof (binding the element to the writer's Ed25519 key) and
|
|
779
|
+
* sends the request without authentication headers. This is required for
|
|
780
|
+
* collections with `writeRoles: ["public"]` — the server's cap-scope check
|
|
781
|
+
* would reject a request carrying a cap whose scope does not cover the path.
|
|
782
|
+
*
|
|
783
|
+
* Typical use-case: writing a sealed invitation to another user's
|
|
784
|
+
* public-write inbox collection without needing a cap scoped to the
|
|
785
|
+
* recipient's namespace. The author proof is optional on the server side
|
|
786
|
+
* (`requireAuthorSignature: false` for a public inbox), but signing anyway
|
|
787
|
+
* binds the stored element to the sender's Ed25519 key for verification in
|
|
788
|
+
* the receive path.
|
|
789
|
+
*
|
|
790
|
+
* The element is sent as `{ data, authorPubkey, authorSignature }`.
|
|
791
|
+
*
|
|
792
|
+
* @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
|
|
793
|
+
* @param element The JSON element to append.
|
|
794
|
+
* @param signer The sender's Ed25519 keypair (signs the author proof).
|
|
795
|
+
*
|
|
796
|
+
* @throws {AppendHttpError} on a non-2xx response.
|
|
797
|
+
*/
|
|
798
|
+
async appendAnonymous(path, element, signer) {
|
|
799
|
+
const sendPath = this.applyNamespace(path);
|
|
800
|
+
const documentKey = stripPushPrefix(path);
|
|
801
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
802
|
+
documentKey,
|
|
803
|
+
element,
|
|
804
|
+
signer.edPubHex,
|
|
805
|
+
signer.edPrivHex
|
|
806
|
+
);
|
|
807
|
+
const body = JSON.stringify({
|
|
808
|
+
[DATA_FIELD]: element,
|
|
809
|
+
[AUTHOR_PUBKEY_FIELD]: authorPubkey,
|
|
810
|
+
[AUTHOR_SIGNATURE_FIELD]: authorSignature
|
|
811
|
+
});
|
|
812
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
813
|
+
method: "POST",
|
|
814
|
+
headers: {
|
|
815
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
816
|
+
[HEADER_ACCEPT]: "application/json"
|
|
817
|
+
},
|
|
818
|
+
body
|
|
819
|
+
});
|
|
820
|
+
if (!res.ok) {
|
|
821
|
+
const detail = await res.text().catch(() => "");
|
|
822
|
+
throw new AppendHttpError(
|
|
823
|
+
res.status,
|
|
824
|
+
`anonymous append failed: HTTP ${res.status} ${detail}`.trim()
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
751
828
|
/**
|
|
752
829
|
* Pull binary data from a blob collection.
|
|
753
830
|
* Returns raw bytes with the content hash from the ETag header.
|
|
@@ -1134,6 +1211,10 @@ function createStarfishStore(options) {
|
|
|
1134
1211
|
});
|
|
1135
1212
|
options.onRemoteUpdate?.(newData);
|
|
1136
1213
|
} catch (err) {
|
|
1214
|
+
if (classifyError(err) === "network") {
|
|
1215
|
+
set({ syncing: false, stale: true }, false, "pull/offline");
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1137
1218
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
1138
1219
|
}
|
|
1139
1220
|
},
|
|
@@ -1291,7 +1372,9 @@ function useSyncInit(config) {
|
|
|
1291
1372
|
capProvider: config.capProvider,
|
|
1292
1373
|
fetch: config.fetch,
|
|
1293
1374
|
cache: config.cache,
|
|
1294
|
-
cacheMaxAgeMs: config.cacheMaxAgeMs
|
|
1375
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs,
|
|
1376
|
+
cacheFallbackStatuses: config.cacheFallbackStatuses,
|
|
1377
|
+
onRevalidated: config.onRevalidated
|
|
1295
1378
|
});
|
|
1296
1379
|
const syncManager = new SyncManager({
|
|
1297
1380
|
client,
|
|
@@ -1335,6 +1418,76 @@ function useSyncInit(config) {
|
|
|
1335
1418
|
]);
|
|
1336
1419
|
return store;
|
|
1337
1420
|
}
|
|
1421
|
+
var _syncStoreRegistry = /* @__PURE__ */ new Map();
|
|
1422
|
+
function acquireSyncStore(config) {
|
|
1423
|
+
const existing = _syncStoreRegistry.get(config.storeName);
|
|
1424
|
+
if (existing) {
|
|
1425
|
+
existing.refCount += 1;
|
|
1426
|
+
return existing.store;
|
|
1427
|
+
}
|
|
1428
|
+
const client = new StarfishClient({
|
|
1429
|
+
baseUrl: config.serverUrl,
|
|
1430
|
+
namespace: config.namespace,
|
|
1431
|
+
capProvider: config.capProvider,
|
|
1432
|
+
fetch: config.fetch,
|
|
1433
|
+
cache: config.cache,
|
|
1434
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs,
|
|
1435
|
+
cacheFallbackStatuses: config.cacheFallbackStatuses,
|
|
1436
|
+
onRevalidated: config.onRevalidated
|
|
1437
|
+
});
|
|
1438
|
+
const syncManager = new SyncManager({
|
|
1439
|
+
client,
|
|
1440
|
+
pullPath: config.pullPath,
|
|
1441
|
+
pushPath: config.pushPath,
|
|
1442
|
+
encryptor: config.encryptor,
|
|
1443
|
+
onConflict: config.onConflict,
|
|
1444
|
+
logger: config.logger,
|
|
1445
|
+
validate: config.validate
|
|
1446
|
+
});
|
|
1447
|
+
const store = createStarfishStore({
|
|
1448
|
+
name: config.storeName,
|
|
1449
|
+
syncManager,
|
|
1450
|
+
storage: config.storage
|
|
1451
|
+
// No onRemoteUpdate: consumers subscribe via store.subscribe() — see module comment.
|
|
1452
|
+
});
|
|
1453
|
+
const entry = { store, refCount: 1 };
|
|
1454
|
+
_syncStoreRegistry.set(config.storeName, entry);
|
|
1455
|
+
store.getState().seed().finally(() => {
|
|
1456
|
+
if (_syncStoreRegistry.get(config.storeName) === entry) {
|
|
1457
|
+
store.getState().pull().catch(() => {
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
return store;
|
|
1462
|
+
}
|
|
1463
|
+
function releaseSyncStore(storeName) {
|
|
1464
|
+
const entry = _syncStoreRegistry.get(storeName);
|
|
1465
|
+
if (!entry) return;
|
|
1466
|
+
entry.refCount -= 1;
|
|
1467
|
+
if (entry.refCount <= 0) _syncStoreRegistry.delete(storeName);
|
|
1468
|
+
}
|
|
1469
|
+
function clearSyncStoreRegistry() {
|
|
1470
|
+
_syncStoreRegistry.clear();
|
|
1471
|
+
}
|
|
1472
|
+
function useSharedSyncStore(config) {
|
|
1473
|
+
const [store, setStore] = useState(null);
|
|
1474
|
+
const storeName = config?.storeName ?? null;
|
|
1475
|
+
const configRef = useRef(config);
|
|
1476
|
+
configRef.current = config;
|
|
1477
|
+
useEffect(() => {
|
|
1478
|
+
if (!storeName) {
|
|
1479
|
+
setStore(null);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
const acquired = acquireSyncStore(configRef.current);
|
|
1483
|
+
setStore(acquired);
|
|
1484
|
+
return () => {
|
|
1485
|
+
releaseSyncStore(storeName);
|
|
1486
|
+
setStore(null);
|
|
1487
|
+
};
|
|
1488
|
+
}, [storeName]);
|
|
1489
|
+
return store;
|
|
1490
|
+
}
|
|
1338
1491
|
function createStarfishLog(options) {
|
|
1339
1492
|
const { cursor } = options;
|
|
1340
1493
|
const storeCreator = (rawSet, get) => {
|
|
@@ -1414,11 +1567,14 @@ function useLogConnectivity(store) {
|
|
|
1414
1567
|
}, [store]);
|
|
1415
1568
|
}
|
|
1416
1569
|
export {
|
|
1570
|
+
acquireSyncStore,
|
|
1417
1571
|
aggregateSyncStatus,
|
|
1572
|
+
clearSyncStoreRegistry,
|
|
1418
1573
|
createStarfishLog,
|
|
1419
1574
|
createStarfishStore,
|
|
1420
1575
|
deriveLogStatus,
|
|
1421
1576
|
deriveSyncStatus,
|
|
1577
|
+
releaseSyncStore,
|
|
1422
1578
|
subscribeLogStatus,
|
|
1423
1579
|
subscribeSyncStatus,
|
|
1424
1580
|
useConnectivity,
|
|
@@ -1426,6 +1582,7 @@ export {
|
|
|
1426
1582
|
useLastSynced,
|
|
1427
1583
|
useLogConnectivity,
|
|
1428
1584
|
useLogStatus,
|
|
1585
|
+
useSharedSyncStore,
|
|
1429
1586
|
useStarfish,
|
|
1430
1587
|
useStarfishData,
|
|
1431
1588
|
useStarfishLog,
|