@drakkar.software/starfish-client 3.0.0-alpha.1 → 3.0.0-alpha.11
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 +27 -0
- package/dist/append-log.d.ts +230 -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/zustand.d.ts +45 -0
- package/dist/bindings/zustand.js +312 -47
- package/dist/bindings/zustand.js.map +3 -3
- package/dist/client.d.ts +112 -3
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +458 -49
- package/dist/index.js.map +4 -4
- package/dist/logger.d.ts +3 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/types.d.ts +28 -1
- package/package.json +2 -2
package/dist/bindings/zustand.js
CHANGED
|
@@ -230,6 +230,22 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|
|
230
230
|
|
|
231
231
|
// src/client.ts
|
|
232
232
|
import {
|
|
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_ALG,
|
|
244
|
+
HEADER_PUB,
|
|
245
|
+
HEADER_CONTENT_TYPE,
|
|
246
|
+
HEADER_ACCEPT,
|
|
247
|
+
DEFAULT_ALG,
|
|
248
|
+
signAppendAuthor,
|
|
233
249
|
signRequest,
|
|
234
250
|
stableStringify
|
|
235
251
|
} from "@drakkar.software/starfish-protocol";
|
|
@@ -252,6 +268,9 @@ var StarfishHttpError = class extends Error {
|
|
|
252
268
|
|
|
253
269
|
// src/client.ts
|
|
254
270
|
var APPEND_DEFAULT_FIELD = "items";
|
|
271
|
+
function stripPushPrefix(path) {
|
|
272
|
+
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
273
|
+
}
|
|
255
274
|
function encodeCapAuth(cap) {
|
|
256
275
|
const json = stableStringify(cap);
|
|
257
276
|
if (typeof btoa === "function") {
|
|
@@ -263,6 +282,7 @@ function encodeCapAuth(cap) {
|
|
|
263
282
|
}
|
|
264
283
|
var StarfishClient = class {
|
|
265
284
|
baseUrl;
|
|
285
|
+
namespace;
|
|
266
286
|
capProvider;
|
|
267
287
|
fetch;
|
|
268
288
|
/**
|
|
@@ -272,6 +292,7 @@ var StarfishClient = class {
|
|
|
272
292
|
plugins;
|
|
273
293
|
constructor(options) {
|
|
274
294
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
295
|
+
this.namespace = options.namespace || void 0;
|
|
275
296
|
this.capProvider = options.capProvider;
|
|
276
297
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
277
298
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
@@ -295,6 +316,20 @@ var StarfishClient = class {
|
|
|
295
316
|
return "";
|
|
296
317
|
}
|
|
297
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Rewrite a request path for the configured namespace. A no-op when no
|
|
321
|
+
* namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
|
|
322
|
+
* (the `/v1` protocol-version segment is part of the namespaced route, matching
|
|
323
|
+
* the Python client and the server's namespace mount).
|
|
324
|
+
*
|
|
325
|
+
* Applied to the path used for BOTH the signature and the URL so the canonical
|
|
326
|
+
* path the client signs equals the path the server reconstructs from the URL.
|
|
327
|
+
* Covers SDK-helper-built paths too — that's the point: a namespace-unaware
|
|
328
|
+
* helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
|
|
329
|
+
*/
|
|
330
|
+
applyNamespace(path) {
|
|
331
|
+
return this.namespace ? `/v1/${this.namespace}${path}` : path;
|
|
332
|
+
}
|
|
298
333
|
/**
|
|
299
334
|
* Build auth headers for a request. When a `capProvider` is set, signs the
|
|
300
335
|
* request with the device's Ed25519 private key and returns the v3 header
|
|
@@ -306,28 +341,57 @@ var StarfishClient = class {
|
|
|
306
341
|
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
307
342
|
*/
|
|
308
343
|
async buildAuthHeaders(method, pathAndQuery, body) {
|
|
309
|
-
if (this.capProvider) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
344
|
+
if (!this.capProvider) return {};
|
|
345
|
+
const capCtx = await this.capProvider.getCap();
|
|
346
|
+
return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
350
|
+
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
351
|
+
* reuse it for BOTH the author signature (over the element data) and the
|
|
352
|
+
* request signature (over the body), without redeeming the cap twice — a
|
|
353
|
+
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
354
|
+
* presenter` bind the server checks.
|
|
355
|
+
*/
|
|
356
|
+
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
357
|
+
const { cap, devEdPrivHex, pubHex, presenterAlg } = capCtx;
|
|
358
|
+
const req = {
|
|
359
|
+
method,
|
|
360
|
+
pathAndQuery,
|
|
361
|
+
body,
|
|
362
|
+
host: this.signingHost()
|
|
363
|
+
};
|
|
364
|
+
const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
|
|
365
|
+
const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
|
|
366
|
+
alg: signAlg
|
|
367
|
+
});
|
|
368
|
+
const headers = {
|
|
369
|
+
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
370
|
+
[HEADER_SIG]: sig,
|
|
371
|
+
[HEADER_TS]: String(ts),
|
|
372
|
+
[HEADER_NONCE]: nonce,
|
|
373
|
+
[HEADER_ALG]: alg
|
|
374
|
+
};
|
|
375
|
+
if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
|
|
376
|
+
return headers;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
380
|
+
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
381
|
+
* device/member cap. This is the SAME key that signs the request, so a server
|
|
382
|
+
* enforcing author proof can bind the stored element to its writer. Returns
|
|
383
|
+
* undefined only for a (malformed) cap with neither — the append then goes
|
|
384
|
+
* unsigned and a server requiring signatures rejects it.
|
|
385
|
+
*/
|
|
386
|
+
appendAuthorKey(capCtx) {
|
|
387
|
+
const { cap, pubHex, presenterAlg } = capCtx;
|
|
388
|
+
const authorPubHex = pubHex ?? cap.sub;
|
|
389
|
+
if (authorPubHex === void 0) return null;
|
|
390
|
+
const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
|
|
391
|
+
return { authorPubHex, signAlg };
|
|
328
392
|
}
|
|
329
393
|
async pull(path, checkpointOrOptions) {
|
|
330
|
-
let pathAndQuery = path;
|
|
394
|
+
let pathAndQuery = this.applyNamespace(path);
|
|
331
395
|
let appendField;
|
|
332
396
|
if (typeof checkpointOrOptions === "number") {
|
|
333
397
|
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
@@ -359,7 +423,7 @@ var StarfishClient = class {
|
|
|
359
423
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
360
424
|
const res = await this.fetch(url, {
|
|
361
425
|
method: "GET",
|
|
362
|
-
headers: {
|
|
426
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
363
427
|
});
|
|
364
428
|
if (!res.ok) {
|
|
365
429
|
throw new StarfishHttpError(res.status, await res.text());
|
|
@@ -371,27 +435,78 @@ var StarfishClient = class {
|
|
|
371
435
|
}
|
|
372
436
|
return result;
|
|
373
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
440
|
+
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
441
|
+
* an ARRAY of path-param sets — one per document to read — so the SAME collection
|
|
442
|
+
* can fan in many documents (e.g. many users' `profile`) in a single request.
|
|
443
|
+
* The server auto-fills the `{identity}` param from the authenticated caller for
|
|
444
|
+
* any set that omits it, so a self-doc collection needs no params. Returns a map
|
|
445
|
+
* of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
|
|
446
|
+
* in request order. Honors the configured namespace.
|
|
447
|
+
*
|
|
448
|
+
* For the common "many docs of one collection" case prefer {@link batchPullMany}.
|
|
449
|
+
*
|
|
450
|
+
* Note: not append/checkpoint-aware — for incremental append-only reads use
|
|
451
|
+
* `pull(path, { since })` (or `AppendLogCursor`) per collection.
|
|
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
|
+
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
460
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
461
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
462
|
+
const res = await this.fetch(url, {
|
|
463
|
+
method: "GET",
|
|
464
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
465
|
+
});
|
|
466
|
+
if (!res.ok) {
|
|
467
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
468
|
+
}
|
|
469
|
+
return await res.json();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Convenience over {@link batchPull} for reading MANY documents of ONE
|
|
473
|
+
* collection in a single round-trip: pass the per-document param-sets and get
|
|
474
|
+
* back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
|
|
475
|
+
* entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
|
|
476
|
+
* issues no request and returns `[]`.
|
|
477
|
+
*/
|
|
478
|
+
async batchPullMany(collection, paramsList) {
|
|
479
|
+
if (paramsList.length === 0) return [];
|
|
480
|
+
const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
|
|
481
|
+
return res.collections[collection] ?? [];
|
|
482
|
+
}
|
|
374
483
|
/**
|
|
375
484
|
* Push synced data to the server.
|
|
376
485
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
377
486
|
* @param data - The full document data to push
|
|
378
487
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
379
488
|
*
|
|
380
|
-
* v3 author
|
|
381
|
-
*
|
|
489
|
+
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
490
|
+
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
491
|
+
* top-level body siblings of `data`, where the server verifies it.
|
|
382
492
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
383
493
|
*/
|
|
384
|
-
async push(path, data, baseHash) {
|
|
494
|
+
async push(path, data, baseHash, author) {
|
|
385
495
|
const body = JSON.stringify({
|
|
386
|
-
data,
|
|
387
|
-
baseHash
|
|
496
|
+
[DATA_FIELD]: data,
|
|
497
|
+
[BASE_HASH_FIELD]: baseHash,
|
|
498
|
+
...author && {
|
|
499
|
+
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
500
|
+
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature
|
|
501
|
+
}
|
|
388
502
|
});
|
|
389
|
-
const
|
|
390
|
-
const
|
|
503
|
+
const sendPath = this.applyNamespace(path);
|
|
504
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
|
|
505
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
391
506
|
method: "POST",
|
|
392
507
|
headers: {
|
|
393
|
-
|
|
394
|
-
|
|
508
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
509
|
+
[HEADER_ACCEPT]: "application/json",
|
|
395
510
|
...authHeaders
|
|
396
511
|
},
|
|
397
512
|
body
|
|
@@ -404,21 +519,77 @@ var StarfishClient = class {
|
|
|
404
519
|
}
|
|
405
520
|
return res.json();
|
|
406
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Append an element to an appendOnly (`by_timestamp`) collection.
|
|
524
|
+
*
|
|
525
|
+
* Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
|
|
526
|
+
* authorized append is always accepted. Each element is stored server-side as
|
|
527
|
+
* `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
|
|
528
|
+
*
|
|
529
|
+
* @param path - the push endpoint (e.g. "/push/events")
|
|
530
|
+
* @param data - the element payload. For a `delegated` collection, encrypt it
|
|
531
|
+
* first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
|
|
532
|
+
* server stores it opaquely and never reads it.
|
|
533
|
+
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
534
|
+
* non-negative integer strictly greater than the latest stored element's ts
|
|
535
|
+
* (else the server responds 409). Omit to let the server assign one.
|
|
536
|
+
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
537
|
+
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
538
|
+
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
539
|
+
* cap is reached (partition by a path parameter for higher volume).
|
|
540
|
+
*/
|
|
541
|
+
async append(path, data, opts = {}) {
|
|
542
|
+
const sendPath = this.applyNamespace(path);
|
|
543
|
+
const bodyObj = { [DATA_FIELD]: data };
|
|
544
|
+
if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
|
|
545
|
+
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
546
|
+
if (capCtx) {
|
|
547
|
+
const authorKey = this.appendAuthorKey(capCtx);
|
|
548
|
+
if (authorKey) {
|
|
549
|
+
const documentKey = stripPushPrefix(path);
|
|
550
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
551
|
+
documentKey,
|
|
552
|
+
data,
|
|
553
|
+
authorKey.authorPubHex,
|
|
554
|
+
capCtx.devEdPrivHex,
|
|
555
|
+
authorKey.signAlg
|
|
556
|
+
);
|
|
557
|
+
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
558
|
+
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const body = JSON.stringify(bodyObj);
|
|
562
|
+
const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
|
|
563
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: {
|
|
566
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
567
|
+
[HEADER_ACCEPT]: "application/json",
|
|
568
|
+
...authHeaders
|
|
569
|
+
},
|
|
570
|
+
body
|
|
571
|
+
});
|
|
572
|
+
if (!res.ok) {
|
|
573
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
574
|
+
}
|
|
575
|
+
return res.json();
|
|
576
|
+
}
|
|
407
577
|
/**
|
|
408
578
|
* Pull binary data from a blob collection.
|
|
409
579
|
* Returns raw bytes with the content hash from the ETag header.
|
|
410
580
|
*/
|
|
411
581
|
async pullBlob(path) {
|
|
412
|
-
const
|
|
413
|
-
const
|
|
582
|
+
const sendPath = this.applyNamespace(path);
|
|
583
|
+
const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
|
|
584
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
414
585
|
method: "GET",
|
|
415
|
-
headers: {
|
|
586
|
+
headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
|
|
416
587
|
});
|
|
417
588
|
if (!res.ok) {
|
|
418
589
|
throw new StarfishHttpError(res.status, await res.text());
|
|
419
590
|
}
|
|
420
591
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
421
|
-
const contentType = res.headers.get(
|
|
592
|
+
const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
|
|
422
593
|
const data = await res.arrayBuffer();
|
|
423
594
|
return { data, hash: etag, contentType };
|
|
424
595
|
}
|
|
@@ -427,12 +598,13 @@ var StarfishClient = class {
|
|
|
427
598
|
* Binary collections use last-write-wins (no conflict detection).
|
|
428
599
|
*/
|
|
429
600
|
async pushBlob(path, data, contentType) {
|
|
430
|
-
const
|
|
431
|
-
const
|
|
601
|
+
const sendPath = this.applyNamespace(path);
|
|
602
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
|
|
603
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
432
604
|
method: "POST",
|
|
433
605
|
headers: {
|
|
434
|
-
|
|
435
|
-
|
|
606
|
+
[HEADER_CONTENT_TYPE]: contentType,
|
|
607
|
+
[HEADER_ACCEPT]: "application/json",
|
|
436
608
|
...authHeaders
|
|
437
609
|
},
|
|
438
610
|
body: data
|
|
@@ -445,7 +617,13 @@ var StarfishClient = class {
|
|
|
445
617
|
};
|
|
446
618
|
|
|
447
619
|
// src/sync.ts
|
|
448
|
-
import {
|
|
620
|
+
import {
|
|
621
|
+
AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
|
|
622
|
+
AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
|
|
623
|
+
deepMerge,
|
|
624
|
+
docAuthorCanonicalInput,
|
|
625
|
+
getBase64
|
|
626
|
+
} from "@drakkar.software/starfish-protocol";
|
|
449
627
|
|
|
450
628
|
// src/validate.ts
|
|
451
629
|
var ValidationError = class extends Error {
|
|
@@ -550,23 +728,24 @@ var SyncManager = class {
|
|
|
550
728
|
try {
|
|
551
729
|
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
552
730
|
if (this.aborted) throw new AbortError();
|
|
553
|
-
let
|
|
731
|
+
let author;
|
|
554
732
|
if (this.signer) {
|
|
555
733
|
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
556
734
|
if (this.aborted) throw new AbortError();
|
|
557
|
-
const
|
|
735
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
736
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
558
737
|
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
559
738
|
if (this.aborted) throw new AbortError();
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
authorSignature: getBase64().encode(sigBytes)
|
|
739
|
+
author = {
|
|
740
|
+
[AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
|
|
741
|
+
[AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
|
|
564
742
|
};
|
|
565
743
|
}
|
|
566
744
|
const result = await this.client.push(
|
|
567
745
|
this.pushPath,
|
|
568
|
-
|
|
569
|
-
this.lastHash
|
|
746
|
+
sealed,
|
|
747
|
+
this.lastHash,
|
|
748
|
+
author
|
|
570
749
|
);
|
|
571
750
|
if (this.aborted) throw new AbortError();
|
|
572
751
|
this.lastHash = result.hash;
|
|
@@ -840,6 +1019,7 @@ function useSyncInit(config) {
|
|
|
840
1019
|
}
|
|
841
1020
|
const client = new StarfishClient({
|
|
842
1021
|
baseUrl: config.serverUrl,
|
|
1022
|
+
namespace: config.namespace,
|
|
843
1023
|
capProvider: config.capProvider,
|
|
844
1024
|
fetch: config.fetch
|
|
845
1025
|
});
|
|
@@ -883,16 +1063,101 @@ function useSyncInit(config) {
|
|
|
883
1063
|
]);
|
|
884
1064
|
return store;
|
|
885
1065
|
}
|
|
1066
|
+
function createStarfishLog(options) {
|
|
1067
|
+
const { cursor } = options;
|
|
1068
|
+
const storeCreator = (rawSet, get) => {
|
|
1069
|
+
const set = rawSet;
|
|
1070
|
+
return {
|
|
1071
|
+
// Seed from the cursor so a warm-started cursor's items show immediately.
|
|
1072
|
+
items: cursor.getItems(),
|
|
1073
|
+
loading: false,
|
|
1074
|
+
online: true,
|
|
1075
|
+
error: null,
|
|
1076
|
+
checkpoint: cursor.getCheckpoint(),
|
|
1077
|
+
pull: async () => {
|
|
1078
|
+
if (get().loading) return [];
|
|
1079
|
+
set({ loading: true, error: null }, false, "log/pull/start");
|
|
1080
|
+
try {
|
|
1081
|
+
const batch = await cursor.pull();
|
|
1082
|
+
set(
|
|
1083
|
+
{ items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
|
|
1084
|
+
false,
|
|
1085
|
+
"log/pull/success"
|
|
1086
|
+
);
|
|
1087
|
+
return batch;
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
|
|
1090
|
+
return [];
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
setOnline: (online) => {
|
|
1094
|
+
set({ online }, false, "log/setOnline");
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
};
|
|
1098
|
+
const withSelector = subscribeWithSelector(storeCreator);
|
|
1099
|
+
return createStore()(
|
|
1100
|
+
options.devtools ? options.devtools(withSelector) : withSelector
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
function deriveLogStatus(state) {
|
|
1104
|
+
if (!state.online) return "offline";
|
|
1105
|
+
if (state.error) return "error";
|
|
1106
|
+
if (state.loading) return "loading";
|
|
1107
|
+
return "idle";
|
|
1108
|
+
}
|
|
1109
|
+
function useStarfishLog(store) {
|
|
1110
|
+
return useStore(store);
|
|
1111
|
+
}
|
|
1112
|
+
function useStarfishLogItems(store, selector) {
|
|
1113
|
+
return useStore(
|
|
1114
|
+
store,
|
|
1115
|
+
(state) => selector ? selector(state.items) : state.items
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
function useLogStatus(store) {
|
|
1119
|
+
return useStore(store, deriveLogStatus);
|
|
1120
|
+
}
|
|
1121
|
+
function subscribeLogStatus(store, callback) {
|
|
1122
|
+
let prev = deriveLogStatus(store.getState());
|
|
1123
|
+
callback(prev);
|
|
1124
|
+
return store.subscribe((state) => {
|
|
1125
|
+
const next = deriveLogStatus(state);
|
|
1126
|
+
if (next !== prev) {
|
|
1127
|
+
prev = next;
|
|
1128
|
+
callback(next);
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
function useLogConnectivity(store) {
|
|
1133
|
+
useEffect(() => {
|
|
1134
|
+
const handleOnline = () => store.getState().setOnline(true);
|
|
1135
|
+
const handleOffline = () => store.getState().setOnline(false);
|
|
1136
|
+
window.addEventListener("online", handleOnline);
|
|
1137
|
+
window.addEventListener("offline", handleOffline);
|
|
1138
|
+
return () => {
|
|
1139
|
+
window.removeEventListener("online", handleOnline);
|
|
1140
|
+
window.removeEventListener("offline", handleOffline);
|
|
1141
|
+
};
|
|
1142
|
+
}, [store]);
|
|
1143
|
+
}
|
|
886
1144
|
export {
|
|
887
1145
|
aggregateSyncStatus,
|
|
1146
|
+
createStarfishLog,
|
|
888
1147
|
createStarfishStore,
|
|
1148
|
+
deriveLogStatus,
|
|
889
1149
|
deriveSyncStatus,
|
|
1150
|
+
subscribeLogStatus,
|
|
890
1151
|
subscribeSyncStatus,
|
|
891
1152
|
useConnectivity,
|
|
892
1153
|
useCrossTabSync,
|
|
893
1154
|
useLastSynced,
|
|
1155
|
+
useLogConnectivity,
|
|
1156
|
+
useLogStatus,
|
|
894
1157
|
useStarfish,
|
|
895
1158
|
useStarfishData,
|
|
1159
|
+
useStarfishLog,
|
|
1160
|
+
useStarfishLogItems,
|
|
896
1161
|
useSyncInit,
|
|
897
1162
|
useSyncStatus
|
|
898
1163
|
};
|