@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 CHANGED
@@ -198,10 +198,18 @@ Omit `capProvider` for unauthenticated public reads.
198
198
 
199
199
  ### Offline-first read cache
200
200
 
201
- 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:
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 (`fetch` rejects — offline/DNS/timeout) returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
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 (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.
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[];
@@ -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,