@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.51
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 +59 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +167 -2
- package/dist/bindings/zustand.js +942 -82
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/blob-seal.d.ts +123 -0
- package/dist/client.d.ts +270 -5
- package/dist/client.js +391 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/export.js +115 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +51 -14
- package/dist/fetch.js.map +2 -2
- package/dist/history.js +61 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +1030 -94
- package/dist/index.js.map +4 -4
- package/dist/kv-cache.d.ts +63 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/mutate.d.ts +39 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.d.ts +83 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +106 -11
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -185,6 +185,10 @@ 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
|
|
190
|
+
cacheFallbackStatuses, // optional — serve cache on 429/5xx (stale-while-revalidate)
|
|
191
|
+
onRevalidated, // optional — called after a background revalidation succeeds
|
|
188
192
|
})
|
|
189
193
|
```
|
|
190
194
|
|
|
@@ -192,6 +196,29 @@ new StarfishClient({
|
|
|
192
196
|
|
|
193
197
|
Omit `capProvider` for unauthenticated public reads.
|
|
194
198
|
|
|
199
|
+
### Offline-first read cache
|
|
200
|
+
|
|
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:
|
|
210
|
+
|
|
211
|
+
- **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
|
|
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)`).
|
|
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).
|
|
214
|
+
- **Error-triggered 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.
|
|
215
|
+
- **Proactive stale-while-revalidate (`staleWhileRevalidate` pull option):** pass `{ staleWhileRevalidate: true }` to `client.pull(path, { staleWhileRevalidate: true })` to serve the cached snapshot immediately and revalidate in the background on every read (not just on errors). On a cache hit the cached result is returned at once (tagged via `pullWasFromCache`) and the background fetch fires immediately (no initial delay). On a miss it falls through to a normal network-first pull. `onRevalidated` fires on success with the fresh `PullResult`. Both SWR paths share the same dedup loop — a concurrent error-triggered loop and an SWR-on-read loop for the same document collapse to one.
|
|
216
|
+
- **`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).
|
|
217
|
+
- **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`).
|
|
218
|
+
- **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
|
|
219
|
+
|
|
220
|
+
Append collections are not cached here; they own warm-start persistence via `AppendLogCursor` (`persistEncrypted`).
|
|
221
|
+
|
|
195
222
|
## `SyncManager`
|
|
196
223
|
|
|
197
224
|
```ts
|
|
@@ -205,6 +232,38 @@ new SyncManager({
|
|
|
205
232
|
|
|
206
233
|
- `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
234
|
- `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
|
|
235
|
+
- `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.
|
|
236
|
+
- **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).
|
|
237
|
+
- **`SyncManager.ingest(result: PullResult)`** applies an externally-delivered `PullResult` to the manager's state (decrypting for E2E, updating `localData`/`lastHash`/`lastCheckpoint`, clearing `lastFromCache`) without a network call. Used by the zustand binding's `mergeResult` action to absorb background revalidation results.
|
|
238
|
+
|
|
239
|
+
**Auto-merge on revalidation:** when `cacheFallbackStatuses` or `staleWhileRevalidate` triggers a background revalidation, `useSyncInit` and `acquireSyncStore` automatically push the fresh `PullResult` into the bound store via `mergeResult` — the store repaints without waiting for the next explicit `pull()`. The consumer's own `onRevalidated` callback is still called after the store update. The `mergeResult` store action is also available for manual use when a caller holds a fresh `PullResult` it wants to push into the store without an extra round-trip.
|
|
240
|
+
|
|
241
|
+
## `AppendLogCursor`
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const log = new AppendLogCursor({
|
|
247
|
+
client, pullPath: "/pull/events",
|
|
248
|
+
appendField, // default "items"
|
|
249
|
+
initialItems, // warm-start seed (raw {ts,data} envelopes) — or pass `since`
|
|
250
|
+
encryptor, // optional: decrypt each element's data (ts/author preserved)
|
|
251
|
+
verifyAuthor, // optional: true | { expectedAuthorPubkey?, alg? }
|
|
252
|
+
onElementError, // optional: "throw" (default) | "skip" — see below
|
|
253
|
+
persistEncrypted, // optional: keep ciphertext for E2EE-safe persistence — see below
|
|
254
|
+
})
|
|
255
|
+
const fresh = await log.pull() // only elements newer than the last held (safe to call concurrently)
|
|
256
|
+
log.getItems() // full accumulated log (ciphertext under persistEncrypted)
|
|
257
|
+
await log.getDecryptedItems() // full log decrypted — render warm-started history
|
|
258
|
+
log.getCheckpoint() // max ts held — persist, and restore via setCheckpoint()
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- Cold start (no seed) → first `pull()` fetches the whole collection; warm start (seeded) → resumes incrementally.
|
|
262
|
+
- `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).
|
|
263
|
+
- `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.
|
|
264
|
+
- `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.
|
|
265
|
+
- `pull()` is safe to call concurrently — overlapping calls serialize internally so they never double-append a window.
|
|
266
|
+
- **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
267
|
|
|
209
268
|
## Other utilities
|
|
210
269
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { verifyAppendAuthor, } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
/** The `/pull/` action prefix; mirrors `PUSH_PATH_PREFIX` for the read side. */
|
|
3
|
+
const PULL_PATH_PREFIX = "/pull/";
|
|
4
|
+
/** The storage `documentKey` for a pull `path`: the path with the `/pull/`
|
|
5
|
+
* action prefix stripped (the namespace lives only in the URL). The author
|
|
6
|
+
* signature binds to this key, so a reader re-derives it the same way the
|
|
7
|
+
* writer did from `/push/…`. */
|
|
8
|
+
function stripPullPrefix(path) {
|
|
9
|
+
return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
|
|
10
|
+
}
|
|
11
|
+
/** Thrown when an append element's author signature fails verification. */
|
|
12
|
+
export class AppendAuthorError extends Error {
|
|
13
|
+
ts;
|
|
14
|
+
constructor(ts) {
|
|
15
|
+
super(`append element author verification failed (ts=${ts})`);
|
|
16
|
+
this.ts = ts;
|
|
17
|
+
this.name = "AppendAuthorError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Largest `ts` among `items`, or `0` when empty. The checkpoint for an
|
|
21
|
+
* append-only log is exactly this — the server returns elements with
|
|
22
|
+
* `ts > checkpoint`, and element timestamps are strictly increasing. */
|
|
23
|
+
export function checkpointOf(items) {
|
|
24
|
+
let max = 0;
|
|
25
|
+
for (const it of items)
|
|
26
|
+
if (it.ts > max)
|
|
27
|
+
max = it.ts;
|
|
28
|
+
return max;
|
|
29
|
+
}
|
|
30
|
+
/** Copy the optional author fields from `src` onto a fresh element with `data`. */
|
|
31
|
+
function withAuthor(ts, data, src) {
|
|
32
|
+
const out = { ts, data };
|
|
33
|
+
if (src.authorPubkey !== undefined)
|
|
34
|
+
out.authorPubkey = src.authorPubkey;
|
|
35
|
+
if (src.authorSignature !== undefined)
|
|
36
|
+
out.authorSignature = src.authorSignature;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A stateful cursor over an append-only collection. It owns the accumulated
|
|
41
|
+
* array of elements and pulls only what is new: each {@link pull} derives the
|
|
42
|
+
* checkpoint from the last element it holds and asks the server for elements
|
|
43
|
+
* with a greater `ts`.
|
|
44
|
+
*
|
|
45
|
+
* This is the incremental, stateful counterpart to the deliberately stateless
|
|
46
|
+
* `client.pull(path, { appendField, since })`, and the sibling of
|
|
47
|
+
* {@link SyncManager} for append-only logs (no merge / push-conflict
|
|
48
|
+
* machinery — a log only grows).
|
|
49
|
+
*
|
|
50
|
+
* The cursor accumulates every pulled element in memory; for an unboundedly
|
|
51
|
+
* large log, pull a bounded window with raw `client.pull(path, { last })` instead.
|
|
52
|
+
*
|
|
53
|
+
* Cold start (nothing persisted) — first `pull()` fetches the whole collection:
|
|
54
|
+
* ```ts
|
|
55
|
+
* const log = new AppendLogCursor({ client, pullPath: "/pull/events" })
|
|
56
|
+
* const all = await log.pull()
|
|
57
|
+
* ```
|
|
58
|
+
* Warm start (resume from persisted data) — first `pull()` fetches only newer
|
|
59
|
+
* elements; persistence is a round-trip of `getItems()`:
|
|
60
|
+
* ```ts
|
|
61
|
+
* const log = new AppendLogCursor({ client, pullPath: "/pull/events",
|
|
62
|
+
* initialItems: await store.load() })
|
|
63
|
+
* const fresh = await log.pull()
|
|
64
|
+
* await store.save(log.getItems())
|
|
65
|
+
* ```
|
|
66
|
+
* Warm start for an **E2EE** log — persist ciphertext, render decrypted:
|
|
67
|
+
* ```ts
|
|
68
|
+
* const log = new AppendLogCursor({ client, pullPath: "/pull/streamchat",
|
|
69
|
+
* encryptor, persistEncrypted: true, onElementError: "skip",
|
|
70
|
+
* initialItems: await store.load() }) // ciphertext from disk
|
|
71
|
+
* const history = await log.getDecryptedItems() // render persisted history
|
|
72
|
+
* const fresh = await log.pull() // decrypted delta
|
|
73
|
+
* await store.save(log.getItems()) // ciphertext back to disk
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export class AppendLogCursor {
|
|
77
|
+
client;
|
|
78
|
+
pullPath;
|
|
79
|
+
appendField;
|
|
80
|
+
encryptor;
|
|
81
|
+
verifyAuthor;
|
|
82
|
+
onElementError;
|
|
83
|
+
persistEncrypted;
|
|
84
|
+
documentKey;
|
|
85
|
+
logger;
|
|
86
|
+
loggerName;
|
|
87
|
+
items;
|
|
88
|
+
lastCheckpoint;
|
|
89
|
+
/** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
|
|
90
|
+
* it so each runs against the checkpoint the previous one advanced — no two
|
|
91
|
+
* overlapping fetches read the same checkpoint and double-append a window. */
|
|
92
|
+
pullChain = Promise.resolve();
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.client = options.client;
|
|
95
|
+
this.pullPath = options.pullPath;
|
|
96
|
+
this.appendField = options.appendField ?? "items";
|
|
97
|
+
this.encryptor = options.encryptor;
|
|
98
|
+
this.verifyAuthor = options.verifyAuthor;
|
|
99
|
+
this.onElementError = options.onElementError ?? "throw";
|
|
100
|
+
this.persistEncrypted = options.persistEncrypted ?? false;
|
|
101
|
+
this.documentKey = stripPullPrefix(options.pullPath);
|
|
102
|
+
this.logger = options.logger;
|
|
103
|
+
this.loggerName =
|
|
104
|
+
options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
105
|
+
const seed = options.initialItems ?? [];
|
|
106
|
+
const seedCheckpoint = checkpointOf(seed);
|
|
107
|
+
if (options.since != null) {
|
|
108
|
+
if (options.since < 0)
|
|
109
|
+
throw new Error("since must be non-negative");
|
|
110
|
+
if (options.since < seedCheckpoint) {
|
|
111
|
+
throw new Error("since must be >= the max ts of initialItems");
|
|
112
|
+
}
|
|
113
|
+
this.lastCheckpoint = options.since;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.lastCheckpoint = seedCheckpoint;
|
|
117
|
+
}
|
|
118
|
+
this.items = [...seed];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fetch elements newer than the current checkpoint, verify + decrypt them,
|
|
122
|
+
* append them to the local log, and return ONLY the newly-fetched batch
|
|
123
|
+
* (decrypted when an `encryptor` is set).
|
|
124
|
+
*
|
|
125
|
+
* Atomic under `onElementError: "throw"` (the default): the batch is fully
|
|
126
|
+
* verified and decrypted into a local before any state mutation, so a
|
|
127
|
+
* verify/decrypt failure throws without advancing the checkpoint past elements
|
|
128
|
+
* that could never be re-fetched. Under `"skip"`, a failing element is dropped
|
|
129
|
+
* from the returned batch but the checkpoint still advances past it.
|
|
130
|
+
*
|
|
131
|
+
* Safe to call concurrently: overlapping calls are serialized internally, so
|
|
132
|
+
* each runs against the checkpoint the previous one advanced (no double-fetch
|
|
133
|
+
* of the same window). The next pull after one completes will pick up anything
|
|
134
|
+
* that arrived in between.
|
|
135
|
+
*/
|
|
136
|
+
async pull() {
|
|
137
|
+
// Chain onto the previous pull (whether it resolved or rejected) so calls
|
|
138
|
+
// run one-at-a-time against the latest checkpoint. `pullChain` swallows
|
|
139
|
+
// outcomes to stay alive; the caller still sees this call's real result.
|
|
140
|
+
const run = this.pullChain.then(() => this.doPull(), () => this.doPull());
|
|
141
|
+
this.pullChain = run.then(() => undefined, () => undefined);
|
|
142
|
+
return run;
|
|
143
|
+
}
|
|
144
|
+
async doPull() {
|
|
145
|
+
this.logger?.pullStart(this.loggerName);
|
|
146
|
+
const start = performance.now();
|
|
147
|
+
try {
|
|
148
|
+
const since = this.lastCheckpoint;
|
|
149
|
+
// Omit `since` on cold start so the request carries no `?checkpoint=`.
|
|
150
|
+
const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
|
|
151
|
+
const raw = await this.client.pull(this.pullPath, opts);
|
|
152
|
+
const batch = []; // decrypted, returned to the caller
|
|
153
|
+
const stored = []; // what we keep in `items` (cipher- or plaintext)
|
|
154
|
+
let maxTs = since;
|
|
155
|
+
let skipped = 0;
|
|
156
|
+
for (const el of raw) {
|
|
157
|
+
// Defensive: guard a misbehaving/mocked server from making us
|
|
158
|
+
// double-append a held element. Gated on `since > 0` to mirror the
|
|
159
|
+
// server (which only filters when checkpoint > 0): on a cold start
|
|
160
|
+
// `since` is 0 and we must NOT drop a legitimate `ts: 0` first element.
|
|
161
|
+
if (since > 0 && el.ts <= since)
|
|
162
|
+
continue;
|
|
163
|
+
// Advance past every windowed element BEFORE verify/decrypt so a skipped
|
|
164
|
+
// element still moves the checkpoint and is never re-fetched.
|
|
165
|
+
if (el.ts > maxTs)
|
|
166
|
+
maxTs = el.ts;
|
|
167
|
+
let decrypted = null;
|
|
168
|
+
try {
|
|
169
|
+
this.verifyOne(el);
|
|
170
|
+
const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
|
|
171
|
+
decrypted = withAuthor(el.ts, data, el);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
// "throw" rethrows here, before any state mutation below — atomic.
|
|
175
|
+
if (this.onElementError !== "skip")
|
|
176
|
+
throw err;
|
|
177
|
+
skipped++;
|
|
178
|
+
}
|
|
179
|
+
if (this.persistEncrypted) {
|
|
180
|
+
// Keep the original ciphertext envelope (even for a skipped element:
|
|
181
|
+
// it is valid data we simply cannot read now — a later key might).
|
|
182
|
+
stored.push(withAuthor(el.ts, el.data, el));
|
|
183
|
+
}
|
|
184
|
+
else if (decrypted) {
|
|
185
|
+
stored.push(decrypted);
|
|
186
|
+
}
|
|
187
|
+
if (decrypted)
|
|
188
|
+
batch.push(decrypted);
|
|
189
|
+
}
|
|
190
|
+
this.items.push(...stored);
|
|
191
|
+
this.lastCheckpoint = maxTs;
|
|
192
|
+
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start), skipped > 0 ? { skippedCount: skipped } : undefined);
|
|
193
|
+
return batch;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Verify a single element's author signature over its RAW (pre-decryption)
|
|
201
|
+
* `data`. Throws {@link AppendAuthorError} on any failure. No-op when
|
|
202
|
+
* verification is disabled. */
|
|
203
|
+
verifyOne(el) {
|
|
204
|
+
if (!this.verifyAuthor)
|
|
205
|
+
return;
|
|
206
|
+
const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
|
|
207
|
+
const { authorPubkey, authorSignature } = el;
|
|
208
|
+
if (!authorPubkey || !authorSignature)
|
|
209
|
+
throw new AppendAuthorError(el.ts);
|
|
210
|
+
// Public keys are hex, which is case-insensitive — compare normalized so a
|
|
211
|
+
// caller passing a differently-cased `expectedAuthorPubkey` isn't falsely rejected.
|
|
212
|
+
if (policy.expectedAuthorPubkey &&
|
|
213
|
+
authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
|
|
214
|
+
throw new AppendAuthorError(el.ts);
|
|
215
|
+
}
|
|
216
|
+
void policy;
|
|
217
|
+
const ok = verifyAppendAuthor(this.documentKey, el.data, authorPubkey, authorSignature);
|
|
218
|
+
if (!ok)
|
|
219
|
+
throw new AppendAuthorError(el.ts);
|
|
220
|
+
}
|
|
221
|
+
/** The full accumulated log (a shallow copy), in `ts` order. Under
|
|
222
|
+
* `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
|
|
223
|
+
* re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
|
|
224
|
+
getItems() {
|
|
225
|
+
return [...this.items];
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The full accumulated log, DECRYPTED — for rendering warm-started history in
|
|
229
|
+
* `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
|
|
230
|
+
* `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
|
|
231
|
+
* cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
|
|
232
|
+
* elements are already plaintext/decrypted and are returned as-is.
|
|
233
|
+
*/
|
|
234
|
+
async getDecryptedItems() {
|
|
235
|
+
const snapshot = [...this.items];
|
|
236
|
+
if (!this.encryptor || !this.persistEncrypted)
|
|
237
|
+
return snapshot;
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const el of snapshot) {
|
|
240
|
+
try {
|
|
241
|
+
this.verifyOne(el);
|
|
242
|
+
const data = await this.encryptor.decrypt(el.data);
|
|
243
|
+
out.push(withAuthor(el.ts, data, el));
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (this.onElementError !== "skip")
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
|
|
253
|
+
* when nothing has been pulled or seeded. */
|
|
254
|
+
getCheckpoint() {
|
|
255
|
+
return this.lastCheckpoint;
|
|
256
|
+
}
|
|
257
|
+
/** Restore the checkpoint without seeding items — for persistence layers that
|
|
258
|
+
* store only the checkpoint. Used to resume incrementally across restarts.
|
|
259
|
+
* Rejects a value below the max `ts` already held: rewinding would make the
|
|
260
|
+
* next pull re-deliver, and duplicate, elements the cursor already has. */
|
|
261
|
+
setCheckpoint(ts) {
|
|
262
|
+
if (ts < checkpointOf(this.items)) {
|
|
263
|
+
throw new Error("checkpoint must be >= the max ts already held");
|
|
264
|
+
}
|
|
265
|
+
this.lastCheckpoint = ts;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Sync API integration for pending changes.
|
|
3
|
+
* Uses the Web Background Sync API to retry failed sync operations
|
|
4
|
+
* when connectivity is restored, even if the app is closed.
|
|
5
|
+
*/
|
|
6
|
+
/** Check if the Background Sync API is supported in the current environment. */
|
|
7
|
+
export function isBackgroundSyncSupported() {
|
|
8
|
+
return (typeof navigator !== "undefined" &&
|
|
9
|
+
"serviceWorker" in navigator &&
|
|
10
|
+
"SyncManager" in globalThis);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Register a background sync event with the active service worker.
|
|
14
|
+
* Returns true if registration succeeded, false if not supported or no active SW.
|
|
15
|
+
*/
|
|
16
|
+
export async function registerBackgroundSync(opts) {
|
|
17
|
+
if (!isBackgroundSyncSupported())
|
|
18
|
+
return false;
|
|
19
|
+
const tag = opts?.tag ?? "starfish-sync";
|
|
20
|
+
try {
|
|
21
|
+
const registration = await navigator.serviceWorker.ready;
|
|
22
|
+
// @ts-expect-error - SyncManager types may not be available
|
|
23
|
+
await registration.sync.register(tag);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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;
|