@drakkar.software/starfish-client 3.0.0-alpha.29 → 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.
@@ -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) {
@@ -762,6 +769,62 @@ var StarfishClient = class {
762
769
  }
763
770
  return res.json();
764
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
+ }
765
828
  /**
766
829
  * Pull binary data from a blob collection.
767
830
  * Returns raw bytes with the content hash from the ETag header.
@@ -1309,7 +1372,9 @@ function useSyncInit(config) {
1309
1372
  capProvider: config.capProvider,
1310
1373
  fetch: config.fetch,
1311
1374
  cache: config.cache,
1312
- cacheMaxAgeMs: config.cacheMaxAgeMs
1375
+ cacheMaxAgeMs: config.cacheMaxAgeMs,
1376
+ cacheFallbackStatuses: config.cacheFallbackStatuses,
1377
+ onRevalidated: config.onRevalidated
1313
1378
  });
1314
1379
  const syncManager = new SyncManager({
1315
1380
  client,
@@ -1353,6 +1418,76 @@ function useSyncInit(config) {
1353
1418
  ]);
1354
1419
  return store;
1355
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
+ }
1356
1491
  function createStarfishLog(options) {
1357
1492
  const { cursor } = options;
1358
1493
  const storeCreator = (rawSet, get) => {
@@ -1432,11 +1567,14 @@ function useLogConnectivity(store) {
1432
1567
  }, [store]);
1433
1568
  }
1434
1569
  export {
1570
+ acquireSyncStore,
1435
1571
  aggregateSyncStatus,
1572
+ clearSyncStoreRegistry,
1436
1573
  createStarfishLog,
1437
1574
  createStarfishStore,
1438
1575
  deriveLogStatus,
1439
1576
  deriveSyncStatus,
1577
+ releaseSyncStore,
1440
1578
  subscribeLogStatus,
1441
1579
  subscribeSyncStatus,
1442
1580
  useConnectivity,
@@ -1444,6 +1582,7 @@ export {
1444
1582
  useLastSynced,
1445
1583
  useLogConnectivity,
1446
1584
  useLogStatus,
1585
+ useSharedSyncStore,
1447
1586
  useStarfish,
1448
1587
  useStarfishData,
1449
1588
  useStarfishLog,