@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.50

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.
Files changed (47) hide show
  1. package/README.md +59 -0
  2. package/dist/append-log.d.ts +228 -0
  3. package/dist/append-log.js +267 -0
  4. package/dist/background-sync.js +29 -0
  5. package/dist/bindings/legend.d.ts +23 -0
  6. package/dist/bindings/legend.js +32 -0
  7. package/dist/bindings/legend.js.map +2 -2
  8. package/dist/bindings/suspense.js +49 -0
  9. package/dist/bindings/zustand.d.ts +167 -2
  10. package/dist/bindings/zustand.js +941 -82
  11. package/dist/bindings/zustand.js.map +4 -4
  12. package/dist/blob-seal.d.ts +123 -0
  13. package/dist/client.d.ts +270 -5
  14. package/dist/client.js +391 -0
  15. package/dist/config.d.ts +9 -0
  16. package/dist/config.js +18 -0
  17. package/dist/debounced-sync.js +120 -0
  18. package/dist/dedup.js +35 -0
  19. package/dist/events.d.ts +150 -0
  20. package/dist/events.js +116 -0
  21. package/dist/events.js.map +7 -0
  22. package/dist/export.js +115 -0
  23. package/dist/fetch.d.ts +40 -0
  24. package/dist/fetch.js +51 -14
  25. package/dist/fetch.js.map +2 -2
  26. package/dist/history.js +61 -0
  27. package/dist/index.d.ts +16 -7
  28. package/dist/index.js +1029 -94
  29. package/dist/index.js.map +4 -4
  30. package/dist/kv-cache.d.ts +63 -0
  31. package/dist/logger.d.ts +3 -0
  32. package/dist/logger.js +80 -0
  33. package/dist/migrate.js +38 -0
  34. package/dist/mobile-lifecycle.d.ts +28 -1
  35. package/dist/mobile-lifecycle.js +94 -0
  36. package/dist/multi-store.js +92 -0
  37. package/dist/mutate.d.ts +39 -0
  38. package/dist/polling.js +52 -0
  39. package/dist/resolvers.js +223 -0
  40. package/dist/service-worker.js +55 -0
  41. package/dist/storage/indexeddb.js +59 -0
  42. package/dist/sync.d.ts +83 -0
  43. package/dist/sync.js +181 -0
  44. package/dist/types.d.ts +106 -11
  45. package/dist/types.js +18 -0
  46. package/dist/validate.js +28 -0
  47. package/package.json +12 -3
package/dist/index.js CHANGED
@@ -1,19 +1,35 @@
1
1
  // src/index.ts
2
2
  import { configurePlatform } from "@drakkar.software/starfish-protocol";
3
- import { stableStringify as stableStringify3, computeHash } from "@drakkar.software/starfish-protocol";
3
+ import { stableStringify as stableStringify2, computeHash } from "@drakkar.software/starfish-protocol";
4
4
  import { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
5
5
 
6
6
  // src/client.ts
7
7
  import {
8
- DEFAULT_ALG,
8
+ AUTHOR_PUBKEY_FIELD,
9
+ AUTHOR_SIGNATURE_FIELD,
10
+ DATA_FIELD,
11
+ TS_FIELD,
12
+ BASE_HASH_FIELD,
13
+ PUSH_PATH_PREFIX,
14
+ HEADER_AUTHORIZATION,
15
+ HEADER_SIG,
16
+ HEADER_TS,
17
+ HEADER_NONCE,
18
+ HEADER_PUB,
19
+ HEADER_CONTENT_TYPE,
20
+ HEADER_ACCEPT,
21
+ PARQUET_MIME_TYPE as PARQUET_MIME_TYPE_VALUE,
22
+ PARQUET_MIME_TYPES as PARQUET_MIME_TYPES_VALUE,
23
+ signAppendAuthor,
9
24
  signRequest,
10
25
  stableStringify
11
26
  } from "@drakkar.software/starfish-protocol";
12
27
 
13
28
  // src/types.ts
14
29
  var ConflictError = class extends Error {
15
- constructor() {
30
+ constructor(currentHash = "") {
16
31
  super("hash_mismatch");
32
+ this.currentHash = currentHash;
17
33
  this.name = "ConflictError";
18
34
  }
19
35
  };
@@ -25,9 +41,59 @@ var StarfishHttpError = class extends Error {
25
41
  this.name = "StarfishHttpError";
26
42
  }
27
43
  };
44
+ var AppendHttpError = class extends Error {
45
+ constructor(status, message) {
46
+ super(message);
47
+ this.status = status;
48
+ this.name = "AppendHttpError";
49
+ }
50
+ };
51
+
52
+ // src/fetch.ts
53
+ function parseRetryAfterMs(header, opts) {
54
+ const { fallbackMs, maxMs } = opts;
55
+ const trimmed = header?.trim();
56
+ if (trimmed) {
57
+ const seconds = Number(trimmed);
58
+ if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
59
+ const date = Date.parse(trimmed);
60
+ if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
61
+ }
62
+ return Math.min(fallbackMs, maxMs);
63
+ }
64
+ function classifyError(err) {
65
+ if (err instanceof Response || err && typeof err === "object" && "status" in err) {
66
+ const status = err.status;
67
+ if (typeof status !== "number" || isNaN(status)) return "unknown";
68
+ if (status === 0) return "network";
69
+ if (status === 401 || status === 403) return "auth";
70
+ if (status === 409) return "conflict";
71
+ if (status === 429) return "rate-limited";
72
+ if (status >= 500) return "server";
73
+ if (status >= 400) return "client";
74
+ }
75
+ if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
76
+ return "unknown";
77
+ }
28
78
 
29
79
  // src/client.ts
30
80
  var APPEND_DEFAULT_FIELD = "items";
81
+ var MAX_REVALIDATE_ATTEMPTS = 5;
82
+ var REVALIDATE_INITIAL_DELAY_MS = 1e3;
83
+ var REVALIDATE_MAX_DELAY_MS = 3e4;
84
+ function sleep(ms) {
85
+ return new Promise((resolve) => setTimeout(resolve, ms));
86
+ }
87
+ function pullCacheKey(pathAndQuery) {
88
+ const q = pathAndQuery.indexOf("?");
89
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
90
+ }
91
+ function pullWasFromCache(result) {
92
+ return result.fromCache === true;
93
+ }
94
+ function stripPushPrefix(path) {
95
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
96
+ }
31
97
  function encodeCapAuth(cap) {
32
98
  const json = stableStringify(cap);
33
99
  if (typeof btoa === "function") {
@@ -42,6 +108,17 @@ var StarfishClient = class {
42
108
  namespace;
43
109
  capProvider;
44
110
  fetch;
111
+ cache;
112
+ cacheMaxAgeMs;
113
+ cacheFallbackStatuses;
114
+ onRevalidated;
115
+ revalidating = /* @__PURE__ */ new Set();
116
+ /**
117
+ * In-memory mirror of the latest document timestamp written to each cache
118
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
119
+ * can guard against stale overwrites without an extra async cache read.
120
+ */
121
+ latestCacheTimestamp = /* @__PURE__ */ new Map();
45
122
  /**
46
123
  * Installed client-side plugins. Currently stored as inert data; no
47
124
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -52,8 +129,21 @@ var StarfishClient = class {
52
129
  this.namespace = options.namespace || void 0;
53
130
  this.capProvider = options.capProvider;
54
131
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
132
+ this.cache = options.cache;
133
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
134
+ this.cacheFallbackStatuses = options.cacheFallbackStatuses;
135
+ this.onRevalidated = options.onRevalidated;
55
136
  this.plugins = options.plugins ? [...options.plugins] : [];
56
137
  }
138
+ /**
139
+ * Mark a `PullResult` as having been served from the offline read-through
140
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
141
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
142
+ */
143
+ tagFromCache(result) {
144
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
145
+ return result;
146
+ }
57
147
  /**
58
148
  * Resolve the host portion of the URL the client will send to. The host
59
149
  * is folded into the signed canonical input as the `h` field so the
@@ -98,38 +188,59 @@ var StarfishClient = class {
98
188
  * The host bound into the signature is derived from `baseUrl` once per call.
99
189
  */
100
190
  async buildAuthHeaders(method, pathAndQuery, body) {
101
- if (this.capProvider) {
102
- const { cap, devEdPrivHex, pubHex, presenterAlg } = await this.capProvider.getCap();
103
- const req = {
104
- method,
105
- pathAndQuery,
106
- body,
107
- host: this.signingHost()
108
- };
109
- const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
110
- const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
111
- alg: signAlg
112
- });
113
- const headers = {
114
- Authorization: `Cap ${encodeCapAuth(cap)}`,
115
- "X-Starfish-Sig": sig,
116
- "X-Starfish-Ts": String(ts),
117
- "X-Starfish-Nonce": nonce,
118
- "X-Starfish-Alg": alg
119
- };
120
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
121
- return headers;
122
- }
123
- return {};
191
+ if (!this.capProvider) return {};
192
+ const capCtx = await this.capProvider.getCap();
193
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
194
+ }
195
+ /**
196
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
197
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
198
+ * reuse it for BOTH the author signature (over the element data) and the
199
+ * request signature (over the body), without redeeming the cap twice a
200
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
201
+ * presenter` bind the server checks.
202
+ */
203
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
204
+ const { cap, devEdPrivHex, pubHex } = capCtx;
205
+ const req = {
206
+ method,
207
+ pathAndQuery,
208
+ body,
209
+ host: this.signingHost()
210
+ };
211
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
212
+ const headers = {
213
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
214
+ [HEADER_SIG]: sig,
215
+ [HEADER_TS]: String(ts),
216
+ [HEADER_NONCE]: nonce
217
+ };
218
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
219
+ return headers;
220
+ }
221
+ /**
222
+ * Resolve the author public key to attach to a signed append: the redeemer's
223
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
224
+ * device/member cap. This is the SAME key that signs the request, so a server
225
+ * enforcing author proof can bind the stored element to its writer. Returns
226
+ * undefined only for a (malformed) cap with neither — the append then goes
227
+ * unsigned and a server requiring signatures rejects it.
228
+ */
229
+ appendAuthorKey(capCtx) {
230
+ const { cap, pubHex } = capCtx;
231
+ const authorPubHex = pubHex ?? cap.sub;
232
+ if (authorPubHex === void 0) return null;
233
+ return { authorPubHex };
124
234
  }
125
235
  async pull(path, checkpointOrOptions) {
126
236
  let pathAndQuery = this.applyNamespace(path);
127
237
  let appendField;
238
+ let swr = false;
128
239
  if (typeof checkpointOrOptions === "number") {
129
240
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
130
241
  } else if (checkpointOrOptions != null) {
131
242
  const opts = checkpointOrOptions;
132
- const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
243
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
133
244
  const params = new URLSearchParams();
134
245
  if (isPullOptions) {
135
246
  if (opts.checkpoint != null && opts.checkpoint > 0) {
@@ -138,68 +249,342 @@ var StarfishClient = class {
138
249
  if (opts.withKeyring) {
139
250
  params.set("withKeyring", "1");
140
251
  }
252
+ swr = opts.staleWhileRevalidate === true;
141
253
  } else {
142
254
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
255
+ if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
256
+ throw new Error("full cannot be combined with since, limit, or last");
257
+ }
143
258
  if (opts.since != null) {
144
259
  if (opts.since < 0) throw new Error("since must be non-negative");
145
260
  params.set("checkpoint", String(opts.since));
146
261
  }
262
+ if (opts.limit != null) {
263
+ if (opts.limit < 0) throw new Error("limit must be non-negative");
264
+ params.set("limit", String(opts.limit));
265
+ }
147
266
  if (opts.last != null) {
148
267
  if (opts.last < 0) throw new Error("last must be non-negative");
149
268
  params.set("last", String(opts.last));
150
269
  }
270
+ if (opts.full) {
271
+ params.set("full", "true");
272
+ }
151
273
  }
152
274
  if (params.size > 0) pathAndQuery += `?${params.toString()}`;
153
275
  }
154
276
  const url = `${this.baseUrl}${pathAndQuery}`;
155
277
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
156
- const res = await this.fetch(url, {
157
- method: "GET",
158
- headers: { Accept: "application/json", ...authHeaders }
159
- });
278
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
279
+ if (swr && cacheKey) {
280
+ const cached = await this.readCache(cacheKey);
281
+ if (cached) {
282
+ this.scheduleRevalidate(
283
+ cacheKey,
284
+ pathAndQuery,
285
+ null,
286
+ /* immediate */
287
+ true
288
+ );
289
+ return cached;
290
+ }
291
+ }
292
+ let res;
293
+ try {
294
+ res = await this.fetch(url, {
295
+ method: "GET",
296
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
297
+ cache: "no-store"
298
+ });
299
+ } catch (err) {
300
+ if (cacheKey) {
301
+ const cached = await this.readCache(cacheKey);
302
+ if (cached) return cached;
303
+ }
304
+ throw err;
305
+ }
160
306
  if (!res.ok) {
161
- throw new StarfishHttpError(res.status, await res.text());
307
+ const status = res.status;
308
+ if (cacheKey && this.cacheFallbackStatuses?.includes(status)) {
309
+ const retryAfterHeader = res.headers.get("Retry-After");
310
+ this.scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader);
311
+ const cached = await this.readCache(cacheKey);
312
+ if (cached) {
313
+ void res.body?.cancel();
314
+ return cached;
315
+ }
316
+ }
317
+ throw new StarfishHttpError(status, await res.text());
162
318
  }
163
319
  const result = await res.json();
164
320
  if (appendField !== void 0) {
165
321
  const list = result.data?.[appendField];
166
322
  return Array.isArray(list) ? list : [];
167
323
  }
324
+ if (cacheKey) this.writeCache(cacheKey, result);
168
325
  return result;
169
326
  }
327
+ /**
328
+ * Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
329
+ * so a failing cache never blocks the caller. No-op when no cache is configured.
330
+ */
331
+ writeCache(cacheKey, result) {
332
+ if (!this.cache) return;
333
+ if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
334
+ this.latestCacheTimestamp.set(cacheKey, result.timestamp);
335
+ }
336
+ const snapshot = {
337
+ data: result.data,
338
+ hash: result.hash,
339
+ timestamp: result.timestamp,
340
+ cachedAt: Date.now()
341
+ };
342
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
343
+ });
344
+ }
345
+ /** Build the URL + auth headers for one revalidation GET. Shared between
346
+ * {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
347
+ async revalidateFetch(pathAndQuery) {
348
+ const url = `${this.baseUrl}${pathAndQuery}`;
349
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
350
+ return this.fetch(url, {
351
+ method: "GET",
352
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
353
+ });
354
+ }
355
+ /**
356
+ * Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
357
+ * Used by both the {@link cacheFallbackStatuses} error path (delayed first
358
+ * attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
359
+ * read path (`immediate: true` — no initial delay on the first attempt). The
360
+ * `revalidating` set deduplicates across both triggers so a concurrent
361
+ * error-triggered loop and an SWR-on-read loop for the same key collapse to one.
362
+ */
363
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
364
+ if (this.revalidating.has(cacheKey)) return;
365
+ this.revalidating.add(cacheKey);
366
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
367
+ this.revalidating.delete(cacheKey);
368
+ });
369
+ }
370
+ /**
371
+ * Background revalidation loop shared by both {@link cacheFallbackStatuses}
372
+ * hits and {@link PullOptions.staleWhileRevalidate} reads.
373
+ *
374
+ * Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
375
+ * When `immediate` is true the first attempt fires without any initial delay
376
+ * (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
377
+ * {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
378
+ */
379
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
380
+ let retryAfterHeader = firstRetryAfter;
381
+ const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
382
+ for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
383
+ if (!immediate || attempt > 0) {
384
+ const delay = parseRetryAfterMs(retryAfterHeader, {
385
+ fallbackMs: Math.min(
386
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
387
+ REVALIDATE_MAX_DELAY_MS
388
+ ),
389
+ maxMs: REVALIDATE_MAX_DELAY_MS
390
+ });
391
+ await sleep(delay);
392
+ }
393
+ try {
394
+ const res = await this.revalidateFetch(pathAndQuery);
395
+ if (res.ok) {
396
+ const result = await res.json();
397
+ const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
398
+ if (result.timestamp >= latestTs) {
399
+ this.writeCache(cacheKey, result);
400
+ this.onRevalidated?.(pathAndQuery, result);
401
+ }
402
+ return;
403
+ }
404
+ if (!fallbackSet?.has(res.status)) {
405
+ return;
406
+ }
407
+ retryAfterHeader = res.headers.get("Retry-After");
408
+ } catch {
409
+ retryAfterHeader = null;
410
+ }
411
+ }
412
+ }
413
+ /**
414
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
415
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
416
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
417
+ * or null when no cache is configured / there's no entry. Namespacing matches
418
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
419
+ */
420
+ async peekCache(path) {
421
+ if (!this.cache) return null;
422
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
423
+ }
424
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
425
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
426
+ * must not break a pull, just miss). */
427
+ async readCache(cacheKey) {
428
+ try {
429
+ const raw = await this.cache.get(cacheKey);
430
+ if (!raw) return null;
431
+ const parsed = JSON.parse(raw);
432
+ if (!parsed || typeof parsed.hash !== "string") return null;
433
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
434
+ return null;
435
+ }
436
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+ /**
442
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
443
+ * the list of distinct collection names; `opts.params` supplies, per collection,
444
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
445
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
446
+ * The server auto-fills the `{identity}` param from the authenticated caller for
447
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
448
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
449
+ * in request order. Honors the configured namespace.
450
+ *
451
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
452
+ *
453
+ * Pass `appendParams` per entry for append-only bounded-tail reads (see {@link batchPullManyAppend}).
454
+ */
455
+ async batchPull(collections, opts = {}) {
456
+ const search = new URLSearchParams();
457
+ search.set("collections", collections.join(","));
458
+ if (opts.params && Object.keys(opts.params).length > 0) {
459
+ search.set("params", JSON.stringify(opts.params));
460
+ }
461
+ if (opts.appendParams && Object.keys(opts.appendParams).length > 0) {
462
+ for (const [col, optsArr] of Object.entries(opts.appendParams)) {
463
+ for (const ap of optsArr) {
464
+ if (ap.full) {
465
+ throw new Error(
466
+ `batchPull: appendParams["${col}"] contains full:true \u2014 full is not supported in batch pull`
467
+ );
468
+ }
469
+ if (ap.since != null && (!Number.isInteger(ap.since) || ap.since < 0)) {
470
+ throw new Error(`batchPull: appendParams["${col}"].since must be a non-negative integer`);
471
+ }
472
+ if (ap.last != null && (!Number.isInteger(ap.last) || ap.last < 0)) {
473
+ throw new Error(`batchPull: appendParams["${col}"].last must be a non-negative integer`);
474
+ }
475
+ if (ap.limit != null && (!Number.isInteger(ap.limit) || ap.limit < 0)) {
476
+ throw new Error(`batchPull: appendParams["${col}"].limit must be a non-negative integer`);
477
+ }
478
+ }
479
+ }
480
+ search.set("appendParams", JSON.stringify(opts.appendParams));
481
+ }
482
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
483
+ const url = `${this.baseUrl}${pathAndQuery}`;
484
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
485
+ const res = await this.fetch(url, {
486
+ method: "GET",
487
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
488
+ });
489
+ if (!res.ok) {
490
+ throw new StarfishHttpError(res.status, await res.text());
491
+ }
492
+ return await res.json();
493
+ }
494
+ /**
495
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
496
+ * collection in a single round-trip: pass the per-document param-sets and get
497
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
498
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
499
+ * issues no request and returns `[]`.
500
+ */
501
+ async batchPullMany(collection, paramsList) {
502
+ if (paramsList.length === 0) return [];
503
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
504
+ return res.collections[collection] ?? [];
505
+ }
506
+ /**
507
+ * Convenience over {@link batchPull} for reading append-only bounded tails from
508
+ * MANY entries of ONE collection in a single round-trip.
509
+ *
510
+ * Each request in `requests` carries optional `params` (path params) and
511
+ * `options` (append bounds: `since`/`last`/`limit`/`appendField`). An empty
512
+ * `requests` issues no request and returns `[]`.
513
+ *
514
+ * Returns an array aligned to `requests` by index. Each element is either:
515
+ * - the filtered array `T[]` extracted from `entry.data[appendField]`, or
516
+ * - `{ error: string }` if the server returned a per-entry error.
517
+ *
518
+ * The `appendField` used for extraction defaults to `"items"` and can be
519
+ * overridden per request via `options.appendField`.
520
+ *
521
+ * The `appendField` option is client-side only (used for result extraction, not sent to the server).
522
+ * It must match the collection's server-configured append field and defaults to `"items"`.
523
+ *
524
+ * Note: `full: true` is not supported in batch and is rejected client-side
525
+ * before the request is sent.
526
+ */
527
+ async batchPullManyAppend(collection, requests) {
528
+ if (requests.length === 0) return [];
529
+ const paramsList = requests.map((r) => r.params ?? {});
530
+ const appendParamsList = requests.map(({ options: { appendField: _af, ...wireOpts } }) => wireOpts);
531
+ const res = await this.batchPull([collection], {
532
+ params: { [collection]: paramsList },
533
+ appendParams: { [collection]: appendParamsList }
534
+ });
535
+ const entries = res.collections[collection] ?? [];
536
+ return entries.map((entry, i) => {
537
+ if (entry.error) return { error: entry.error };
538
+ const appendField = requests[i]?.options.appendField ?? APPEND_DEFAULT_FIELD;
539
+ const data = entry.data;
540
+ const items = data?.[appendField];
541
+ return Array.isArray(items) ? items : [];
542
+ });
543
+ }
170
544
  /**
171
545
  * Push synced data to the server.
172
546
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
173
547
  * @param data - The full document data to push
174
548
  * @param baseHash - Hash of the document this push is based on (null for first push)
175
549
  *
176
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
177
- * and are produced by `SyncManager` when a `signer` is configured.
550
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
551
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
552
+ * top-level body siblings of `data`, where the server verifies it.
178
553
  * @throws {ConflictError} if the server detects a hash mismatch (409)
179
554
  */
180
- async push(path, data, baseHash) {
555
+ async push(path, data, baseHash, author) {
181
556
  const body = JSON.stringify({
182
- data,
183
- baseHash
557
+ [DATA_FIELD]: data,
558
+ [BASE_HASH_FIELD]: baseHash,
559
+ ...author && {
560
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
561
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
562
+ }
184
563
  });
185
564
  const sendPath = this.applyNamespace(path);
186
565
  const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
187
566
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
188
567
  method: "POST",
189
568
  headers: {
190
- "Content-Type": "application/json",
191
- Accept: "application/json",
569
+ [HEADER_CONTENT_TYPE]: "application/json",
570
+ [HEADER_ACCEPT]: "application/json",
192
571
  ...authHeaders
193
572
  },
194
573
  body
195
574
  });
196
575
  if (res.status === 409) {
197
- throw new ConflictError();
576
+ const conflict = await res.json().catch(() => null);
577
+ throw new ConflictError(conflict?.currentHash ?? "");
198
578
  }
199
579
  if (!res.ok) {
200
580
  throw new StarfishHttpError(res.status, await res.text());
201
581
  }
202
- return res.json();
582
+ const result = await res.json();
583
+ if (this.cache) {
584
+ const pullPath = sendPath.replace("/push/", "/pull/");
585
+ this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
586
+ }
587
+ return result;
203
588
  }
204
589
  /**
205
590
  * Append an element to an appendOnly (`by_timestamp`) collection.
@@ -215,20 +600,37 @@ var StarfishClient = class {
215
600
  * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
216
601
  * non-negative integer strictly greater than the latest stored element's ts
217
602
  * (else the server responds 409). Omit to let the server assign one.
218
- * @throws {StarfishHttpError} on a non-2xx response (e.g. 409 for a
219
- * non-monotonic timestamp).
603
+ * @throws {StarfishHttpError} on a non-2xx response e.g. 409
604
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
605
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
606
+ * cap is reached (partition by a path parameter for higher volume).
220
607
  */
221
608
  async append(path, data, opts = {}) {
222
- const bodyObj = { data };
223
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
224
- const body = JSON.stringify(bodyObj);
225
609
  const sendPath = this.applyNamespace(path);
226
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
610
+ const bodyObj = { [DATA_FIELD]: data };
611
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
612
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
613
+ if (capCtx) {
614
+ const authorKey = this.appendAuthorKey(capCtx);
615
+ if (authorKey) {
616
+ const documentKey2 = stripPushPrefix(path);
617
+ const { authorPubkey, authorSignature } = signAppendAuthor(
618
+ documentKey2,
619
+ data,
620
+ authorKey.authorPubHex,
621
+ capCtx.devEdPrivHex
622
+ );
623
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
624
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
625
+ }
626
+ }
627
+ const body = JSON.stringify(bodyObj);
628
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
227
629
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
228
630
  method: "POST",
229
631
  headers: {
230
- "Content-Type": "application/json",
231
- Accept: "application/json",
632
+ [HEADER_CONTENT_TYPE]: "application/json",
633
+ [HEADER_ACCEPT]: "application/json",
232
634
  ...authHeaders
233
635
  },
234
636
  body
@@ -238,6 +640,62 @@ var StarfishClient = class {
238
640
  }
239
641
  return res.json();
240
642
  }
643
+ /**
644
+ * Append one element to a **public-write** append-only collection with an
645
+ * Ed25519 author proof but **no cap `Authorization` header**.
646
+ *
647
+ * Unlike {@link append}, which always attaches a cap-signed `Authorization`
648
+ * header from the configured `capProvider`, this method signs only the
649
+ * append-author proof (binding the element to the writer's Ed25519 key) and
650
+ * sends the request without authentication headers. This is required for
651
+ * collections with `writeRoles: ["public"]` — the server's cap-scope check
652
+ * would reject a request carrying a cap whose scope does not cover the path.
653
+ *
654
+ * Typical use-case: writing a sealed invitation to another user's
655
+ * public-write inbox collection without needing a cap scoped to the
656
+ * recipient's namespace. The author proof is optional on the server side
657
+ * (`requireAuthorSignature: false` for a public inbox), but signing anyway
658
+ * binds the stored element to the sender's Ed25519 key for verification in
659
+ * the receive path.
660
+ *
661
+ * The element is sent as `{ data, authorPubkey, authorSignature }`.
662
+ *
663
+ * @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
664
+ * @param element The JSON element to append.
665
+ * @param signer The sender's Ed25519 keypair (signs the author proof).
666
+ *
667
+ * @throws {AppendHttpError} on a non-2xx response.
668
+ */
669
+ async appendAnonymous(path, element, signer) {
670
+ const sendPath = this.applyNamespace(path);
671
+ const documentKey2 = stripPushPrefix(path);
672
+ const { authorPubkey, authorSignature } = signAppendAuthor(
673
+ documentKey2,
674
+ element,
675
+ signer.edPubHex,
676
+ signer.edPrivHex
677
+ );
678
+ const body = JSON.stringify({
679
+ [DATA_FIELD]: element,
680
+ [AUTHOR_PUBKEY_FIELD]: authorPubkey,
681
+ [AUTHOR_SIGNATURE_FIELD]: authorSignature
682
+ });
683
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
684
+ method: "POST",
685
+ headers: {
686
+ [HEADER_CONTENT_TYPE]: "application/json",
687
+ [HEADER_ACCEPT]: "application/json"
688
+ },
689
+ body
690
+ });
691
+ if (!res.ok) {
692
+ const detail = await res.text().catch(() => "");
693
+ throw new AppendHttpError(
694
+ res.status,
695
+ `anonymous append failed: HTTP ${res.status} ${detail}`.trim()
696
+ );
697
+ }
698
+ }
241
699
  /**
242
700
  * Pull binary data from a blob collection.
243
701
  * Returns raw bytes with the content hash from the ETag header.
@@ -247,13 +705,13 @@ var StarfishClient = class {
247
705
  const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
248
706
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
249
707
  method: "GET",
250
- headers: { Accept: "*/*", ...authHeaders }
708
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
251
709
  });
252
710
  if (!res.ok) {
253
711
  throw new StarfishHttpError(res.status, await res.text());
254
712
  }
255
713
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
256
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
714
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
257
715
  const data = await res.arrayBuffer();
258
716
  return { data, hash: etag, contentType };
259
717
  }
@@ -267,8 +725,8 @@ var StarfishClient = class {
267
725
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
268
726
  method: "POST",
269
727
  headers: {
270
- "Content-Type": contentType,
271
- Accept: "application/json",
728
+ [HEADER_CONTENT_TYPE]: contentType,
729
+ [HEADER_ACCEPT]: "application/json",
272
730
  ...authHeaders
273
731
  },
274
732
  body: data
@@ -278,10 +736,82 @@ var StarfishClient = class {
278
736
  }
279
737
  return res.json();
280
738
  }
739
+ /**
740
+ * Push an Apache Parquet file to a Parquet collection.
741
+ *
742
+ * Thin wrapper over {@link pushBlob} that fixes `Content-Type` to
743
+ * `application/vnd.apache.parquet` so the S3 object is tagged correctly
744
+ * for DuckDB and CDN consumption.
745
+ *
746
+ * @example
747
+ * ```ts
748
+ * const parquetBytes = await generateParquet(rows)
749
+ * const result = await client.pushParquet("/push/analytics/alice/q1.parquet", parquetBytes)
750
+ * console.log("stored hash:", result.hash)
751
+ * ```
752
+ */
753
+ async pushParquet(path, data) {
754
+ return this.pushBlob(path, data, PARQUET_MIME_TYPE_VALUE);
755
+ }
756
+ /**
757
+ * Pull an Apache Parquet file from a Parquet collection.
758
+ *
759
+ * Thin wrapper over {@link pullBlob} for API symmetry with
760
+ * {@link pushParquet}.
761
+ *
762
+ * @example
763
+ * ```ts
764
+ * const result = await client.pullParquet("/pull/analytics/alice/q1.parquet")
765
+ * // result.data → ArrayBuffer
766
+ * // result.contentType → "application/vnd.apache.parquet"
767
+ * ```
768
+ */
769
+ async pullParquet(path) {
770
+ const result = await this.pullBlob(path);
771
+ if (!PARQUET_MIME_TYPES_VALUE.includes(result.contentType)) {
772
+ throw new StarfishHttpError(
773
+ 415,
774
+ `Expected a Parquet content-type, got: ${result.contentType}`
775
+ );
776
+ }
777
+ return result;
778
+ }
281
779
  };
282
780
 
781
+ // src/blob-seal.ts
782
+ function documentKey(path) {
783
+ if (path.startsWith("/push/")) return path.slice("/push/".length);
784
+ if (path.startsWith("/pull/")) return path.slice("/pull/".length);
785
+ return path;
786
+ }
787
+ async function sealAndPushBlob(client, sealer, path, bytes, opts = {}) {
788
+ const { aad = documentKey(path), maxBytes } = opts;
789
+ if (maxBytes !== void 0 && bytes.length > maxBytes) {
790
+ throw new RangeError(
791
+ `sealAndPushBlob: payload is ${bytes.length} bytes \u2014 maximum allowed is ${maxBytes} bytes`
792
+ );
793
+ }
794
+ const sealed = await sealer.sealBytes(bytes, aad);
795
+ return client.pushBlob(path, sealed, "application/octet-stream");
796
+ }
797
+ async function pullAndOpenBlob(client, sealer, path, opts = {}) {
798
+ const { aad = documentKey(path) } = opts;
799
+ const result = await client.pullBlob(path);
800
+ const stored = new Uint8Array(result.data);
801
+ return sealer.openBytes(stored, aad);
802
+ }
803
+
804
+ // src/index.ts
805
+ import { PARQUET_MIME_TYPE, PARQUET_MIME_TYPES } from "@drakkar.software/starfish-protocol";
806
+
283
807
  // src/sync.ts
284
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
808
+ import {
809
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
810
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
811
+ deepMerge,
812
+ docAuthorCanonicalInput,
813
+ getBase64
814
+ } from "@drakkar.software/starfish-protocol";
285
815
 
286
816
  // src/validate.ts
287
817
  var ValidationError = class extends Error {
@@ -321,6 +851,9 @@ var SyncManager = class {
321
851
  lastCheckpoint = 0;
322
852
  localData = {};
323
853
  aborted = false;
854
+ lastFromCache = false;
855
+ /** True once {@link seedFromCache} has successfully seeded localData from the cache. */
856
+ seeded = false;
324
857
  constructor(options) {
325
858
  this.client = options.client;
326
859
  this.pullPath = options.pullPath;
@@ -342,6 +875,36 @@ var SyncManager = class {
342
875
  getData() {
343
876
  return { ...this.localData };
344
877
  }
878
+ /**
879
+ * Returns true when `pull()` / `ingest()` should merge against the current
880
+ * `localData` rather than replace it wholesale.
881
+ *
882
+ * Two situations establish a merge baseline:
883
+ * - A successful prior pull/ingest advanced `lastCheckpoint` beyond 0 (the
884
+ * normal steady-state case, unchanged since alpha.36).
885
+ * - A cache seed painted `localData` via {@link seedFromCache} AND the store
886
+ * uses a custom conflict resolver (i.e. NOT the default `deepMerge`). For a
887
+ * union/custom resolver the seeded snapshot is a real baseline that must not
888
+ * be clobbered by a short first live response (a cache-fallback on 429/5xx
889
+ * or a momentarily-short concurrent server snapshot). For the default
890
+ * `deepMerge` resolver we keep the pre-fix wholesale-replace behaviour so
891
+ * non-union stores are byte-identical to alpha.36.
892
+ */
893
+ hasMergeBaseline() {
894
+ return this.lastCheckpoint > 0 || this.seeded && this.onConflict !== deepMerge;
895
+ }
896
+ /**
897
+ * Merge a remote snapshot with local (optimistic) data using this manager's
898
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
899
+ * {@link pull} overwrites the store's data with the server snapshot, which
900
+ * would drop un-pushed local writes (they live only in the store, never in
901
+ * `localData` until a push succeeds). The zustand binding calls this on pull
902
+ * while the store is dirty so those writes survive. `local` wins by the same
903
+ * rules as a push conflict.
904
+ */
905
+ resolve(local, remote) {
906
+ return this.onConflict(local, remote);
907
+ }
345
908
  getHash() {
346
909
  return this.lastHash;
347
910
  }
@@ -349,9 +912,95 @@ var SyncManager = class {
349
912
  setHash(hash) {
350
913
  this.lastHash = hash;
351
914
  }
915
+ /**
916
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
917
+ * from the client's offline read-through cache rather than a live server
918
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
919
+ * offline indicator without treating a cache hit as "reachable". Reset to
920
+ * false by the next successful network pull.
921
+ */
922
+ getLastPullFromCache() {
923
+ return this.lastFromCache;
924
+ }
925
+ /**
926
+ * Cache-first paint: seed `localData` from the client's read-through cache
927
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
928
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
929
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
930
+ * the initial live {@link pull}.
931
+ *
932
+ * `lastCheckpoint` is intentionally left at 0 so the first live pull sends a
933
+ * full (re)sync request to the server, not a delta. However, for stores with
934
+ * a custom conflict resolver (e.g. `createUnionMerge`) the seeded snapshot is
935
+ * treated as a merge baseline: {@link hasMergeBaseline} returns true, so the
936
+ * first pull/ingest merges against the seed rather than replacing it wholesale.
937
+ * This closes the bootstrap window where a short first-pull response (a cache-
938
+ * fallback on 429/5xx or a momentarily-short concurrent snapshot) would
939
+ * silently drop items the resolver was configured to preserve. For the default
940
+ * `deepMerge` resolver the first pull still takes the snapshot wholesale —
941
+ * behaviour is byte-identical to alpha.36.
942
+ *
943
+ * Requires the client to have been built with a `cache`.
944
+ */
945
+ async seedFromCache() {
946
+ if (this.aborted) return false;
947
+ const cached = await this.client.peekCache(this.pullPath);
948
+ if (!cached) return false;
949
+ let data;
950
+ try {
951
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
952
+ } catch {
953
+ return false;
954
+ }
955
+ if (this.aborted) return false;
956
+ this.localData = data;
957
+ this.lastHash = cached.hash;
958
+ this.seeded = true;
959
+ this.lastFromCache = true;
960
+ return true;
961
+ }
352
962
  getCheckpoint() {
353
963
  return this.lastCheckpoint;
354
964
  }
965
+ /**
966
+ * Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
967
+ * firing a network request. Used by the zustand binding's `mergeResult`
968
+ * action to absorb a background revalidation result (delivered via
969
+ * {@link StarfishClientOptions.onRevalidated}) into the store.
970
+ *
971
+ * Like {@link pull}, `ingest` conflict-merges the snapshot against the
972
+ * established baseline via `this.onConflict` when a merge baseline exists
973
+ * ({@link hasMergeBaseline}) — so a union-merge store does not lose array
974
+ * items when a revalidation result (e.g. a stale cache-fallback on 429/5xx)
975
+ * is a shorter snapshot. The baseline is established by either a prior
976
+ * pull/ingest that advanced `lastCheckpoint`, or by a successful
977
+ * {@link seedFromCache} for a store with a custom resolver. The first ingest
978
+ * without such a baseline takes the snapshot wholesale (default `deepMerge`
979
+ * stores are byte-identical to alpha.36). Sets `lastFromCache = false` (a
980
+ * revalidation is a live response) so the binding can clear its `stale` flag.
981
+ *
982
+ * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
983
+ * time the revalidation request was sent and the time it resolves, the
984
+ * result is from an older document version. Ingesting it would clobber the
985
+ * user's just-saved edit and reset `lastHash` to a stale server hash
986
+ * (causing a spurious 409 on the next push). We silently drop the result in
987
+ * that case — the store's post-push state is already correct.
988
+ */
989
+ async ingest(result) {
990
+ if (this.aborted) return;
991
+ if (result.timestamp < this.lastCheckpoint) return;
992
+ let incoming;
993
+ if (this.encryptor) {
994
+ incoming = await this.encryptor.decrypt(result.data);
995
+ if (this.aborted) return;
996
+ } else {
997
+ incoming = result.data;
998
+ }
999
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
1000
+ this.lastHash = result.hash;
1001
+ this.lastCheckpoint = result.timestamp;
1002
+ this.lastFromCache = false;
1003
+ }
355
1004
  async pull() {
356
1005
  if (this.aborted) throw new AbortError();
357
1006
  this.logger?.pullStart(this.loggerName);
@@ -359,17 +1008,16 @@ var SyncManager = class {
359
1008
  try {
360
1009
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
361
1010
  if (this.aborted) throw new AbortError();
1011
+ this.lastFromCache = pullWasFromCache(result);
1012
+ let incoming;
362
1013
  if (this.encryptor) {
363
- const decrypted = await this.encryptor.decrypt(result.data);
1014
+ incoming = await this.encryptor.decrypt(result.data);
364
1015
  if (this.aborted) throw new AbortError();
365
- this.localData = decrypted;
366
- result.data = decrypted;
367
- } else if (this.lastCheckpoint > 0) {
368
- this.localData = deepMerge(this.localData, result.data);
369
- result.data = this.localData;
370
1016
  } else {
371
- this.localData = result.data;
1017
+ incoming = result.data;
372
1018
  }
1019
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
1020
+ result.data = this.localData;
373
1021
  this.lastHash = result.hash;
374
1022
  this.lastCheckpoint = result.timestamp;
375
1023
  this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
@@ -393,23 +1041,24 @@ var SyncManager = class {
393
1041
  try {
394
1042
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
395
1043
  if (this.aborted) throw new AbortError();
396
- let payload = sealed;
1044
+ let author;
397
1045
  if (this.signer) {
398
1046
  const { devEdPubHex, sign } = await this.signer.getSigner();
399
1047
  if (this.aborted) throw new AbortError();
400
- const canonical = stableStringify2(sealed);
1048
+ const documentKey2 = stripPushPrefix(this.pushPath);
1049
+ const canonical = docAuthorCanonicalInput(documentKey2, sealed);
401
1050
  const sigBytes = await sign(new TextEncoder().encode(canonical));
402
1051
  if (this.aborted) throw new AbortError();
403
- payload = {
404
- ...sealed,
405
- authorPubkey: devEdPubHex,
406
- authorSignature: getBase64().encode(sigBytes)
1052
+ author = {
1053
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
1054
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
407
1055
  };
408
1056
  }
409
1057
  const result = await this.client.push(
410
1058
  this.pushPath,
411
- payload,
412
- this.lastHash
1059
+ sealed,
1060
+ this.lastHash,
1061
+ author
413
1062
  );
414
1063
  if (this.aborted) throw new AbortError();
415
1064
  this.lastHash = result.hash;
@@ -451,6 +1100,210 @@ var SyncManager = class {
451
1100
  }
452
1101
  };
453
1102
 
1103
+ // src/append-log.ts
1104
+ import {
1105
+ verifyAppendAuthor
1106
+ } from "@drakkar.software/starfish-protocol";
1107
+ var PULL_PATH_PREFIX = "/pull/";
1108
+ function stripPullPrefix(path) {
1109
+ return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
1110
+ }
1111
+ var AppendAuthorError = class extends Error {
1112
+ constructor(ts) {
1113
+ super(`append element author verification failed (ts=${ts})`);
1114
+ this.ts = ts;
1115
+ this.name = "AppendAuthorError";
1116
+ }
1117
+ };
1118
+ function checkpointOf(items) {
1119
+ let max = 0;
1120
+ for (const it of items) if (it.ts > max) max = it.ts;
1121
+ return max;
1122
+ }
1123
+ function withAuthor(ts, data, src) {
1124
+ const out = { ts, data };
1125
+ if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
1126
+ if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
1127
+ return out;
1128
+ }
1129
+ var AppendLogCursor = class {
1130
+ client;
1131
+ pullPath;
1132
+ appendField;
1133
+ encryptor;
1134
+ verifyAuthor;
1135
+ onElementError;
1136
+ persistEncrypted;
1137
+ documentKey;
1138
+ logger;
1139
+ loggerName;
1140
+ items;
1141
+ lastCheckpoint;
1142
+ /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
1143
+ * it so each runs against the checkpoint the previous one advanced — no two
1144
+ * overlapping fetches read the same checkpoint and double-append a window. */
1145
+ pullChain = Promise.resolve();
1146
+ constructor(options) {
1147
+ this.client = options.client;
1148
+ this.pullPath = options.pullPath;
1149
+ this.appendField = options.appendField ?? "items";
1150
+ this.encryptor = options.encryptor;
1151
+ this.verifyAuthor = options.verifyAuthor;
1152
+ this.onElementError = options.onElementError ?? "throw";
1153
+ this.persistEncrypted = options.persistEncrypted ?? false;
1154
+ this.documentKey = stripPullPrefix(options.pullPath);
1155
+ this.logger = options.logger;
1156
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
1157
+ const seed = options.initialItems ?? [];
1158
+ const seedCheckpoint = checkpointOf(seed);
1159
+ if (options.since != null) {
1160
+ if (options.since < 0) throw new Error("since must be non-negative");
1161
+ if (options.since < seedCheckpoint) {
1162
+ throw new Error("since must be >= the max ts of initialItems");
1163
+ }
1164
+ this.lastCheckpoint = options.since;
1165
+ } else {
1166
+ this.lastCheckpoint = seedCheckpoint;
1167
+ }
1168
+ this.items = [...seed];
1169
+ }
1170
+ /**
1171
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
1172
+ * append them to the local log, and return ONLY the newly-fetched batch
1173
+ * (decrypted when an `encryptor` is set).
1174
+ *
1175
+ * Atomic under `onElementError: "throw"` (the default): the batch is fully
1176
+ * verified and decrypted into a local before any state mutation, so a
1177
+ * verify/decrypt failure throws without advancing the checkpoint past elements
1178
+ * that could never be re-fetched. Under `"skip"`, a failing element is dropped
1179
+ * from the returned batch but the checkpoint still advances past it.
1180
+ *
1181
+ * Safe to call concurrently: overlapping calls are serialized internally, so
1182
+ * each runs against the checkpoint the previous one advanced (no double-fetch
1183
+ * of the same window). The next pull after one completes will pick up anything
1184
+ * that arrived in between.
1185
+ */
1186
+ async pull() {
1187
+ const run = this.pullChain.then(
1188
+ () => this.doPull(),
1189
+ () => this.doPull()
1190
+ );
1191
+ this.pullChain = run.then(
1192
+ () => void 0,
1193
+ () => void 0
1194
+ );
1195
+ return run;
1196
+ }
1197
+ async doPull() {
1198
+ this.logger?.pullStart(this.loggerName);
1199
+ const start = performance.now();
1200
+ try {
1201
+ const since = this.lastCheckpoint;
1202
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField, full: true };
1203
+ const raw = await this.client.pull(this.pullPath, opts);
1204
+ const batch = [];
1205
+ const stored = [];
1206
+ let skipped = 0;
1207
+ const eligible = raw.filter((el) => !(since > 0 && el.ts <= since));
1208
+ let maxTs = since;
1209
+ for (const el of eligible) if (el.ts > maxTs) maxTs = el.ts;
1210
+ const decryptResults = await Promise.all(eligible.map(async (el) => {
1211
+ try {
1212
+ this.verifyOne(el);
1213
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
1214
+ return { el, decrypted: withAuthor(el.ts, data, el) };
1215
+ } catch (err) {
1216
+ if (this.onElementError !== "skip") throw err;
1217
+ skipped++;
1218
+ return { el, decrypted: null };
1219
+ }
1220
+ }));
1221
+ for (const { el, decrypted } of decryptResults) {
1222
+ if (this.persistEncrypted) {
1223
+ stored.push(withAuthor(el.ts, el.data, el));
1224
+ } else if (decrypted) {
1225
+ stored.push(decrypted);
1226
+ }
1227
+ if (decrypted) batch.push(decrypted);
1228
+ }
1229
+ this.items.push(...stored);
1230
+ this.lastCheckpoint = maxTs;
1231
+ this.logger?.pullSuccess(
1232
+ this.loggerName,
1233
+ Math.round(performance.now() - start),
1234
+ skipped > 0 ? { skippedCount: skipped } : void 0
1235
+ );
1236
+ return batch;
1237
+ } catch (err) {
1238
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
1239
+ throw err;
1240
+ }
1241
+ }
1242
+ /** Verify a single element's author signature over its RAW (pre-decryption)
1243
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
1244
+ * verification is disabled. */
1245
+ verifyOne(el) {
1246
+ if (!this.verifyAuthor) return;
1247
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
1248
+ const { authorPubkey, authorSignature } = el;
1249
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
1250
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
1251
+ throw new AppendAuthorError(el.ts);
1252
+ }
1253
+ void policy;
1254
+ const ok = verifyAppendAuthor(
1255
+ this.documentKey,
1256
+ el.data,
1257
+ authorPubkey,
1258
+ authorSignature
1259
+ );
1260
+ if (!ok) throw new AppendAuthorError(el.ts);
1261
+ }
1262
+ /** The full accumulated log (a shallow copy), in `ts` order. Under
1263
+ * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
1264
+ * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
1265
+ getItems() {
1266
+ return [...this.items];
1267
+ }
1268
+ /**
1269
+ * The full accumulated log, DECRYPTED — for rendering warm-started history in
1270
+ * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
1271
+ * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
1272
+ * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
1273
+ * elements are already plaintext/decrypted and are returned as-is.
1274
+ */
1275
+ async getDecryptedItems() {
1276
+ const snapshot = [...this.items];
1277
+ if (!this.encryptor || !this.persistEncrypted) return snapshot;
1278
+ const results = await Promise.all(snapshot.map(async (el) => {
1279
+ try {
1280
+ this.verifyOne(el);
1281
+ const data = await this.encryptor.decrypt(el.data);
1282
+ return withAuthor(el.ts, data, el);
1283
+ } catch (err) {
1284
+ if (this.onElementError !== "skip") throw err;
1285
+ return null;
1286
+ }
1287
+ }));
1288
+ return results.filter((el) => el !== null);
1289
+ }
1290
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
1291
+ * when nothing has been pulled or seeded. */
1292
+ getCheckpoint() {
1293
+ return this.lastCheckpoint;
1294
+ }
1295
+ /** Restore the checkpoint without seeding items — for persistence layers that
1296
+ * store only the checkpoint. Used to resume incrementally across restarts.
1297
+ * Rejects a value below the max `ts` already held: rewinding would make the
1298
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
1299
+ setCheckpoint(ts) {
1300
+ if (ts < checkpointOf(this.items)) {
1301
+ throw new Error("checkpoint must be >= the max ts already held");
1302
+ }
1303
+ this.lastCheckpoint = ts;
1304
+ }
1305
+ };
1306
+
454
1307
  // src/index.ts
455
1308
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
456
1309
 
@@ -570,22 +1423,6 @@ function createMigrator(config) {
570
1423
  };
571
1424
  }
572
1425
 
573
- // src/fetch.ts
574
- function classifyError(err) {
575
- if (err instanceof Response || err && typeof err === "object" && "status" in err) {
576
- const status = err.status;
577
- if (typeof status !== "number" || isNaN(status)) return "unknown";
578
- if (status === 0) return "network";
579
- if (status === 401 || status === 403) return "auth";
580
- if (status === 409) return "conflict";
581
- if (status === 429) return "rate-limited";
582
- if (status >= 500) return "server";
583
- if (status >= 400) return "client";
584
- }
585
- if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
586
- return "unknown";
587
- }
588
-
589
1426
  // src/resolvers.ts
590
1427
  function shallowEqual(a, b) {
591
1428
  if (a === b) return true;
@@ -594,8 +1431,7 @@ function shallowEqual(a, b) {
594
1431
  if (typeof a !== "object") return false;
595
1432
  if (Array.isArray(a) !== Array.isArray(b)) return false;
596
1433
  if (Array.isArray(a) && Array.isArray(b)) {
597
- if (a.length !== b.length) return false;
598
- return a.every((v, i) => shallowEqual(v, b[i]));
1434
+ return a.length === b.length && a.every((v, i) => shallowEqual(v, b[i]));
599
1435
  }
600
1436
  const aObj = a;
601
1437
  const bObj = b;
@@ -864,6 +1700,32 @@ function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
864
1700
  });
865
1701
  }
866
1702
 
1703
+ // src/mutate.ts
1704
+ async function mutateDoc(client, path, mutator, options = {}) {
1705
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
1706
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1707
+ let data = null;
1708
+ let hash = null;
1709
+ try {
1710
+ const res = await client.pull(path);
1711
+ data = res.data ?? null;
1712
+ hash = res.hash ?? null;
1713
+ } catch (err) {
1714
+ if (!(err instanceof StarfishHttpError) || err.status !== 404) throw err;
1715
+ }
1716
+ const next = mutator({ data, hash });
1717
+ if (next === null) return null;
1718
+ try {
1719
+ await client.push(path, next, hash);
1720
+ return next;
1721
+ } catch (err) {
1722
+ if (err instanceof ConflictError && attempt < maxAttempts - 1) continue;
1723
+ throw err;
1724
+ }
1725
+ }
1726
+ throw new ConflictError();
1727
+ }
1728
+
867
1729
  // src/config.ts
868
1730
  async function fetchServerConfig(baseUrl, options) {
869
1731
  const url = `${baseUrl.replace(/\/$/, "")}/config`;
@@ -933,6 +1795,46 @@ function createIndexedDBStorage(opts) {
933
1795
  };
934
1796
  }
935
1797
 
1798
+ // src/kv-cache.ts
1799
+ function createKvPullCache(kv, opts = {}) {
1800
+ const prefix = opts.prefix ?? "starfish.pullcache.";
1801
+ const maxAgeMs = opts.maxAgeMs;
1802
+ return {
1803
+ async get(key) {
1804
+ try {
1805
+ const raw = await kv.getItem(prefix + key);
1806
+ if (raw === null || raw === void 0) return null;
1807
+ let payload;
1808
+ let cachedAt;
1809
+ try {
1810
+ const envelope = JSON.parse(raw);
1811
+ if (typeof envelope.payload === "string") {
1812
+ payload = envelope.payload;
1813
+ cachedAt = envelope._cachedAt;
1814
+ } else {
1815
+ payload = raw;
1816
+ }
1817
+ } catch {
1818
+ payload = raw;
1819
+ }
1820
+ if (maxAgeMs !== void 0 && cachedAt !== void 0) {
1821
+ if (Date.now() - cachedAt > maxAgeMs) return null;
1822
+ }
1823
+ return payload;
1824
+ } catch {
1825
+ return null;
1826
+ }
1827
+ },
1828
+ async set(key, value) {
1829
+ try {
1830
+ const envelope = { payload: value, _cachedAt: Date.now() };
1831
+ await kv.setItem(prefix + key, JSON.stringify(envelope));
1832
+ } catch {
1833
+ }
1834
+ }
1835
+ };
1836
+ }
1837
+
936
1838
  // src/export.ts
937
1839
  function exportData(data, opts) {
938
1840
  const format = opts?.format ?? "json";
@@ -1071,12 +1973,8 @@ async function unregisterServiceWorkers() {
1071
1973
  if (!isServiceWorkerSupported()) return false;
1072
1974
  try {
1073
1975
  const registrations = await navigator.serviceWorker.getRegistrations();
1074
- let unregistered = false;
1075
- for (const registration of registrations) {
1076
- const result = await registration.unregister();
1077
- if (result) unregistered = true;
1078
- }
1079
- return unregistered;
1976
+ const results = await Promise.all(registrations.map((r) => r.unregister()));
1977
+ return results.some(Boolean);
1080
1978
  } catch {
1081
1979
  return false;
1082
1980
  }
@@ -1236,6 +2134,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1236
2134
  netUnsub?.();
1237
2135
  };
1238
2136
  }
2137
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
2138
+ const { pullOnForeground = true } = options;
2139
+ const appSub = deps.appState.addEventListener("change", (appState) => {
2140
+ if (appState === "active" && pullOnForeground) {
2141
+ const { online, loading } = store.getState();
2142
+ if (online && !loading) {
2143
+ store.getState().pull().catch((err) => {
2144
+ console.error("[Starfish] foreground log pull failed:", err);
2145
+ });
2146
+ }
2147
+ }
2148
+ });
2149
+ let netUnsub = null;
2150
+ if (deps.netInfo) {
2151
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
2152
+ store.getState().setOnline(!!isConnected);
2153
+ });
2154
+ }
2155
+ return () => {
2156
+ appSub.remove();
2157
+ netUnsub?.();
2158
+ };
2159
+ }
1239
2160
 
1240
2161
  // src/multi-store.ts
1241
2162
  function createMultiStoreSync(options) {
@@ -1288,22 +2209,30 @@ function createMultiStoreSync(options) {
1288
2209
  }
1289
2210
  export {
1290
2211
  AbortError,
2212
+ AppendAuthorError,
2213
+ AppendHttpError,
2214
+ AppendLogCursor,
1291
2215
  ConflictError,
1292
2216
  ENCRYPTED_KEY,
2217
+ PARQUET_MIME_TYPE,
2218
+ PARQUET_MIME_TYPES,
1293
2219
  SnapshotHistory,
1294
2220
  StarfishClient,
1295
2221
  StarfishHttpError,
1296
2222
  SyncManager,
1297
2223
  ValidationError,
1298
2224
  buildRevocationList,
2225
+ checkpointOf,
1299
2226
  classifyError,
1300
2227
  computeHash,
1301
2228
  configurePlatform,
1302
2229
  consoleSyncLogger,
2230
+ createAppendLogMobileLifecycle,
1303
2231
  createDebouncedPush,
1304
2232
  createDebouncedSync,
1305
2233
  createDedupFetch,
1306
2234
  createIndexedDBStorage,
2235
+ createKvPullCache,
1307
2236
  createMetricsCollector,
1308
2237
  createMigrator,
1309
2238
  createMobileLifecycle,
@@ -1318,14 +2247,20 @@ export {
1318
2247
  importData,
1319
2248
  isBackgroundSyncSupported,
1320
2249
  isServiceWorkerSupported,
2250
+ mutateDoc,
1321
2251
  noopSyncLogger,
2252
+ parseRetryAfterMs,
1322
2253
  pruneTombstones,
2254
+ pullAndOpenBlob,
2255
+ pullWasFromCache,
1323
2256
  registerBackgroundSync,
1324
2257
  registerServiceWorker,
1325
2258
  revocationListCanonicalSigningInput,
1326
- stableStringify3 as stableStringify,
2259
+ sealAndPushBlob,
2260
+ stableStringify2 as stableStringify,
1327
2261
  startAdaptivePolling,
1328
2262
  startPolling,
2263
+ stripPushPrefix,
1329
2264
  timestampWinner,
1330
2265
  unregisterServiceWorkers,
1331
2266
  withConflictMeta