@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/README.md CHANGED
@@ -206,6 +206,27 @@ new SyncManager({
206
206
  - `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
207
  - `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
208
208
 
209
+ ## `AppendLogCursor`
210
+
211
+ Incremental, stateful cursor over an append-only collection — the log counterpart to `SyncManager`. It owns the accumulated array and pulls only what's new; the checkpoint is **derived from the last element it holds**, so it resumes from persisted data on a fresh page.
212
+
213
+ ```ts
214
+ const log = new AppendLogCursor({
215
+ client, pullPath: "/pull/events",
216
+ appendField, // default "items"
217
+ initialItems, // warm-start seed (raw {ts,data} envelopes) — or pass `since`
218
+ encryptor, // optional: decrypt each element's data (ts/author preserved)
219
+ verifyAuthor, // optional: true | { expectedAuthorPubkey?, alg? }
220
+ })
221
+ const fresh = await log.pull() // only elements newer than the last held
222
+ log.getItems() // full accumulated log
223
+ log.getCheckpoint() // max ts held — persist, and restore via setCheckpoint()
224
+ ```
225
+
226
+ - Cold start (no seed) → first `pull()` fetches the whole collection; warm start (seeded) → resumes incrementally.
227
+ - `verifyAuthor` verifies each element's author signature over the stored (pre-decryption) data and throws `AppendAuthorError` atomically on any failure (nothing is appended, checkpoint unchanged).
228
+ - **Reactive bindings:** `createStarfishLog` (Zustand, `./zustand`) + hooks `useStarfishLog` / `useStarfishLogItems` / `useLogStatus` / `useLogConnectivity`; `createStarfishLogObservable` (Legend, `./legend`); `createAppendLogMobileLifecycle` (pull on app foreground). `startPolling` and `createSuspenseResource` work with `cursor.pull()` directly.
229
+
209
230
  ## Other utilities
210
231
 
211
232
  The package also re-exports the v2 ergonomics that survived intact: `consoleSyncLogger`, `noopSyncLogger`, `createMetricsCollector`, `createMigrator`, `createSchemaValidator`, `classifyError`, conflict resolvers (`createUnionMerge`, `createSoftDeleteResolver`, `timestampWinner`, `pruneTombstones`), `SnapshotHistory`, `startPolling`/`startAdaptivePolling`, `createDedupFetch`, `fetchServerConfig`, `pullEntitlements`, `createIndexedDBStorage`, `exportData`/`importData`, `createDebouncedSync`/`createDebouncedPush`, `createMultiStoreSync`, `createMobileLifecycle`, and the Zustand/Legend bindings via the `./zustand` and `./legend` subpaths.
@@ -0,0 +1,158 @@
1
+ import { type Alg, type Encryptor } from "@drakkar.software/starfish-protocol";
2
+ import type { StarfishClient } from "./client.js";
3
+ import type { SyncLogger } from "./logger.js";
4
+ /**
5
+ * A single stored element of an append-only collection, exactly as returned by
6
+ * `client.pull(path, { appendField })`. `ts` is the server-assigned, strictly
7
+ * increasing element timestamp; `data` is the payload (plaintext under the
8
+ * `"none"` encryption mode, the opaque encryptor wrapper under `"delegated"`).
9
+ *
10
+ * When an {@link AppendLogCursor} is given an `encryptor`, the elements it
11
+ * stores and returns carry the **decrypted** `data` while preserving `ts` and
12
+ * the author fields — so the shape is uniform and re-seedable.
13
+ */
14
+ export interface AppendElement {
15
+ ts: number;
16
+ data: Record<string, unknown>;
17
+ authorPubkey?: string;
18
+ authorSignature?: string;
19
+ }
20
+ /** Per-element author-signature verification policy for {@link AppendLogCursor}. */
21
+ export interface AuthorVerifier {
22
+ /** If set, every element's `authorPubkey` MUST equal this key (compared as
23
+ * case-insensitive hex), else the pull fails. Omit to accept any signing key
24
+ * (verify only that the signature is valid for the element's self-declared
25
+ * `authorPubkey` — see the `verifyAuthor` note on restricting authors). */
26
+ expectedAuthorPubkey?: string;
27
+ /** Signing suite the signatures were produced under. Defaults to `DEFAULT_ALG`. */
28
+ alg?: Alg;
29
+ }
30
+ export interface AppendLogCursorOptions {
31
+ client: StarfishClient;
32
+ /** Pull endpoint path, e.g. `"/pull/events"`. */
33
+ pullPath: string;
34
+ /** Array field name in the pulled document. Defaults to `"items"`. */
35
+ appendField?: string;
36
+ /**
37
+ * Warm-start seed: raw envelopes the caller persisted last session. The
38
+ * cursor adopts them verbatim (never re-decrypts/re-verifies them) and
39
+ * derives its initial checkpoint from their max `ts`.
40
+ */
41
+ initialItems?: AppendElement[];
42
+ /**
43
+ * Explicit checkpoint-only seed (ms). Resume incrementally without
44
+ * rehydrating history. When given together with `initialItems`, it must be
45
+ * `>= max(ts of initialItems)` (a lower value would re-fetch held items).
46
+ */
47
+ since?: number;
48
+ /**
49
+ * When set, each freshly-pulled element's `.data` is decrypted via this
50
+ * encryptor (the `ts`/author fields are preserved). Author verification, when
51
+ * enabled, runs over the original (pre-decryption) `data`.
52
+ *
53
+ * Caveat: a returned / `getItems()` element then holds DECRYPTED `data` but an
54
+ * `authorSignature` computed over the stored CIPHERTEXT — they no longer match,
55
+ * so do NOT re-verify a decrypted element with `verifyAppendAuthor`. The cursor
56
+ * already verified it (over the ciphertext) at pull time when `verifyAuthor` is
57
+ * on; `authorPubkey` is retained for identity.
58
+ */
59
+ encryptor?: Encryptor;
60
+ /**
61
+ * `true` to verify every element's author signature, or a policy object.
62
+ *
63
+ * This verifies the signature is valid for the element's self-declared
64
+ * `authorPubkey` — it does NOT by itself restrict WHICH authors are accepted.
65
+ * To restrict authorship, set `expectedAuthorPubkey` (single author), or check
66
+ * each `el.authorPubkey` against your own authorization source (keyring /
67
+ * member list / cap allow-list) after pull — for a multi-writer log, the
68
+ * authorized set lives there and changes over time, not here.
69
+ *
70
+ * The signature covers `data` + the document key, but NOT `ts`: a malicious
71
+ * server cannot forge content, but can reorder or re-timestamp authentic
72
+ * elements, so trust `ts` only as far as you trust the server.
73
+ */
74
+ verifyAuthor?: boolean | AuthorVerifier;
75
+ /** Structured logger for pull events. */
76
+ logger?: SyncLogger;
77
+ /** Name passed to logger methods (default: derived from `pullPath`). */
78
+ loggerName?: string;
79
+ }
80
+ /** Thrown when an append element's author signature fails verification. */
81
+ export declare class AppendAuthorError extends Error {
82
+ readonly ts: number;
83
+ constructor(ts: number);
84
+ }
85
+ /** Largest `ts` among `items`, or `0` when empty. The checkpoint for an
86
+ * append-only log is exactly this — the server returns elements with
87
+ * `ts > checkpoint`, and element timestamps are strictly increasing. */
88
+ export declare function checkpointOf(items: readonly {
89
+ ts: number;
90
+ }[]): number;
91
+ /**
92
+ * A stateful cursor over an append-only collection. It owns the accumulated
93
+ * array of elements and pulls only what is new: each {@link pull} derives the
94
+ * checkpoint from the last element it holds and asks the server for elements
95
+ * with a greater `ts`.
96
+ *
97
+ * This is the incremental, stateful counterpart to the deliberately stateless
98
+ * `client.pull(path, { appendField, since })`, and the sibling of
99
+ * {@link SyncManager} for append-only logs (no merge / push-conflict
100
+ * machinery — a log only grows).
101
+ *
102
+ * The cursor accumulates every pulled element in memory; for an unboundedly
103
+ * large log, pull a bounded window with raw `client.pull(path, { last })` instead.
104
+ *
105
+ * Cold start (nothing persisted) — first `pull()` fetches the whole collection:
106
+ * ```ts
107
+ * const log = new AppendLogCursor({ client, pullPath: "/pull/events" })
108
+ * const all = await log.pull()
109
+ * ```
110
+ * Warm start (resume from persisted data) — first `pull()` fetches only newer
111
+ * elements; persistence is a round-trip of `getItems()` (see the `encryptor`
112
+ * caveat when decrypting):
113
+ * ```ts
114
+ * const log = new AppendLogCursor({ client, pullPath: "/pull/events",
115
+ * initialItems: await store.load() })
116
+ * const fresh = await log.pull()
117
+ * await store.save(log.getItems())
118
+ * ```
119
+ */
120
+ export declare class AppendLogCursor {
121
+ private readonly client;
122
+ private readonly pullPath;
123
+ private readonly appendField;
124
+ private readonly encryptor?;
125
+ private readonly verifyAuthor?;
126
+ private readonly documentKey;
127
+ private readonly logger?;
128
+ private readonly loggerName;
129
+ private readonly items;
130
+ private lastCheckpoint;
131
+ constructor(options: AppendLogCursorOptions);
132
+ /**
133
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
134
+ * append them to the local log, and return ONLY the newly-fetched batch.
135
+ *
136
+ * Atomic: the batch is fully verified and decrypted into a local before any
137
+ * state mutation, so a verify/decrypt failure throws without advancing the
138
+ * checkpoint past elements that could never be re-fetched.
139
+ *
140
+ * Not safe to call concurrently: like `SyncManager.pull`, overlapping calls
141
+ * read the same checkpoint and would fetch — and append — the same window twice.
142
+ */
143
+ pull(): Promise<AppendElement[]>;
144
+ /** Verify a single element's author signature over its RAW (pre-decryption)
145
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
146
+ * verification is disabled. */
147
+ private verifyOne;
148
+ /** The full accumulated log (a shallow copy), in `ts` order. */
149
+ getItems(): AppendElement[];
150
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
151
+ * when nothing has been pulled or seeded. */
152
+ getCheckpoint(): number;
153
+ /** Restore the checkpoint without seeding items — for persistence layers that
154
+ * store only the checkpoint. Used to resume incrementally across restarts.
155
+ * Rejects a value below the max `ts` already held: rewinding would make the
156
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
157
+ setCheckpoint(ts: number): void;
158
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Observable } from "@legendapp/state";
2
2
  import type { SyncManager } from "../sync.js";
3
+ import type { AppendLogCursor, AppendElement } from "../append-log.js";
3
4
  export interface StarfishLegendState {
4
5
  data: Record<string, unknown>;
5
6
  syncing: boolean;
@@ -23,3 +24,25 @@ export interface CreateStarfishObservableOptions {
23
24
  produce?: <T>(base: T, recipe: (draft: T) => T | void) => T;
24
25
  }
25
26
  export declare function createStarfishObservable(options: CreateStarfishObservableOptions): StarfishLegendStore;
27
+ export interface StarfishLogObservableState {
28
+ /** The full accumulated log, newest appended last. */
29
+ items: AppendElement[];
30
+ /** A `pull()` is in flight. */
31
+ loading: boolean;
32
+ online: boolean;
33
+ error: string | null;
34
+ /** The cursor's checkpoint (max `ts` held). */
35
+ checkpoint: number;
36
+ }
37
+ export interface StarfishLogObservableStore {
38
+ /** The observable state tree — read fields with `.get()` inside `observer` components. */
39
+ state: Observable<StarfishLogObservableState>;
40
+ /** Pull elements newer than the checkpoint, append them, return the new batch.
41
+ * Errors are captured into `state.error`. */
42
+ pull: () => Promise<AppendElement[]>;
43
+ setOnline: (online: boolean) => void;
44
+ }
45
+ export interface CreateStarfishLogObservableOptions {
46
+ cursor: AppendLogCursor;
47
+ }
48
+ export declare function createStarfishLogObservable(options: CreateStarfishLogObservableOptions): StarfishLogObservableStore;
@@ -57,7 +57,39 @@ function createStarfishObservable(options) {
57
57
  };
58
58
  return { state, pull, set, flush, setOnline };
59
59
  }
60
+ function createStarfishLogObservable(options) {
61
+ const { cursor } = options;
62
+ const state = observable({
63
+ // Seed from the cursor so a warm-started cursor's items show immediately.
64
+ items: cursor.getItems(),
65
+ loading: false,
66
+ online: true,
67
+ error: null,
68
+ checkpoint: cursor.getCheckpoint()
69
+ });
70
+ const pull = async () => {
71
+ if (state.loading.get()) return [];
72
+ state.loading.set(true);
73
+ state.error.set(null);
74
+ try {
75
+ const batch = await cursor.pull();
76
+ state.items.set(cursor.getItems());
77
+ state.checkpoint.set(cursor.getCheckpoint());
78
+ return batch;
79
+ } catch (err) {
80
+ state.error.set(err instanceof Error ? err.message : String(err));
81
+ return [];
82
+ } finally {
83
+ state.loading.set(false);
84
+ }
85
+ };
86
+ const setOnline = (online) => {
87
+ state.online.set(online);
88
+ };
89
+ return { state, pull, setOnline };
90
+ }
60
91
  export {
92
+ createStarfishLogObservable,
61
93
  createStarfishObservable
62
94
  };
63
95
  //# sourceMappingURL=legend.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/bindings/legend.ts"],
4
- "sourcesContent": ["import { observable } from \"@legendapp/state\"\nimport type { Observable } from \"@legendapp/state\"\nimport type { SyncManager } from \"../sync.js\"\n\nexport interface StarfishLegendState {\n data: Record<string, unknown>\n syncing: boolean\n online: boolean\n dirty: boolean\n error: string | null\n}\n\nexport interface StarfishLegendStore {\n /** The observable state tree \u2014 read fields with `.get()` inside `observer` components. */\n state: Observable<StarfishLegendState>\n pull: () => Promise<void>\n set: (modifier: (current: Record<string, unknown>) => Record<string, unknown>) => void\n flush: () => Promise<void>\n setOnline: (online: boolean) => void\n}\n\nexport interface CreateStarfishObservableOptions {\n /** Unique name for this collection (used for persistence keys when applicable). */\n name: string\n syncManager: SyncManager\n /** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */\n produce?: <T>(base: T, recipe: (draft: T) => T | void) => T\n}\n\nexport function createStarfishObservable(\n options: CreateStarfishObservableOptions,\n): StarfishLegendStore {\n const state = observable<StarfishLegendState>({\n data: {},\n syncing: false,\n online: true,\n dirty: false,\n error: null,\n })\n\n const flush = async (): Promise<void> => {\n if (state.syncing.get() || !state.dirty.get()) return\n state.syncing.set(true)\n state.error.set(null)\n try {\n await options.syncManager.push(state.data.get())\n state.data.set(options.syncManager.getData())\n state.dirty.set(false)\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n } finally {\n state.syncing.set(false)\n }\n }\n\n const pull = async (): Promise<void> => {\n state.syncing.set(true)\n state.error.set(null)\n try {\n await options.syncManager.pull()\n state.data.set(options.syncManager.getData())\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n } finally {\n state.syncing.set(false)\n }\n }\n\n const set = (\n modifier: (current: Record<string, unknown>) => Record<string, unknown>,\n ): void => {\n try {\n const current = state.data.get()\n const next = options.produce\n ? options.produce(\n current,\n modifier as (draft: Record<string, unknown>) => Record<string, unknown> | void,\n )\n : modifier(current)\n state.data.set(next)\n state.dirty.set(true)\n state.error.set(null)\n if (state.online.get()) flush().catch(() => {})\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n }\n }\n\n const setOnline = (online: boolean): void => {\n state.online.set(online)\n if (online && state.dirty.get()) flush().catch(() => {})\n }\n\n return { state, pull, set, flush, setOnline }\n}\n"],
5
- "mappings": ";AAAA,SAAS,kBAAkB;AA6BpB,SAAS,yBACd,SACqB;AACrB,QAAM,QAAQ,WAAgC;AAAA,IAC5C,MAAM,CAAC;AAAA,IACP,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,OAAO;AAAA,IACP,OAAO;AAAA,EACT,CAAC;AAED,QAAM,QAAQ,YAA2B;AACvC,QAAI,MAAM,QAAQ,IAAI,KAAK,CAAC,MAAM,MAAM,IAAI,EAAG;AAC/C,UAAM,QAAQ,IAAI,IAAI;AACtB,UAAM,MAAM,IAAI,IAAI;AACpB,QAAI;AACF,YAAM,QAAQ,YAAY,KAAK,MAAM,KAAK,IAAI,CAAC;AAC/C,YAAM,KAAK,IAAI,QAAQ,YAAY,QAAQ,CAAC;AAC5C,YAAM,MAAM,IAAI,KAAK;AAAA,IACvB,SAAS,KAAK;AACZ,YAAM,MAAM,IAAI,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,IAClE,UAAE;AACA,YAAM,QAAQ,IAAI,KAAK;AAAA,IACzB;AAAA,EACF;AAEA,QAAM,OAAO,YAA2B;AACtC,UAAM,QAAQ,IAAI,IAAI;AACtB,UAAM,MAAM,IAAI,IAAI;AACpB,QAAI;AACF,YAAM,QAAQ,YAAY,KAAK;AAC/B,YAAM,KAAK,IAAI,QAAQ,YAAY,QAAQ,CAAC;AAAA,IAC9C,SAAS,KAAK;AACZ,YAAM,MAAM,IAAI,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,IAClE,UAAE;AACA,YAAM,QAAQ,IAAI,KAAK;AAAA,IACzB;AAAA,EACF;AAEA,QAAM,MAAM,CACV,aACS;AACT,QAAI;AACF,YAAM,UAAU,MAAM,KAAK,IAAI;AAC/B,YAAM,OAAO,QAAQ,UACjB,QAAQ;AAAA,QACN;AAAA,QACA;AAAA,MACF,IACA,SAAS,OAAO;AACpB,YAAM,KAAK,IAAI,IAAI;AACnB,YAAM,MAAM,IAAI,IAAI;AACpB,YAAM,MAAM,IAAI,IAAI;AACpB,UAAI,MAAM,OAAO,IAAI,EAAG,OAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IAChD,SAAS,KAAK;AACZ,YAAM,MAAM,IAAI,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,IAClE;AAAA,EACF;AAEA,QAAM,YAAY,CAAC,WAA0B;AAC3C,UAAM,OAAO,IAAI,MAAM;AACvB,QAAI,UAAU,MAAM,MAAM,IAAI,EAAG,OAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACzD;AAEA,SAAO,EAAE,OAAO,MAAM,KAAK,OAAO,UAAU;AAC9C;",
4
+ "sourcesContent": ["import { observable } from \"@legendapp/state\"\nimport type { Observable } from \"@legendapp/state\"\nimport type { SyncManager } from \"../sync.js\"\nimport type { AppendLogCursor, AppendElement } from \"../append-log.js\"\n\nexport interface StarfishLegendState {\n data: Record<string, unknown>\n syncing: boolean\n online: boolean\n dirty: boolean\n error: string | null\n}\n\nexport interface StarfishLegendStore {\n /** The observable state tree \u2014 read fields with `.get()` inside `observer` components. */\n state: Observable<StarfishLegendState>\n pull: () => Promise<void>\n set: (modifier: (current: Record<string, unknown>) => Record<string, unknown>) => void\n flush: () => Promise<void>\n setOnline: (online: boolean) => void\n}\n\nexport interface CreateStarfishObservableOptions {\n /** Unique name for this collection (used for persistence keys when applicable). */\n name: string\n syncManager: SyncManager\n /** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */\n produce?: <T>(base: T, recipe: (draft: T) => T | void) => T\n}\n\nexport function createStarfishObservable(\n options: CreateStarfishObservableOptions,\n): StarfishLegendStore {\n const state = observable<StarfishLegendState>({\n data: {},\n syncing: false,\n online: true,\n dirty: false,\n error: null,\n })\n\n const flush = async (): Promise<void> => {\n if (state.syncing.get() || !state.dirty.get()) return\n state.syncing.set(true)\n state.error.set(null)\n try {\n await options.syncManager.push(state.data.get())\n state.data.set(options.syncManager.getData())\n state.dirty.set(false)\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n } finally {\n state.syncing.set(false)\n }\n }\n\n const pull = async (): Promise<void> => {\n state.syncing.set(true)\n state.error.set(null)\n try {\n await options.syncManager.pull()\n state.data.set(options.syncManager.getData())\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n } finally {\n state.syncing.set(false)\n }\n }\n\n const set = (\n modifier: (current: Record<string, unknown>) => Record<string, unknown>,\n ): void => {\n try {\n const current = state.data.get()\n const next = options.produce\n ? options.produce(\n current,\n modifier as (draft: Record<string, unknown>) => Record<string, unknown> | void,\n )\n : modifier(current)\n state.data.set(next)\n state.dirty.set(true)\n state.error.set(null)\n if (state.online.get()) flush().catch(() => {})\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n }\n }\n\n const setOnline = (online: boolean): void => {\n state.online.set(online)\n if (online && state.dirty.get()) flush().catch(() => {})\n }\n\n return { state, pull, set, flush, setOnline }\n}\n\n// \u2500\u2500 Append-only log binding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// The reactive counterpart for an append-only collection, backed by an\n// `AppendLogCursor`. Read-only (a log only grows): no `set`/`flush`/`dirty`.\n// The cursor owns the items + checkpoint; persist via `getItems()` and\n// rehydrate by constructing the cursor with `initialItems`.\n//\n// The store assumes it is the SOLE driver of its cursor (it seeds from\n// `cursor.getItems()` at construction and updates only via its own `pull()`);\n// don't also call `cursor.pull()` directly, or the observable will go stale.\n\nexport interface StarfishLogObservableState {\n /** The full accumulated log, newest appended last. */\n items: AppendElement[]\n /** A `pull()` is in flight. */\n loading: boolean\n online: boolean\n error: string | null\n /** The cursor's checkpoint (max `ts` held). */\n checkpoint: number\n}\n\nexport interface StarfishLogObservableStore {\n /** The observable state tree \u2014 read fields with `.get()` inside `observer` components. */\n state: Observable<StarfishLogObservableState>\n /** Pull elements newer than the checkpoint, append them, return the new batch.\n * Errors are captured into `state.error`. */\n pull: () => Promise<AppendElement[]>\n setOnline: (online: boolean) => void\n}\n\nexport interface CreateStarfishLogObservableOptions {\n cursor: AppendLogCursor\n}\n\nexport function createStarfishLogObservable(\n options: CreateStarfishLogObservableOptions,\n): StarfishLogObservableStore {\n const { cursor } = options\n const state = observable<StarfishLogObservableState>({\n // Seed from the cursor so a warm-started cursor's items show immediately.\n items: cursor.getItems(),\n loading: false,\n online: true,\n error: null,\n checkpoint: cursor.getCheckpoint(),\n })\n\n const pull = async (): Promise<AppendElement[]> => {\n if (state.loading.get()) return []\n state.loading.set(true)\n state.error.set(null)\n try {\n const batch = await cursor.pull()\n state.items.set(cursor.getItems())\n state.checkpoint.set(cursor.getCheckpoint())\n return batch\n } catch (err) {\n state.error.set(err instanceof Error ? err.message : String(err))\n return []\n } finally {\n state.loading.set(false)\n }\n }\n\n const setOnline = (online: boolean): void => {\n state.online.set(online)\n }\n\n return { state, pull, setOnline }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,7 @@ import { type StateStorage } from "zustand/middleware";
3
3
  import type { DevtoolsOptions } from "zustand/middleware";
4
4
  import type { Encryptor } from "@drakkar.software/starfish-protocol";
5
5
  import { SyncManager } from "../sync.js";
6
+ import { AppendLogCursor, type AppendElement } from "../append-log.js";
6
7
  import type { StarfishCapProvider, ConflictResolver } from "../types.js";
7
8
  import type { SyncLogger } from "../logger.js";
8
9
  import type { Validator } from "../validate.js";
@@ -135,3 +136,40 @@ export interface SyncInitConfig {
135
136
  * Pass `null` to disable sync (returns `null`).
136
137
  */
137
138
  export declare function useSyncInit(config: SyncInitConfig | null): StoreApi<StarfishStore> | null;
139
+ export interface StarfishLogState {
140
+ /** The full accumulated log, newest appended last. */
141
+ items: AppendElement[];
142
+ /** A `pull()` is in flight. */
143
+ loading: boolean;
144
+ online: boolean;
145
+ error: string | null;
146
+ /** The cursor's checkpoint (max `ts` held). */
147
+ checkpoint: number;
148
+ }
149
+ export interface StarfishLogActions {
150
+ /** Pull elements newer than the checkpoint, append them, and return the new
151
+ * batch. Errors are captured into `error` (mirroring the SyncManager store). */
152
+ pull: () => Promise<AppendElement[]>;
153
+ setOnline: (online: boolean) => void;
154
+ }
155
+ export type StarfishLogStore = StarfishLogState & StarfishLogActions;
156
+ export interface CreateStarfishLogOptions {
157
+ cursor: AppendLogCursor;
158
+ devtools?: (storeCreator: any) => any;
159
+ }
160
+ export declare function createStarfishLog(options: CreateStarfishLogOptions): StoreApi<StarfishLogStore>;
161
+ /** Derived status for an append-log store. */
162
+ export type LogStatus = "idle" | "loading" | "error" | "offline";
163
+ /** Derive a single status from log store state. */
164
+ export declare function deriveLogStatus(state: StarfishLogState): LogStatus;
165
+ /** Use the full append-log store state and actions. */
166
+ export declare function useStarfishLog(store: StoreApi<StarfishLogStore>): StarfishLogStore;
167
+ /** Use only the accumulated items, with an optional selector for fine-grained subscriptions. */
168
+ export declare function useStarfishLogItems<T = AppendElement[]>(store: StoreApi<StarfishLogStore>, selector?: (items: AppendElement[]) => T): T;
169
+ /** Use the derived log status (idle | loading | error | offline). */
170
+ export declare function useLogStatus(store: StoreApi<StarfishLogStore>): LogStatus;
171
+ /** Subscribe to log status changes outside of React. Invoked immediately with the
172
+ * current status, then on every change. Returns an unsubscribe function. */
173
+ export declare function subscribeLogStatus(store: StoreApi<StarfishLogStore>, callback: (status: LogStatus) => void): () => void;
174
+ /** Binds browser online/offline events to the log store's setOnline action. Cleans up on unmount. */
175
+ export declare function useLogConnectivity(store: StoreApi<StarfishLogStore>): void;
@@ -435,6 +435,35 @@ var StarfishClient = class {
435
435
  }
436
436
  return result;
437
437
  }
438
+ /**
439
+ * Pull several collections in one round-trip via `/batch/pull`. `collections`
440
+ * is the list of collection names; `opts.params` supplies path params per
441
+ * collection (serialized to a URL-encoded JSON `params` query). The server
442
+ * auto-fills the `{identity}` param from the authenticated caller, so per-user
443
+ * collections need no params. Returns a map of collection name → its pulled
444
+ * document or a per-collection `{ error }`. Honors the configured namespace.
445
+ *
446
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
447
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
448
+ */
449
+ async batchPull(collections, opts = {}) {
450
+ const search = new URLSearchParams();
451
+ search.set("collections", collections.join(","));
452
+ if (opts.params && Object.keys(opts.params).length > 0) {
453
+ search.set("params", JSON.stringify(opts.params));
454
+ }
455
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
456
+ const url = `${this.baseUrl}${pathAndQuery}`;
457
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
458
+ const res = await this.fetch(url, {
459
+ method: "GET",
460
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
461
+ });
462
+ if (!res.ok) {
463
+ throw new StarfishHttpError(res.status, await res.text());
464
+ }
465
+ return await res.json();
466
+ }
438
467
  /**
439
468
  * Push synced data to the server.
440
469
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
@@ -1018,16 +1047,101 @@ function useSyncInit(config) {
1018
1047
  ]);
1019
1048
  return store;
1020
1049
  }
1050
+ function createStarfishLog(options) {
1051
+ const { cursor } = options;
1052
+ const storeCreator = (rawSet, get) => {
1053
+ const set = rawSet;
1054
+ return {
1055
+ // Seed from the cursor so a warm-started cursor's items show immediately.
1056
+ items: cursor.getItems(),
1057
+ loading: false,
1058
+ online: true,
1059
+ error: null,
1060
+ checkpoint: cursor.getCheckpoint(),
1061
+ pull: async () => {
1062
+ if (get().loading) return [];
1063
+ set({ loading: true, error: null }, false, "log/pull/start");
1064
+ try {
1065
+ const batch = await cursor.pull();
1066
+ set(
1067
+ { items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
1068
+ false,
1069
+ "log/pull/success"
1070
+ );
1071
+ return batch;
1072
+ } catch (err) {
1073
+ set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
1074
+ return [];
1075
+ }
1076
+ },
1077
+ setOnline: (online) => {
1078
+ set({ online }, false, "log/setOnline");
1079
+ }
1080
+ };
1081
+ };
1082
+ const withSelector = subscribeWithSelector(storeCreator);
1083
+ return createStore()(
1084
+ options.devtools ? options.devtools(withSelector) : withSelector
1085
+ );
1086
+ }
1087
+ function deriveLogStatus(state) {
1088
+ if (!state.online) return "offline";
1089
+ if (state.error) return "error";
1090
+ if (state.loading) return "loading";
1091
+ return "idle";
1092
+ }
1093
+ function useStarfishLog(store) {
1094
+ return useStore(store);
1095
+ }
1096
+ function useStarfishLogItems(store, selector) {
1097
+ return useStore(
1098
+ store,
1099
+ (state) => selector ? selector(state.items) : state.items
1100
+ );
1101
+ }
1102
+ function useLogStatus(store) {
1103
+ return useStore(store, deriveLogStatus);
1104
+ }
1105
+ function subscribeLogStatus(store, callback) {
1106
+ let prev = deriveLogStatus(store.getState());
1107
+ callback(prev);
1108
+ return store.subscribe((state) => {
1109
+ const next = deriveLogStatus(state);
1110
+ if (next !== prev) {
1111
+ prev = next;
1112
+ callback(next);
1113
+ }
1114
+ });
1115
+ }
1116
+ function useLogConnectivity(store) {
1117
+ useEffect(() => {
1118
+ const handleOnline = () => store.getState().setOnline(true);
1119
+ const handleOffline = () => store.getState().setOnline(false);
1120
+ window.addEventListener("online", handleOnline);
1121
+ window.addEventListener("offline", handleOffline);
1122
+ return () => {
1123
+ window.removeEventListener("online", handleOnline);
1124
+ window.removeEventListener("offline", handleOffline);
1125
+ };
1126
+ }, [store]);
1127
+ }
1021
1128
  export {
1022
1129
  aggregateSyncStatus,
1130
+ createStarfishLog,
1023
1131
  createStarfishStore,
1132
+ deriveLogStatus,
1024
1133
  deriveSyncStatus,
1134
+ subscribeLogStatus,
1025
1135
  subscribeSyncStatus,
1026
1136
  useConnectivity,
1027
1137
  useCrossTabSync,
1028
1138
  useLastSynced,
1139
+ useLogConnectivity,
1140
+ useLogStatus,
1029
1141
  useStarfish,
1030
1142
  useStarfishData,
1143
+ useStarfishLog,
1144
+ useStarfishLogItems,
1031
1145
  useSyncInit,
1032
1146
  useSyncStatus
1033
1147
  };