@drakkar.software/starfish-client 3.0.0-alpha.8 → 3.0.0-alpha.9

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/dist/index.js CHANGED
@@ -210,6 +210,35 @@ var StarfishClient = class {
210
210
  }
211
211
  return result;
212
212
  }
213
+ /**
214
+ * Pull several collections in one round-trip via `/batch/pull`. `collections`
215
+ * is the list of collection names; `opts.params` supplies path params per
216
+ * collection (serialized to a URL-encoded JSON `params` query). The server
217
+ * auto-fills the `{identity}` param from the authenticated caller, so per-user
218
+ * collections need no params. Returns a map of collection name → its pulled
219
+ * document or a per-collection `{ error }`. Honors the configured namespace.
220
+ *
221
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
222
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
223
+ */
224
+ async batchPull(collections, opts = {}) {
225
+ const search = new URLSearchParams();
226
+ search.set("collections", collections.join(","));
227
+ if (opts.params && Object.keys(opts.params).length > 0) {
228
+ search.set("params", JSON.stringify(opts.params));
229
+ }
230
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
231
+ const url = `${this.baseUrl}${pathAndQuery}`;
232
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
233
+ const res = await this.fetch(url, {
234
+ method: "GET",
235
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
236
+ });
237
+ if (!res.ok) {
238
+ throw new StarfishHttpError(res.status, await res.text());
239
+ }
240
+ return await res.json();
241
+ }
213
242
  /**
214
243
  * Push synced data to the server.
215
244
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
@@ -524,6 +553,140 @@ var SyncManager = class {
524
553
  }
525
554
  };
526
555
 
556
+ // src/append-log.ts
557
+ import {
558
+ DEFAULT_ALG as DEFAULT_ALG2,
559
+ verifyAppendAuthor
560
+ } from "@drakkar.software/starfish-protocol";
561
+ var PULL_PATH_PREFIX = "/pull/";
562
+ function stripPullPrefix(path) {
563
+ return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
564
+ }
565
+ var AppendAuthorError = class extends Error {
566
+ constructor(ts) {
567
+ super(`append element author verification failed (ts=${ts})`);
568
+ this.ts = ts;
569
+ this.name = "AppendAuthorError";
570
+ }
571
+ };
572
+ function checkpointOf(items) {
573
+ let max = 0;
574
+ for (const it of items) if (it.ts > max) max = it.ts;
575
+ return max;
576
+ }
577
+ var AppendLogCursor = class {
578
+ client;
579
+ pullPath;
580
+ appendField;
581
+ encryptor;
582
+ verifyAuthor;
583
+ documentKey;
584
+ logger;
585
+ loggerName;
586
+ items;
587
+ lastCheckpoint;
588
+ constructor(options) {
589
+ this.client = options.client;
590
+ this.pullPath = options.pullPath;
591
+ this.appendField = options.appendField ?? "items";
592
+ this.encryptor = options.encryptor;
593
+ this.verifyAuthor = options.verifyAuthor;
594
+ this.documentKey = stripPullPrefix(options.pullPath);
595
+ this.logger = options.logger;
596
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
597
+ const seed = options.initialItems ?? [];
598
+ const seedCheckpoint = checkpointOf(seed);
599
+ if (options.since != null) {
600
+ if (options.since < 0) throw new Error("since must be non-negative");
601
+ if (options.since < seedCheckpoint) {
602
+ throw new Error("since must be >= the max ts of initialItems");
603
+ }
604
+ this.lastCheckpoint = options.since;
605
+ } else {
606
+ this.lastCheckpoint = seedCheckpoint;
607
+ }
608
+ this.items = [...seed];
609
+ }
610
+ /**
611
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
612
+ * append them to the local log, and return ONLY the newly-fetched batch.
613
+ *
614
+ * Atomic: the batch is fully verified and decrypted into a local before any
615
+ * state mutation, so a verify/decrypt failure throws without advancing the
616
+ * checkpoint past elements that could never be re-fetched.
617
+ *
618
+ * Not safe to call concurrently: like `SyncManager.pull`, overlapping calls
619
+ * read the same checkpoint and would fetch — and append — the same window twice.
620
+ */
621
+ async pull() {
622
+ this.logger?.pullStart(this.loggerName);
623
+ const start = performance.now();
624
+ try {
625
+ const since = this.lastCheckpoint;
626
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
627
+ const raw = await this.client.pull(this.pullPath, opts);
628
+ const batch = [];
629
+ let maxTs = since;
630
+ for (const el of raw) {
631
+ if (since > 0 && el.ts <= since) continue;
632
+ this.verifyOne(el);
633
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
634
+ const out = { ts: el.ts, data };
635
+ if (el.authorPubkey !== void 0) out.authorPubkey = el.authorPubkey;
636
+ if (el.authorSignature !== void 0) out.authorSignature = el.authorSignature;
637
+ batch.push(out);
638
+ if (el.ts > maxTs) maxTs = el.ts;
639
+ }
640
+ this.items.push(...batch);
641
+ this.lastCheckpoint = maxTs;
642
+ this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
643
+ return batch;
644
+ } catch (err) {
645
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
646
+ throw err;
647
+ }
648
+ }
649
+ /** Verify a single element's author signature over its RAW (pre-decryption)
650
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
651
+ * verification is disabled. */
652
+ verifyOne(el) {
653
+ if (!this.verifyAuthor) return;
654
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
655
+ const { authorPubkey, authorSignature } = el;
656
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
657
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
658
+ throw new AppendAuthorError(el.ts);
659
+ }
660
+ const ok = verifyAppendAuthor(
661
+ this.documentKey,
662
+ el.data,
663
+ authorPubkey,
664
+ authorSignature,
665
+ policy.alg ?? DEFAULT_ALG2
666
+ );
667
+ if (!ok) throw new AppendAuthorError(el.ts);
668
+ }
669
+ /** The full accumulated log (a shallow copy), in `ts` order. */
670
+ getItems() {
671
+ return [...this.items];
672
+ }
673
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
674
+ * when nothing has been pulled or seeded. */
675
+ getCheckpoint() {
676
+ return this.lastCheckpoint;
677
+ }
678
+ /** Restore the checkpoint without seeding items — for persistence layers that
679
+ * store only the checkpoint. Used to resume incrementally across restarts.
680
+ * Rejects a value below the max `ts` already held: rewinding would make the
681
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
682
+ setCheckpoint(ts) {
683
+ if (ts < checkpointOf(this.items)) {
684
+ throw new Error("checkpoint must be >= the max ts already held");
685
+ }
686
+ this.lastCheckpoint = ts;
687
+ }
688
+ };
689
+
527
690
  // src/index.ts
528
691
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
529
692
 
@@ -1309,6 +1472,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1309
1472
  netUnsub?.();
1310
1473
  };
1311
1474
  }
1475
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
1476
+ const { pullOnForeground = true } = options;
1477
+ const appSub = deps.appState.addEventListener("change", (appState) => {
1478
+ if (appState === "active" && pullOnForeground) {
1479
+ const { online, loading } = store.getState();
1480
+ if (online && !loading) {
1481
+ store.getState().pull().catch((err) => {
1482
+ console.error("[Starfish] foreground log pull failed:", err);
1483
+ });
1484
+ }
1485
+ }
1486
+ });
1487
+ let netUnsub = null;
1488
+ if (deps.netInfo) {
1489
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
1490
+ store.getState().setOnline(!!isConnected);
1491
+ });
1492
+ }
1493
+ return () => {
1494
+ appSub.remove();
1495
+ netUnsub?.();
1496
+ };
1497
+ }
1312
1498
 
1313
1499
  // src/multi-store.ts
1314
1500
  function createMultiStoreSync(options) {
@@ -1361,6 +1547,8 @@ function createMultiStoreSync(options) {
1361
1547
  }
1362
1548
  export {
1363
1549
  AbortError,
1550
+ AppendAuthorError,
1551
+ AppendLogCursor,
1364
1552
  ConflictError,
1365
1553
  ENCRYPTED_KEY,
1366
1554
  SnapshotHistory,
@@ -1369,10 +1557,12 @@ export {
1369
1557
  SyncManager,
1370
1558
  ValidationError,
1371
1559
  buildRevocationList,
1560
+ checkpointOf,
1372
1561
  classifyError,
1373
1562
  computeHash,
1374
1563
  configurePlatform,
1375
1564
  consoleSyncLogger,
1565
+ createAppendLogMobileLifecycle,
1376
1566
  createDebouncedPush,
1377
1567
  createDebouncedSync,
1378
1568
  createDedupFetch,