@drakkar.software/starfish-client 3.0.0-alpha.4 → 3.0.0-alpha.40

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