@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +167 -2
- package/dist/bindings/zustand.js +942 -82
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/blob-seal.d.ts +123 -0
- package/dist/client.d.ts +270 -5
- package/dist/client.js +391 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/export.js +115 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +51 -14
- package/dist/fetch.js.map +2 -2
- package/dist/history.js +61 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +1030 -94
- package/dist/index.js.map +4 -4
- package/dist/kv-cache.d.ts +63 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/mutate.d.ts +39 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.d.ts +83 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +106 -11
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +12 -3
package/dist/bindings/zustand.js
CHANGED
|
@@ -230,15 +230,31 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|
|
230
230
|
|
|
231
231
|
// src/client.ts
|
|
232
232
|
import {
|
|
233
|
-
|
|
233
|
+
AUTHOR_PUBKEY_FIELD,
|
|
234
|
+
AUTHOR_SIGNATURE_FIELD,
|
|
235
|
+
DATA_FIELD,
|
|
236
|
+
TS_FIELD,
|
|
237
|
+
BASE_HASH_FIELD,
|
|
238
|
+
PUSH_PATH_PREFIX,
|
|
239
|
+
HEADER_AUTHORIZATION,
|
|
240
|
+
HEADER_SIG,
|
|
241
|
+
HEADER_TS,
|
|
242
|
+
HEADER_NONCE,
|
|
243
|
+
HEADER_PUB,
|
|
244
|
+
HEADER_CONTENT_TYPE,
|
|
245
|
+
HEADER_ACCEPT,
|
|
246
|
+
PARQUET_MIME_TYPE as PARQUET_MIME_TYPE_VALUE,
|
|
247
|
+
PARQUET_MIME_TYPES as PARQUET_MIME_TYPES_VALUE,
|
|
248
|
+
signAppendAuthor,
|
|
234
249
|
signRequest,
|
|
235
250
|
stableStringify
|
|
236
251
|
} from "@drakkar.software/starfish-protocol";
|
|
237
252
|
|
|
238
253
|
// src/types.ts
|
|
239
254
|
var ConflictError = class extends Error {
|
|
240
|
-
constructor() {
|
|
255
|
+
constructor(currentHash = "") {
|
|
241
256
|
super("hash_mismatch");
|
|
257
|
+
this.currentHash = currentHash;
|
|
242
258
|
this.name = "ConflictError";
|
|
243
259
|
}
|
|
244
260
|
};
|
|
@@ -250,9 +266,59 @@ var StarfishHttpError = class extends Error {
|
|
|
250
266
|
this.name = "StarfishHttpError";
|
|
251
267
|
}
|
|
252
268
|
};
|
|
269
|
+
var AppendHttpError = class extends Error {
|
|
270
|
+
constructor(status, message) {
|
|
271
|
+
super(message);
|
|
272
|
+
this.status = status;
|
|
273
|
+
this.name = "AppendHttpError";
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/fetch.ts
|
|
278
|
+
function parseRetryAfterMs(header, opts) {
|
|
279
|
+
const { fallbackMs, maxMs } = opts;
|
|
280
|
+
const trimmed = header?.trim();
|
|
281
|
+
if (trimmed) {
|
|
282
|
+
const seconds = Number(trimmed);
|
|
283
|
+
if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
|
|
284
|
+
const date = Date.parse(trimmed);
|
|
285
|
+
if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
|
|
286
|
+
}
|
|
287
|
+
return Math.min(fallbackMs, maxMs);
|
|
288
|
+
}
|
|
289
|
+
function classifyError(err) {
|
|
290
|
+
if (err instanceof Response || err && typeof err === "object" && "status" in err) {
|
|
291
|
+
const status = err.status;
|
|
292
|
+
if (typeof status !== "number" || isNaN(status)) return "unknown";
|
|
293
|
+
if (status === 0) return "network";
|
|
294
|
+
if (status === 401 || status === 403) return "auth";
|
|
295
|
+
if (status === 409) return "conflict";
|
|
296
|
+
if (status === 429) return "rate-limited";
|
|
297
|
+
if (status >= 500) return "server";
|
|
298
|
+
if (status >= 400) return "client";
|
|
299
|
+
}
|
|
300
|
+
if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
|
|
301
|
+
return "unknown";
|
|
302
|
+
}
|
|
253
303
|
|
|
254
304
|
// src/client.ts
|
|
255
305
|
var APPEND_DEFAULT_FIELD = "items";
|
|
306
|
+
var MAX_REVALIDATE_ATTEMPTS = 5;
|
|
307
|
+
var REVALIDATE_INITIAL_DELAY_MS = 1e3;
|
|
308
|
+
var REVALIDATE_MAX_DELAY_MS = 3e4;
|
|
309
|
+
function sleep(ms) {
|
|
310
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
311
|
+
}
|
|
312
|
+
function pullCacheKey(pathAndQuery) {
|
|
313
|
+
const q = pathAndQuery.indexOf("?");
|
|
314
|
+
return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
315
|
+
}
|
|
316
|
+
function pullWasFromCache(result) {
|
|
317
|
+
return result.fromCache === true;
|
|
318
|
+
}
|
|
319
|
+
function stripPushPrefix(path) {
|
|
320
|
+
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
321
|
+
}
|
|
256
322
|
function encodeCapAuth(cap) {
|
|
257
323
|
const json = stableStringify(cap);
|
|
258
324
|
if (typeof btoa === "function") {
|
|
@@ -267,6 +333,17 @@ var StarfishClient = class {
|
|
|
267
333
|
namespace;
|
|
268
334
|
capProvider;
|
|
269
335
|
fetch;
|
|
336
|
+
cache;
|
|
337
|
+
cacheMaxAgeMs;
|
|
338
|
+
cacheFallbackStatuses;
|
|
339
|
+
onRevalidated;
|
|
340
|
+
revalidating = /* @__PURE__ */ new Set();
|
|
341
|
+
/**
|
|
342
|
+
* In-memory mirror of the latest document timestamp written to each cache
|
|
343
|
+
* key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
|
|
344
|
+
* can guard against stale overwrites without an extra async cache read.
|
|
345
|
+
*/
|
|
346
|
+
latestCacheTimestamp = /* @__PURE__ */ new Map();
|
|
270
347
|
/**
|
|
271
348
|
* Installed client-side plugins. Currently stored as inert data; no
|
|
272
349
|
* hooks fire yet. Extensions can inspect this list if needed.
|
|
@@ -277,8 +354,21 @@ var StarfishClient = class {
|
|
|
277
354
|
this.namespace = options.namespace || void 0;
|
|
278
355
|
this.capProvider = options.capProvider;
|
|
279
356
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
357
|
+
this.cache = options.cache;
|
|
358
|
+
this.cacheMaxAgeMs = options.cacheMaxAgeMs;
|
|
359
|
+
this.cacheFallbackStatuses = options.cacheFallbackStatuses;
|
|
360
|
+
this.onRevalidated = options.onRevalidated;
|
|
280
361
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
281
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Mark a `PullResult` as having been served from the offline read-through
|
|
365
|
+
* cache (transport was unreachable). Non-enumerable so it doesn't leak into
|
|
366
|
+
* JSON / equality / re-caching; read via {@link pullWasFromCache}.
|
|
367
|
+
*/
|
|
368
|
+
tagFromCache(result) {
|
|
369
|
+
Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
282
372
|
/**
|
|
283
373
|
* Resolve the host portion of the URL the client will send to. The host
|
|
284
374
|
* is folded into the signed canonical input as the `h` field so the
|
|
@@ -323,38 +413,59 @@ var StarfishClient = class {
|
|
|
323
413
|
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
324
414
|
*/
|
|
325
415
|
async buildAuthHeaders(method, pathAndQuery, body) {
|
|
326
|
-
if (this.capProvider) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
416
|
+
if (!this.capProvider) return {};
|
|
417
|
+
const capCtx = await this.capProvider.getCap();
|
|
418
|
+
return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
422
|
+
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
423
|
+
* reuse it for BOTH the author signature (over the element data) and the
|
|
424
|
+
* request signature (over the body), without redeeming the cap twice — a
|
|
425
|
+
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
426
|
+
* presenter` bind the server checks.
|
|
427
|
+
*/
|
|
428
|
+
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
429
|
+
const { cap, devEdPrivHex, pubHex } = capCtx;
|
|
430
|
+
const req = {
|
|
431
|
+
method,
|
|
432
|
+
pathAndQuery,
|
|
433
|
+
body,
|
|
434
|
+
host: this.signingHost()
|
|
435
|
+
};
|
|
436
|
+
const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
|
|
437
|
+
const headers = {
|
|
438
|
+
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
439
|
+
[HEADER_SIG]: sig,
|
|
440
|
+
[HEADER_TS]: String(ts),
|
|
441
|
+
[HEADER_NONCE]: nonce
|
|
442
|
+
};
|
|
443
|
+
if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
|
|
444
|
+
return headers;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
448
|
+
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
449
|
+
* device/member cap. This is the SAME key that signs the request, so a server
|
|
450
|
+
* enforcing author proof can bind the stored element to its writer. Returns
|
|
451
|
+
* undefined only for a (malformed) cap with neither — the append then goes
|
|
452
|
+
* unsigned and a server requiring signatures rejects it.
|
|
453
|
+
*/
|
|
454
|
+
appendAuthorKey(capCtx) {
|
|
455
|
+
const { cap, pubHex } = capCtx;
|
|
456
|
+
const authorPubHex = pubHex ?? cap.sub;
|
|
457
|
+
if (authorPubHex === void 0) return null;
|
|
458
|
+
return { authorPubHex };
|
|
349
459
|
}
|
|
350
460
|
async pull(path, checkpointOrOptions) {
|
|
351
461
|
let pathAndQuery = this.applyNamespace(path);
|
|
352
462
|
let appendField;
|
|
463
|
+
let swr = false;
|
|
353
464
|
if (typeof checkpointOrOptions === "number") {
|
|
354
465
|
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
355
466
|
} else if (checkpointOrOptions != null) {
|
|
356
467
|
const opts = checkpointOrOptions;
|
|
357
|
-
const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
|
|
468
|
+
const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0 || opts.staleWhileRevalidate !== void 0;
|
|
358
469
|
const params = new URLSearchParams();
|
|
359
470
|
if (isPullOptions) {
|
|
360
471
|
if (opts.checkpoint != null && opts.checkpoint > 0) {
|
|
@@ -363,68 +474,343 @@ var StarfishClient = class {
|
|
|
363
474
|
if (opts.withKeyring) {
|
|
364
475
|
params.set("withKeyring", "1");
|
|
365
476
|
}
|
|
477
|
+
swr = opts.staleWhileRevalidate === true;
|
|
366
478
|
} else {
|
|
367
479
|
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
480
|
+
if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
|
|
481
|
+
throw new Error("full cannot be combined with since, limit, or last");
|
|
482
|
+
}
|
|
368
483
|
if (opts.since != null) {
|
|
369
484
|
if (opts.since < 0) throw new Error("since must be non-negative");
|
|
370
485
|
params.set("checkpoint", String(opts.since));
|
|
371
486
|
}
|
|
487
|
+
if (opts.limit != null) {
|
|
488
|
+
if (opts.limit < 0) throw new Error("limit must be non-negative");
|
|
489
|
+
params.set("limit", String(opts.limit));
|
|
490
|
+
}
|
|
372
491
|
if (opts.last != null) {
|
|
373
492
|
if (opts.last < 0) throw new Error("last must be non-negative");
|
|
374
493
|
params.set("last", String(opts.last));
|
|
375
494
|
}
|
|
495
|
+
if (opts.full) {
|
|
496
|
+
params.set("full", "true");
|
|
497
|
+
}
|
|
376
498
|
}
|
|
377
499
|
if (params.size > 0) pathAndQuery += `?${params.toString()}`;
|
|
378
500
|
}
|
|
379
501
|
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
380
502
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
503
|
+
const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
|
|
504
|
+
if (swr && cacheKey) {
|
|
505
|
+
const cached = await this.readCache(cacheKey);
|
|
506
|
+
if (cached) {
|
|
507
|
+
this.scheduleRevalidate(
|
|
508
|
+
cacheKey,
|
|
509
|
+
pathAndQuery,
|
|
510
|
+
null,
|
|
511
|
+
/* immediate */
|
|
512
|
+
true
|
|
513
|
+
);
|
|
514
|
+
return cached;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
let res;
|
|
518
|
+
try {
|
|
519
|
+
res = await this.fetch(url, {
|
|
520
|
+
method: "GET",
|
|
521
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
|
|
522
|
+
cache: "no-store"
|
|
523
|
+
});
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (cacheKey) {
|
|
526
|
+
const cached = await this.readCache(cacheKey);
|
|
527
|
+
if (cached) return cached;
|
|
528
|
+
}
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
385
531
|
if (!res.ok) {
|
|
386
|
-
|
|
532
|
+
const status = res.status;
|
|
533
|
+
if (cacheKey && this.cacheFallbackStatuses?.includes(status)) {
|
|
534
|
+
const retryAfterHeader = res.headers.get("Retry-After");
|
|
535
|
+
this.scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader);
|
|
536
|
+
const cached = await this.readCache(cacheKey);
|
|
537
|
+
if (cached) {
|
|
538
|
+
void res.body?.cancel();
|
|
539
|
+
return cached;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
throw new StarfishHttpError(status, await res.text());
|
|
387
543
|
}
|
|
388
544
|
const result = await res.json();
|
|
389
545
|
if (appendField !== void 0) {
|
|
390
546
|
const list = result.data?.[appendField];
|
|
391
547
|
return Array.isArray(list) ? list : [];
|
|
392
548
|
}
|
|
549
|
+
if (cacheKey) this.writeCache(cacheKey, result);
|
|
393
550
|
return result;
|
|
394
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Write a pull snapshot to the cache. Fire-and-forget; errors are swallowed
|
|
554
|
+
* so a failing cache never blocks the caller. No-op when no cache is configured.
|
|
555
|
+
*/
|
|
556
|
+
writeCache(cacheKey, result) {
|
|
557
|
+
if (!this.cache) return;
|
|
558
|
+
if (!result.hash) return;
|
|
559
|
+
if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
|
|
560
|
+
this.latestCacheTimestamp.set(cacheKey, result.timestamp);
|
|
561
|
+
}
|
|
562
|
+
const snapshot = {
|
|
563
|
+
data: result.data,
|
|
564
|
+
hash: result.hash,
|
|
565
|
+
timestamp: result.timestamp,
|
|
566
|
+
cachedAt: Date.now()
|
|
567
|
+
};
|
|
568
|
+
void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/** Build the URL + auth headers for one revalidation GET. Shared between
|
|
572
|
+
* {@link pull} and {@link revalidateLoop} to avoid duplicated fetch setup. */
|
|
573
|
+
async revalidateFetch(pathAndQuery) {
|
|
574
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
575
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
576
|
+
return this.fetch(url, {
|
|
577
|
+
method: "GET",
|
|
578
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Deduplicated fire-and-forget: starts one revalidation loop per cacheKey.
|
|
583
|
+
* Used by both the {@link cacheFallbackStatuses} error path (delayed first
|
|
584
|
+
* attempt, honoring `Retry-After`) and the {@link PullOptions.staleWhileRevalidate}
|
|
585
|
+
* read path (`immediate: true` — no initial delay on the first attempt). The
|
|
586
|
+
* `revalidating` set deduplicates across both triggers so a concurrent
|
|
587
|
+
* error-triggered loop and an SWR-on-read loop for the same key collapse to one.
|
|
588
|
+
*/
|
|
589
|
+
scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader, immediate = false) {
|
|
590
|
+
if (this.revalidating.has(cacheKey)) return;
|
|
591
|
+
this.revalidating.add(cacheKey);
|
|
592
|
+
void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader, immediate).finally(() => {
|
|
593
|
+
this.revalidating.delete(cacheKey);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Background revalidation loop shared by both {@link cacheFallbackStatuses}
|
|
598
|
+
* hits and {@link PullOptions.staleWhileRevalidate} reads.
|
|
599
|
+
*
|
|
600
|
+
* Retries (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS} times.
|
|
601
|
+
* When `immediate` is true the first attempt fires without any initial delay
|
|
602
|
+
* (SWR-on-read path). On a live 2xx the fresh snapshot is written to cache and
|
|
603
|
+
* {@link onRevalidated} fires. Stops early on a non-fallback status (403/404).
|
|
604
|
+
*/
|
|
605
|
+
async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter, immediate = false) {
|
|
606
|
+
let retryAfterHeader = firstRetryAfter;
|
|
607
|
+
const fallbackSet = this.cacheFallbackStatuses ? new Set(this.cacheFallbackStatuses) : null;
|
|
608
|
+
for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
|
|
609
|
+
if (!immediate || attempt > 0) {
|
|
610
|
+
const delay = parseRetryAfterMs(retryAfterHeader, {
|
|
611
|
+
fallbackMs: Math.min(
|
|
612
|
+
REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
|
|
613
|
+
REVALIDATE_MAX_DELAY_MS
|
|
614
|
+
),
|
|
615
|
+
maxMs: REVALIDATE_MAX_DELAY_MS
|
|
616
|
+
});
|
|
617
|
+
await sleep(delay);
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const res = await this.revalidateFetch(pathAndQuery);
|
|
621
|
+
if (res.ok) {
|
|
622
|
+
const result = await res.json();
|
|
623
|
+
const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
|
|
624
|
+
if (result.timestamp >= latestTs) {
|
|
625
|
+
this.writeCache(cacheKey, result);
|
|
626
|
+
this.onRevalidated?.(pathAndQuery, result);
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (!fallbackSet?.has(res.status)) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
retryAfterHeader = res.headers.get("Retry-After");
|
|
634
|
+
} catch {
|
|
635
|
+
retryAfterHeader = null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Read the cached snapshot for a document `path` WITHOUT hitting the network —
|
|
641
|
+
* the basis for cache-first paint (seed the UI from the last-synced snapshot,
|
|
642
|
+
* then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
|
|
643
|
+
* or null when no cache is configured / there's no entry. Namespacing matches
|
|
644
|
+
* {@link pull}, so the key lines up with whatever `pull` wrote.
|
|
645
|
+
*/
|
|
646
|
+
async peekCache(path) {
|
|
647
|
+
if (!this.cache) return null;
|
|
648
|
+
return this.readCache(pullCacheKey(this.applyNamespace(path)));
|
|
649
|
+
}
|
|
650
|
+
/** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
|
|
651
|
+
* null on a miss or an unparseable blob (never throws — a corrupt cache entry
|
|
652
|
+
* must not break a pull, just miss). */
|
|
653
|
+
async readCache(cacheKey) {
|
|
654
|
+
try {
|
|
655
|
+
const raw = await this.cache.get(cacheKey);
|
|
656
|
+
if (!raw) return null;
|
|
657
|
+
const parsed = JSON.parse(raw);
|
|
658
|
+
if (!parsed || typeof parsed.hash !== "string") return null;
|
|
659
|
+
if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
|
|
663
|
+
} catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
669
|
+
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
670
|
+
* an ARRAY of path-param sets — one per document to read — so the SAME collection
|
|
671
|
+
* can fan in many documents (e.g. many users' `profile`) in a single request.
|
|
672
|
+
* The server auto-fills the `{identity}` param from the authenticated caller for
|
|
673
|
+
* any set that omits it, so a self-doc collection needs no params. Returns a map
|
|
674
|
+
* of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
|
|
675
|
+
* in request order. Honors the configured namespace.
|
|
676
|
+
*
|
|
677
|
+
* For the common "many docs of one collection" case prefer {@link batchPullMany}.
|
|
678
|
+
*
|
|
679
|
+
* Pass `appendParams` per entry for append-only bounded-tail reads (see {@link batchPullManyAppend}).
|
|
680
|
+
*/
|
|
681
|
+
async batchPull(collections, opts = {}) {
|
|
682
|
+
const search = new URLSearchParams();
|
|
683
|
+
search.set("collections", collections.join(","));
|
|
684
|
+
if (opts.params && Object.keys(opts.params).length > 0) {
|
|
685
|
+
search.set("params", JSON.stringify(opts.params));
|
|
686
|
+
}
|
|
687
|
+
if (opts.appendParams && Object.keys(opts.appendParams).length > 0) {
|
|
688
|
+
for (const [col, optsArr] of Object.entries(opts.appendParams)) {
|
|
689
|
+
for (const ap of optsArr) {
|
|
690
|
+
if (ap.full) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`batchPull: appendParams["${col}"] contains full:true \u2014 full is not supported in batch pull`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (ap.since != null && (!Number.isInteger(ap.since) || ap.since < 0)) {
|
|
696
|
+
throw new Error(`batchPull: appendParams["${col}"].since must be a non-negative integer`);
|
|
697
|
+
}
|
|
698
|
+
if (ap.last != null && (!Number.isInteger(ap.last) || ap.last < 0)) {
|
|
699
|
+
throw new Error(`batchPull: appendParams["${col}"].last must be a non-negative integer`);
|
|
700
|
+
}
|
|
701
|
+
if (ap.limit != null && (!Number.isInteger(ap.limit) || ap.limit < 0)) {
|
|
702
|
+
throw new Error(`batchPull: appendParams["${col}"].limit must be a non-negative integer`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
search.set("appendParams", JSON.stringify(opts.appendParams));
|
|
707
|
+
}
|
|
708
|
+
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
709
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
710
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
711
|
+
const res = await this.fetch(url, {
|
|
712
|
+
method: "GET",
|
|
713
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
714
|
+
});
|
|
715
|
+
if (!res.ok) {
|
|
716
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
717
|
+
}
|
|
718
|
+
return await res.json();
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Convenience over {@link batchPull} for reading MANY documents of ONE
|
|
722
|
+
* collection in a single round-trip: pass the per-document param-sets and get
|
|
723
|
+
* back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
|
|
724
|
+
* entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
|
|
725
|
+
* issues no request and returns `[]`.
|
|
726
|
+
*/
|
|
727
|
+
async batchPullMany(collection, paramsList) {
|
|
728
|
+
if (paramsList.length === 0) return [];
|
|
729
|
+
const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
|
|
730
|
+
return res.collections[collection] ?? [];
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Convenience over {@link batchPull} for reading append-only bounded tails from
|
|
734
|
+
* MANY entries of ONE collection in a single round-trip.
|
|
735
|
+
*
|
|
736
|
+
* Each request in `requests` carries optional `params` (path params) and
|
|
737
|
+
* `options` (append bounds: `since`/`last`/`limit`/`appendField`). An empty
|
|
738
|
+
* `requests` issues no request and returns `[]`.
|
|
739
|
+
*
|
|
740
|
+
* Returns an array aligned to `requests` by index. Each element is either:
|
|
741
|
+
* - the filtered array `T[]` extracted from `entry.data[appendField]`, or
|
|
742
|
+
* - `{ error: string }` if the server returned a per-entry error.
|
|
743
|
+
*
|
|
744
|
+
* The `appendField` used for extraction defaults to `"items"` and can be
|
|
745
|
+
* overridden per request via `options.appendField`.
|
|
746
|
+
*
|
|
747
|
+
* The `appendField` option is client-side only (used for result extraction, not sent to the server).
|
|
748
|
+
* It must match the collection's server-configured append field and defaults to `"items"`.
|
|
749
|
+
*
|
|
750
|
+
* Note: `full: true` is not supported in batch and is rejected client-side
|
|
751
|
+
* before the request is sent.
|
|
752
|
+
*/
|
|
753
|
+
async batchPullManyAppend(collection, requests) {
|
|
754
|
+
if (requests.length === 0) return [];
|
|
755
|
+
const paramsList = requests.map((r) => r.params ?? {});
|
|
756
|
+
const appendParamsList = requests.map(({ options: { appendField: _af, ...wireOpts } }) => wireOpts);
|
|
757
|
+
const res = await this.batchPull([collection], {
|
|
758
|
+
params: { [collection]: paramsList },
|
|
759
|
+
appendParams: { [collection]: appendParamsList }
|
|
760
|
+
});
|
|
761
|
+
const entries = res.collections[collection] ?? [];
|
|
762
|
+
return entries.map((entry, i) => {
|
|
763
|
+
if (entry.error) return { error: entry.error };
|
|
764
|
+
const appendField = requests[i]?.options.appendField ?? APPEND_DEFAULT_FIELD;
|
|
765
|
+
const data = entry.data;
|
|
766
|
+
const items = data?.[appendField];
|
|
767
|
+
return Array.isArray(items) ? items : [];
|
|
768
|
+
});
|
|
769
|
+
}
|
|
395
770
|
/**
|
|
396
771
|
* Push synced data to the server.
|
|
397
772
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
398
773
|
* @param data - The full document data to push
|
|
399
774
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
400
775
|
*
|
|
401
|
-
* v3 author
|
|
402
|
-
*
|
|
776
|
+
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
777
|
+
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
778
|
+
* top-level body siblings of `data`, where the server verifies it.
|
|
403
779
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
404
780
|
*/
|
|
405
|
-
async push(path, data, baseHash) {
|
|
781
|
+
async push(path, data, baseHash, author) {
|
|
406
782
|
const body = JSON.stringify({
|
|
407
|
-
data,
|
|
408
|
-
baseHash
|
|
783
|
+
[DATA_FIELD]: data,
|
|
784
|
+
[BASE_HASH_FIELD]: baseHash,
|
|
785
|
+
...author && {
|
|
786
|
+
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
787
|
+
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature
|
|
788
|
+
}
|
|
409
789
|
});
|
|
410
790
|
const sendPath = this.applyNamespace(path);
|
|
411
791
|
const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
|
|
412
792
|
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
413
793
|
method: "POST",
|
|
414
794
|
headers: {
|
|
415
|
-
|
|
416
|
-
|
|
795
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
796
|
+
[HEADER_ACCEPT]: "application/json",
|
|
417
797
|
...authHeaders
|
|
418
798
|
},
|
|
419
799
|
body
|
|
420
800
|
});
|
|
421
801
|
if (res.status === 409) {
|
|
422
|
-
|
|
802
|
+
const conflict = await res.json().catch(() => null);
|
|
803
|
+
throw new ConflictError(conflict?.currentHash ?? "");
|
|
423
804
|
}
|
|
424
805
|
if (!res.ok) {
|
|
425
806
|
throw new StarfishHttpError(res.status, await res.text());
|
|
426
807
|
}
|
|
427
|
-
|
|
808
|
+
const result = await res.json();
|
|
809
|
+
if (this.cache) {
|
|
810
|
+
const pullPath = sendPath.replace("/push/", "/pull/");
|
|
811
|
+
this.writeCache(pullCacheKey(pullPath), { data, hash: result.hash, timestamp: result.timestamp });
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
428
814
|
}
|
|
429
815
|
/**
|
|
430
816
|
* Append an element to an appendOnly (`by_timestamp`) collection.
|
|
@@ -440,20 +826,37 @@ var StarfishClient = class {
|
|
|
440
826
|
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
441
827
|
* non-negative integer strictly greater than the latest stored element's ts
|
|
442
828
|
* (else the server responds 409). Omit to let the server assign one.
|
|
443
|
-
* @throws {StarfishHttpError} on a non-2xx response
|
|
444
|
-
* non-monotonic timestamp
|
|
829
|
+
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
830
|
+
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
831
|
+
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
832
|
+
* cap is reached (partition by a path parameter for higher volume).
|
|
445
833
|
*/
|
|
446
834
|
async append(path, data, opts = {}) {
|
|
447
|
-
const bodyObj = { data };
|
|
448
|
-
if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
|
|
449
|
-
const body = JSON.stringify(bodyObj);
|
|
450
835
|
const sendPath = this.applyNamespace(path);
|
|
451
|
-
const
|
|
836
|
+
const bodyObj = { [DATA_FIELD]: data };
|
|
837
|
+
if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
|
|
838
|
+
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
839
|
+
if (capCtx) {
|
|
840
|
+
const authorKey = this.appendAuthorKey(capCtx);
|
|
841
|
+
if (authorKey) {
|
|
842
|
+
const documentKey = stripPushPrefix(path);
|
|
843
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
844
|
+
documentKey,
|
|
845
|
+
data,
|
|
846
|
+
authorKey.authorPubHex,
|
|
847
|
+
capCtx.devEdPrivHex
|
|
848
|
+
);
|
|
849
|
+
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
850
|
+
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const body = JSON.stringify(bodyObj);
|
|
854
|
+
const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
|
|
452
855
|
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
453
856
|
method: "POST",
|
|
454
857
|
headers: {
|
|
455
|
-
|
|
456
|
-
|
|
858
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
859
|
+
[HEADER_ACCEPT]: "application/json",
|
|
457
860
|
...authHeaders
|
|
458
861
|
},
|
|
459
862
|
body
|
|
@@ -463,6 +866,62 @@ var StarfishClient = class {
|
|
|
463
866
|
}
|
|
464
867
|
return res.json();
|
|
465
868
|
}
|
|
869
|
+
/**
|
|
870
|
+
* Append one element to a **public-write** append-only collection with an
|
|
871
|
+
* Ed25519 author proof but **no cap `Authorization` header**.
|
|
872
|
+
*
|
|
873
|
+
* Unlike {@link append}, which always attaches a cap-signed `Authorization`
|
|
874
|
+
* header from the configured `capProvider`, this method signs only the
|
|
875
|
+
* append-author proof (binding the element to the writer's Ed25519 key) and
|
|
876
|
+
* sends the request without authentication headers. This is required for
|
|
877
|
+
* collections with `writeRoles: ["public"]` — the server's cap-scope check
|
|
878
|
+
* would reject a request carrying a cap whose scope does not cover the path.
|
|
879
|
+
*
|
|
880
|
+
* Typical use-case: writing a sealed invitation to another user's
|
|
881
|
+
* public-write inbox collection without needing a cap scoped to the
|
|
882
|
+
* recipient's namespace. The author proof is optional on the server side
|
|
883
|
+
* (`requireAuthorSignature: false` for a public inbox), but signing anyway
|
|
884
|
+
* binds the stored element to the sender's Ed25519 key for verification in
|
|
885
|
+
* the receive path.
|
|
886
|
+
*
|
|
887
|
+
* The element is sent as `{ data, authorPubkey, authorSignature }`.
|
|
888
|
+
*
|
|
889
|
+
* @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
|
|
890
|
+
* @param element The JSON element to append.
|
|
891
|
+
* @param signer The sender's Ed25519 keypair (signs the author proof).
|
|
892
|
+
*
|
|
893
|
+
* @throws {AppendHttpError} on a non-2xx response.
|
|
894
|
+
*/
|
|
895
|
+
async appendAnonymous(path, element, signer) {
|
|
896
|
+
const sendPath = this.applyNamespace(path);
|
|
897
|
+
const documentKey = stripPushPrefix(path);
|
|
898
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
899
|
+
documentKey,
|
|
900
|
+
element,
|
|
901
|
+
signer.edPubHex,
|
|
902
|
+
signer.edPrivHex
|
|
903
|
+
);
|
|
904
|
+
const body = JSON.stringify({
|
|
905
|
+
[DATA_FIELD]: element,
|
|
906
|
+
[AUTHOR_PUBKEY_FIELD]: authorPubkey,
|
|
907
|
+
[AUTHOR_SIGNATURE_FIELD]: authorSignature
|
|
908
|
+
});
|
|
909
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: {
|
|
912
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
913
|
+
[HEADER_ACCEPT]: "application/json"
|
|
914
|
+
},
|
|
915
|
+
body
|
|
916
|
+
});
|
|
917
|
+
if (!res.ok) {
|
|
918
|
+
const detail = await res.text().catch(() => "");
|
|
919
|
+
throw new AppendHttpError(
|
|
920
|
+
res.status,
|
|
921
|
+
`anonymous append failed: HTTP ${res.status} ${detail}`.trim()
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
466
925
|
/**
|
|
467
926
|
* Pull binary data from a blob collection.
|
|
468
927
|
* Returns raw bytes with the content hash from the ETag header.
|
|
@@ -472,13 +931,13 @@ var StarfishClient = class {
|
|
|
472
931
|
const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
|
|
473
932
|
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
474
933
|
method: "GET",
|
|
475
|
-
headers: {
|
|
934
|
+
headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
|
|
476
935
|
});
|
|
477
936
|
if (!res.ok) {
|
|
478
937
|
throw new StarfishHttpError(res.status, await res.text());
|
|
479
938
|
}
|
|
480
939
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
481
|
-
const contentType = res.headers.get(
|
|
940
|
+
const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
|
|
482
941
|
const data = await res.arrayBuffer();
|
|
483
942
|
return { data, hash: etag, contentType };
|
|
484
943
|
}
|
|
@@ -492,8 +951,8 @@ var StarfishClient = class {
|
|
|
492
951
|
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
493
952
|
method: "POST",
|
|
494
953
|
headers: {
|
|
495
|
-
|
|
496
|
-
|
|
954
|
+
[HEADER_CONTENT_TYPE]: contentType,
|
|
955
|
+
[HEADER_ACCEPT]: "application/json",
|
|
497
956
|
...authHeaders
|
|
498
957
|
},
|
|
499
958
|
body: data
|
|
@@ -503,10 +962,56 @@ var StarfishClient = class {
|
|
|
503
962
|
}
|
|
504
963
|
return res.json();
|
|
505
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* Push an Apache Parquet file to a Parquet collection.
|
|
967
|
+
*
|
|
968
|
+
* Thin wrapper over {@link pushBlob} that fixes `Content-Type` to
|
|
969
|
+
* `application/vnd.apache.parquet` so the S3 object is tagged correctly
|
|
970
|
+
* for DuckDB and CDN consumption.
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* ```ts
|
|
974
|
+
* const parquetBytes = await generateParquet(rows)
|
|
975
|
+
* const result = await client.pushParquet("/push/analytics/alice/q1.parquet", parquetBytes)
|
|
976
|
+
* console.log("stored hash:", result.hash)
|
|
977
|
+
* ```
|
|
978
|
+
*/
|
|
979
|
+
async pushParquet(path, data) {
|
|
980
|
+
return this.pushBlob(path, data, PARQUET_MIME_TYPE_VALUE);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Pull an Apache Parquet file from a Parquet collection.
|
|
984
|
+
*
|
|
985
|
+
* Thin wrapper over {@link pullBlob} for API symmetry with
|
|
986
|
+
* {@link pushParquet}.
|
|
987
|
+
*
|
|
988
|
+
* @example
|
|
989
|
+
* ```ts
|
|
990
|
+
* const result = await client.pullParquet("/pull/analytics/alice/q1.parquet")
|
|
991
|
+
* // result.data → ArrayBuffer
|
|
992
|
+
* // result.contentType → "application/vnd.apache.parquet"
|
|
993
|
+
* ```
|
|
994
|
+
*/
|
|
995
|
+
async pullParquet(path) {
|
|
996
|
+
const result = await this.pullBlob(path);
|
|
997
|
+
if (!PARQUET_MIME_TYPES_VALUE.includes(result.contentType)) {
|
|
998
|
+
throw new StarfishHttpError(
|
|
999
|
+
415,
|
|
1000
|
+
`Expected a Parquet content-type, got: ${result.contentType}`
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
506
1005
|
};
|
|
507
1006
|
|
|
508
1007
|
// src/sync.ts
|
|
509
|
-
import {
|
|
1008
|
+
import {
|
|
1009
|
+
AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
|
|
1010
|
+
AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
|
|
1011
|
+
deepMerge,
|
|
1012
|
+
docAuthorCanonicalInput,
|
|
1013
|
+
getBase64
|
|
1014
|
+
} from "@drakkar.software/starfish-protocol";
|
|
510
1015
|
|
|
511
1016
|
// src/validate.ts
|
|
512
1017
|
var ValidationError = class extends Error {
|
|
@@ -539,6 +1044,9 @@ var SyncManager = class {
|
|
|
539
1044
|
lastCheckpoint = 0;
|
|
540
1045
|
localData = {};
|
|
541
1046
|
aborted = false;
|
|
1047
|
+
lastFromCache = false;
|
|
1048
|
+
/** True once {@link seedFromCache} has successfully seeded localData from the cache. */
|
|
1049
|
+
seeded = false;
|
|
542
1050
|
constructor(options) {
|
|
543
1051
|
this.client = options.client;
|
|
544
1052
|
this.pullPath = options.pullPath;
|
|
@@ -560,6 +1068,36 @@ var SyncManager = class {
|
|
|
560
1068
|
getData() {
|
|
561
1069
|
return { ...this.localData };
|
|
562
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Returns true when `pull()` / `ingest()` should merge against the current
|
|
1073
|
+
* `localData` rather than replace it wholesale.
|
|
1074
|
+
*
|
|
1075
|
+
* Two situations establish a merge baseline:
|
|
1076
|
+
* - A successful prior pull/ingest advanced `lastCheckpoint` beyond 0 (the
|
|
1077
|
+
* normal steady-state case, unchanged since alpha.36).
|
|
1078
|
+
* - A cache seed painted `localData` via {@link seedFromCache} AND the store
|
|
1079
|
+
* uses a custom conflict resolver (i.e. NOT the default `deepMerge`). For a
|
|
1080
|
+
* union/custom resolver the seeded snapshot is a real baseline that must not
|
|
1081
|
+
* be clobbered by a short first live response (a cache-fallback on 429/5xx
|
|
1082
|
+
* or a momentarily-short concurrent server snapshot). For the default
|
|
1083
|
+
* `deepMerge` resolver we keep the pre-fix wholesale-replace behaviour so
|
|
1084
|
+
* non-union stores are byte-identical to alpha.36.
|
|
1085
|
+
*/
|
|
1086
|
+
hasMergeBaseline() {
|
|
1087
|
+
return this.lastCheckpoint > 0 || this.seeded && this.onConflict !== deepMerge;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Merge a remote snapshot with local (optimistic) data using this manager's
|
|
1091
|
+
* conflict resolver — the same resolver the push-conflict path uses. A plain
|
|
1092
|
+
* {@link pull} overwrites the store's data with the server snapshot, which
|
|
1093
|
+
* would drop un-pushed local writes (they live only in the store, never in
|
|
1094
|
+
* `localData` until a push succeeds). The zustand binding calls this on pull
|
|
1095
|
+
* while the store is dirty so those writes survive. `local` wins by the same
|
|
1096
|
+
* rules as a push conflict.
|
|
1097
|
+
*/
|
|
1098
|
+
resolve(local, remote) {
|
|
1099
|
+
return this.onConflict(local, remote);
|
|
1100
|
+
}
|
|
563
1101
|
getHash() {
|
|
564
1102
|
return this.lastHash;
|
|
565
1103
|
}
|
|
@@ -567,9 +1105,95 @@ var SyncManager = class {
|
|
|
567
1105
|
setHash(hash) {
|
|
568
1106
|
this.lastHash = hash;
|
|
569
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
1110
|
+
* from the client's offline read-through cache rather than a live server
|
|
1111
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
1112
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
1113
|
+
* false by the next successful network pull.
|
|
1114
|
+
*/
|
|
1115
|
+
getLastPullFromCache() {
|
|
1116
|
+
return this.lastFromCache;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
1120
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
1121
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
1122
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
1123
|
+
* the initial live {@link pull}.
|
|
1124
|
+
*
|
|
1125
|
+
* `lastCheckpoint` is intentionally left at 0 so the first live pull sends a
|
|
1126
|
+
* full (re)sync request to the server, not a delta. However, for stores with
|
|
1127
|
+
* a custom conflict resolver (e.g. `createUnionMerge`) the seeded snapshot is
|
|
1128
|
+
* treated as a merge baseline: {@link hasMergeBaseline} returns true, so the
|
|
1129
|
+
* first pull/ingest merges against the seed rather than replacing it wholesale.
|
|
1130
|
+
* This closes the bootstrap window where a short first-pull response (a cache-
|
|
1131
|
+
* fallback on 429/5xx or a momentarily-short concurrent snapshot) would
|
|
1132
|
+
* silently drop items the resolver was configured to preserve. For the default
|
|
1133
|
+
* `deepMerge` resolver the first pull still takes the snapshot wholesale —
|
|
1134
|
+
* behaviour is byte-identical to alpha.36.
|
|
1135
|
+
*
|
|
1136
|
+
* Requires the client to have been built with a `cache`.
|
|
1137
|
+
*/
|
|
1138
|
+
async seedFromCache() {
|
|
1139
|
+
if (this.aborted) return false;
|
|
1140
|
+
const cached = await this.client.peekCache(this.pullPath);
|
|
1141
|
+
if (!cached) return false;
|
|
1142
|
+
let data;
|
|
1143
|
+
try {
|
|
1144
|
+
data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
if (this.aborted) return false;
|
|
1149
|
+
this.localData = data;
|
|
1150
|
+
this.lastHash = cached.hash;
|
|
1151
|
+
this.seeded = true;
|
|
1152
|
+
this.lastFromCache = true;
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
570
1155
|
getCheckpoint() {
|
|
571
1156
|
return this.lastCheckpoint;
|
|
572
1157
|
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
|
|
1160
|
+
* firing a network request. Used by the zustand binding's `mergeResult`
|
|
1161
|
+
* action to absorb a background revalidation result (delivered via
|
|
1162
|
+
* {@link StarfishClientOptions.onRevalidated}) into the store.
|
|
1163
|
+
*
|
|
1164
|
+
* Like {@link pull}, `ingest` conflict-merges the snapshot against the
|
|
1165
|
+
* established baseline via `this.onConflict` when a merge baseline exists
|
|
1166
|
+
* ({@link hasMergeBaseline}) — so a union-merge store does not lose array
|
|
1167
|
+
* items when a revalidation result (e.g. a stale cache-fallback on 429/5xx)
|
|
1168
|
+
* is a shorter snapshot. The baseline is established by either a prior
|
|
1169
|
+
* pull/ingest that advanced `lastCheckpoint`, or by a successful
|
|
1170
|
+
* {@link seedFromCache} for a store with a custom resolver. The first ingest
|
|
1171
|
+
* without such a baseline takes the snapshot wholesale (default `deepMerge`
|
|
1172
|
+
* stores are byte-identical to alpha.36). Sets `lastFromCache = false` (a
|
|
1173
|
+
* revalidation is a live response) so the binding can clear its `stale` flag.
|
|
1174
|
+
*
|
|
1175
|
+
* **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
|
|
1176
|
+
* time the revalidation request was sent and the time it resolves, the
|
|
1177
|
+
* result is from an older document version. Ingesting it would clobber the
|
|
1178
|
+
* user's just-saved edit and reset `lastHash` to a stale server hash
|
|
1179
|
+
* (causing a spurious 409 on the next push). We silently drop the result in
|
|
1180
|
+
* that case — the store's post-push state is already correct.
|
|
1181
|
+
*/
|
|
1182
|
+
async ingest(result) {
|
|
1183
|
+
if (this.aborted) return;
|
|
1184
|
+
if (result.timestamp < this.lastCheckpoint) return;
|
|
1185
|
+
let incoming;
|
|
1186
|
+
if (this.encryptor) {
|
|
1187
|
+
incoming = await this.encryptor.decrypt(result.data);
|
|
1188
|
+
if (this.aborted) return;
|
|
1189
|
+
} else {
|
|
1190
|
+
incoming = result.data;
|
|
1191
|
+
}
|
|
1192
|
+
this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
|
|
1193
|
+
this.lastHash = result.hash;
|
|
1194
|
+
this.lastCheckpoint = result.timestamp;
|
|
1195
|
+
this.lastFromCache = false;
|
|
1196
|
+
}
|
|
573
1197
|
async pull() {
|
|
574
1198
|
if (this.aborted) throw new AbortError();
|
|
575
1199
|
this.logger?.pullStart(this.loggerName);
|
|
@@ -577,17 +1201,16 @@ var SyncManager = class {
|
|
|
577
1201
|
try {
|
|
578
1202
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
579
1203
|
if (this.aborted) throw new AbortError();
|
|
1204
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
1205
|
+
let incoming;
|
|
580
1206
|
if (this.encryptor) {
|
|
581
|
-
|
|
1207
|
+
incoming = await this.encryptor.decrypt(result.data);
|
|
582
1208
|
if (this.aborted) throw new AbortError();
|
|
583
|
-
this.localData = decrypted;
|
|
584
|
-
result.data = decrypted;
|
|
585
|
-
} else if (this.lastCheckpoint > 0) {
|
|
586
|
-
this.localData = deepMerge(this.localData, result.data);
|
|
587
|
-
result.data = this.localData;
|
|
588
1209
|
} else {
|
|
589
|
-
|
|
1210
|
+
incoming = result.data;
|
|
590
1211
|
}
|
|
1212
|
+
this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
|
|
1213
|
+
result.data = this.localData;
|
|
591
1214
|
this.lastHash = result.hash;
|
|
592
1215
|
this.lastCheckpoint = result.timestamp;
|
|
593
1216
|
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
@@ -611,23 +1234,24 @@ var SyncManager = class {
|
|
|
611
1234
|
try {
|
|
612
1235
|
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
613
1236
|
if (this.aborted) throw new AbortError();
|
|
614
|
-
let
|
|
1237
|
+
let author;
|
|
615
1238
|
if (this.signer) {
|
|
616
1239
|
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
617
1240
|
if (this.aborted) throw new AbortError();
|
|
618
|
-
const
|
|
1241
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
1242
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
619
1243
|
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
620
1244
|
if (this.aborted) throw new AbortError();
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
authorSignature: getBase64().encode(sigBytes)
|
|
1245
|
+
author = {
|
|
1246
|
+
[AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
|
|
1247
|
+
[AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
|
|
625
1248
|
};
|
|
626
1249
|
}
|
|
627
1250
|
const result = await this.client.push(
|
|
628
1251
|
this.pushPath,
|
|
629
|
-
|
|
630
|
-
this.lastHash
|
|
1252
|
+
sealed,
|
|
1253
|
+
this.lastHash,
|
|
1254
|
+
author
|
|
631
1255
|
);
|
|
632
1256
|
if (this.aborted) throw new AbortError();
|
|
633
1257
|
this.lastHash = result.hash;
|
|
@@ -742,6 +1366,38 @@ function createStarfishStore(options) {
|
|
|
742
1366
|
const { name, syncManager, storage } = options;
|
|
743
1367
|
const storeCreator = (rawSet, get) => {
|
|
744
1368
|
const set = rawSet;
|
|
1369
|
+
let retryTimer;
|
|
1370
|
+
let retryAttempt = 0;
|
|
1371
|
+
const scheduleFlushRetry = () => {
|
|
1372
|
+
const retryOpts = options.flushRetry;
|
|
1373
|
+
if (!retryOpts) return;
|
|
1374
|
+
const maxRetries = retryOpts.maxRetries ?? 5;
|
|
1375
|
+
if (retryAttempt >= maxRetries) return;
|
|
1376
|
+
const initialMs = retryOpts.initialDelayMs ?? 500;
|
|
1377
|
+
const maxMs = retryOpts.maxDelayMs ?? 3e4;
|
|
1378
|
+
const delayMs = Math.min(initialMs * Math.pow(2, retryAttempt), maxMs) + Math.random() * 100;
|
|
1379
|
+
retryAttempt++;
|
|
1380
|
+
clearTimeout(retryTimer);
|
|
1381
|
+
retryTimer = setTimeout(() => {
|
|
1382
|
+
if (get().dirty && get().online && !get().syncing) {
|
|
1383
|
+
get().flush().catch(() => {
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
}, delayMs);
|
|
1387
|
+
};
|
|
1388
|
+
const cancelFlushRetry = () => {
|
|
1389
|
+
clearTimeout(retryTimer);
|
|
1390
|
+
retryTimer = void 0;
|
|
1391
|
+
retryAttempt = 0;
|
|
1392
|
+
};
|
|
1393
|
+
const commitRemote = (label) => {
|
|
1394
|
+
const remote = syncManager.getData();
|
|
1395
|
+
const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
|
|
1396
|
+
set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, label);
|
|
1397
|
+
if (get().online && get().dirty) get().flush().catch(() => {
|
|
1398
|
+
});
|
|
1399
|
+
options.onRemoteUpdate?.(newData);
|
|
1400
|
+
};
|
|
745
1401
|
return {
|
|
746
1402
|
data: {},
|
|
747
1403
|
syncing: false,
|
|
@@ -749,20 +1405,38 @@ function createStarfishStore(options) {
|
|
|
749
1405
|
dirty: false,
|
|
750
1406
|
error: null,
|
|
751
1407
|
hash: null,
|
|
1408
|
+
stale: false,
|
|
1409
|
+
seed: async () => {
|
|
1410
|
+
try {
|
|
1411
|
+
const seeded = await syncManager.seedFromCache();
|
|
1412
|
+
if (!seeded) return;
|
|
1413
|
+
if (get().dirty || Object.keys(get().data).length > 0) return;
|
|
1414
|
+
set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
|
|
1415
|
+
} catch {
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
752
1418
|
pull: async () => {
|
|
753
|
-
set({ syncing: true, error: null }, false, "pull/start");
|
|
1419
|
+
set(get().stale ? { error: null } : { syncing: true, error: null }, false, "pull/start");
|
|
754
1420
|
try {
|
|
755
1421
|
await syncManager.pull();
|
|
756
|
-
|
|
757
|
-
set({ data: newData, syncing: false, hash: syncManager.getHash() }, false, "pull/success");
|
|
758
|
-
options.onRemoteUpdate?.(newData);
|
|
1422
|
+
commitRemote("pull/success");
|
|
759
1423
|
} catch (err) {
|
|
1424
|
+
if (classifyError(err) === "network") {
|
|
1425
|
+
set({ syncing: false, stale: true }, false, "pull/offline");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
760
1428
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
761
1429
|
}
|
|
762
1430
|
},
|
|
1431
|
+
mergeResult: async (result) => {
|
|
1432
|
+
await syncManager.ingest(result);
|
|
1433
|
+
commitRemote("merge/success");
|
|
1434
|
+
},
|
|
763
1435
|
set: (modifier) => {
|
|
764
1436
|
try {
|
|
765
1437
|
const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
|
|
1438
|
+
retryAttempt = 0;
|
|
1439
|
+
clearTimeout(retryTimer);
|
|
766
1440
|
set({ data: next, dirty: true, error: null }, false, "set");
|
|
767
1441
|
if (get().online) get().flush().catch(() => {
|
|
768
1442
|
});
|
|
@@ -778,15 +1452,22 @@ function createStarfishStore(options) {
|
|
|
778
1452
|
set({ syncing: true, error: null }, false, "flush/start");
|
|
779
1453
|
try {
|
|
780
1454
|
await syncManager.push(get().data);
|
|
781
|
-
|
|
1455
|
+
cancelFlushRetry();
|
|
1456
|
+
set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
|
|
782
1457
|
} catch (err) {
|
|
1458
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "AbortError");
|
|
783
1459
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
1460
|
+
if (!isAbort) scheduleFlushRetry();
|
|
784
1461
|
}
|
|
785
1462
|
},
|
|
786
1463
|
setOnline: (online) => {
|
|
787
1464
|
set({ online }, false, "setOnline");
|
|
788
|
-
if (online && get().dirty)
|
|
789
|
-
|
|
1465
|
+
if (online && get().dirty) {
|
|
1466
|
+
get().flush().catch(() => {
|
|
1467
|
+
});
|
|
1468
|
+
} else if (!online) {
|
|
1469
|
+
cancelFlushRetry();
|
|
1470
|
+
}
|
|
790
1471
|
}
|
|
791
1472
|
};
|
|
792
1473
|
};
|
|
@@ -824,6 +1505,9 @@ function aggregateSyncStatus(statuses) {
|
|
|
824
1505
|
function useStarfish(store) {
|
|
825
1506
|
return useStore(store);
|
|
826
1507
|
}
|
|
1508
|
+
function useStarfishState(store, selector) {
|
|
1509
|
+
return useStore(store, selector);
|
|
1510
|
+
}
|
|
827
1511
|
function useStarfishData(store, selector) {
|
|
828
1512
|
return useStore(
|
|
829
1513
|
store,
|
|
@@ -884,7 +1568,7 @@ function useLastSynced(store) {
|
|
|
884
1568
|
}, [store, computeLabel]);
|
|
885
1569
|
useEffect(() => {
|
|
886
1570
|
const timer = setInterval(() => {
|
|
887
|
-
setLabel(computeLabel());
|
|
1571
|
+
if (!document.hidden) setLabel(computeLabel());
|
|
888
1572
|
}, 5e3);
|
|
889
1573
|
return () => clearInterval(timer);
|
|
890
1574
|
}, [computeLabel]);
|
|
@@ -895,14 +1579,24 @@ function useSyncInit(config) {
|
|
|
895
1579
|
const onDataRef = useRef(config?.onData);
|
|
896
1580
|
onDataRef.current = config?.onData;
|
|
897
1581
|
useEffect(() => {
|
|
898
|
-
if (!config)
|
|
899
|
-
setStore(null);
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
1582
|
+
if (!config) return;
|
|
902
1583
|
const client = new StarfishClient({
|
|
903
1584
|
baseUrl: config.serverUrl,
|
|
1585
|
+
namespace: config.namespace,
|
|
904
1586
|
capProvider: config.capProvider,
|
|
905
|
-
fetch: config.fetch
|
|
1587
|
+
fetch: config.fetch,
|
|
1588
|
+
cache: config.cache,
|
|
1589
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs,
|
|
1590
|
+
cacheFallbackStatuses: config.cacheFallbackStatuses,
|
|
1591
|
+
// Auto-merge: when a background revalidation delivers a fresh snapshot,
|
|
1592
|
+
// push it into the store so the UI heals without waiting for the next pull.
|
|
1593
|
+
// newStore is referenced by closure — safe because onRevalidated only fires
|
|
1594
|
+
// asynchronously, well after the store is created below.
|
|
1595
|
+
onRevalidated: (path, result) => {
|
|
1596
|
+
newStore.getState().mergeResult(result).catch(() => {
|
|
1597
|
+
});
|
|
1598
|
+
config.onRevalidated?.(path, result);
|
|
1599
|
+
}
|
|
906
1600
|
});
|
|
907
1601
|
const syncManager = new SyncManager({
|
|
908
1602
|
client,
|
|
@@ -930,7 +1624,9 @@ function useSyncInit(config) {
|
|
|
930
1624
|
}
|
|
931
1625
|
});
|
|
932
1626
|
setStore(newStore);
|
|
933
|
-
newStore.getState().
|
|
1627
|
+
newStore.getState().seed().finally(() => {
|
|
1628
|
+
newStore.getState().pull().catch(() => {
|
|
1629
|
+
});
|
|
934
1630
|
});
|
|
935
1631
|
return () => {
|
|
936
1632
|
setStore(null);
|
|
@@ -942,18 +1638,182 @@ function useSyncInit(config) {
|
|
|
942
1638
|
config?.encryptor,
|
|
943
1639
|
config?.storeName
|
|
944
1640
|
]);
|
|
1641
|
+
return config ? store : null;
|
|
1642
|
+
}
|
|
1643
|
+
var _syncStoreRegistry = /* @__PURE__ */ new Map();
|
|
1644
|
+
function acquireSyncStore(config) {
|
|
1645
|
+
const existing = _syncStoreRegistry.get(config.storeName);
|
|
1646
|
+
if (existing) {
|
|
1647
|
+
existing.refCount += 1;
|
|
1648
|
+
return existing.store;
|
|
1649
|
+
}
|
|
1650
|
+
const client = new StarfishClient({
|
|
1651
|
+
baseUrl: config.serverUrl,
|
|
1652
|
+
namespace: config.namespace,
|
|
1653
|
+
capProvider: config.capProvider,
|
|
1654
|
+
fetch: config.fetch,
|
|
1655
|
+
cache: config.cache,
|
|
1656
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs,
|
|
1657
|
+
cacheFallbackStatuses: config.cacheFallbackStatuses,
|
|
1658
|
+
// Auto-merge: push fresh revalidated snapshots into the store.
|
|
1659
|
+
// store is referenced by closure — safe because onRevalidated only fires
|
|
1660
|
+
// asynchronously, well after the store is created below.
|
|
1661
|
+
onRevalidated: (path, result) => {
|
|
1662
|
+
store.getState().mergeResult(result).catch(() => {
|
|
1663
|
+
});
|
|
1664
|
+
config.onRevalidated?.(path, result);
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
const syncManager = new SyncManager({
|
|
1668
|
+
client,
|
|
1669
|
+
pullPath: config.pullPath,
|
|
1670
|
+
pushPath: config.pushPath,
|
|
1671
|
+
encryptor: config.encryptor,
|
|
1672
|
+
onConflict: config.onConflict,
|
|
1673
|
+
logger: config.logger,
|
|
1674
|
+
validate: config.validate
|
|
1675
|
+
});
|
|
1676
|
+
const store = createStarfishStore({
|
|
1677
|
+
name: config.storeName,
|
|
1678
|
+
syncManager,
|
|
1679
|
+
storage: config.storage
|
|
1680
|
+
// No onRemoteUpdate: consumers subscribe via store.subscribe() — see module comment.
|
|
1681
|
+
});
|
|
1682
|
+
const entry = { store, refCount: 1 };
|
|
1683
|
+
_syncStoreRegistry.set(config.storeName, entry);
|
|
1684
|
+
store.getState().seed().finally(() => {
|
|
1685
|
+
if (_syncStoreRegistry.get(config.storeName) === entry) {
|
|
1686
|
+
store.getState().pull().catch(() => {
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
945
1690
|
return store;
|
|
946
1691
|
}
|
|
1692
|
+
function releaseSyncStore(storeName) {
|
|
1693
|
+
const entry = _syncStoreRegistry.get(storeName);
|
|
1694
|
+
if (!entry) return;
|
|
1695
|
+
entry.refCount -= 1;
|
|
1696
|
+
if (entry.refCount <= 0) _syncStoreRegistry.delete(storeName);
|
|
1697
|
+
}
|
|
1698
|
+
function clearSyncStoreRegistry() {
|
|
1699
|
+
_syncStoreRegistry.clear();
|
|
1700
|
+
}
|
|
1701
|
+
function useSharedSyncStore(config) {
|
|
1702
|
+
const [store, setStore] = useState(null);
|
|
1703
|
+
const storeName = config?.storeName ?? null;
|
|
1704
|
+
const configRef = useRef(config);
|
|
1705
|
+
configRef.current = config;
|
|
1706
|
+
useEffect(() => {
|
|
1707
|
+
if (!storeName) return;
|
|
1708
|
+
const acquired = acquireSyncStore(configRef.current);
|
|
1709
|
+
setStore(acquired);
|
|
1710
|
+
return () => {
|
|
1711
|
+
releaseSyncStore(storeName);
|
|
1712
|
+
setStore(null);
|
|
1713
|
+
};
|
|
1714
|
+
}, [storeName]);
|
|
1715
|
+
return storeName ? store : null;
|
|
1716
|
+
}
|
|
1717
|
+
function createStarfishLog(options) {
|
|
1718
|
+
const { cursor } = options;
|
|
1719
|
+
const storeCreator = (rawSet, get) => {
|
|
1720
|
+
const set = rawSet;
|
|
1721
|
+
return {
|
|
1722
|
+
// Seed from the cursor so a warm-started cursor's items show immediately.
|
|
1723
|
+
items: cursor.getItems(),
|
|
1724
|
+
loading: false,
|
|
1725
|
+
online: true,
|
|
1726
|
+
error: null,
|
|
1727
|
+
checkpoint: cursor.getCheckpoint(),
|
|
1728
|
+
pull: async () => {
|
|
1729
|
+
if (get().loading) return [];
|
|
1730
|
+
set({ loading: true, error: null }, false, "log/pull/start");
|
|
1731
|
+
try {
|
|
1732
|
+
const batch = await cursor.pull();
|
|
1733
|
+
set(
|
|
1734
|
+
{ items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
|
|
1735
|
+
false,
|
|
1736
|
+
"log/pull/success"
|
|
1737
|
+
);
|
|
1738
|
+
return batch;
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
|
|
1741
|
+
return [];
|
|
1742
|
+
}
|
|
1743
|
+
},
|
|
1744
|
+
setOnline: (online) => {
|
|
1745
|
+
set({ online }, false, "log/setOnline");
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
const withSelector = subscribeWithSelector(storeCreator);
|
|
1750
|
+
return createStore()(
|
|
1751
|
+
options.devtools ? options.devtools(withSelector) : withSelector
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
function deriveLogStatus(state) {
|
|
1755
|
+
if (!state.online) return "offline";
|
|
1756
|
+
if (state.error) return "error";
|
|
1757
|
+
if (state.loading) return "loading";
|
|
1758
|
+
return "idle";
|
|
1759
|
+
}
|
|
1760
|
+
function useStarfishLog(store) {
|
|
1761
|
+
return useStore(store);
|
|
1762
|
+
}
|
|
1763
|
+
function useStarfishLogItems(store, selector) {
|
|
1764
|
+
return useStore(
|
|
1765
|
+
store,
|
|
1766
|
+
(state) => selector ? selector(state.items) : state.items
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
function useLogStatus(store) {
|
|
1770
|
+
return useStore(store, deriveLogStatus);
|
|
1771
|
+
}
|
|
1772
|
+
function subscribeLogStatus(store, callback) {
|
|
1773
|
+
let prev = deriveLogStatus(store.getState());
|
|
1774
|
+
callback(prev);
|
|
1775
|
+
return store.subscribe((state) => {
|
|
1776
|
+
const next = deriveLogStatus(state);
|
|
1777
|
+
if (next !== prev) {
|
|
1778
|
+
prev = next;
|
|
1779
|
+
callback(next);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
function useLogConnectivity(store) {
|
|
1784
|
+
useEffect(() => {
|
|
1785
|
+
const handleOnline = () => store.getState().setOnline(true);
|
|
1786
|
+
const handleOffline = () => store.getState().setOnline(false);
|
|
1787
|
+
window.addEventListener("online", handleOnline);
|
|
1788
|
+
window.addEventListener("offline", handleOffline);
|
|
1789
|
+
return () => {
|
|
1790
|
+
window.removeEventListener("online", handleOnline);
|
|
1791
|
+
window.removeEventListener("offline", handleOffline);
|
|
1792
|
+
};
|
|
1793
|
+
}, [store]);
|
|
1794
|
+
}
|
|
947
1795
|
export {
|
|
1796
|
+
acquireSyncStore,
|
|
948
1797
|
aggregateSyncStatus,
|
|
1798
|
+
clearSyncStoreRegistry,
|
|
1799
|
+
createStarfishLog,
|
|
949
1800
|
createStarfishStore,
|
|
1801
|
+
deriveLogStatus,
|
|
950
1802
|
deriveSyncStatus,
|
|
1803
|
+
releaseSyncStore,
|
|
1804
|
+
subscribeLogStatus,
|
|
951
1805
|
subscribeSyncStatus,
|
|
952
1806
|
useConnectivity,
|
|
953
1807
|
useCrossTabSync,
|
|
954
1808
|
useLastSynced,
|
|
1809
|
+
useLogConnectivity,
|
|
1810
|
+
useLogStatus,
|
|
1811
|
+
useSharedSyncStore,
|
|
955
1812
|
useStarfish,
|
|
956
1813
|
useStarfishData,
|
|
1814
|
+
useStarfishLog,
|
|
1815
|
+
useStarfishLogItems,
|
|
1816
|
+
useStarfishState,
|
|
957
1817
|
useSyncInit,
|
|
958
1818
|
useSyncStatus
|
|
959
1819
|
};
|