@drakkar.software/starfish-client 1.19.0 → 1.19.2
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/dist/bindings/legend.js +60 -60
- package/dist/bindings/legend.js.map +7 -0
- package/dist/bindings/zustand.js +785 -250
- package/dist/bindings/zustand.js.map +7 -0
- package/dist/broadcast.js +69 -79
- package/dist/broadcast.js.map +7 -0
- package/dist/fetch.js +131 -156
- package/dist/fetch.js.map +7 -0
- package/dist/group-crypto.js +188 -213
- package/dist/group-crypto.js.map +7 -0
- package/dist/identity.js +346 -154
- package/dist/identity.js.map +7 -0
- package/dist/index.js +1344 -25
- package/dist/index.js.map +7 -0
- package/dist/testing.js +64 -83
- package/dist/testing.js.map +7 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,25 +1,1344 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { configurePlatform } from "@drakkar.software/starfish-protocol";
|
|
3
|
+
import { stableStringify as stableStringify2, computeHash } from "@drakkar.software/starfish-protocol";
|
|
4
|
+
|
|
5
|
+
// src/types.ts
|
|
6
|
+
var ConflictError = class extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("hash_mismatch");
|
|
9
|
+
this.name = "ConflictError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var StarfishHttpError = class extends Error {
|
|
13
|
+
constructor(status, body) {
|
|
14
|
+
super(`HTTP ${status}: ${body}`);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
this.name = "StarfishHttpError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/client.ts
|
|
22
|
+
var StarfishClient = class {
|
|
23
|
+
baseUrl;
|
|
24
|
+
auth;
|
|
25
|
+
fetch;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
28
|
+
this.auth = options.auth;
|
|
29
|
+
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pull synced data from the server.
|
|
33
|
+
* @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
|
|
34
|
+
* @param checkpoint - Only return data updated after this timestamp (0 = full pull)
|
|
35
|
+
*/
|
|
36
|
+
async pull(path, checkpoint) {
|
|
37
|
+
const url = checkpoint ? `${this.baseUrl}${path}?checkpoint=${checkpoint}` : `${this.baseUrl}${path}`;
|
|
38
|
+
const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
|
|
39
|
+
const res = await this.fetch(url, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: { Accept: "application/json", ...authHeaders }
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
45
|
+
}
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Push synced data to the server.
|
|
50
|
+
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
51
|
+
* @param data - The full document data to push
|
|
52
|
+
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
53
|
+
* @param authorSignature - Optional author signature for provenance
|
|
54
|
+
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
55
|
+
*/
|
|
56
|
+
async push(path, data, baseHash, authorSignature) {
|
|
57
|
+
const body = JSON.stringify({
|
|
58
|
+
data,
|
|
59
|
+
baseHash,
|
|
60
|
+
...authorSignature && { authorSignature }
|
|
61
|
+
});
|
|
62
|
+
const authHeaders = this.auth ? await this.auth({ method: "POST", path, body }) : {};
|
|
63
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
Accept: "application/json",
|
|
68
|
+
...authHeaders
|
|
69
|
+
},
|
|
70
|
+
body
|
|
71
|
+
});
|
|
72
|
+
if (res.status === 409) {
|
|
73
|
+
throw new ConflictError();
|
|
74
|
+
}
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
77
|
+
}
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Pull binary data from a blob collection.
|
|
82
|
+
* Returns raw bytes with the content hash from the ETag header.
|
|
83
|
+
*/
|
|
84
|
+
async pullBlob(path) {
|
|
85
|
+
const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
|
|
86
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
87
|
+
method: "GET",
|
|
88
|
+
headers: { Accept: "*/*", ...authHeaders }
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
92
|
+
}
|
|
93
|
+
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
94
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
95
|
+
const data = await res.arrayBuffer();
|
|
96
|
+
return { data, hash: etag, contentType };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Push binary data to a blob collection.
|
|
100
|
+
* Binary collections use last-write-wins (no conflict detection).
|
|
101
|
+
*/
|
|
102
|
+
async pushBlob(path, data, contentType) {
|
|
103
|
+
const authHeaders = this.auth ? await this.auth({ method: "POST", path, body: null }) : {};
|
|
104
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": contentType,
|
|
108
|
+
Accept: "application/json",
|
|
109
|
+
...authHeaders
|
|
110
|
+
},
|
|
111
|
+
body: data
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
115
|
+
}
|
|
116
|
+
return res.json();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// src/sync.ts
|
|
121
|
+
import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
|
|
122
|
+
|
|
123
|
+
// src/crypto.ts
|
|
124
|
+
import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
|
|
125
|
+
var ALGO = "AES-GCM";
|
|
126
|
+
function createEncryptor(secret, salt, info = "starfish-e2e") {
|
|
127
|
+
if (!secret) throw new Error("encryptionSecret must not be empty");
|
|
128
|
+
if (!salt) throw new Error("encryptionSalt must not be empty");
|
|
129
|
+
const keyPromise = deriveKey(secret, salt, info);
|
|
130
|
+
return {
|
|
131
|
+
async encrypt(data) {
|
|
132
|
+
const key = await keyPromise;
|
|
133
|
+
const c = getCrypto();
|
|
134
|
+
const b64 = getBase64();
|
|
135
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
136
|
+
const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
|
|
137
|
+
const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
|
|
138
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
139
|
+
combined.set(iv);
|
|
140
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
141
|
+
return { [ENCRYPTED_KEY]: b64.encode(combined) };
|
|
142
|
+
},
|
|
143
|
+
async decrypt(wrapper) {
|
|
144
|
+
const encoded = wrapper[ENCRYPTED_KEY];
|
|
145
|
+
if (typeof encoded !== "string") {
|
|
146
|
+
throw new Error("Expected encrypted data but received unencrypted document");
|
|
147
|
+
}
|
|
148
|
+
const key = await keyPromise;
|
|
149
|
+
const c = getCrypto();
|
|
150
|
+
const b64 = getBase64();
|
|
151
|
+
const combined = b64.decode(encoded);
|
|
152
|
+
if (combined.length < IV_BYTES) {
|
|
153
|
+
throw new Error("Encrypted data is too short");
|
|
154
|
+
}
|
|
155
|
+
const iv = combined.slice(0, IV_BYTES);
|
|
156
|
+
const ciphertext = combined.slice(IV_BYTES);
|
|
157
|
+
try {
|
|
158
|
+
const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
|
|
159
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/validate.ts
|
|
168
|
+
var ValidationError = class extends Error {
|
|
169
|
+
constructor(errors) {
|
|
170
|
+
super(`Validation failed: ${errors.join("; ")}`);
|
|
171
|
+
this.errors = errors;
|
|
172
|
+
this.name = "ValidationError";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
function createSchemaValidator(ajv, schema) {
|
|
176
|
+
const validate = ajv.compile(schema);
|
|
177
|
+
return (data) => {
|
|
178
|
+
if (validate(data)) return true;
|
|
179
|
+
return [ajv.errorsText(validate.errors)];
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/sync.ts
|
|
184
|
+
var SyncManager = class {
|
|
185
|
+
client;
|
|
186
|
+
pullPath;
|
|
187
|
+
pushPath;
|
|
188
|
+
onConflict;
|
|
189
|
+
maxRetries;
|
|
190
|
+
encryptor;
|
|
191
|
+
signData;
|
|
192
|
+
logger;
|
|
193
|
+
loggerName;
|
|
194
|
+
validate;
|
|
195
|
+
lastHash = null;
|
|
196
|
+
lastCheckpoint = 0;
|
|
197
|
+
localData = {};
|
|
198
|
+
constructor(options) {
|
|
199
|
+
this.client = options.client;
|
|
200
|
+
this.pullPath = options.pullPath;
|
|
201
|
+
this.pushPath = options.pushPath;
|
|
202
|
+
this.onConflict = options.onConflict ?? deepMerge;
|
|
203
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
204
|
+
this.signData = options.signData;
|
|
205
|
+
this.logger = options.logger;
|
|
206
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
207
|
+
this.validate = options.validate;
|
|
208
|
+
this.encryptor = options.encryptor ?? (options.encryptionSecret && options.encryptionSalt ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo) : null);
|
|
209
|
+
}
|
|
210
|
+
getData() {
|
|
211
|
+
return { ...this.localData };
|
|
212
|
+
}
|
|
213
|
+
getHash() {
|
|
214
|
+
return this.lastHash;
|
|
215
|
+
}
|
|
216
|
+
getCheckpoint() {
|
|
217
|
+
return this.lastCheckpoint;
|
|
218
|
+
}
|
|
219
|
+
async pull() {
|
|
220
|
+
this.logger?.pullStart(this.loggerName);
|
|
221
|
+
const start = performance.now();
|
|
222
|
+
try {
|
|
223
|
+
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
224
|
+
if (this.encryptor) {
|
|
225
|
+
const decrypted = await this.encryptor.decrypt(result.data);
|
|
226
|
+
this.localData = decrypted;
|
|
227
|
+
result.data = decrypted;
|
|
228
|
+
} else if (this.lastCheckpoint > 0) {
|
|
229
|
+
this.localData = deepMerge(this.localData, result.data);
|
|
230
|
+
result.data = this.localData;
|
|
231
|
+
} else {
|
|
232
|
+
this.localData = result.data;
|
|
233
|
+
}
|
|
234
|
+
this.lastHash = result.hash;
|
|
235
|
+
this.lastCheckpoint = result.timestamp;
|
|
236
|
+
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
237
|
+
return result;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async push(data) {
|
|
244
|
+
if (this.validate) {
|
|
245
|
+
const result = this.validate(data);
|
|
246
|
+
if (result !== true) throw new ValidationError(result);
|
|
247
|
+
}
|
|
248
|
+
this.logger?.pushStart(this.loggerName);
|
|
249
|
+
const start = performance.now();
|
|
250
|
+
let attempt = 0;
|
|
251
|
+
let pendingData = data;
|
|
252
|
+
while (attempt <= this.maxRetries) {
|
|
253
|
+
try {
|
|
254
|
+
const payload = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
255
|
+
const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
|
|
256
|
+
const result = await this.client.push(
|
|
257
|
+
this.pushPath,
|
|
258
|
+
payload,
|
|
259
|
+
this.lastHash,
|
|
260
|
+
sig
|
|
261
|
+
);
|
|
262
|
+
this.lastHash = result.hash;
|
|
263
|
+
this.lastCheckpoint = result.timestamp;
|
|
264
|
+
this.localData = pendingData;
|
|
265
|
+
this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
266
|
+
return result;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
269
|
+
this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
this.logger?.conflict(this.loggerName, attempt + 1);
|
|
273
|
+
try {
|
|
274
|
+
const remote = await this.client.pull(this.pullPath);
|
|
275
|
+
const remoteData = this.encryptor ? await this.encryptor.decrypt(remote.data) : remote.data;
|
|
276
|
+
this.lastHash = remote.hash;
|
|
277
|
+
this.lastCheckpoint = remote.timestamp;
|
|
278
|
+
pendingData = this.onConflict(pendingData, remoteData);
|
|
279
|
+
} catch (resolveErr) {
|
|
280
|
+
const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
|
|
281
|
+
this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
|
|
282
|
+
throw resolveErr;
|
|
283
|
+
}
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2e3) + Math.random() * 100));
|
|
285
|
+
attempt++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
throw new ConflictError();
|
|
289
|
+
}
|
|
290
|
+
async update(modifier) {
|
|
291
|
+
await this.pull();
|
|
292
|
+
const updated = modifier(this.localData);
|
|
293
|
+
return this.push(updated);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/logger.ts
|
|
298
|
+
var consoleSyncLogger = {
|
|
299
|
+
pullStart: (s) => console.log(`[starfish:${s}] pull started`),
|
|
300
|
+
pullSuccess: (s, ms, m) => {
|
|
301
|
+
let msg = `[starfish:${s}] pull OK (${ms}ms)`;
|
|
302
|
+
if (m?.bytesTransferred) msg += ` ${m.bytesTransferred}B`;
|
|
303
|
+
if (m?.cacheHit) msg += ` (cache hit)`;
|
|
304
|
+
console.log(msg);
|
|
305
|
+
},
|
|
306
|
+
pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
|
|
307
|
+
pushStart: (s) => console.log(`[starfish:${s}] push started`),
|
|
308
|
+
pushSuccess: (s, ms, m) => {
|
|
309
|
+
let msg = `[starfish:${s}] push OK (${ms}ms)`;
|
|
310
|
+
if (m?.bytesTransferred) msg += ` ${m.bytesTransferred}B`;
|
|
311
|
+
console.log(msg);
|
|
312
|
+
},
|
|
313
|
+
pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
|
|
314
|
+
conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`)
|
|
315
|
+
};
|
|
316
|
+
var noopSyncLogger = {
|
|
317
|
+
pullStart: () => {
|
|
318
|
+
},
|
|
319
|
+
pullSuccess: () => {
|
|
320
|
+
},
|
|
321
|
+
pullError: () => {
|
|
322
|
+
},
|
|
323
|
+
pushStart: () => {
|
|
324
|
+
},
|
|
325
|
+
pushSuccess: () => {
|
|
326
|
+
},
|
|
327
|
+
pushError: () => {
|
|
328
|
+
},
|
|
329
|
+
conflict: () => {
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
function createMetricsCollector() {
|
|
333
|
+
const stores = /* @__PURE__ */ new Map();
|
|
334
|
+
function ensureStore(name) {
|
|
335
|
+
let s = stores.get(name);
|
|
336
|
+
if (!s) {
|
|
337
|
+
s = { totalPulls: 0, totalPushes: 0, totalDurationMs: 0, totalBytes: 0, totalConflicts: 0 };
|
|
338
|
+
stores.set(name, s);
|
|
339
|
+
}
|
|
340
|
+
return s;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
recordPull(name, durationMs, metrics) {
|
|
344
|
+
const s = ensureStore(name);
|
|
345
|
+
s.totalPulls++;
|
|
346
|
+
s.totalDurationMs += durationMs;
|
|
347
|
+
if (metrics?.bytesTransferred) s.totalBytes += metrics.bytesTransferred;
|
|
348
|
+
},
|
|
349
|
+
recordPush(name, durationMs, metrics) {
|
|
350
|
+
const s = ensureStore(name);
|
|
351
|
+
s.totalPushes++;
|
|
352
|
+
s.totalDurationMs += durationMs;
|
|
353
|
+
if (metrics?.bytesTransferred) s.totalBytes += metrics.bytesTransferred;
|
|
354
|
+
},
|
|
355
|
+
recordConflict(name) {
|
|
356
|
+
ensureStore(name).totalConflicts++;
|
|
357
|
+
},
|
|
358
|
+
getSummary() {
|
|
359
|
+
const result = {};
|
|
360
|
+
for (const [name, s] of stores) {
|
|
361
|
+
const totalOps = s.totalPulls + s.totalPushes;
|
|
362
|
+
result[name] = {
|
|
363
|
+
totalPulls: s.totalPulls,
|
|
364
|
+
totalPushes: s.totalPushes,
|
|
365
|
+
avgDurationMs: totalOps > 0 ? Math.round(s.totalDurationMs / totalOps) : 0,
|
|
366
|
+
totalBytes: s.totalBytes,
|
|
367
|
+
totalConflicts: s.totalConflicts
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
},
|
|
372
|
+
reset() {
|
|
373
|
+
stores.clear();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/migrate.ts
|
|
379
|
+
function createMigrator(config) {
|
|
380
|
+
for (let v = 1; v < config.currentVersion; v++) {
|
|
381
|
+
if (!config.migrations[v]) {
|
|
382
|
+
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return (data) => {
|
|
386
|
+
const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
|
|
387
|
+
if (version > config.currentVersion) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (version === config.currentVersion) return data;
|
|
393
|
+
let result = { ...data };
|
|
394
|
+
for (let v = version; v < config.currentVersion; v++) {
|
|
395
|
+
const fn = config.migrations[v];
|
|
396
|
+
if (!fn) {
|
|
397
|
+
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
result = fn(result);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
404
|
+
{ cause: err }
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
result._schemaVersion = config.currentVersion;
|
|
409
|
+
return result;
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/fetch.ts
|
|
414
|
+
function classifyError(err) {
|
|
415
|
+
if (err instanceof Response || err && typeof err === "object" && "status" in err) {
|
|
416
|
+
const status = err.status;
|
|
417
|
+
if (typeof status !== "number" || isNaN(status)) return "unknown";
|
|
418
|
+
if (status === 0) return "network";
|
|
419
|
+
if (status === 401 || status === 403) return "auth";
|
|
420
|
+
if (status === 409) return "conflict";
|
|
421
|
+
if (status === 429) return "rate-limited";
|
|
422
|
+
if (status >= 500) return "server";
|
|
423
|
+
if (status >= 400) return "client";
|
|
424
|
+
}
|
|
425
|
+
if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
|
|
426
|
+
return "unknown";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/resolvers.ts
|
|
430
|
+
function shallowEqual(a, b) {
|
|
431
|
+
if (a === b) return true;
|
|
432
|
+
if (a == null || b == null) return a === b;
|
|
433
|
+
if (typeof a !== typeof b) return false;
|
|
434
|
+
if (typeof a !== "object") return false;
|
|
435
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
436
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
437
|
+
if (a.length !== b.length) return false;
|
|
438
|
+
return a.every((v, i) => shallowEqual(v, b[i]));
|
|
439
|
+
}
|
|
440
|
+
const aObj = a;
|
|
441
|
+
const bObj = b;
|
|
442
|
+
const aKeys = Object.keys(aObj);
|
|
443
|
+
const bKeys = Object.keys(bObj);
|
|
444
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
445
|
+
return aKeys.every((k) => shallowEqual(aObj[k], bObj[k]));
|
|
446
|
+
}
|
|
447
|
+
function withConflictMeta(resolver) {
|
|
448
|
+
return (local, remote) => {
|
|
449
|
+
const conflictedFields = [];
|
|
450
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
451
|
+
for (const key of allKeys) {
|
|
452
|
+
const lv = local[key];
|
|
453
|
+
const rv = remote[key];
|
|
454
|
+
if (!shallowEqual(lv, rv)) {
|
|
455
|
+
conflictedFields.push(key);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const data = resolver(local, remote);
|
|
459
|
+
let resolvedBy = "merged";
|
|
460
|
+
if (shallowEqual(data, local)) resolvedBy = "local";
|
|
461
|
+
else if (shallowEqual(data, remote)) resolvedBy = "remote";
|
|
462
|
+
return {
|
|
463
|
+
data,
|
|
464
|
+
meta: {
|
|
465
|
+
conflictedFields,
|
|
466
|
+
resolvedBy,
|
|
467
|
+
timestamp: Date.now()
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function compareTimestamps(a, b) {
|
|
473
|
+
if (typeof a === "number" && typeof b === "number") return a >= b;
|
|
474
|
+
return String(a ?? "") >= String(b ?? "");
|
|
475
|
+
}
|
|
476
|
+
function createUnionMerge(options) {
|
|
477
|
+
const idKey = options?.idKey ?? "id";
|
|
478
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
479
|
+
const docTsKey = options?.documentTimestampKey ?? "timestamp";
|
|
480
|
+
return (local, remote) => {
|
|
481
|
+
const result = {};
|
|
482
|
+
const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
|
|
483
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
484
|
+
for (const key of allKeys) {
|
|
485
|
+
const lv = local[key];
|
|
486
|
+
const rv = remote[key];
|
|
487
|
+
if (Array.isArray(lv) && Array.isArray(rv)) {
|
|
488
|
+
const map = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const item of rv) {
|
|
490
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
491
|
+
map.set(item[idKey], item);
|
|
492
|
+
} else {
|
|
493
|
+
map.set(/* @__PURE__ */ Symbol(), item);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const item of lv) {
|
|
497
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
498
|
+
const localItem = item;
|
|
499
|
+
const id = localItem[idKey];
|
|
500
|
+
const remoteItem = map.get(id);
|
|
501
|
+
if (!remoteItem) {
|
|
502
|
+
map.set(id, localItem);
|
|
503
|
+
} else {
|
|
504
|
+
if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
|
|
505
|
+
map.set(id, localItem);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
map.set(/* @__PURE__ */ Symbol(), item);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
result[key] = [...map.values()];
|
|
513
|
+
} else if (lv !== void 0 && rv !== void 0) {
|
|
514
|
+
result[key] = localNewer ? lv : rv;
|
|
515
|
+
} else {
|
|
516
|
+
result[key] = lv ?? rv;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return result;
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function createSoftDeleteResolver(options) {
|
|
523
|
+
const idKey = options?.idKey ?? "id";
|
|
524
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
525
|
+
const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
|
|
526
|
+
const baseMerge = createUnionMerge(options);
|
|
527
|
+
return (local, remote) => {
|
|
528
|
+
const merged = baseMerge(local, remote);
|
|
529
|
+
const tombstones = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const source of [local, remote]) {
|
|
531
|
+
for (const key of Object.keys(source)) {
|
|
532
|
+
const arr = source[key];
|
|
533
|
+
if (!Array.isArray(arr)) continue;
|
|
534
|
+
for (const item of arr) {
|
|
535
|
+
if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
|
|
536
|
+
const rec = item;
|
|
537
|
+
const id = rec[idKey];
|
|
538
|
+
const deletedAt = rec[deletedAtKey];
|
|
539
|
+
if (typeof deletedAt === "number" || typeof deletedAt === "string") {
|
|
540
|
+
const existing = tombstones.get(id);
|
|
541
|
+
if (existing == null || compareTimestamps(deletedAt, existing)) tombstones.set(id, deletedAt);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const key of Object.keys(merged)) {
|
|
548
|
+
const value = merged[key];
|
|
549
|
+
if (!Array.isArray(value)) continue;
|
|
550
|
+
merged[key] = value.filter((item) => {
|
|
551
|
+
if (!item || typeof item !== "object" || !(idKey in item)) return true;
|
|
552
|
+
const rec = item;
|
|
553
|
+
const id = rec[idKey];
|
|
554
|
+
const deletedAt = tombstones.get(id);
|
|
555
|
+
if (deletedAt == null) return true;
|
|
556
|
+
if (rec[deletedAtKey] != null) return true;
|
|
557
|
+
return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return merged;
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function timestampWinner(timestampKey = "timestamp") {
|
|
564
|
+
return (local, remote) => {
|
|
565
|
+
return compareTimestamps(local[timestampKey], remote[timestampKey]) ? local : remote;
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1e3, deletedAtKey = "_deletedAt") {
|
|
569
|
+
const cutoff = Date.now() - ttlMs;
|
|
570
|
+
return items.filter((item) => {
|
|
571
|
+
const deletedAt = item[deletedAtKey];
|
|
572
|
+
if (deletedAt == null) return true;
|
|
573
|
+
if (typeof deletedAt === "number") return deletedAt > cutoff;
|
|
574
|
+
if (typeof deletedAt === "string") return new Date(deletedAt).getTime() > cutoff;
|
|
575
|
+
return false;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/history.ts
|
|
580
|
+
var SnapshotHistory = class {
|
|
581
|
+
snapshots = [];
|
|
582
|
+
maxSnapshots;
|
|
583
|
+
storageKey;
|
|
584
|
+
constructor(options) {
|
|
585
|
+
this.maxSnapshots = options?.maxSnapshots ?? 20;
|
|
586
|
+
this.storageKey = options?.storageKey;
|
|
587
|
+
if (this.storageKey) {
|
|
588
|
+
try {
|
|
589
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
590
|
+
if (raw) {
|
|
591
|
+
const parsed = JSON.parse(raw);
|
|
592
|
+
if (Array.isArray(parsed)) this.snapshots = parsed;
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** Take a labeled snapshot of the given data. */
|
|
599
|
+
take(label, data) {
|
|
600
|
+
this.snapshots.push({
|
|
601
|
+
timestamp: Date.now(),
|
|
602
|
+
label,
|
|
603
|
+
data: JSON.stringify(data)
|
|
604
|
+
});
|
|
605
|
+
if (this.snapshots.length > this.maxSnapshots) {
|
|
606
|
+
this.snapshots = this.snapshots.slice(-this.maxSnapshots);
|
|
607
|
+
}
|
|
608
|
+
this.persist();
|
|
609
|
+
}
|
|
610
|
+
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
611
|
+
restore(index) {
|
|
612
|
+
const snapshot = this.snapshots[index];
|
|
613
|
+
if (!snapshot) return void 0;
|
|
614
|
+
try {
|
|
615
|
+
return JSON.parse(snapshot.data);
|
|
616
|
+
} catch {
|
|
617
|
+
return void 0;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** List available snapshots (metadata only, no data payload). */
|
|
621
|
+
list() {
|
|
622
|
+
return this.snapshots.map(({ timestamp, label }) => ({ timestamp, label }));
|
|
623
|
+
}
|
|
624
|
+
/** Clear all snapshots. */
|
|
625
|
+
clear() {
|
|
626
|
+
this.snapshots = [];
|
|
627
|
+
this.persist();
|
|
628
|
+
}
|
|
629
|
+
persist() {
|
|
630
|
+
if (!this.storageKey) return;
|
|
631
|
+
try {
|
|
632
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.snapshots));
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// src/polling.ts
|
|
639
|
+
var DEFAULT_INTERVALS = {
|
|
640
|
+
"slow-2g": 12e4,
|
|
641
|
+
"2g": 6e4,
|
|
642
|
+
"3g": 3e4,
|
|
643
|
+
"4g": 1e4
|
|
644
|
+
};
|
|
645
|
+
var DEFAULT_FALLBACK_MS = 15e3;
|
|
646
|
+
function startPolling(pullFn, getState, intervalMs = 3e4) {
|
|
647
|
+
const timer = setInterval(() => {
|
|
648
|
+
const { online, syncing } = getState();
|
|
649
|
+
if (online && !syncing) pullFn().catch((err) => {
|
|
650
|
+
console.error("[Starfish] poll failed:", err);
|
|
651
|
+
});
|
|
652
|
+
}, intervalMs);
|
|
653
|
+
return () => clearInterval(timer);
|
|
654
|
+
}
|
|
655
|
+
function startAdaptivePolling(pullFn, getState, options) {
|
|
656
|
+
let intervalMs;
|
|
657
|
+
if (options?.intervalMs != null) {
|
|
658
|
+
intervalMs = options.intervalMs;
|
|
659
|
+
} else {
|
|
660
|
+
const intervals = options?.intervals ?? DEFAULT_INTERVALS;
|
|
661
|
+
let effectiveType;
|
|
662
|
+
if (typeof navigator !== "undefined" && "connection" in navigator) {
|
|
663
|
+
effectiveType = navigator.connection.effectiveType;
|
|
664
|
+
}
|
|
665
|
+
intervalMs = (effectiveType != null ? intervals[effectiveType] : void 0) ?? DEFAULT_FALLBACK_MS;
|
|
666
|
+
}
|
|
667
|
+
let paused = false;
|
|
668
|
+
const timer = setInterval(() => {
|
|
669
|
+
if (paused) return;
|
|
670
|
+
const { online, syncing } = getState();
|
|
671
|
+
if (online && !syncing) pullFn().catch((err) => {
|
|
672
|
+
console.error("[Starfish] adaptive poll failed:", err);
|
|
673
|
+
});
|
|
674
|
+
}, intervalMs);
|
|
675
|
+
return {
|
|
676
|
+
pause: () => {
|
|
677
|
+
paused = true;
|
|
678
|
+
},
|
|
679
|
+
resume: () => {
|
|
680
|
+
paused = false;
|
|
681
|
+
},
|
|
682
|
+
stop: () => clearInterval(timer)
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/dedup.ts
|
|
687
|
+
function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
|
|
688
|
+
const inflightGets = /* @__PURE__ */ new Map();
|
|
689
|
+
return (async (input, init) => {
|
|
690
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
691
|
+
if (method !== "GET") {
|
|
692
|
+
return baseFetch(input, init);
|
|
693
|
+
}
|
|
694
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
695
|
+
const existing = inflightGets.get(url);
|
|
696
|
+
if (existing) {
|
|
697
|
+
return existing.then((res) => res.clone());
|
|
698
|
+
}
|
|
699
|
+
const promise = baseFetch(input, init).then((res) => res).finally(() => {
|
|
700
|
+
inflightGets.delete(url);
|
|
701
|
+
});
|
|
702
|
+
inflightGets.set(url, promise);
|
|
703
|
+
return promise.then((res) => res.clone());
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/config.ts
|
|
708
|
+
async function fetchServerConfig(baseUrl, options) {
|
|
709
|
+
const url = `${baseUrl.replace(/\/$/, "")}/config`;
|
|
710
|
+
const res = await fetch(url, {
|
|
711
|
+
method: "GET",
|
|
712
|
+
headers: options?.headers
|
|
713
|
+
});
|
|
714
|
+
if (!res.ok) {
|
|
715
|
+
throw new Error(`fetchServerConfig: ${res.status} ${res.statusText}`);
|
|
716
|
+
}
|
|
717
|
+
return res.json();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/storage/indexeddb.ts
|
|
721
|
+
function openDB(dbName, storeName) {
|
|
722
|
+
return new Promise((resolve, reject) => {
|
|
723
|
+
const request = indexedDB.open(dbName, 1);
|
|
724
|
+
request.onupgradeneeded = () => {
|
|
725
|
+
const db = request.result;
|
|
726
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
727
|
+
db.createObjectStore(storeName);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
request.onsuccess = () => resolve(request.result);
|
|
731
|
+
request.onerror = () => reject(request.error);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function idbRequest(request) {
|
|
735
|
+
return new Promise((resolve, reject) => {
|
|
736
|
+
request.onsuccess = () => resolve(request.result);
|
|
737
|
+
request.onerror = () => reject(request.error);
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function createIndexedDBStorage(opts) {
|
|
741
|
+
const dbName = opts?.dbName ?? "starfish";
|
|
742
|
+
const storeName = opts?.storeName ?? "state";
|
|
743
|
+
let dbPromise = null;
|
|
744
|
+
function getDB() {
|
|
745
|
+
if (!dbPromise) {
|
|
746
|
+
dbPromise = openDB(dbName, storeName).catch((err) => {
|
|
747
|
+
dbPromise = null;
|
|
748
|
+
throw err;
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return dbPromise;
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
async getItem(name) {
|
|
755
|
+
const db = await getDB();
|
|
756
|
+
const tx = db.transaction(storeName, "readonly");
|
|
757
|
+
const store = tx.objectStore(storeName);
|
|
758
|
+
const result = await idbRequest(store.get(name));
|
|
759
|
+
return result ?? null;
|
|
760
|
+
},
|
|
761
|
+
async setItem(name, value) {
|
|
762
|
+
const db = await getDB();
|
|
763
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
764
|
+
const store = tx.objectStore(storeName);
|
|
765
|
+
await idbRequest(store.put(value, name));
|
|
766
|
+
},
|
|
767
|
+
async removeItem(name) {
|
|
768
|
+
const db = await getDB();
|
|
769
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
770
|
+
const store = tx.objectStore(storeName);
|
|
771
|
+
await idbRequest(store.delete(name));
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/export.ts
|
|
777
|
+
function exportData(data, opts) {
|
|
778
|
+
const format = opts?.format ?? "json";
|
|
779
|
+
if (format === "json") {
|
|
780
|
+
return opts?.pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
781
|
+
}
|
|
782
|
+
return toCsv(data);
|
|
783
|
+
}
|
|
784
|
+
function importData(raw, format = "json") {
|
|
785
|
+
if (format === "json") {
|
|
786
|
+
const parsed = JSON.parse(raw);
|
|
787
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
788
|
+
throw new Error("Expected a JSON object");
|
|
789
|
+
}
|
|
790
|
+
return parsed;
|
|
791
|
+
}
|
|
792
|
+
return fromCsv(raw);
|
|
793
|
+
}
|
|
794
|
+
function exportToBlob(data, opts) {
|
|
795
|
+
const format = opts?.format ?? "json";
|
|
796
|
+
const content = exportData(data, opts);
|
|
797
|
+
const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
|
|
798
|
+
return new Blob([content], { type: mimeType });
|
|
799
|
+
}
|
|
800
|
+
function toCsv(data) {
|
|
801
|
+
const keys = Object.keys(data);
|
|
802
|
+
const header = keys.map(escapeCsvField).join(",");
|
|
803
|
+
const values = keys.map((k) => {
|
|
804
|
+
const v = data[k];
|
|
805
|
+
if (v === null || v === void 0) return "";
|
|
806
|
+
if (typeof v === "object") return escapeCsvField(JSON.stringify(v));
|
|
807
|
+
return escapeCsvField(String(v));
|
|
808
|
+
});
|
|
809
|
+
return `${header}
|
|
810
|
+
${values.join(",")}`;
|
|
811
|
+
}
|
|
812
|
+
function fromCsv(raw) {
|
|
813
|
+
const lines = raw.trim().split("\n");
|
|
814
|
+
if (lines.length < 2) {
|
|
815
|
+
throw new Error("CSV must have at least a header row and a data row");
|
|
816
|
+
}
|
|
817
|
+
const headers = parseCsvLine(lines[0]);
|
|
818
|
+
const values = parseCsvLine(lines[1]);
|
|
819
|
+
const result = {};
|
|
820
|
+
for (let i = 0; i < headers.length; i++) {
|
|
821
|
+
const key = headers[i];
|
|
822
|
+
const val = values[i] ?? "";
|
|
823
|
+
try {
|
|
824
|
+
result[key] = JSON.parse(val);
|
|
825
|
+
} catch {
|
|
826
|
+
result[key] = val;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return result;
|
|
830
|
+
}
|
|
831
|
+
function escapeCsvField(field) {
|
|
832
|
+
if (field.includes(",") || field.includes('"') || field.includes("\n")) {
|
|
833
|
+
return `"${field.replace(/"/g, '""')}"`;
|
|
834
|
+
}
|
|
835
|
+
return field;
|
|
836
|
+
}
|
|
837
|
+
function parseCsvLine(line) {
|
|
838
|
+
const result = [];
|
|
839
|
+
let current = "";
|
|
840
|
+
let inQuotes = false;
|
|
841
|
+
for (let i = 0; i < line.length; i++) {
|
|
842
|
+
const ch = line[i];
|
|
843
|
+
if (inQuotes) {
|
|
844
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
845
|
+
current += '"';
|
|
846
|
+
i++;
|
|
847
|
+
} else if (ch === '"') {
|
|
848
|
+
inQuotes = false;
|
|
849
|
+
} else {
|
|
850
|
+
current += ch;
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
if (ch === '"') {
|
|
854
|
+
inQuotes = true;
|
|
855
|
+
} else if (ch === ",") {
|
|
856
|
+
result.push(current);
|
|
857
|
+
current = "";
|
|
858
|
+
} else {
|
|
859
|
+
current += ch;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
result.push(current);
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/background-sync.ts
|
|
868
|
+
function isBackgroundSyncSupported() {
|
|
869
|
+
return typeof navigator !== "undefined" && "serviceWorker" in navigator && "SyncManager" in globalThis;
|
|
870
|
+
}
|
|
871
|
+
async function registerBackgroundSync(opts) {
|
|
872
|
+
if (!isBackgroundSyncSupported()) return false;
|
|
873
|
+
const tag = opts?.tag ?? "starfish-sync";
|
|
874
|
+
try {
|
|
875
|
+
const registration = await navigator.serviceWorker.ready;
|
|
876
|
+
await registration.sync.register(tag);
|
|
877
|
+
return true;
|
|
878
|
+
} catch {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/service-worker.ts
|
|
884
|
+
function isServiceWorkerSupported() {
|
|
885
|
+
return typeof navigator !== "undefined" && "serviceWorker" in navigator;
|
|
886
|
+
}
|
|
887
|
+
async function registerServiceWorker(scriptUrl, opts) {
|
|
888
|
+
if (!isServiceWorkerSupported()) return null;
|
|
889
|
+
try {
|
|
890
|
+
const registration = await navigator.serviceWorker.register(scriptUrl, {
|
|
891
|
+
scope: opts?.scope
|
|
892
|
+
});
|
|
893
|
+
if (opts?.onUpdate) {
|
|
894
|
+
registration.onupdatefound = () => {
|
|
895
|
+
const installingWorker = registration.installing;
|
|
896
|
+
if (installingWorker) {
|
|
897
|
+
installingWorker.onstatechange = () => {
|
|
898
|
+
if (installingWorker.state === "installed" && navigator.serviceWorker.controller) {
|
|
899
|
+
opts.onUpdate(registration);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
return registration;
|
|
906
|
+
} catch {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
async function unregisterServiceWorkers() {
|
|
911
|
+
if (!isServiceWorkerSupported()) return false;
|
|
912
|
+
try {
|
|
913
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
914
|
+
let unregistered = false;
|
|
915
|
+
for (const registration of registrations) {
|
|
916
|
+
const result = await registration.unregister();
|
|
917
|
+
if (result) unregistered = true;
|
|
918
|
+
}
|
|
919
|
+
return unregistered;
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/bindings/suspense.ts
|
|
926
|
+
function createSuspenseResource(fetcher) {
|
|
927
|
+
let status = "pending";
|
|
928
|
+
let result;
|
|
929
|
+
let error;
|
|
930
|
+
let promise = null;
|
|
931
|
+
function init() {
|
|
932
|
+
if (promise) return promise;
|
|
933
|
+
promise = fetcher().then(
|
|
934
|
+
(value) => {
|
|
935
|
+
status = "resolved";
|
|
936
|
+
result = value;
|
|
937
|
+
},
|
|
938
|
+
(err) => {
|
|
939
|
+
status = "rejected";
|
|
940
|
+
error = err;
|
|
941
|
+
}
|
|
942
|
+
);
|
|
943
|
+
return promise;
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
read() {
|
|
947
|
+
switch (status) {
|
|
948
|
+
case "pending":
|
|
949
|
+
throw init();
|
|
950
|
+
case "resolved":
|
|
951
|
+
return result;
|
|
952
|
+
case "rejected":
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/debounced-sync.ts
|
|
960
|
+
var DEFAULT_DELAY_MS = 2e3;
|
|
961
|
+
var DEFAULT_WARN_BYTES = 900 * 1024;
|
|
962
|
+
var DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
963
|
+
function checkPayloadSize(doc, opts) {
|
|
964
|
+
const estimatedBytes = Math.ceil(JSON.stringify(doc).length * 1.34);
|
|
965
|
+
if (estimatedBytes > opts.maxBytes) {
|
|
966
|
+
if (opts.onSizeExceeded) {
|
|
967
|
+
opts.onSizeExceeded(estimatedBytes);
|
|
968
|
+
} else {
|
|
969
|
+
console.error(
|
|
970
|
+
`[starfish] Push blocked: estimated payload ${(estimatedBytes / 1024).toFixed(0)} KB exceeds limit of ${(opts.maxBytes / 1024).toFixed(0)} KB. Prune your data before syncing.`
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
if (estimatedBytes > opts.warnBytes) {
|
|
976
|
+
if (opts.onSizeWarning) {
|
|
977
|
+
opts.onSizeWarning(estimatedBytes);
|
|
978
|
+
} else {
|
|
979
|
+
console.warn(
|
|
980
|
+
`[starfish] Payload approaching limit: estimated ${(estimatedBytes / 1024).toFixed(0)} KB (warn threshold: ${(opts.warnBytes / 1024).toFixed(0)} KB).`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
function createDebouncedSync(store, options = {}) {
|
|
987
|
+
const {
|
|
988
|
+
delayMs = DEFAULT_DELAY_MS,
|
|
989
|
+
warnBytes = DEFAULT_WARN_BYTES,
|
|
990
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
991
|
+
serialize,
|
|
992
|
+
onSizeWarning,
|
|
993
|
+
onSizeExceeded
|
|
994
|
+
} = options;
|
|
995
|
+
let timer = null;
|
|
996
|
+
function cancel() {
|
|
997
|
+
if (timer !== null) {
|
|
998
|
+
clearTimeout(timer);
|
|
999
|
+
timer = null;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function notify() {
|
|
1003
|
+
cancel();
|
|
1004
|
+
timer = setTimeout(() => {
|
|
1005
|
+
timer = null;
|
|
1006
|
+
const current = store.getState().data;
|
|
1007
|
+
const doc = serialize ? serialize(current) : current;
|
|
1008
|
+
if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded })) return;
|
|
1009
|
+
store.getState().set(() => doc);
|
|
1010
|
+
}, delayMs);
|
|
1011
|
+
}
|
|
1012
|
+
return { notify, cancel };
|
|
1013
|
+
}
|
|
1014
|
+
function createDebouncedPush(syncManager, options) {
|
|
1015
|
+
const {
|
|
1016
|
+
delayMs = DEFAULT_DELAY_MS,
|
|
1017
|
+
warnBytes = DEFAULT_WARN_BYTES,
|
|
1018
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
1019
|
+
serialize,
|
|
1020
|
+
onSizeWarning,
|
|
1021
|
+
onSizeExceeded,
|
|
1022
|
+
onError
|
|
1023
|
+
} = options;
|
|
1024
|
+
let timer = null;
|
|
1025
|
+
function cancel() {
|
|
1026
|
+
if (timer !== null) {
|
|
1027
|
+
clearTimeout(timer);
|
|
1028
|
+
timer = null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function notify() {
|
|
1032
|
+
cancel();
|
|
1033
|
+
timer = setTimeout(() => {
|
|
1034
|
+
timer = null;
|
|
1035
|
+
const doc = serialize();
|
|
1036
|
+
if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded })) return;
|
|
1037
|
+
syncManager.push(doc).catch((err) => {
|
|
1038
|
+
if (onError) {
|
|
1039
|
+
onError(err);
|
|
1040
|
+
} else {
|
|
1041
|
+
console.warn("[starfish] Push failed:", err);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}, delayMs);
|
|
1045
|
+
}
|
|
1046
|
+
return { notify, cancel };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/mobile-lifecycle.ts
|
|
1050
|
+
function createMobileLifecycle(store, deps, options = {}) {
|
|
1051
|
+
const { pullOnForeground = true, flushOnBackground = true } = options;
|
|
1052
|
+
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
1053
|
+
if (appState === "background" && flushOnBackground) {
|
|
1054
|
+
if (store.getState().dirty) {
|
|
1055
|
+
store.getState().flush().catch((err) => {
|
|
1056
|
+
console.error("[Starfish] background flush failed:", err);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
} else if (appState === "active" && pullOnForeground) {
|
|
1060
|
+
const { online, syncing } = store.getState();
|
|
1061
|
+
if (online && !syncing) {
|
|
1062
|
+
store.getState().pull().catch((err) => {
|
|
1063
|
+
console.error("[Starfish] foreground pull failed:", err);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
let netUnsub = null;
|
|
1069
|
+
if (deps.netInfo) {
|
|
1070
|
+
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
1071
|
+
store.getState().setOnline(!!isConnected);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
return () => {
|
|
1075
|
+
appSub.remove();
|
|
1076
|
+
netUnsub?.();
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/multi-store.ts
|
|
1081
|
+
function createMultiStoreSync(options) {
|
|
1082
|
+
const { slices, version, migrations = {} } = options;
|
|
1083
|
+
for (const fromVersion of Object.keys(migrations)) {
|
|
1084
|
+
const v = Number(fromVersion);
|
|
1085
|
+
if (isNaN(v) || v < 1) {
|
|
1086
|
+
throw new Error(`Migration key must be a positive integer, got: "${fromVersion}"`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function serialize() {
|
|
1090
|
+
const data = {};
|
|
1091
|
+
for (const key of Object.keys(slices)) {
|
|
1092
|
+
data[key] = slices[key].serialize();
|
|
1093
|
+
}
|
|
1094
|
+
return { version, timestamp: Date.now(), data };
|
|
1095
|
+
}
|
|
1096
|
+
function restore(doc) {
|
|
1097
|
+
if (typeof doc !== "object" || doc === null) {
|
|
1098
|
+
throw new Error("restore: expected a BackupDocument object");
|
|
1099
|
+
}
|
|
1100
|
+
const docVersion = doc.version ?? 1;
|
|
1101
|
+
if (typeof docVersion !== "number" || !Number.isInteger(docVersion) || docVersion < 1) {
|
|
1102
|
+
throw new Error(`restore: invalid document version: ${String(doc.version)}`);
|
|
1103
|
+
}
|
|
1104
|
+
if (docVersion > version) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`restore: document version ${docVersion} is newer than current version ${version}. Update the app to restore this backup.`
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
let data = typeof doc.data === "object" && doc.data !== null ? { ...doc.data } : {};
|
|
1110
|
+
for (let v = docVersion; v < version; v++) {
|
|
1111
|
+
const migration = migrations[v];
|
|
1112
|
+
if (!migration) continue;
|
|
1113
|
+
try {
|
|
1114
|
+
data = migration(data);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1117
|
+
throw new Error(`restore: migration from version ${v} to ${v + 1} failed: ${msg}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (const key of Object.keys(slices)) {
|
|
1121
|
+
const sliceData = data[key];
|
|
1122
|
+
if (sliceData !== void 0) {
|
|
1123
|
+
slices[key].restore(sliceData);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return { serialize, restore, version };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/group-crypto.ts
|
|
1131
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
1132
|
+
import { getCrypto as getCrypto2, getBase64 as getBase642, IV_BYTES as IV_BYTES2, deriveKey as deriveKey2 } from "@drakkar.software/starfish-protocol";
|
|
1133
|
+
function bytesToHex(bytes) {
|
|
1134
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1135
|
+
}
|
|
1136
|
+
function hexToBytes(hex) {
|
|
1137
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
1138
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1139
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
1140
|
+
}
|
|
1141
|
+
return bytes;
|
|
1142
|
+
}
|
|
1143
|
+
var ALGO2 = "AES-GCM";
|
|
1144
|
+
var GROUP_WRAP_SALT = "starfish-group-wrap";
|
|
1145
|
+
var GROUP_WRAP_INFO = "starfish-group-wrap";
|
|
1146
|
+
var GROUP_ECDH_DOMAIN = "starfish-group-ecdh";
|
|
1147
|
+
var GROUP_DATA_INFO = "starfish-group";
|
|
1148
|
+
var GEK_BYTES = 32;
|
|
1149
|
+
async function deriveGroupKeyPair(passphrase, userId) {
|
|
1150
|
+
const c = getCrypto2();
|
|
1151
|
+
const enc = new TextEncoder();
|
|
1152
|
+
const input = enc.encode(`${passphrase}:${userId}:${GROUP_ECDH_DOMAIN}`);
|
|
1153
|
+
const hash = await c.subtle.digest("SHA-256", input);
|
|
1154
|
+
const privateKeyBytes = new Uint8Array(hash);
|
|
1155
|
+
const publicKeyBytes = x25519.getPublicKey(privateKeyBytes);
|
|
1156
|
+
return { privateKey: bytesToHex(privateKeyBytes), publicKey: bytesToHex(publicKeyBytes) };
|
|
1157
|
+
}
|
|
1158
|
+
function generateGroupKey() {
|
|
1159
|
+
const c = getCrypto2();
|
|
1160
|
+
return bytesToHex(c.getRandomValues(new Uint8Array(GEK_BYTES)));
|
|
1161
|
+
}
|
|
1162
|
+
async function wrapGroupKey(gek, memberPublicKey, wrapperPrivateKey) {
|
|
1163
|
+
const sharedSecret = x25519.getSharedSecret(hexToBytes(wrapperPrivateKey), hexToBytes(memberPublicKey));
|
|
1164
|
+
const wrappingKey = await deriveKey2(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO);
|
|
1165
|
+
const c = getCrypto2();
|
|
1166
|
+
const b64 = getBase642();
|
|
1167
|
+
const iv = c.getRandomValues(new Uint8Array(IV_BYTES2));
|
|
1168
|
+
const encrypted = await c.subtle.encrypt({ name: ALGO2, iv }, wrappingKey, hexToBytes(gek).buffer);
|
|
1169
|
+
const combined = new Uint8Array(IV_BYTES2 + encrypted.byteLength);
|
|
1170
|
+
combined.set(iv);
|
|
1171
|
+
combined.set(new Uint8Array(encrypted), IV_BYTES2);
|
|
1172
|
+
return b64.encode(combined);
|
|
1173
|
+
}
|
|
1174
|
+
async function unwrapGroupKey(wrapped, memberPrivateKey, adminPublicKey) {
|
|
1175
|
+
const sharedSecret = x25519.getSharedSecret(hexToBytes(memberPrivateKey), hexToBytes(adminPublicKey));
|
|
1176
|
+
const wrappingKey = await deriveKey2(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO);
|
|
1177
|
+
const b64 = getBase642();
|
|
1178
|
+
const c = getCrypto2();
|
|
1179
|
+
const combined = b64.decode(wrapped);
|
|
1180
|
+
const iv = combined.slice(0, IV_BYTES2);
|
|
1181
|
+
const ciphertext = combined.slice(IV_BYTES2);
|
|
1182
|
+
try {
|
|
1183
|
+
const decrypted = await c.subtle.decrypt({ name: ALGO2, iv }, wrappingKey, ciphertext);
|
|
1184
|
+
return bytesToHex(new Uint8Array(decrypted));
|
|
1185
|
+
} catch {
|
|
1186
|
+
throw new Error("Failed to unwrap group key: decryption failed (wrong keys or corrupted data)");
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
async function createGroupKeyring(adminKeyPair, members, gek) {
|
|
1190
|
+
const resolvedGek = gek ?? generateGroupKey();
|
|
1191
|
+
const wrappedKeys = {};
|
|
1192
|
+
for (const [memberId, memberPublicKey] of Object.entries(members)) {
|
|
1193
|
+
wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey);
|
|
1194
|
+
}
|
|
1195
|
+
const keyring = {
|
|
1196
|
+
currentEpoch: 1,
|
|
1197
|
+
epochs: {
|
|
1198
|
+
"1": { adminPublicKey: adminKeyPair.publicKey, wrappedKeys }
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
return { keyring, gek: resolvedGek };
|
|
1202
|
+
}
|
|
1203
|
+
async function addGroupMember(keyring, adminKeyPair, currentGek, newMemberId, newMemberPublicKey) {
|
|
1204
|
+
const epochKey = String(keyring.currentEpoch);
|
|
1205
|
+
const epochKeyring = keyring.epochs[epochKey];
|
|
1206
|
+
if (!epochKeyring) throw new Error(`Epoch ${keyring.currentEpoch} not found in keyring`);
|
|
1207
|
+
if (epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {
|
|
1208
|
+
throw new Error(`Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`);
|
|
1209
|
+
}
|
|
1210
|
+
const wrapped = await wrapGroupKey(currentGek, newMemberPublicKey, adminKeyPair.privateKey);
|
|
1211
|
+
return {
|
|
1212
|
+
...keyring,
|
|
1213
|
+
epochs: {
|
|
1214
|
+
...keyring.epochs,
|
|
1215
|
+
[epochKey]: {
|
|
1216
|
+
...epochKeyring,
|
|
1217
|
+
wrappedKeys: { ...epochKeyring.wrappedKeys, [newMemberId]: wrapped }
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
async function rotateGroupKey(keyring, adminKeyPair, remainingMembers, newGek) {
|
|
1223
|
+
const epochKey = String(keyring.currentEpoch);
|
|
1224
|
+
const epochKeyring = keyring.epochs[epochKey];
|
|
1225
|
+
if (epochKeyring && epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
`Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
const resolvedGek = newGek ?? generateGroupKey();
|
|
1231
|
+
const newEpoch = keyring.currentEpoch + 1;
|
|
1232
|
+
const wrappedKeys = {};
|
|
1233
|
+
for (const [memberId, memberPublicKey] of Object.entries(remainingMembers)) {
|
|
1234
|
+
wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey);
|
|
1235
|
+
}
|
|
1236
|
+
const newKeyring = {
|
|
1237
|
+
currentEpoch: newEpoch,
|
|
1238
|
+
epochs: {
|
|
1239
|
+
...keyring.epochs,
|
|
1240
|
+
[String(newEpoch)]: { adminPublicKey: adminKeyPair.publicKey, wrappedKeys }
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
return { keyring: newKeyring, gek: resolvedGek };
|
|
1244
|
+
}
|
|
1245
|
+
async function createGroupEncryptor(keyring, myIdentity, myPrivateKey) {
|
|
1246
|
+
const epochEncryptors = /* @__PURE__ */ new Map();
|
|
1247
|
+
for (const [epochStr, epochKeyring] of Object.entries(keyring.epochs)) {
|
|
1248
|
+
const epoch = parseInt(epochStr, 10);
|
|
1249
|
+
const wrapped = epochKeyring.wrappedKeys[myIdentity];
|
|
1250
|
+
if (!wrapped) continue;
|
|
1251
|
+
const gek = await unwrapGroupKey(wrapped, myPrivateKey, epochKeyring.adminPublicKey);
|
|
1252
|
+
epochEncryptors.set(epoch, createEncryptor(gek, `epoch-${epoch}`, GROUP_DATA_INFO));
|
|
1253
|
+
}
|
|
1254
|
+
const currentEpoch = keyring.currentEpoch;
|
|
1255
|
+
const currentEncryptor = epochEncryptors.get(currentEpoch);
|
|
1256
|
+
if (!currentEncryptor) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`No wrapped key found for identity "${myIdentity}" in epoch ${currentEpoch}. Ensure the admin has added this member to the keyring.`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
async encrypt(data) {
|
|
1263
|
+
const encrypted = await currentEncryptor.encrypt(data);
|
|
1264
|
+
return { ...encrypted, _epoch: currentEpoch };
|
|
1265
|
+
},
|
|
1266
|
+
async decrypt(wrapper) {
|
|
1267
|
+
const epoch = typeof wrapper._epoch === "number" ? wrapper._epoch : currentEpoch;
|
|
1268
|
+
const encryptor = epochEncryptors.get(epoch);
|
|
1269
|
+
if (!encryptor) {
|
|
1270
|
+
throw new Error(
|
|
1271
|
+
`No key available for epoch ${epoch}. This document was encrypted in a different epoch. Ensure your keyring is up to date.`
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return encryptor.decrypt(wrapper);
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/entitlements.ts
|
|
1280
|
+
async function pullEntitlements(client, userId, opts) {
|
|
1281
|
+
const path = (opts?.path ?? "/pull/users/{userId}/entitlements").replace("{userId}", userId);
|
|
1282
|
+
const field = opts?.field ?? "features";
|
|
1283
|
+
try {
|
|
1284
|
+
const result = await client.pull(path);
|
|
1285
|
+
const list = result.data?.[field];
|
|
1286
|
+
if (!Array.isArray(list)) return [];
|
|
1287
|
+
return list.filter((s) => typeof s === "string");
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
if (err instanceof StarfishHttpError && err.status === 404) return [];
|
|
1290
|
+
throw err;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
export {
|
|
1294
|
+
ConflictError,
|
|
1295
|
+
ENCRYPTED_KEY,
|
|
1296
|
+
SnapshotHistory,
|
|
1297
|
+
StarfishClient,
|
|
1298
|
+
StarfishHttpError,
|
|
1299
|
+
SyncManager,
|
|
1300
|
+
ValidationError,
|
|
1301
|
+
addGroupMember,
|
|
1302
|
+
classifyError,
|
|
1303
|
+
computeHash,
|
|
1304
|
+
configurePlatform,
|
|
1305
|
+
consoleSyncLogger,
|
|
1306
|
+
createDebouncedPush,
|
|
1307
|
+
createDebouncedSync,
|
|
1308
|
+
createDedupFetch,
|
|
1309
|
+
createEncryptor,
|
|
1310
|
+
createGroupEncryptor,
|
|
1311
|
+
createGroupKeyring,
|
|
1312
|
+
createIndexedDBStorage,
|
|
1313
|
+
createMetricsCollector,
|
|
1314
|
+
createMigrator,
|
|
1315
|
+
createMobileLifecycle,
|
|
1316
|
+
createMultiStoreSync,
|
|
1317
|
+
createSchemaValidator,
|
|
1318
|
+
createSoftDeleteResolver,
|
|
1319
|
+
createSuspenseResource,
|
|
1320
|
+
createUnionMerge,
|
|
1321
|
+
deriveGroupKeyPair,
|
|
1322
|
+
exportData,
|
|
1323
|
+
exportToBlob,
|
|
1324
|
+
fetchServerConfig,
|
|
1325
|
+
generateGroupKey,
|
|
1326
|
+
importData,
|
|
1327
|
+
isBackgroundSyncSupported,
|
|
1328
|
+
isServiceWorkerSupported,
|
|
1329
|
+
noopSyncLogger,
|
|
1330
|
+
pruneTombstones,
|
|
1331
|
+
pullEntitlements,
|
|
1332
|
+
registerBackgroundSync,
|
|
1333
|
+
registerServiceWorker,
|
|
1334
|
+
rotateGroupKey,
|
|
1335
|
+
stableStringify2 as stableStringify,
|
|
1336
|
+
startAdaptivePolling,
|
|
1337
|
+
startPolling,
|
|
1338
|
+
timestampWinner,
|
|
1339
|
+
unregisterServiceWorkers,
|
|
1340
|
+
unwrapGroupKey,
|
|
1341
|
+
withConflictMeta,
|
|
1342
|
+
wrapGroupKey
|
|
1343
|
+
};
|
|
1344
|
+
//# sourceMappingURL=index.js.map
|