@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.22
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/_crypto_helpers.d.ts +4 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -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/cap-mint.d.ts +20 -0
- package/dist/cap-mint.js +12 -0
- package/dist/cap-mint.js.map +7 -0
- package/dist/client.d.ts +128 -5
- package/dist/client.js +316 -37
- package/dist/config.d.ts +9 -0
- package/dist/directory.d.ts +9 -0
- package/dist/directory.js +24 -0
- package/dist/directory.js.map +7 -0
- package/dist/identity.d.ts +4 -82
- package/dist/identity.js +2 -354
- package/dist/identity.js.map +4 -4
- package/dist/index.d.ts +9 -5
- package/dist/index.js +578 -60
- package/dist/index.js.map +4 -4
- package/dist/keyring.d.ts +6 -0
- package/dist/keyring.js +26 -0
- package/dist/keyring.js.map +7 -0
- package/dist/logger.d.ts +3 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +41 -2
- package/dist/mutate.d.ts +39 -0
- package/dist/pairing.d.ts +6 -0
- package/dist/pairing.js +26 -0
- package/dist/pairing.js.map +7 -0
- package/dist/polling.js +2 -2
- package/dist/recipients.d.ts +6 -0
- package/dist/recipients.js +16 -0
- package/dist/recipients.js.map +7 -0
- package/dist/sync.d.ts +28 -0
- package/dist/sync.js +68 -14
- package/dist/types.d.ts +62 -0
- package/package.json +2 -2
- package/dist/append.d.ts +0 -50
- package/dist/bindings/broadcast.d.ts +0 -19
- package/dist/bindings/broadcast.js +0 -65
- package/dist/bindings/react.d.ts +0 -12
- package/dist/bindings/react.js +0 -25
- package/dist/crypto.js +0 -49
- package/dist/entitlements.js +0 -41
- package/dist/group-crypto.d.ts +0 -111
- package/dist/group-crypto.js +0 -205
- package/dist/group-crypto.js.map +0 -7
package/dist/index.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { configurePlatform } from "@drakkar.software/starfish-protocol";
|
|
3
|
-
import { stableStringify as
|
|
3
|
+
import { stableStringify as stableStringify2, computeHash } from "@drakkar.software/starfish-protocol";
|
|
4
4
|
import { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
|
|
5
5
|
|
|
6
6
|
// src/client.ts
|
|
7
7
|
import {
|
|
8
|
+
AUTHOR_PUBKEY_FIELD,
|
|
9
|
+
AUTHOR_SIGNATURE_FIELD,
|
|
10
|
+
DATA_FIELD,
|
|
11
|
+
TS_FIELD,
|
|
12
|
+
BASE_HASH_FIELD,
|
|
13
|
+
PUSH_PATH_PREFIX,
|
|
14
|
+
HEADER_AUTHORIZATION,
|
|
15
|
+
HEADER_SIG,
|
|
16
|
+
HEADER_TS,
|
|
17
|
+
HEADER_NONCE,
|
|
18
|
+
HEADER_PUB,
|
|
19
|
+
HEADER_CONTENT_TYPE,
|
|
20
|
+
HEADER_ACCEPT,
|
|
21
|
+
signAppendAuthor,
|
|
8
22
|
signRequest,
|
|
9
23
|
stableStringify
|
|
10
24
|
} from "@drakkar.software/starfish-protocol";
|
|
@@ -27,6 +41,16 @@ var StarfishHttpError = class extends Error {
|
|
|
27
41
|
|
|
28
42
|
// src/client.ts
|
|
29
43
|
var APPEND_DEFAULT_FIELD = "items";
|
|
44
|
+
function pullCacheKey(pathAndQuery) {
|
|
45
|
+
const q = pathAndQuery.indexOf("?");
|
|
46
|
+
return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
47
|
+
}
|
|
48
|
+
function pullWasFromCache(result) {
|
|
49
|
+
return result.fromCache === true;
|
|
50
|
+
}
|
|
51
|
+
function stripPushPrefix(path) {
|
|
52
|
+
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
53
|
+
}
|
|
30
54
|
function encodeCapAuth(cap) {
|
|
31
55
|
const json = stableStringify(cap);
|
|
32
56
|
if (typeof btoa === "function") {
|
|
@@ -38,8 +62,11 @@ function encodeCapAuth(cap) {
|
|
|
38
62
|
}
|
|
39
63
|
var StarfishClient = class {
|
|
40
64
|
baseUrl;
|
|
65
|
+
namespace;
|
|
41
66
|
capProvider;
|
|
42
67
|
fetch;
|
|
68
|
+
cache;
|
|
69
|
+
cacheMaxAgeMs;
|
|
43
70
|
/**
|
|
44
71
|
* Installed client-side plugins. Currently stored as inert data; no
|
|
45
72
|
* hooks fire yet. Extensions can inspect this list if needed.
|
|
@@ -47,10 +74,22 @@ var StarfishClient = class {
|
|
|
47
74
|
plugins;
|
|
48
75
|
constructor(options) {
|
|
49
76
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
77
|
+
this.namespace = options.namespace || void 0;
|
|
50
78
|
this.capProvider = options.capProvider;
|
|
51
79
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
80
|
+
this.cache = options.cache;
|
|
81
|
+
this.cacheMaxAgeMs = options.cacheMaxAgeMs;
|
|
52
82
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
53
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Mark a `PullResult` as having been served from the offline read-through
|
|
86
|
+
* cache (transport was unreachable). Non-enumerable so it doesn't leak into
|
|
87
|
+
* JSON / equality / re-caching; read via {@link pullWasFromCache}.
|
|
88
|
+
*/
|
|
89
|
+
tagFromCache(result) {
|
|
90
|
+
Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
54
93
|
/**
|
|
55
94
|
* Resolve the host portion of the URL the client will send to. The host
|
|
56
95
|
* is folded into the signed canonical input as the `h` field so the
|
|
@@ -70,6 +109,20 @@ var StarfishClient = class {
|
|
|
70
109
|
return "";
|
|
71
110
|
}
|
|
72
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Rewrite a request path for the configured namespace. A no-op when no
|
|
114
|
+
* namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
|
|
115
|
+
* (the `/v1` protocol-version segment is part of the namespaced route, matching
|
|
116
|
+
* the Python client and the server's namespace mount).
|
|
117
|
+
*
|
|
118
|
+
* Applied to the path used for BOTH the signature and the URL so the canonical
|
|
119
|
+
* path the client signs equals the path the server reconstructs from the URL.
|
|
120
|
+
* Covers SDK-helper-built paths too — that's the point: a namespace-unaware
|
|
121
|
+
* helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
|
|
122
|
+
*/
|
|
123
|
+
applyNamespace(path) {
|
|
124
|
+
return this.namespace ? `/v1/${this.namespace}${path}` : path;
|
|
125
|
+
}
|
|
73
126
|
/**
|
|
74
127
|
* Build auth headers for a request. When a `capProvider` is set, signs the
|
|
75
128
|
* request with the device's Ed25519 private key and returns the v3 header
|
|
@@ -81,28 +134,52 @@ var StarfishClient = class {
|
|
|
81
134
|
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
82
135
|
*/
|
|
83
136
|
async buildAuthHeaders(method, pathAndQuery, body) {
|
|
84
|
-
if (this.capProvider) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
137
|
+
if (!this.capProvider) return {};
|
|
138
|
+
const capCtx = await this.capProvider.getCap();
|
|
139
|
+
return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
143
|
+
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
144
|
+
* reuse it for BOTH the author signature (over the element data) and the
|
|
145
|
+
* request signature (over the body), without redeeming the cap twice — a
|
|
146
|
+
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
147
|
+
* presenter` bind the server checks.
|
|
148
|
+
*/
|
|
149
|
+
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
150
|
+
const { cap, devEdPrivHex, pubHex } = capCtx;
|
|
151
|
+
const req = {
|
|
152
|
+
method,
|
|
153
|
+
pathAndQuery,
|
|
154
|
+
body,
|
|
155
|
+
host: this.signingHost()
|
|
156
|
+
};
|
|
157
|
+
const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
|
|
158
|
+
const headers = {
|
|
159
|
+
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
160
|
+
[HEADER_SIG]: sig,
|
|
161
|
+
[HEADER_TS]: String(ts),
|
|
162
|
+
[HEADER_NONCE]: nonce
|
|
163
|
+
};
|
|
164
|
+
if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
|
|
165
|
+
return headers;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
169
|
+
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
170
|
+
* device/member cap. This is the SAME key that signs the request, so a server
|
|
171
|
+
* enforcing author proof can bind the stored element to its writer. Returns
|
|
172
|
+
* undefined only for a (malformed) cap with neither — the append then goes
|
|
173
|
+
* unsigned and a server requiring signatures rejects it.
|
|
174
|
+
*/
|
|
175
|
+
appendAuthorKey(capCtx) {
|
|
176
|
+
const { cap, pubHex } = capCtx;
|
|
177
|
+
const authorPubHex = pubHex ?? cap.sub;
|
|
178
|
+
if (authorPubHex === void 0) return null;
|
|
179
|
+
return { authorPubHex };
|
|
103
180
|
}
|
|
104
181
|
async pull(path, checkpointOrOptions) {
|
|
105
|
-
let pathAndQuery = path;
|
|
182
|
+
let pathAndQuery = this.applyNamespace(path);
|
|
106
183
|
let appendField;
|
|
107
184
|
if (typeof checkpointOrOptions === "number") {
|
|
108
185
|
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
@@ -119,23 +196,43 @@ var StarfishClient = class {
|
|
|
119
196
|
}
|
|
120
197
|
} else {
|
|
121
198
|
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
199
|
+
if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
|
|
200
|
+
throw new Error("full cannot be combined with since, limit, or last");
|
|
201
|
+
}
|
|
122
202
|
if (opts.since != null) {
|
|
123
203
|
if (opts.since < 0) throw new Error("since must be non-negative");
|
|
124
204
|
params.set("checkpoint", String(opts.since));
|
|
125
205
|
}
|
|
206
|
+
if (opts.limit != null) {
|
|
207
|
+
if (opts.limit < 0) throw new Error("limit must be non-negative");
|
|
208
|
+
params.set("limit", String(opts.limit));
|
|
209
|
+
}
|
|
126
210
|
if (opts.last != null) {
|
|
127
211
|
if (opts.last < 0) throw new Error("last must be non-negative");
|
|
128
212
|
params.set("last", String(opts.last));
|
|
129
213
|
}
|
|
214
|
+
if (opts.full) {
|
|
215
|
+
params.set("full", "true");
|
|
216
|
+
}
|
|
130
217
|
}
|
|
131
218
|
if (params.size > 0) pathAndQuery += `?${params.toString()}`;
|
|
132
219
|
}
|
|
133
220
|
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
134
221
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
222
|
+
const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
res = await this.fetch(url, {
|
|
226
|
+
method: "GET",
|
|
227
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (cacheKey) {
|
|
231
|
+
const cached = await this.readCache(cacheKey);
|
|
232
|
+
if (cached) return cached;
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
139
236
|
if (!res.ok) {
|
|
140
237
|
throw new StarfishHttpError(res.status, await res.text());
|
|
141
238
|
}
|
|
@@ -144,29 +241,118 @@ var StarfishClient = class {
|
|
|
144
241
|
const list = result.data?.[appendField];
|
|
145
242
|
return Array.isArray(list) ? list : [];
|
|
146
243
|
}
|
|
244
|
+
if (cacheKey) {
|
|
245
|
+
const snapshot = {
|
|
246
|
+
data: result.data,
|
|
247
|
+
hash: result.hash,
|
|
248
|
+
timestamp: result.timestamp,
|
|
249
|
+
cachedAt: Date.now()
|
|
250
|
+
};
|
|
251
|
+
void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
|
|
252
|
+
});
|
|
253
|
+
}
|
|
147
254
|
return result;
|
|
148
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Read the cached snapshot for a document `path` WITHOUT hitting the network —
|
|
258
|
+
* the basis for cache-first paint (seed the UI from the last-synced snapshot,
|
|
259
|
+
* then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
|
|
260
|
+
* or null when no cache is configured / there's no entry. Namespacing matches
|
|
261
|
+
* {@link pull}, so the key lines up with whatever `pull` wrote.
|
|
262
|
+
*/
|
|
263
|
+
async peekCache(path) {
|
|
264
|
+
if (!this.cache) return null;
|
|
265
|
+
return this.readCache(pullCacheKey(this.applyNamespace(path)));
|
|
266
|
+
}
|
|
267
|
+
/** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
|
|
268
|
+
* null on a miss or an unparseable blob (never throws — a corrupt cache entry
|
|
269
|
+
* must not break a pull, just miss). */
|
|
270
|
+
async readCache(cacheKey) {
|
|
271
|
+
try {
|
|
272
|
+
const raw = await this.cache.get(cacheKey);
|
|
273
|
+
if (!raw) return null;
|
|
274
|
+
const parsed = JSON.parse(raw);
|
|
275
|
+
if (!parsed || typeof parsed.hash !== "string") return null;
|
|
276
|
+
if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
286
|
+
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
287
|
+
* an ARRAY of path-param sets — one per document to read — so the SAME collection
|
|
288
|
+
* can fan in many documents (e.g. many users' `profile`) in a single request.
|
|
289
|
+
* The server auto-fills the `{identity}` param from the authenticated caller for
|
|
290
|
+
* any set that omits it, so a self-doc collection needs no params. Returns a map
|
|
291
|
+
* of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
|
|
292
|
+
* in request order. Honors the configured namespace.
|
|
293
|
+
*
|
|
294
|
+
* For the common "many docs of one collection" case prefer {@link batchPullMany}.
|
|
295
|
+
*
|
|
296
|
+
* Note: not append/checkpoint-aware — for incremental append-only reads use
|
|
297
|
+
* `pull(path, { since })` (or `AppendLogCursor`) per collection.
|
|
298
|
+
*/
|
|
299
|
+
async batchPull(collections, opts = {}) {
|
|
300
|
+
const search = new URLSearchParams();
|
|
301
|
+
search.set("collections", collections.join(","));
|
|
302
|
+
if (opts.params && Object.keys(opts.params).length > 0) {
|
|
303
|
+
search.set("params", JSON.stringify(opts.params));
|
|
304
|
+
}
|
|
305
|
+
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
306
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
307
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
308
|
+
const res = await this.fetch(url, {
|
|
309
|
+
method: "GET",
|
|
310
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
311
|
+
});
|
|
312
|
+
if (!res.ok) {
|
|
313
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
314
|
+
}
|
|
315
|
+
return await res.json();
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Convenience over {@link batchPull} for reading MANY documents of ONE
|
|
319
|
+
* collection in a single round-trip: pass the per-document param-sets and get
|
|
320
|
+
* back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
|
|
321
|
+
* entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
|
|
322
|
+
* issues no request and returns `[]`.
|
|
323
|
+
*/
|
|
324
|
+
async batchPullMany(collection, paramsList) {
|
|
325
|
+
if (paramsList.length === 0) return [];
|
|
326
|
+
const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
|
|
327
|
+
return res.collections[collection] ?? [];
|
|
328
|
+
}
|
|
149
329
|
/**
|
|
150
330
|
* Push synced data to the server.
|
|
151
331
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
152
332
|
* @param data - The full document data to push
|
|
153
333
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
154
334
|
*
|
|
155
|
-
* v3 author
|
|
156
|
-
*
|
|
335
|
+
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
336
|
+
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
337
|
+
* top-level body siblings of `data`, where the server verifies it.
|
|
157
338
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
158
339
|
*/
|
|
159
|
-
async push(path, data, baseHash) {
|
|
340
|
+
async push(path, data, baseHash, author) {
|
|
160
341
|
const body = JSON.stringify({
|
|
161
|
-
data,
|
|
162
|
-
baseHash
|
|
342
|
+
[DATA_FIELD]: data,
|
|
343
|
+
[BASE_HASH_FIELD]: baseHash,
|
|
344
|
+
...author && {
|
|
345
|
+
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
346
|
+
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature
|
|
347
|
+
}
|
|
163
348
|
});
|
|
164
|
-
const
|
|
165
|
-
const
|
|
349
|
+
const sendPath = this.applyNamespace(path);
|
|
350
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
|
|
351
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
166
352
|
method: "POST",
|
|
167
353
|
headers: {
|
|
168
|
-
|
|
169
|
-
|
|
354
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
355
|
+
[HEADER_ACCEPT]: "application/json",
|
|
170
356
|
...authHeaders
|
|
171
357
|
},
|
|
172
358
|
body
|
|
@@ -193,19 +379,37 @@ var StarfishClient = class {
|
|
|
193
379
|
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
194
380
|
* non-negative integer strictly greater than the latest stored element's ts
|
|
195
381
|
* (else the server responds 409). Omit to let the server assign one.
|
|
196
|
-
* @throws {StarfishHttpError} on a non-2xx response
|
|
197
|
-
* non-monotonic timestamp
|
|
382
|
+
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
383
|
+
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
384
|
+
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
385
|
+
* cap is reached (partition by a path parameter for higher volume).
|
|
198
386
|
*/
|
|
199
387
|
async append(path, data, opts = {}) {
|
|
200
|
-
const
|
|
201
|
-
|
|
388
|
+
const sendPath = this.applyNamespace(path);
|
|
389
|
+
const bodyObj = { [DATA_FIELD]: data };
|
|
390
|
+
if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
|
|
391
|
+
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
392
|
+
if (capCtx) {
|
|
393
|
+
const authorKey = this.appendAuthorKey(capCtx);
|
|
394
|
+
if (authorKey) {
|
|
395
|
+
const documentKey = stripPushPrefix(path);
|
|
396
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
397
|
+
documentKey,
|
|
398
|
+
data,
|
|
399
|
+
authorKey.authorPubHex,
|
|
400
|
+
capCtx.devEdPrivHex
|
|
401
|
+
);
|
|
402
|
+
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
403
|
+
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
202
406
|
const body = JSON.stringify(bodyObj);
|
|
203
|
-
const authHeaders = await this.
|
|
204
|
-
const res = await this.fetch(`${this.baseUrl}${
|
|
407
|
+
const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
|
|
408
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
205
409
|
method: "POST",
|
|
206
410
|
headers: {
|
|
207
|
-
|
|
208
|
-
|
|
411
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
412
|
+
[HEADER_ACCEPT]: "application/json",
|
|
209
413
|
...authHeaders
|
|
210
414
|
},
|
|
211
415
|
body
|
|
@@ -220,16 +424,17 @@ var StarfishClient = class {
|
|
|
220
424
|
* Returns raw bytes with the content hash from the ETag header.
|
|
221
425
|
*/
|
|
222
426
|
async pullBlob(path) {
|
|
223
|
-
const
|
|
224
|
-
const
|
|
427
|
+
const sendPath = this.applyNamespace(path);
|
|
428
|
+
const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
|
|
429
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
225
430
|
method: "GET",
|
|
226
|
-
headers: {
|
|
431
|
+
headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
|
|
227
432
|
});
|
|
228
433
|
if (!res.ok) {
|
|
229
434
|
throw new StarfishHttpError(res.status, await res.text());
|
|
230
435
|
}
|
|
231
436
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
232
|
-
const contentType = res.headers.get(
|
|
437
|
+
const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
|
|
233
438
|
const data = await res.arrayBuffer();
|
|
234
439
|
return { data, hash: etag, contentType };
|
|
235
440
|
}
|
|
@@ -238,12 +443,13 @@ var StarfishClient = class {
|
|
|
238
443
|
* Binary collections use last-write-wins (no conflict detection).
|
|
239
444
|
*/
|
|
240
445
|
async pushBlob(path, data, contentType) {
|
|
241
|
-
const
|
|
242
|
-
const
|
|
446
|
+
const sendPath = this.applyNamespace(path);
|
|
447
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
|
|
448
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
243
449
|
method: "POST",
|
|
244
450
|
headers: {
|
|
245
|
-
|
|
246
|
-
|
|
451
|
+
[HEADER_CONTENT_TYPE]: contentType,
|
|
452
|
+
[HEADER_ACCEPT]: "application/json",
|
|
247
453
|
...authHeaders
|
|
248
454
|
},
|
|
249
455
|
body: data
|
|
@@ -256,7 +462,13 @@ var StarfishClient = class {
|
|
|
256
462
|
};
|
|
257
463
|
|
|
258
464
|
// src/sync.ts
|
|
259
|
-
import {
|
|
465
|
+
import {
|
|
466
|
+
AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
|
|
467
|
+
AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
|
|
468
|
+
deepMerge,
|
|
469
|
+
docAuthorCanonicalInput,
|
|
470
|
+
getBase64
|
|
471
|
+
} from "@drakkar.software/starfish-protocol";
|
|
260
472
|
|
|
261
473
|
// src/validate.ts
|
|
262
474
|
var ValidationError = class extends Error {
|
|
@@ -296,6 +508,7 @@ var SyncManager = class {
|
|
|
296
508
|
lastCheckpoint = 0;
|
|
297
509
|
localData = {};
|
|
298
510
|
aborted = false;
|
|
511
|
+
lastFromCache = false;
|
|
299
512
|
constructor(options) {
|
|
300
513
|
this.client = options.client;
|
|
301
514
|
this.pullPath = options.pullPath;
|
|
@@ -317,6 +530,18 @@ var SyncManager = class {
|
|
|
317
530
|
getData() {
|
|
318
531
|
return { ...this.localData };
|
|
319
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Merge a remote snapshot with local (optimistic) data using this manager's
|
|
535
|
+
* conflict resolver — the same resolver the push-conflict path uses. A plain
|
|
536
|
+
* {@link pull} overwrites the store's data with the server snapshot, which
|
|
537
|
+
* would drop un-pushed local writes (they live only in the store, never in
|
|
538
|
+
* `localData` until a push succeeds). The zustand binding calls this on pull
|
|
539
|
+
* while the store is dirty so those writes survive. `local` wins by the same
|
|
540
|
+
* rules as a push conflict.
|
|
541
|
+
*/
|
|
542
|
+
resolve(local, remote) {
|
|
543
|
+
return this.onConflict(local, remote);
|
|
544
|
+
}
|
|
320
545
|
getHash() {
|
|
321
546
|
return this.lastHash;
|
|
322
547
|
}
|
|
@@ -324,6 +549,40 @@ var SyncManager = class {
|
|
|
324
549
|
setHash(hash) {
|
|
325
550
|
this.lastHash = hash;
|
|
326
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
554
|
+
* from the client's offline read-through cache rather than a live server
|
|
555
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
556
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
557
|
+
* false by the next successful network pull.
|
|
558
|
+
*/
|
|
559
|
+
getLastPullFromCache() {
|
|
560
|
+
return this.lastFromCache;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
564
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
565
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
566
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
567
|
+
* the initial live {@link pull}, which then supersedes the seeded snapshot.
|
|
568
|
+
* Requires the client to have been built with a `cache`.
|
|
569
|
+
*/
|
|
570
|
+
async seedFromCache() {
|
|
571
|
+
if (this.aborted) return false;
|
|
572
|
+
const cached = await this.client.peekCache(this.pullPath);
|
|
573
|
+
if (!cached) return false;
|
|
574
|
+
let data;
|
|
575
|
+
try {
|
|
576
|
+
data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
|
|
577
|
+
} catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
if (this.aborted) return false;
|
|
581
|
+
this.localData = data;
|
|
582
|
+
this.lastHash = cached.hash;
|
|
583
|
+
this.lastFromCache = true;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
327
586
|
getCheckpoint() {
|
|
328
587
|
return this.lastCheckpoint;
|
|
329
588
|
}
|
|
@@ -334,6 +593,7 @@ var SyncManager = class {
|
|
|
334
593
|
try {
|
|
335
594
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
336
595
|
if (this.aborted) throw new AbortError();
|
|
596
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
337
597
|
if (this.encryptor) {
|
|
338
598
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
339
599
|
if (this.aborted) throw new AbortError();
|
|
@@ -368,23 +628,24 @@ var SyncManager = class {
|
|
|
368
628
|
try {
|
|
369
629
|
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
370
630
|
if (this.aborted) throw new AbortError();
|
|
371
|
-
let
|
|
631
|
+
let author;
|
|
372
632
|
if (this.signer) {
|
|
373
633
|
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
374
634
|
if (this.aborted) throw new AbortError();
|
|
375
|
-
const
|
|
635
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
636
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
376
637
|
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
377
638
|
if (this.aborted) throw new AbortError();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
authorSignature: getBase64().encode(sigBytes)
|
|
639
|
+
author = {
|
|
640
|
+
[AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
|
|
641
|
+
[AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
|
|
382
642
|
};
|
|
383
643
|
}
|
|
384
644
|
const result = await this.client.push(
|
|
385
645
|
this.pushPath,
|
|
386
|
-
|
|
387
|
-
this.lastHash
|
|
646
|
+
sealed,
|
|
647
|
+
this.lastHash,
|
|
648
|
+
author
|
|
388
649
|
);
|
|
389
650
|
if (this.aborted) throw new AbortError();
|
|
390
651
|
this.lastHash = result.hash;
|
|
@@ -426,6 +687,208 @@ var SyncManager = class {
|
|
|
426
687
|
}
|
|
427
688
|
};
|
|
428
689
|
|
|
690
|
+
// src/append-log.ts
|
|
691
|
+
import {
|
|
692
|
+
verifyAppendAuthor
|
|
693
|
+
} from "@drakkar.software/starfish-protocol";
|
|
694
|
+
var PULL_PATH_PREFIX = "/pull/";
|
|
695
|
+
function stripPullPrefix(path) {
|
|
696
|
+
return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
|
|
697
|
+
}
|
|
698
|
+
var AppendAuthorError = class extends Error {
|
|
699
|
+
constructor(ts) {
|
|
700
|
+
super(`append element author verification failed (ts=${ts})`);
|
|
701
|
+
this.ts = ts;
|
|
702
|
+
this.name = "AppendAuthorError";
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
function checkpointOf(items) {
|
|
706
|
+
let max = 0;
|
|
707
|
+
for (const it of items) if (it.ts > max) max = it.ts;
|
|
708
|
+
return max;
|
|
709
|
+
}
|
|
710
|
+
function withAuthor(ts, data, src) {
|
|
711
|
+
const out = { ts, data };
|
|
712
|
+
if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
|
|
713
|
+
if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
var AppendLogCursor = class {
|
|
717
|
+
client;
|
|
718
|
+
pullPath;
|
|
719
|
+
appendField;
|
|
720
|
+
encryptor;
|
|
721
|
+
verifyAuthor;
|
|
722
|
+
onElementError;
|
|
723
|
+
persistEncrypted;
|
|
724
|
+
documentKey;
|
|
725
|
+
logger;
|
|
726
|
+
loggerName;
|
|
727
|
+
items;
|
|
728
|
+
lastCheckpoint;
|
|
729
|
+
/** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
|
|
730
|
+
* it so each runs against the checkpoint the previous one advanced — no two
|
|
731
|
+
* overlapping fetches read the same checkpoint and double-append a window. */
|
|
732
|
+
pullChain = Promise.resolve();
|
|
733
|
+
constructor(options) {
|
|
734
|
+
this.client = options.client;
|
|
735
|
+
this.pullPath = options.pullPath;
|
|
736
|
+
this.appendField = options.appendField ?? "items";
|
|
737
|
+
this.encryptor = options.encryptor;
|
|
738
|
+
this.verifyAuthor = options.verifyAuthor;
|
|
739
|
+
this.onElementError = options.onElementError ?? "throw";
|
|
740
|
+
this.persistEncrypted = options.persistEncrypted ?? false;
|
|
741
|
+
this.documentKey = stripPullPrefix(options.pullPath);
|
|
742
|
+
this.logger = options.logger;
|
|
743
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
744
|
+
const seed = options.initialItems ?? [];
|
|
745
|
+
const seedCheckpoint = checkpointOf(seed);
|
|
746
|
+
if (options.since != null) {
|
|
747
|
+
if (options.since < 0) throw new Error("since must be non-negative");
|
|
748
|
+
if (options.since < seedCheckpoint) {
|
|
749
|
+
throw new Error("since must be >= the max ts of initialItems");
|
|
750
|
+
}
|
|
751
|
+
this.lastCheckpoint = options.since;
|
|
752
|
+
} else {
|
|
753
|
+
this.lastCheckpoint = seedCheckpoint;
|
|
754
|
+
}
|
|
755
|
+
this.items = [...seed];
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Fetch elements newer than the current checkpoint, verify + decrypt them,
|
|
759
|
+
* append them to the local log, and return ONLY the newly-fetched batch
|
|
760
|
+
* (decrypted when an `encryptor` is set).
|
|
761
|
+
*
|
|
762
|
+
* Atomic under `onElementError: "throw"` (the default): the batch is fully
|
|
763
|
+
* verified and decrypted into a local before any state mutation, so a
|
|
764
|
+
* verify/decrypt failure throws without advancing the checkpoint past elements
|
|
765
|
+
* that could never be re-fetched. Under `"skip"`, a failing element is dropped
|
|
766
|
+
* from the returned batch but the checkpoint still advances past it.
|
|
767
|
+
*
|
|
768
|
+
* Safe to call concurrently: overlapping calls are serialized internally, so
|
|
769
|
+
* each runs against the checkpoint the previous one advanced (no double-fetch
|
|
770
|
+
* of the same window). The next pull after one completes will pick up anything
|
|
771
|
+
* that arrived in between.
|
|
772
|
+
*/
|
|
773
|
+
async pull() {
|
|
774
|
+
const run = this.pullChain.then(
|
|
775
|
+
() => this.doPull(),
|
|
776
|
+
() => this.doPull()
|
|
777
|
+
);
|
|
778
|
+
this.pullChain = run.then(
|
|
779
|
+
() => void 0,
|
|
780
|
+
() => void 0
|
|
781
|
+
);
|
|
782
|
+
return run;
|
|
783
|
+
}
|
|
784
|
+
async doPull() {
|
|
785
|
+
this.logger?.pullStart(this.loggerName);
|
|
786
|
+
const start = performance.now();
|
|
787
|
+
try {
|
|
788
|
+
const since = this.lastCheckpoint;
|
|
789
|
+
const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField, full: true };
|
|
790
|
+
const raw = await this.client.pull(this.pullPath, opts);
|
|
791
|
+
const batch = [];
|
|
792
|
+
const stored = [];
|
|
793
|
+
let maxTs = since;
|
|
794
|
+
let skipped = 0;
|
|
795
|
+
for (const el of raw) {
|
|
796
|
+
if (since > 0 && el.ts <= since) continue;
|
|
797
|
+
if (el.ts > maxTs) maxTs = el.ts;
|
|
798
|
+
let decrypted = null;
|
|
799
|
+
try {
|
|
800
|
+
this.verifyOne(el);
|
|
801
|
+
const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
|
|
802
|
+
decrypted = withAuthor(el.ts, data, el);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (this.onElementError !== "skip") throw err;
|
|
805
|
+
skipped++;
|
|
806
|
+
}
|
|
807
|
+
if (this.persistEncrypted) {
|
|
808
|
+
stored.push(withAuthor(el.ts, el.data, el));
|
|
809
|
+
} else if (decrypted) {
|
|
810
|
+
stored.push(decrypted);
|
|
811
|
+
}
|
|
812
|
+
if (decrypted) batch.push(decrypted);
|
|
813
|
+
}
|
|
814
|
+
this.items.push(...stored);
|
|
815
|
+
this.lastCheckpoint = maxTs;
|
|
816
|
+
this.logger?.pullSuccess(
|
|
817
|
+
this.loggerName,
|
|
818
|
+
Math.round(performance.now() - start),
|
|
819
|
+
skipped > 0 ? { skippedCount: skipped } : void 0
|
|
820
|
+
);
|
|
821
|
+
return batch;
|
|
822
|
+
} catch (err) {
|
|
823
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
824
|
+
throw err;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/** Verify a single element's author signature over its RAW (pre-decryption)
|
|
828
|
+
* `data`. Throws {@link AppendAuthorError} on any failure. No-op when
|
|
829
|
+
* verification is disabled. */
|
|
830
|
+
verifyOne(el) {
|
|
831
|
+
if (!this.verifyAuthor) return;
|
|
832
|
+
const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
|
|
833
|
+
const { authorPubkey, authorSignature } = el;
|
|
834
|
+
if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
|
|
835
|
+
if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
|
|
836
|
+
throw new AppendAuthorError(el.ts);
|
|
837
|
+
}
|
|
838
|
+
void policy;
|
|
839
|
+
const ok = verifyAppendAuthor(
|
|
840
|
+
this.documentKey,
|
|
841
|
+
el.data,
|
|
842
|
+
authorPubkey,
|
|
843
|
+
authorSignature
|
|
844
|
+
);
|
|
845
|
+
if (!ok) throw new AppendAuthorError(el.ts);
|
|
846
|
+
}
|
|
847
|
+
/** The full accumulated log (a shallow copy), in `ts` order. Under
|
|
848
|
+
* `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
|
|
849
|
+
* re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
|
|
850
|
+
getItems() {
|
|
851
|
+
return [...this.items];
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* The full accumulated log, DECRYPTED — for rendering warm-started history in
|
|
855
|
+
* `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
|
|
856
|
+
* `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
|
|
857
|
+
* cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
|
|
858
|
+
* elements are already plaintext/decrypted and are returned as-is.
|
|
859
|
+
*/
|
|
860
|
+
async getDecryptedItems() {
|
|
861
|
+
const snapshot = [...this.items];
|
|
862
|
+
if (!this.encryptor || !this.persistEncrypted) return snapshot;
|
|
863
|
+
const out = [];
|
|
864
|
+
for (const el of snapshot) {
|
|
865
|
+
try {
|
|
866
|
+
this.verifyOne(el);
|
|
867
|
+
const data = await this.encryptor.decrypt(el.data);
|
|
868
|
+
out.push(withAuthor(el.ts, data, el));
|
|
869
|
+
} catch (err) {
|
|
870
|
+
if (this.onElementError !== "skip") throw err;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
/** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
|
|
876
|
+
* when nothing has been pulled or seeded. */
|
|
877
|
+
getCheckpoint() {
|
|
878
|
+
return this.lastCheckpoint;
|
|
879
|
+
}
|
|
880
|
+
/** Restore the checkpoint without seeding items — for persistence layers that
|
|
881
|
+
* store only the checkpoint. Used to resume incrementally across restarts.
|
|
882
|
+
* Rejects a value below the max `ts` already held: rewinding would make the
|
|
883
|
+
* next pull re-deliver, and duplicate, elements the cursor already has. */
|
|
884
|
+
setCheckpoint(ts) {
|
|
885
|
+
if (ts < checkpointOf(this.items)) {
|
|
886
|
+
throw new Error("checkpoint must be >= the max ts already held");
|
|
887
|
+
}
|
|
888
|
+
this.lastCheckpoint = ts;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
|
|
429
892
|
// src/index.ts
|
|
430
893
|
import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
|
|
431
894
|
|
|
@@ -839,6 +1302,32 @@ function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
|
|
|
839
1302
|
});
|
|
840
1303
|
}
|
|
841
1304
|
|
|
1305
|
+
// src/mutate.ts
|
|
1306
|
+
async function mutateDoc(client, path, mutator, options = {}) {
|
|
1307
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
|
1308
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1309
|
+
let data = null;
|
|
1310
|
+
let hash = null;
|
|
1311
|
+
try {
|
|
1312
|
+
const res = await client.pull(path);
|
|
1313
|
+
data = res.data ?? null;
|
|
1314
|
+
hash = res.hash ?? null;
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
if (!(err instanceof StarfishHttpError) || err.status !== 404) throw err;
|
|
1317
|
+
}
|
|
1318
|
+
const next = mutator({ data, hash });
|
|
1319
|
+
if (next === null) return null;
|
|
1320
|
+
try {
|
|
1321
|
+
await client.push(path, next, hash);
|
|
1322
|
+
return next;
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
if (err instanceof ConflictError && attempt < maxAttempts - 1) continue;
|
|
1325
|
+
throw err;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
throw new ConflictError();
|
|
1329
|
+
}
|
|
1330
|
+
|
|
842
1331
|
// src/config.ts
|
|
843
1332
|
async function fetchServerConfig(baseUrl, options) {
|
|
844
1333
|
const url = `${baseUrl.replace(/\/$/, "")}/config`;
|
|
@@ -1211,6 +1700,29 @@ function createMobileLifecycle(store, deps, options = {}) {
|
|
|
1211
1700
|
netUnsub?.();
|
|
1212
1701
|
};
|
|
1213
1702
|
}
|
|
1703
|
+
function createAppendLogMobileLifecycle(store, deps, options = {}) {
|
|
1704
|
+
const { pullOnForeground = true } = options;
|
|
1705
|
+
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
1706
|
+
if (appState === "active" && pullOnForeground) {
|
|
1707
|
+
const { online, loading } = store.getState();
|
|
1708
|
+
if (online && !loading) {
|
|
1709
|
+
store.getState().pull().catch((err) => {
|
|
1710
|
+
console.error("[Starfish] foreground log pull failed:", err);
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
let netUnsub = null;
|
|
1716
|
+
if (deps.netInfo) {
|
|
1717
|
+
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
1718
|
+
store.getState().setOnline(!!isConnected);
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
return () => {
|
|
1722
|
+
appSub.remove();
|
|
1723
|
+
netUnsub?.();
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1214
1726
|
|
|
1215
1727
|
// src/multi-store.ts
|
|
1216
1728
|
function createMultiStoreSync(options) {
|
|
@@ -1263,6 +1775,8 @@ function createMultiStoreSync(options) {
|
|
|
1263
1775
|
}
|
|
1264
1776
|
export {
|
|
1265
1777
|
AbortError,
|
|
1778
|
+
AppendAuthorError,
|
|
1779
|
+
AppendLogCursor,
|
|
1266
1780
|
ConflictError,
|
|
1267
1781
|
ENCRYPTED_KEY,
|
|
1268
1782
|
SnapshotHistory,
|
|
@@ -1271,10 +1785,12 @@ export {
|
|
|
1271
1785
|
SyncManager,
|
|
1272
1786
|
ValidationError,
|
|
1273
1787
|
buildRevocationList,
|
|
1788
|
+
checkpointOf,
|
|
1274
1789
|
classifyError,
|
|
1275
1790
|
computeHash,
|
|
1276
1791
|
configurePlatform,
|
|
1277
1792
|
consoleSyncLogger,
|
|
1793
|
+
createAppendLogMobileLifecycle,
|
|
1278
1794
|
createDebouncedPush,
|
|
1279
1795
|
createDebouncedSync,
|
|
1280
1796
|
createDedupFetch,
|
|
@@ -1293,12 +1809,14 @@ export {
|
|
|
1293
1809
|
importData,
|
|
1294
1810
|
isBackgroundSyncSupported,
|
|
1295
1811
|
isServiceWorkerSupported,
|
|
1812
|
+
mutateDoc,
|
|
1296
1813
|
noopSyncLogger,
|
|
1297
1814
|
pruneTombstones,
|
|
1815
|
+
pullWasFromCache,
|
|
1298
1816
|
registerBackgroundSync,
|
|
1299
1817
|
registerServiceWorker,
|
|
1300
1818
|
revocationListCanonicalSigningInput,
|
|
1301
|
-
|
|
1819
|
+
stableStringify2 as stableStringify,
|
|
1302
1820
|
startAdaptivePolling,
|
|
1303
1821
|
startPolling,
|
|
1304
1822
|
timestampWinner,
|