@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.21
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 +44 -0
- package/dist/append-log.d.ts +228 -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 +72 -1
- package/dist/bindings/zustand.js +427 -63
- package/dist/bindings/zustand.js.map +3 -3
- package/dist/client.d.ts +128 -5
- package/dist/config.d.ts +9 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +578 -60
- package/dist/index.js.map +4 -4
- package/dist/logger.d.ts +3 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mutate.d.ts +39 -0
- package/dist/sync.d.ts +28 -0
- package/dist/types.d.ts +62 -0
- package/package.json +2 -2
package/dist/bindings/zustand.js
CHANGED
|
@@ -230,6 +230,20 @@ 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_PUB,
|
|
244
|
+
HEADER_CONTENT_TYPE,
|
|
245
|
+
HEADER_ACCEPT,
|
|
246
|
+
signAppendAuthor,
|
|
233
247
|
signRequest,
|
|
234
248
|
stableStringify
|
|
235
249
|
} from "@drakkar.software/starfish-protocol";
|
|
@@ -252,6 +266,16 @@ var StarfishHttpError = class extends Error {
|
|
|
252
266
|
|
|
253
267
|
// src/client.ts
|
|
254
268
|
var APPEND_DEFAULT_FIELD = "items";
|
|
269
|
+
function pullCacheKey(pathAndQuery) {
|
|
270
|
+
const q = pathAndQuery.indexOf("?");
|
|
271
|
+
return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
272
|
+
}
|
|
273
|
+
function pullWasFromCache(result) {
|
|
274
|
+
return result.fromCache === true;
|
|
275
|
+
}
|
|
276
|
+
function stripPushPrefix(path) {
|
|
277
|
+
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
278
|
+
}
|
|
255
279
|
function encodeCapAuth(cap) {
|
|
256
280
|
const json = stableStringify(cap);
|
|
257
281
|
if (typeof btoa === "function") {
|
|
@@ -263,8 +287,11 @@ function encodeCapAuth(cap) {
|
|
|
263
287
|
}
|
|
264
288
|
var StarfishClient = class {
|
|
265
289
|
baseUrl;
|
|
290
|
+
namespace;
|
|
266
291
|
capProvider;
|
|
267
292
|
fetch;
|
|
293
|
+
cache;
|
|
294
|
+
cacheMaxAgeMs;
|
|
268
295
|
/**
|
|
269
296
|
* Installed client-side plugins. Currently stored as inert data; no
|
|
270
297
|
* hooks fire yet. Extensions can inspect this list if needed.
|
|
@@ -272,10 +299,22 @@ var StarfishClient = class {
|
|
|
272
299
|
plugins;
|
|
273
300
|
constructor(options) {
|
|
274
301
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
302
|
+
this.namespace = options.namespace || void 0;
|
|
275
303
|
this.capProvider = options.capProvider;
|
|
276
304
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
305
|
+
this.cache = options.cache;
|
|
306
|
+
this.cacheMaxAgeMs = options.cacheMaxAgeMs;
|
|
277
307
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
278
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Mark a `PullResult` as having been served from the offline read-through
|
|
311
|
+
* cache (transport was unreachable). Non-enumerable so it doesn't leak into
|
|
312
|
+
* JSON / equality / re-caching; read via {@link pullWasFromCache}.
|
|
313
|
+
*/
|
|
314
|
+
tagFromCache(result) {
|
|
315
|
+
Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
279
318
|
/**
|
|
280
319
|
* Resolve the host portion of the URL the client will send to. The host
|
|
281
320
|
* is folded into the signed canonical input as the `h` field so the
|
|
@@ -295,6 +334,20 @@ var StarfishClient = class {
|
|
|
295
334
|
return "";
|
|
296
335
|
}
|
|
297
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Rewrite a request path for the configured namespace. A no-op when no
|
|
339
|
+
* namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
|
|
340
|
+
* (the `/v1` protocol-version segment is part of the namespaced route, matching
|
|
341
|
+
* the Python client and the server's namespace mount).
|
|
342
|
+
*
|
|
343
|
+
* Applied to the path used for BOTH the signature and the URL so the canonical
|
|
344
|
+
* path the client signs equals the path the server reconstructs from the URL.
|
|
345
|
+
* Covers SDK-helper-built paths too — that's the point: a namespace-unaware
|
|
346
|
+
* helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
|
|
347
|
+
*/
|
|
348
|
+
applyNamespace(path) {
|
|
349
|
+
return this.namespace ? `/v1/${this.namespace}${path}` : path;
|
|
350
|
+
}
|
|
298
351
|
/**
|
|
299
352
|
* Build auth headers for a request. When a `capProvider` is set, signs the
|
|
300
353
|
* request with the device's Ed25519 private key and returns the v3 header
|
|
@@ -306,28 +359,52 @@ var StarfishClient = class {
|
|
|
306
359
|
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
307
360
|
*/
|
|
308
361
|
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
|
-
|
|
362
|
+
if (!this.capProvider) return {};
|
|
363
|
+
const capCtx = await this.capProvider.getCap();
|
|
364
|
+
return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
368
|
+
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
369
|
+
* reuse it for BOTH the author signature (over the element data) and the
|
|
370
|
+
* request signature (over the body), without redeeming the cap twice — a
|
|
371
|
+
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
372
|
+
* presenter` bind the server checks.
|
|
373
|
+
*/
|
|
374
|
+
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
375
|
+
const { cap, devEdPrivHex, pubHex } = capCtx;
|
|
376
|
+
const req = {
|
|
377
|
+
method,
|
|
378
|
+
pathAndQuery,
|
|
379
|
+
body,
|
|
380
|
+
host: this.signingHost()
|
|
381
|
+
};
|
|
382
|
+
const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
|
|
383
|
+
const headers = {
|
|
384
|
+
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
385
|
+
[HEADER_SIG]: sig,
|
|
386
|
+
[HEADER_TS]: String(ts),
|
|
387
|
+
[HEADER_NONCE]: nonce
|
|
388
|
+
};
|
|
389
|
+
if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
|
|
390
|
+
return headers;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
394
|
+
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
395
|
+
* device/member cap. This is the SAME key that signs the request, so a server
|
|
396
|
+
* enforcing author proof can bind the stored element to its writer. Returns
|
|
397
|
+
* undefined only for a (malformed) cap with neither — the append then goes
|
|
398
|
+
* unsigned and a server requiring signatures rejects it.
|
|
399
|
+
*/
|
|
400
|
+
appendAuthorKey(capCtx) {
|
|
401
|
+
const { cap, pubHex } = capCtx;
|
|
402
|
+
const authorPubHex = pubHex ?? cap.sub;
|
|
403
|
+
if (authorPubHex === void 0) return null;
|
|
404
|
+
return { authorPubHex };
|
|
328
405
|
}
|
|
329
406
|
async pull(path, checkpointOrOptions) {
|
|
330
|
-
let pathAndQuery = path;
|
|
407
|
+
let pathAndQuery = this.applyNamespace(path);
|
|
331
408
|
let appendField;
|
|
332
409
|
if (typeof checkpointOrOptions === "number") {
|
|
333
410
|
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
@@ -344,23 +421,43 @@ var StarfishClient = class {
|
|
|
344
421
|
}
|
|
345
422
|
} else {
|
|
346
423
|
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
424
|
+
if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
|
|
425
|
+
throw new Error("full cannot be combined with since, limit, or last");
|
|
426
|
+
}
|
|
347
427
|
if (opts.since != null) {
|
|
348
428
|
if (opts.since < 0) throw new Error("since must be non-negative");
|
|
349
429
|
params.set("checkpoint", String(opts.since));
|
|
350
430
|
}
|
|
431
|
+
if (opts.limit != null) {
|
|
432
|
+
if (opts.limit < 0) throw new Error("limit must be non-negative");
|
|
433
|
+
params.set("limit", String(opts.limit));
|
|
434
|
+
}
|
|
351
435
|
if (opts.last != null) {
|
|
352
436
|
if (opts.last < 0) throw new Error("last must be non-negative");
|
|
353
437
|
params.set("last", String(opts.last));
|
|
354
438
|
}
|
|
439
|
+
if (opts.full) {
|
|
440
|
+
params.set("full", "true");
|
|
441
|
+
}
|
|
355
442
|
}
|
|
356
443
|
if (params.size > 0) pathAndQuery += `?${params.toString()}`;
|
|
357
444
|
}
|
|
358
445
|
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
359
446
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
447
|
+
const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
|
|
448
|
+
let res;
|
|
449
|
+
try {
|
|
450
|
+
res = await this.fetch(url, {
|
|
451
|
+
method: "GET",
|
|
452
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
453
|
+
});
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (cacheKey) {
|
|
456
|
+
const cached = await this.readCache(cacheKey);
|
|
457
|
+
if (cached) return cached;
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
364
461
|
if (!res.ok) {
|
|
365
462
|
throw new StarfishHttpError(res.status, await res.text());
|
|
366
463
|
}
|
|
@@ -369,29 +466,118 @@ var StarfishClient = class {
|
|
|
369
466
|
const list = result.data?.[appendField];
|
|
370
467
|
return Array.isArray(list) ? list : [];
|
|
371
468
|
}
|
|
469
|
+
if (cacheKey) {
|
|
470
|
+
const snapshot = {
|
|
471
|
+
data: result.data,
|
|
472
|
+
hash: result.hash,
|
|
473
|
+
timestamp: result.timestamp,
|
|
474
|
+
cachedAt: Date.now()
|
|
475
|
+
};
|
|
476
|
+
void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
|
|
477
|
+
});
|
|
478
|
+
}
|
|
372
479
|
return result;
|
|
373
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Read the cached snapshot for a document `path` WITHOUT hitting the network —
|
|
483
|
+
* the basis for cache-first paint (seed the UI from the last-synced snapshot,
|
|
484
|
+
* then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
|
|
485
|
+
* or null when no cache is configured / there's no entry. Namespacing matches
|
|
486
|
+
* {@link pull}, so the key lines up with whatever `pull` wrote.
|
|
487
|
+
*/
|
|
488
|
+
async peekCache(path) {
|
|
489
|
+
if (!this.cache) return null;
|
|
490
|
+
return this.readCache(pullCacheKey(this.applyNamespace(path)));
|
|
491
|
+
}
|
|
492
|
+
/** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
|
|
493
|
+
* null on a miss or an unparseable blob (never throws — a corrupt cache entry
|
|
494
|
+
* must not break a pull, just miss). */
|
|
495
|
+
async readCache(cacheKey) {
|
|
496
|
+
try {
|
|
497
|
+
const raw = await this.cache.get(cacheKey);
|
|
498
|
+
if (!raw) return null;
|
|
499
|
+
const parsed = JSON.parse(raw);
|
|
500
|
+
if (!parsed || typeof parsed.hash !== "string") return null;
|
|
501
|
+
if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
511
|
+
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
512
|
+
* an ARRAY of path-param sets — one per document to read — so the SAME collection
|
|
513
|
+
* can fan in many documents (e.g. many users' `profile`) in a single request.
|
|
514
|
+
* The server auto-fills the `{identity}` param from the authenticated caller for
|
|
515
|
+
* any set that omits it, so a self-doc collection needs no params. Returns a map
|
|
516
|
+
* of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
|
|
517
|
+
* in request order. Honors the configured namespace.
|
|
518
|
+
*
|
|
519
|
+
* For the common "many docs of one collection" case prefer {@link batchPullMany}.
|
|
520
|
+
*
|
|
521
|
+
* Note: not append/checkpoint-aware — for incremental append-only reads use
|
|
522
|
+
* `pull(path, { since })` (or `AppendLogCursor`) per collection.
|
|
523
|
+
*/
|
|
524
|
+
async batchPull(collections, opts = {}) {
|
|
525
|
+
const search = new URLSearchParams();
|
|
526
|
+
search.set("collections", collections.join(","));
|
|
527
|
+
if (opts.params && Object.keys(opts.params).length > 0) {
|
|
528
|
+
search.set("params", JSON.stringify(opts.params));
|
|
529
|
+
}
|
|
530
|
+
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
531
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
532
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
533
|
+
const res = await this.fetch(url, {
|
|
534
|
+
method: "GET",
|
|
535
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok) {
|
|
538
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
539
|
+
}
|
|
540
|
+
return await res.json();
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Convenience over {@link batchPull} for reading MANY documents of ONE
|
|
544
|
+
* collection in a single round-trip: pass the per-document param-sets and get
|
|
545
|
+
* back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
|
|
546
|
+
* entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
|
|
547
|
+
* issues no request and returns `[]`.
|
|
548
|
+
*/
|
|
549
|
+
async batchPullMany(collection, paramsList) {
|
|
550
|
+
if (paramsList.length === 0) return [];
|
|
551
|
+
const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
|
|
552
|
+
return res.collections[collection] ?? [];
|
|
553
|
+
}
|
|
374
554
|
/**
|
|
375
555
|
* Push synced data to the server.
|
|
376
556
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
377
557
|
* @param data - The full document data to push
|
|
378
558
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
379
559
|
*
|
|
380
|
-
* v3 author
|
|
381
|
-
*
|
|
560
|
+
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
561
|
+
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
562
|
+
* top-level body siblings of `data`, where the server verifies it.
|
|
382
563
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
383
564
|
*/
|
|
384
|
-
async push(path, data, baseHash) {
|
|
565
|
+
async push(path, data, baseHash, author) {
|
|
385
566
|
const body = JSON.stringify({
|
|
386
|
-
data,
|
|
387
|
-
baseHash
|
|
567
|
+
[DATA_FIELD]: data,
|
|
568
|
+
[BASE_HASH_FIELD]: baseHash,
|
|
569
|
+
...author && {
|
|
570
|
+
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
571
|
+
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature
|
|
572
|
+
}
|
|
388
573
|
});
|
|
389
|
-
const
|
|
390
|
-
const
|
|
574
|
+
const sendPath = this.applyNamespace(path);
|
|
575
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
|
|
576
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
391
577
|
method: "POST",
|
|
392
578
|
headers: {
|
|
393
|
-
|
|
394
|
-
|
|
579
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
580
|
+
[HEADER_ACCEPT]: "application/json",
|
|
395
581
|
...authHeaders
|
|
396
582
|
},
|
|
397
583
|
body
|
|
@@ -418,19 +604,37 @@ var StarfishClient = class {
|
|
|
418
604
|
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
419
605
|
* non-negative integer strictly greater than the latest stored element's ts
|
|
420
606
|
* (else the server responds 409). Omit to let the server assign one.
|
|
421
|
-
* @throws {StarfishHttpError} on a non-2xx response
|
|
422
|
-
* non-monotonic timestamp
|
|
607
|
+
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
608
|
+
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
609
|
+
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
610
|
+
* cap is reached (partition by a path parameter for higher volume).
|
|
423
611
|
*/
|
|
424
612
|
async append(path, data, opts = {}) {
|
|
425
|
-
const
|
|
426
|
-
|
|
613
|
+
const sendPath = this.applyNamespace(path);
|
|
614
|
+
const bodyObj = { [DATA_FIELD]: data };
|
|
615
|
+
if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
|
|
616
|
+
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
617
|
+
if (capCtx) {
|
|
618
|
+
const authorKey = this.appendAuthorKey(capCtx);
|
|
619
|
+
if (authorKey) {
|
|
620
|
+
const documentKey = stripPushPrefix(path);
|
|
621
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
622
|
+
documentKey,
|
|
623
|
+
data,
|
|
624
|
+
authorKey.authorPubHex,
|
|
625
|
+
capCtx.devEdPrivHex
|
|
626
|
+
);
|
|
627
|
+
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
628
|
+
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
427
631
|
const body = JSON.stringify(bodyObj);
|
|
428
|
-
const authHeaders = await this.
|
|
429
|
-
const res = await this.fetch(`${this.baseUrl}${
|
|
632
|
+
const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
|
|
633
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
430
634
|
method: "POST",
|
|
431
635
|
headers: {
|
|
432
|
-
|
|
433
|
-
|
|
636
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
637
|
+
[HEADER_ACCEPT]: "application/json",
|
|
434
638
|
...authHeaders
|
|
435
639
|
},
|
|
436
640
|
body
|
|
@@ -445,16 +649,17 @@ var StarfishClient = class {
|
|
|
445
649
|
* Returns raw bytes with the content hash from the ETag header.
|
|
446
650
|
*/
|
|
447
651
|
async pullBlob(path) {
|
|
448
|
-
const
|
|
449
|
-
const
|
|
652
|
+
const sendPath = this.applyNamespace(path);
|
|
653
|
+
const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
|
|
654
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
450
655
|
method: "GET",
|
|
451
|
-
headers: {
|
|
656
|
+
headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
|
|
452
657
|
});
|
|
453
658
|
if (!res.ok) {
|
|
454
659
|
throw new StarfishHttpError(res.status, await res.text());
|
|
455
660
|
}
|
|
456
661
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
457
|
-
const contentType = res.headers.get(
|
|
662
|
+
const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
|
|
458
663
|
const data = await res.arrayBuffer();
|
|
459
664
|
return { data, hash: etag, contentType };
|
|
460
665
|
}
|
|
@@ -463,12 +668,13 @@ var StarfishClient = class {
|
|
|
463
668
|
* Binary collections use last-write-wins (no conflict detection).
|
|
464
669
|
*/
|
|
465
670
|
async pushBlob(path, data, contentType) {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
671
|
+
const sendPath = this.applyNamespace(path);
|
|
672
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
|
|
673
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
468
674
|
method: "POST",
|
|
469
675
|
headers: {
|
|
470
|
-
|
|
471
|
-
|
|
676
|
+
[HEADER_CONTENT_TYPE]: contentType,
|
|
677
|
+
[HEADER_ACCEPT]: "application/json",
|
|
472
678
|
...authHeaders
|
|
473
679
|
},
|
|
474
680
|
body: data
|
|
@@ -481,7 +687,13 @@ var StarfishClient = class {
|
|
|
481
687
|
};
|
|
482
688
|
|
|
483
689
|
// src/sync.ts
|
|
484
|
-
import {
|
|
690
|
+
import {
|
|
691
|
+
AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
|
|
692
|
+
AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
|
|
693
|
+
deepMerge,
|
|
694
|
+
docAuthorCanonicalInput,
|
|
695
|
+
getBase64
|
|
696
|
+
} from "@drakkar.software/starfish-protocol";
|
|
485
697
|
|
|
486
698
|
// src/validate.ts
|
|
487
699
|
var ValidationError = class extends Error {
|
|
@@ -514,6 +726,7 @@ var SyncManager = class {
|
|
|
514
726
|
lastCheckpoint = 0;
|
|
515
727
|
localData = {};
|
|
516
728
|
aborted = false;
|
|
729
|
+
lastFromCache = false;
|
|
517
730
|
constructor(options) {
|
|
518
731
|
this.client = options.client;
|
|
519
732
|
this.pullPath = options.pullPath;
|
|
@@ -535,6 +748,18 @@ var SyncManager = class {
|
|
|
535
748
|
getData() {
|
|
536
749
|
return { ...this.localData };
|
|
537
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Merge a remote snapshot with local (optimistic) data using this manager's
|
|
753
|
+
* conflict resolver — the same resolver the push-conflict path uses. A plain
|
|
754
|
+
* {@link pull} overwrites the store's data with the server snapshot, which
|
|
755
|
+
* would drop un-pushed local writes (they live only in the store, never in
|
|
756
|
+
* `localData` until a push succeeds). The zustand binding calls this on pull
|
|
757
|
+
* while the store is dirty so those writes survive. `local` wins by the same
|
|
758
|
+
* rules as a push conflict.
|
|
759
|
+
*/
|
|
760
|
+
resolve(local, remote) {
|
|
761
|
+
return this.onConflict(local, remote);
|
|
762
|
+
}
|
|
538
763
|
getHash() {
|
|
539
764
|
return this.lastHash;
|
|
540
765
|
}
|
|
@@ -542,6 +767,40 @@ var SyncManager = class {
|
|
|
542
767
|
setHash(hash) {
|
|
543
768
|
this.lastHash = hash;
|
|
544
769
|
}
|
|
770
|
+
/**
|
|
771
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
772
|
+
* from the client's offline read-through cache rather than a live server
|
|
773
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
774
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
775
|
+
* false by the next successful network pull.
|
|
776
|
+
*/
|
|
777
|
+
getLastPullFromCache() {
|
|
778
|
+
return this.lastFromCache;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
782
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
783
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
784
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
785
|
+
* the initial live {@link pull}, which then supersedes the seeded snapshot.
|
|
786
|
+
* Requires the client to have been built with a `cache`.
|
|
787
|
+
*/
|
|
788
|
+
async seedFromCache() {
|
|
789
|
+
if (this.aborted) return false;
|
|
790
|
+
const cached = await this.client.peekCache(this.pullPath);
|
|
791
|
+
if (!cached) return false;
|
|
792
|
+
let data;
|
|
793
|
+
try {
|
|
794
|
+
data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
|
|
795
|
+
} catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
if (this.aborted) return false;
|
|
799
|
+
this.localData = data;
|
|
800
|
+
this.lastHash = cached.hash;
|
|
801
|
+
this.lastFromCache = true;
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
545
804
|
getCheckpoint() {
|
|
546
805
|
return this.lastCheckpoint;
|
|
547
806
|
}
|
|
@@ -552,6 +811,7 @@ var SyncManager = class {
|
|
|
552
811
|
try {
|
|
553
812
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
554
813
|
if (this.aborted) throw new AbortError();
|
|
814
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
555
815
|
if (this.encryptor) {
|
|
556
816
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
557
817
|
if (this.aborted) throw new AbortError();
|
|
@@ -586,23 +846,24 @@ var SyncManager = class {
|
|
|
586
846
|
try {
|
|
587
847
|
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
588
848
|
if (this.aborted) throw new AbortError();
|
|
589
|
-
let
|
|
849
|
+
let author;
|
|
590
850
|
if (this.signer) {
|
|
591
851
|
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
592
852
|
if (this.aborted) throw new AbortError();
|
|
593
|
-
const
|
|
853
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
854
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
594
855
|
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
595
856
|
if (this.aborted) throw new AbortError();
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
authorSignature: getBase64().encode(sigBytes)
|
|
857
|
+
author = {
|
|
858
|
+
[AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
|
|
859
|
+
[AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
|
|
600
860
|
};
|
|
601
861
|
}
|
|
602
862
|
const result = await this.client.push(
|
|
603
863
|
this.pushPath,
|
|
604
|
-
|
|
605
|
-
this.lastHash
|
|
864
|
+
sealed,
|
|
865
|
+
this.lastHash,
|
|
866
|
+
author
|
|
606
867
|
);
|
|
607
868
|
if (this.aborted) throw new AbortError();
|
|
608
869
|
this.lastHash = result.hash;
|
|
@@ -724,12 +985,25 @@ function createStarfishStore(options) {
|
|
|
724
985
|
dirty: false,
|
|
725
986
|
error: null,
|
|
726
987
|
hash: null,
|
|
988
|
+
stale: false,
|
|
989
|
+
seed: async () => {
|
|
990
|
+
try {
|
|
991
|
+
const seeded = await syncManager.seedFromCache();
|
|
992
|
+
if (!seeded) return;
|
|
993
|
+
if (get().dirty || Object.keys(get().data).length > 0) return;
|
|
994
|
+
set({ data: syncManager.getData(), hash: syncManager.getHash(), stale: true }, false, "seed");
|
|
995
|
+
} catch {
|
|
996
|
+
}
|
|
997
|
+
},
|
|
727
998
|
pull: async () => {
|
|
728
999
|
set({ syncing: true, error: null }, false, "pull/start");
|
|
729
1000
|
try {
|
|
730
1001
|
await syncManager.pull();
|
|
731
|
-
const
|
|
732
|
-
|
|
1002
|
+
const remote = syncManager.getData();
|
|
1003
|
+
const newData = get().dirty ? syncManager.resolve(get().data, remote) : remote;
|
|
1004
|
+
set({ data: newData, syncing: false, hash: syncManager.getHash(), stale: syncManager.getLastPullFromCache() }, false, "pull/success");
|
|
1005
|
+
if (get().online && get().dirty) get().flush().catch(() => {
|
|
1006
|
+
});
|
|
733
1007
|
options.onRemoteUpdate?.(newData);
|
|
734
1008
|
} catch (err) {
|
|
735
1009
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
@@ -753,7 +1027,7 @@ function createStarfishStore(options) {
|
|
|
753
1027
|
set({ syncing: true, error: null }, false, "flush/start");
|
|
754
1028
|
try {
|
|
755
1029
|
await syncManager.push(get().data);
|
|
756
|
-
set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash() }, false, "flush/success");
|
|
1030
|
+
set({ data: syncManager.getData(), syncing: false, dirty: false, hash: syncManager.getHash(), stale: false }, false, "flush/success");
|
|
757
1031
|
} catch (err) {
|
|
758
1032
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
759
1033
|
}
|
|
@@ -876,8 +1150,11 @@ function useSyncInit(config) {
|
|
|
876
1150
|
}
|
|
877
1151
|
const client = new StarfishClient({
|
|
878
1152
|
baseUrl: config.serverUrl,
|
|
1153
|
+
namespace: config.namespace,
|
|
879
1154
|
capProvider: config.capProvider,
|
|
880
|
-
fetch: config.fetch
|
|
1155
|
+
fetch: config.fetch,
|
|
1156
|
+
cache: config.cache,
|
|
1157
|
+
cacheMaxAgeMs: config.cacheMaxAgeMs
|
|
881
1158
|
});
|
|
882
1159
|
const syncManager = new SyncManager({
|
|
883
1160
|
client,
|
|
@@ -905,7 +1182,9 @@ function useSyncInit(config) {
|
|
|
905
1182
|
}
|
|
906
1183
|
});
|
|
907
1184
|
setStore(newStore);
|
|
908
|
-
newStore.getState().
|
|
1185
|
+
newStore.getState().seed().finally(() => {
|
|
1186
|
+
newStore.getState().pull().catch(() => {
|
|
1187
|
+
});
|
|
909
1188
|
});
|
|
910
1189
|
return () => {
|
|
911
1190
|
setStore(null);
|
|
@@ -919,16 +1198,101 @@ function useSyncInit(config) {
|
|
|
919
1198
|
]);
|
|
920
1199
|
return store;
|
|
921
1200
|
}
|
|
1201
|
+
function createStarfishLog(options) {
|
|
1202
|
+
const { cursor } = options;
|
|
1203
|
+
const storeCreator = (rawSet, get) => {
|
|
1204
|
+
const set = rawSet;
|
|
1205
|
+
return {
|
|
1206
|
+
// Seed from the cursor so a warm-started cursor's items show immediately.
|
|
1207
|
+
items: cursor.getItems(),
|
|
1208
|
+
loading: false,
|
|
1209
|
+
online: true,
|
|
1210
|
+
error: null,
|
|
1211
|
+
checkpoint: cursor.getCheckpoint(),
|
|
1212
|
+
pull: async () => {
|
|
1213
|
+
if (get().loading) return [];
|
|
1214
|
+
set({ loading: true, error: null }, false, "log/pull/start");
|
|
1215
|
+
try {
|
|
1216
|
+
const batch = await cursor.pull();
|
|
1217
|
+
set(
|
|
1218
|
+
{ items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
|
|
1219
|
+
false,
|
|
1220
|
+
"log/pull/success"
|
|
1221
|
+
);
|
|
1222
|
+
return batch;
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
setOnline: (online) => {
|
|
1229
|
+
set({ online }, false, "log/setOnline");
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
};
|
|
1233
|
+
const withSelector = subscribeWithSelector(storeCreator);
|
|
1234
|
+
return createStore()(
|
|
1235
|
+
options.devtools ? options.devtools(withSelector) : withSelector
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
function deriveLogStatus(state) {
|
|
1239
|
+
if (!state.online) return "offline";
|
|
1240
|
+
if (state.error) return "error";
|
|
1241
|
+
if (state.loading) return "loading";
|
|
1242
|
+
return "idle";
|
|
1243
|
+
}
|
|
1244
|
+
function useStarfishLog(store) {
|
|
1245
|
+
return useStore(store);
|
|
1246
|
+
}
|
|
1247
|
+
function useStarfishLogItems(store, selector) {
|
|
1248
|
+
return useStore(
|
|
1249
|
+
store,
|
|
1250
|
+
(state) => selector ? selector(state.items) : state.items
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
function useLogStatus(store) {
|
|
1254
|
+
return useStore(store, deriveLogStatus);
|
|
1255
|
+
}
|
|
1256
|
+
function subscribeLogStatus(store, callback) {
|
|
1257
|
+
let prev = deriveLogStatus(store.getState());
|
|
1258
|
+
callback(prev);
|
|
1259
|
+
return store.subscribe((state) => {
|
|
1260
|
+
const next = deriveLogStatus(state);
|
|
1261
|
+
if (next !== prev) {
|
|
1262
|
+
prev = next;
|
|
1263
|
+
callback(next);
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
function useLogConnectivity(store) {
|
|
1268
|
+
useEffect(() => {
|
|
1269
|
+
const handleOnline = () => store.getState().setOnline(true);
|
|
1270
|
+
const handleOffline = () => store.getState().setOnline(false);
|
|
1271
|
+
window.addEventListener("online", handleOnline);
|
|
1272
|
+
window.addEventListener("offline", handleOffline);
|
|
1273
|
+
return () => {
|
|
1274
|
+
window.removeEventListener("online", handleOnline);
|
|
1275
|
+
window.removeEventListener("offline", handleOffline);
|
|
1276
|
+
};
|
|
1277
|
+
}, [store]);
|
|
1278
|
+
}
|
|
922
1279
|
export {
|
|
923
1280
|
aggregateSyncStatus,
|
|
1281
|
+
createStarfishLog,
|
|
924
1282
|
createStarfishStore,
|
|
1283
|
+
deriveLogStatus,
|
|
925
1284
|
deriveSyncStatus,
|
|
1285
|
+
subscribeLogStatus,
|
|
926
1286
|
subscribeSyncStatus,
|
|
927
1287
|
useConnectivity,
|
|
928
1288
|
useCrossTabSync,
|
|
929
1289
|
useLastSynced,
|
|
1290
|
+
useLogConnectivity,
|
|
1291
|
+
useLogStatus,
|
|
930
1292
|
useStarfish,
|
|
931
1293
|
useStarfishData,
|
|
1294
|
+
useStarfishLog,
|
|
1295
|
+
useStarfishLogItems,
|
|
932
1296
|
useSyncInit,
|
|
933
1297
|
useSyncStatus
|
|
934
1298
|
};
|