@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.21

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
@@ -185,6 +185,8 @@ new StarfishClient({
185
185
  baseUrl: "https://api.example.com/v1",
186
186
  capProvider, // v3 — signs every request. Replaces v2 `auth`/`authProvider`.
187
187
  fetch, // optional custom fetch
188
+ cache, // optional offline-first read-through cache — see below
189
+ cacheMaxAgeMs, // optional TTL (ms) for cache entries
188
190
  })
189
191
  ```
190
192
 
@@ -192,6 +194,19 @@ new StarfishClient({
192
194
 
193
195
  Omit `capProvider` for unauthenticated public reads.
194
196
 
197
+ ### Offline-first read cache
198
+
199
+ 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:
200
+
201
+ - **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
202
+ - **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)`).
203
+ - **Real HTTP errors propagate:** 404/403/5xx are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning.
204
+ - **`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).
205
+ - **Ciphertext-at-rest by construction:** the cache stores the raw server payload, which for E2E (`delegated`) collections is the sealed ciphertext the server holds — never the decrypted form. Decryption happens in memory on read (see `SyncManager.seedFromCache`).
206
+ - **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
207
+
208
+ Append collections are not cached here; they own warm-start persistence via `AppendLogCursor` (`persistEncrypted`).
209
+
195
210
  ## `SyncManager`
196
211
 
197
212
  ```ts
@@ -205,6 +220,35 @@ new SyncManager({
205
220
 
206
221
  - `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
222
  - `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
223
+ - `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.
224
+ - **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.
225
+
226
+ ## `AppendLogCursor`
227
+
228
+ 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.
229
+
230
+ ```ts
231
+ const log = new AppendLogCursor({
232
+ client, pullPath: "/pull/events",
233
+ appendField, // default "items"
234
+ initialItems, // warm-start seed (raw {ts,data} envelopes) — or pass `since`
235
+ encryptor, // optional: decrypt each element's data (ts/author preserved)
236
+ verifyAuthor, // optional: true | { expectedAuthorPubkey?, alg? }
237
+ onElementError, // optional: "throw" (default) | "skip" — see below
238
+ persistEncrypted, // optional: keep ciphertext for E2EE-safe persistence — see below
239
+ })
240
+ const fresh = await log.pull() // only elements newer than the last held (safe to call concurrently)
241
+ log.getItems() // full accumulated log (ciphertext under persistEncrypted)
242
+ await log.getDecryptedItems() // full log decrypted — render warm-started history
243
+ log.getCheckpoint() // max ts held — persist, and restore via setCheckpoint()
244
+ ```
245
+
246
+ - Cold start (no seed) → first `pull()` fetches the whole collection; warm start (seeded) → resumes incrementally.
247
+ - `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).
248
+ - `onElementError: "skip"` drops an element that fails verify/decrypt **and advances the checkpoint past it** (never re-fetched), so one unreadable element in a multi-writer / E2EE log can't blank the whole log. Default `"throw"` keeps the atomic behavior. SECURITY: `"skip"` also drops author-verification failures silently — combine with `verifyAuthor.expectedAuthorPubkey` or a post-pull `authorPubkey` check for strict authorship.
249
+ - `persistEncrypted: true` (with an `encryptor`) stores each element's **ciphertext** so `getItems()` is safe to persist at rest for an E2EE log; `pull()` still returns decrypted, and `getDecryptedItems()` decrypts the full held log for warm-start rendering.
250
+ - `pull()` is safe to call concurrently — overlapping calls serialize internally so they never double-append a window.
251
+ - **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.
208
252
 
209
253
  ## Other utilities
210
254
 
@@ -0,0 +1,228 @@
1
+ import { 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. The exception is
13
+ * `persistEncrypted` mode (see {@link AppendLogCursorOptions.persistEncrypted}),
14
+ * where the stored elements keep their **ciphertext** `data` (E2EE-safe to
15
+ * persist) and decryption happens only on read via {@link AppendLogCursor.pull}
16
+ * and {@link AppendLogCursor.getDecryptedItems}.
17
+ */
18
+ export interface AppendElement {
19
+ ts: number;
20
+ data: Record<string, unknown>;
21
+ authorPubkey?: string;
22
+ authorSignature?: string;
23
+ }
24
+ /** Per-element author-signature verification policy for {@link AppendLogCursor}. */
25
+ export interface AuthorVerifier {
26
+ /** If set, every element's `authorPubkey` MUST equal this key (compared as
27
+ * case-insensitive hex), else the pull fails. Omit to accept any signing key
28
+ * (verify only that the signature is valid for the element's self-declared
29
+ * `authorPubkey` — see the `verifyAuthor` note on restricting authors). */
30
+ expectedAuthorPubkey?: string;
31
+ }
32
+ /**
33
+ * What to do when a single element fails verification or decryption during a
34
+ * {@link AppendLogCursor.pull} (or {@link AppendLogCursor.getDecryptedItems}).
35
+ *
36
+ * - `"throw"` (default): the pull is **atomic** — the first bad element throws
37
+ * and NO state is mutated, so the checkpoint never advances past an element
38
+ * that could not be re-fetched. Use when every element must be readable.
39
+ * - `"skip"`: a bad element is dropped from the returned/decrypted batch and the
40
+ * checkpoint still **advances past it** (so it is never re-fetched), letting one
41
+ * poison element fail without blanking the whole log. Intended for tolerating
42
+ * **decrypt** failures in a multi-writer / E2EE log (keyring skew, a foreign or
43
+ * corrupt element). SECURITY NOTE: `"skip"` ALSO silently drops elements that
44
+ * fail *author* verification rather than failing loudly — so if you also need
45
+ * strict authorship, set `verifyAuthor.expectedAuthorPubkey` (single author) or
46
+ * check each element's `authorPubkey` against your authorized set after pull.
47
+ */
48
+ export type ElementErrorPolicy = "throw" | "skip";
49
+ export interface AppendLogCursorOptions {
50
+ client: StarfishClient;
51
+ /** Pull endpoint path, e.g. `"/pull/events"`. */
52
+ pullPath: string;
53
+ /** Array field name in the pulled document. Defaults to `"items"`. */
54
+ appendField?: string;
55
+ /**
56
+ * Warm-start seed: raw envelopes the caller persisted last session. The
57
+ * cursor adopts them verbatim and derives its initial checkpoint from their
58
+ * max `ts`. Under the default mode they are taken as already-decrypted (and
59
+ * never re-decrypted/re-verified); under `persistEncrypted` they are the
60
+ * persisted **ciphertext** and are decrypted on read (see `persistEncrypted`).
61
+ */
62
+ initialItems?: AppendElement[];
63
+ /**
64
+ * Explicit checkpoint-only seed (ms). Resume incrementally without
65
+ * rehydrating history. When given together with `initialItems`, it must be
66
+ * `>= max(ts of initialItems)` (a lower value would re-fetch held items).
67
+ */
68
+ since?: number;
69
+ /**
70
+ * When set, each freshly-pulled element's `.data` is decrypted via this
71
+ * encryptor (the `ts`/author fields are preserved). Author verification, when
72
+ * enabled, runs over the original (pre-decryption) `data`.
73
+ *
74
+ * Caveat (default mode): a returned / `getItems()` element then holds DECRYPTED
75
+ * `data` but an `authorSignature` computed over the stored CIPHERTEXT — they no
76
+ * longer match, so do NOT re-verify a decrypted element with `verifyAppendAuthor`.
77
+ * The cursor already verified it (over the ciphertext) at pull time when
78
+ * `verifyAuthor` is on; `authorPubkey` is retained for identity. (Under
79
+ * `persistEncrypted` the stored elements keep their ciphertext, so this caveat
80
+ * does not apply to `getItems()`.)
81
+ */
82
+ encryptor?: Encryptor;
83
+ /**
84
+ * Per-element failure policy for verification/decryption. Defaults to
85
+ * `"throw"` (atomic pull — preserves the pre-existing behavior). See
86
+ * {@link ElementErrorPolicy}.
87
+ */
88
+ onElementError?: ElementErrorPolicy;
89
+ /**
90
+ * Keep the **ciphertext** form of each element in the cursor's accumulated log
91
+ * instead of the decrypted form (requires `encryptor`; a no-op without one,
92
+ * since plaintext is already its own stored form). This makes
93
+ * {@link AppendLogCursor.getItems} return the persistable ciphertext — safe to
94
+ * write to disk for an end-to-end-encrypted log without leaking plaintext at
95
+ * rest — while {@link AppendLogCursor.pull} still returns the freshly-decrypted
96
+ * batch and {@link AppendLogCursor.getDecryptedItems} returns the full log
97
+ * decrypted (for warm-start rendering). Defaults to `false` (store decrypted).
98
+ */
99
+ persistEncrypted?: boolean;
100
+ /**
101
+ * `true` to verify every element's author signature, or a policy object.
102
+ *
103
+ * This verifies the signature is valid for the element's self-declared
104
+ * `authorPubkey` — it does NOT by itself restrict WHICH authors are accepted.
105
+ * To restrict authorship, set `expectedAuthorPubkey` (single author), or check
106
+ * each `el.authorPubkey` against your own authorization source (keyring /
107
+ * member list / cap allow-list) after pull — for a multi-writer log, the
108
+ * authorized set lives there and changes over time, not here.
109
+ *
110
+ * The signature covers `data` + the document key, but NOT `ts`: a malicious
111
+ * server cannot forge content, but can reorder or re-timestamp authentic
112
+ * elements, so trust `ts` only as far as you trust the server.
113
+ */
114
+ verifyAuthor?: boolean | AuthorVerifier;
115
+ /** Structured logger for pull events. */
116
+ logger?: SyncLogger;
117
+ /** Name passed to logger methods (default: derived from `pullPath`). */
118
+ loggerName?: string;
119
+ }
120
+ /** Thrown when an append element's author signature fails verification. */
121
+ export declare class AppendAuthorError extends Error {
122
+ readonly ts: number;
123
+ constructor(ts: number);
124
+ }
125
+ /** Largest `ts` among `items`, or `0` when empty. The checkpoint for an
126
+ * append-only log is exactly this — the server returns elements with
127
+ * `ts > checkpoint`, and element timestamps are strictly increasing. */
128
+ export declare function checkpointOf(items: readonly {
129
+ ts: number;
130
+ }[]): number;
131
+ /**
132
+ * A stateful cursor over an append-only collection. It owns the accumulated
133
+ * array of elements and pulls only what is new: each {@link pull} derives the
134
+ * checkpoint from the last element it holds and asks the server for elements
135
+ * with a greater `ts`.
136
+ *
137
+ * This is the incremental, stateful counterpart to the deliberately stateless
138
+ * `client.pull(path, { appendField, since })`, and the sibling of
139
+ * {@link SyncManager} for append-only logs (no merge / push-conflict
140
+ * machinery — a log only grows).
141
+ *
142
+ * The cursor accumulates every pulled element in memory; for an unboundedly
143
+ * large log, pull a bounded window with raw `client.pull(path, { last })` instead.
144
+ *
145
+ * Cold start (nothing persisted) — first `pull()` fetches the whole collection:
146
+ * ```ts
147
+ * const log = new AppendLogCursor({ client, pullPath: "/pull/events" })
148
+ * const all = await log.pull()
149
+ * ```
150
+ * Warm start (resume from persisted data) — first `pull()` fetches only newer
151
+ * elements; persistence is a round-trip of `getItems()`:
152
+ * ```ts
153
+ * const log = new AppendLogCursor({ client, pullPath: "/pull/events",
154
+ * initialItems: await store.load() })
155
+ * const fresh = await log.pull()
156
+ * await store.save(log.getItems())
157
+ * ```
158
+ * Warm start for an **E2EE** log — persist ciphertext, render decrypted:
159
+ * ```ts
160
+ * const log = new AppendLogCursor({ client, pullPath: "/pull/streamchat",
161
+ * encryptor, persistEncrypted: true, onElementError: "skip",
162
+ * initialItems: await store.load() }) // ciphertext from disk
163
+ * const history = await log.getDecryptedItems() // render persisted history
164
+ * const fresh = await log.pull() // decrypted delta
165
+ * await store.save(log.getItems()) // ciphertext back to disk
166
+ * ```
167
+ */
168
+ export declare class AppendLogCursor {
169
+ private readonly client;
170
+ private readonly pullPath;
171
+ private readonly appendField;
172
+ private readonly encryptor?;
173
+ private readonly verifyAuthor?;
174
+ private readonly onElementError;
175
+ private readonly persistEncrypted;
176
+ private readonly documentKey;
177
+ private readonly logger?;
178
+ private readonly loggerName;
179
+ private readonly items;
180
+ private lastCheckpoint;
181
+ /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
182
+ * it so each runs against the checkpoint the previous one advanced — no two
183
+ * overlapping fetches read the same checkpoint and double-append a window. */
184
+ private pullChain;
185
+ constructor(options: AppendLogCursorOptions);
186
+ /**
187
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
188
+ * append them to the local log, and return ONLY the newly-fetched batch
189
+ * (decrypted when an `encryptor` is set).
190
+ *
191
+ * Atomic under `onElementError: "throw"` (the default): the batch is fully
192
+ * verified and decrypted into a local before any state mutation, so a
193
+ * verify/decrypt failure throws without advancing the checkpoint past elements
194
+ * that could never be re-fetched. Under `"skip"`, a failing element is dropped
195
+ * from the returned batch but the checkpoint still advances past it.
196
+ *
197
+ * Safe to call concurrently: overlapping calls are serialized internally, so
198
+ * each runs against the checkpoint the previous one advanced (no double-fetch
199
+ * of the same window). The next pull after one completes will pick up anything
200
+ * that arrived in between.
201
+ */
202
+ pull(): Promise<AppendElement[]>;
203
+ private doPull;
204
+ /** Verify a single element's author signature over its RAW (pre-decryption)
205
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
206
+ * verification is disabled. */
207
+ private verifyOne;
208
+ /** The full accumulated log (a shallow copy), in `ts` order. Under
209
+ * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
210
+ * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
211
+ getItems(): AppendElement[];
212
+ /**
213
+ * The full accumulated log, DECRYPTED — for rendering warm-started history in
214
+ * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
215
+ * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
216
+ * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
217
+ * elements are already plaintext/decrypted and are returned as-is.
218
+ */
219
+ getDecryptedItems(): Promise<AppendElement[]>;
220
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
221
+ * when nothing has been pulled or seeded. */
222
+ getCheckpoint(): number;
223
+ /** Restore the checkpoint without seeding items — for persistence layers that
224
+ * store only the checkpoint. Used to resume incrementally across restarts.
225
+ * Rejects a value below the max `ts` already held: rewinding would make the
226
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
227
+ setCheckpoint(ts: number): void;
228
+ }
@@ -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,7 +3,8 @@ 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 type { StarfishCapProvider, ConflictResolver } from "../types.js";
6
+ import { AppendLogCursor, type AppendElement } from "../append-log.js";
7
+ import type { StarfishCapProvider, ConflictResolver, PullCache } from "../types.js";
7
8
  import type { SyncLogger } from "../logger.js";
8
9
  import type { Validator } from "../validate.js";
9
10
  export interface StarfishState {
@@ -14,6 +15,14 @@ export interface StarfishState {
14
15
  error: string | null;
15
16
  /** Last-known server hash, persisted alongside `data`/`dirty`. Restored into the bound SyncManager on hydration. */
16
17
  hash: string | null;
18
+ /**
19
+ * True when the currently-shown `data` came from the offline read-through
20
+ * cache (a cache-first {@link StarfishActions.seed} or a {@link StarfishActions.pull}
21
+ * the client served from cache because the transport was unreachable) rather
22
+ * than a live server response. A successful live pull/flush clears it. Use it
23
+ * to drive an "offline / showing last-synced data" indicator.
24
+ */
25
+ stale: boolean;
17
26
  }
18
27
  export interface StarfishActions {
19
28
  pull: () => Promise<void>;
@@ -22,6 +31,14 @@ export interface StarfishActions {
22
31
  restore: (data: Record<string, unknown>) => void;
23
32
  flush: () => Promise<void>;
24
33
  setOnline: (online: boolean) => void;
34
+ /**
35
+ * Cache-first paint: populate `data` from the client's offline read-through
36
+ * cache (decrypting in memory for E2E collections) without touching the
37
+ * network. A no-op when the client has no cache configured or there's no
38
+ * (unexpired) entry. {@link useSyncInit} calls this once before the initial
39
+ * pull; the live pull then supersedes the seeded snapshot.
40
+ */
41
+ seed: () => Promise<void>;
25
42
  }
26
43
  export type StarfishStore = StarfishState & StarfishActions;
27
44
  export interface CreateStarfishStoreOptions {
@@ -104,6 +121,13 @@ export declare function useConnectivity(store: StoreApi<StarfishStore>): void;
104
121
  export declare function useLastSynced(store: StoreApi<StarfishStore>): string;
105
122
  export interface SyncInitConfig {
106
123
  serverUrl: string;
124
+ /**
125
+ * Optional server namespace, forwarded to the underlying {@link StarfishClient}
126
+ * so `pullPath`/`pushPath` are rewritten to `/v1/<namespace>/…` (signed AND sent).
127
+ * Leave unset for a root-mounted server. Pass the bare name (e.g. `"octochat"`),
128
+ * not `/v1/octochat` — the `/v1/` is added by the client.
129
+ */
130
+ namespace?: string;
107
131
  capProvider?: StarfishCapProvider;
108
132
  pullPath: string;
109
133
  pushPath: string;
@@ -115,6 +139,16 @@ export interface SyncInitConfig {
115
139
  storeName?: string;
116
140
  storage?: StateStorage | false;
117
141
  fetch?: typeof globalThis.fetch;
142
+ /**
143
+ * Offline-first read-through cache for the underlying {@link StarfishClient}
144
+ * (see {@link StarfishClientOptions.cache}). When set, the store seeds from the
145
+ * last-synced ciphertext on creation (cache-first paint, decrypted in memory)
146
+ * and the live pull falls back to it when the transport is unreachable; the
147
+ * store's `stale` flag reflects whether the shown data is from cache.
148
+ */
149
+ cache?: PullCache;
150
+ /** Max age (ms) for {@link cache} entries; see {@link StarfishClientOptions.cacheMaxAgeMs}. */
151
+ cacheMaxAgeMs?: number;
118
152
  logger?: SyncLogger;
119
153
  validate?: Validator;
120
154
  }
@@ -128,3 +162,40 @@ export interface SyncInitConfig {
128
162
  * Pass `null` to disable sync (returns `null`).
129
163
  */
130
164
  export declare function useSyncInit(config: SyncInitConfig | null): StoreApi<StarfishStore> | null;
165
+ export interface StarfishLogState {
166
+ /** The full accumulated log, newest appended last. */
167
+ items: AppendElement[];
168
+ /** A `pull()` is in flight. */
169
+ loading: boolean;
170
+ online: boolean;
171
+ error: string | null;
172
+ /** The cursor's checkpoint (max `ts` held). */
173
+ checkpoint: number;
174
+ }
175
+ export interface StarfishLogActions {
176
+ /** Pull elements newer than the checkpoint, append them, and return the new
177
+ * batch. Errors are captured into `error` (mirroring the SyncManager store). */
178
+ pull: () => Promise<AppendElement[]>;
179
+ setOnline: (online: boolean) => void;
180
+ }
181
+ export type StarfishLogStore = StarfishLogState & StarfishLogActions;
182
+ export interface CreateStarfishLogOptions {
183
+ cursor: AppendLogCursor;
184
+ devtools?: (storeCreator: any) => any;
185
+ }
186
+ export declare function createStarfishLog(options: CreateStarfishLogOptions): StoreApi<StarfishLogStore>;
187
+ /** Derived status for an append-log store. */
188
+ export type LogStatus = "idle" | "loading" | "error" | "offline";
189
+ /** Derive a single status from log store state. */
190
+ export declare function deriveLogStatus(state: StarfishLogState): LogStatus;
191
+ /** Use the full append-log store state and actions. */
192
+ export declare function useStarfishLog(store: StoreApi<StarfishLogStore>): StarfishLogStore;
193
+ /** Use only the accumulated items, with an optional selector for fine-grained subscriptions. */
194
+ export declare function useStarfishLogItems<T = AppendElement[]>(store: StoreApi<StarfishLogStore>, selector?: (items: AppendElement[]) => T): T;
195
+ /** Use the derived log status (idle | loading | error | offline). */
196
+ export declare function useLogStatus(store: StoreApi<StarfishLogStore>): LogStatus;
197
+ /** Subscribe to log status changes outside of React. Invoked immediately with the
198
+ * current status, then on every change. Returns an unsubscribe function. */
199
+ export declare function subscribeLogStatus(store: StoreApi<StarfishLogStore>, callback: (status: LogStatus) => void): () => void;
200
+ /** Binds browser online/offline events to the log store's setOnline action. Cleans up on unmount. */
201
+ export declare function useLogConnectivity(store: StoreApi<StarfishLogStore>): void;