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