mpb-localkit 1.3.0
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/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +853 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/index.d.ts +404 -0
- package/dist/core/index.js +1780 -0
- package/dist/core/index.js.map +1 -0
- package/dist/react/index.d.ts +90 -0
- package/dist/react/index.js +230 -0
- package/dist/react/index.js.map +1 -0
- package/dist/svelte/index.d.ts +60 -0
- package/dist/svelte/index.js +151 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/vue/index.d.ts +97 -0
- package/dist/vue/index.js +133 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +120 -0
|
@@ -0,0 +1,1780 @@
|
|
|
1
|
+
export { z } from 'zod';
|
|
2
|
+
import { v7 } from 'uuid';
|
|
3
|
+
|
|
4
|
+
// src/core/schema/index.ts
|
|
5
|
+
|
|
6
|
+
// src/core/schema/collection.ts
|
|
7
|
+
function collection(schema, options) {
|
|
8
|
+
return {
|
|
9
|
+
schema,
|
|
10
|
+
collectionName: "",
|
|
11
|
+
version: options?.version,
|
|
12
|
+
migrate: options?.migrate
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/core/storage/memory.ts
|
|
17
|
+
var MemoryAdapter = class {
|
|
18
|
+
store = /* @__PURE__ */ new Map();
|
|
19
|
+
getCollection(collection2) {
|
|
20
|
+
if (!this.store.has(collection2)) {
|
|
21
|
+
this.store.set(collection2, /* @__PURE__ */ new Map());
|
|
22
|
+
}
|
|
23
|
+
return this.store.get(collection2);
|
|
24
|
+
}
|
|
25
|
+
async get(collection2, id) {
|
|
26
|
+
const doc = this.getCollection(collection2).get(id);
|
|
27
|
+
if (!doc || doc._deleted) return null;
|
|
28
|
+
return { ...doc };
|
|
29
|
+
}
|
|
30
|
+
async getRaw(collection2, id) {
|
|
31
|
+
const doc = this.getCollection(collection2).get(id);
|
|
32
|
+
if (!doc) return null;
|
|
33
|
+
return { ...doc };
|
|
34
|
+
}
|
|
35
|
+
async getMany(collection2, filter) {
|
|
36
|
+
const col = this.getCollection(collection2);
|
|
37
|
+
return Array.from(col.values()).filter((doc) => {
|
|
38
|
+
if (doc._deleted) return false;
|
|
39
|
+
if (!filter) return true;
|
|
40
|
+
return Object.entries(filter).every(([k, v]) => doc[k] === v);
|
|
41
|
+
}).map((doc) => ({ ...doc }));
|
|
42
|
+
}
|
|
43
|
+
async put(collection2, id, doc) {
|
|
44
|
+
this.getCollection(collection2).set(id, { ...doc, _id: id, _collection: collection2 });
|
|
45
|
+
}
|
|
46
|
+
async delete(collection2, id) {
|
|
47
|
+
const col = this.getCollection(collection2);
|
|
48
|
+
const existing = col.get(id);
|
|
49
|
+
if (!existing) return;
|
|
50
|
+
col.set(id, { ...existing, _deleted: true, _updatedAt: Date.now() });
|
|
51
|
+
}
|
|
52
|
+
async getChangesSince(timestamp) {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const [collection2, col] of this.store.entries()) {
|
|
55
|
+
for (const doc of col.values()) {
|
|
56
|
+
if (doc._updatedAt > timestamp) {
|
|
57
|
+
results.push({
|
|
58
|
+
collection: collection2,
|
|
59
|
+
id: doc._id,
|
|
60
|
+
doc: { ...doc },
|
|
61
|
+
updatedAt: doc._updatedAt,
|
|
62
|
+
deleted: doc._deleted
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
function generateId() {
|
|
71
|
+
return v7();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/core/storage/encrypted.ts
|
|
75
|
+
var METADATA_FIELDS = /* @__PURE__ */ new Set(["_id", "_collection", "_updatedAt", "_deleted", "_schemaVersion"]);
|
|
76
|
+
var ENCRYPTED_FIELDS = /* @__PURE__ */ new Set(["_encrypted", "_iv", "_keyVersion"]);
|
|
77
|
+
function toBase64(buf) {
|
|
78
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
79
|
+
let binary = "";
|
|
80
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
81
|
+
return btoa(binary);
|
|
82
|
+
}
|
|
83
|
+
function fromBase64(s) {
|
|
84
|
+
const binary = atob(s);
|
|
85
|
+
const bytes = new Uint8Array(binary.length);
|
|
86
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
87
|
+
return bytes;
|
|
88
|
+
}
|
|
89
|
+
function encode(s) {
|
|
90
|
+
return new TextEncoder().encode(s);
|
|
91
|
+
}
|
|
92
|
+
async function deriveAesGcmKey(password, salt, iterations, extractable = false) {
|
|
93
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encode(password), "PBKDF2", false, [
|
|
94
|
+
"deriveBits",
|
|
95
|
+
"deriveKey"
|
|
96
|
+
]);
|
|
97
|
+
return crypto.subtle.deriveKey(
|
|
98
|
+
{ name: "PBKDF2", salt: encode(salt), iterations, hash: "SHA-256" },
|
|
99
|
+
keyMaterial,
|
|
100
|
+
{ name: "AES-GCM", length: 256 },
|
|
101
|
+
extractable,
|
|
102
|
+
["encrypt", "decrypt"]
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
async function deriveAesKwKey(password, salt, iterations) {
|
|
106
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encode(password), "PBKDF2", false, [
|
|
107
|
+
"deriveBits",
|
|
108
|
+
"deriveKey"
|
|
109
|
+
]);
|
|
110
|
+
return crypto.subtle.deriveKey(
|
|
111
|
+
{ name: "PBKDF2", salt: encode(salt), iterations, hash: "SHA-256" },
|
|
112
|
+
keyMaterial,
|
|
113
|
+
{ name: "AES-KW", length: 256 },
|
|
114
|
+
false,
|
|
115
|
+
["wrapKey", "unwrapKey"]
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
async function verifySentinel(key) {
|
|
119
|
+
const sentinel = encode("offlinekit-sentinel");
|
|
120
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
121
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, sentinel);
|
|
122
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext).catch(() => null);
|
|
123
|
+
if (!plaintext || new TextDecoder().decode(plaintext) !== "offlinekit-sentinel") {
|
|
124
|
+
throw new Error("EncryptedStorageAdapter: key verification failed \u2014 wrong password or key");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function resolveKeySpec(spec, iterations, wrap) {
|
|
128
|
+
if (spec instanceof CryptoKey) {
|
|
129
|
+
return spec;
|
|
130
|
+
} else if (wrap) {
|
|
131
|
+
const { password, salt } = spec;
|
|
132
|
+
const wrappingKey = await deriveAesKwKey(password, salt, iterations);
|
|
133
|
+
if (wrap.wrapped) {
|
|
134
|
+
return crypto.subtle.unwrapKey(
|
|
135
|
+
"raw",
|
|
136
|
+
fromBase64(wrap.wrapped),
|
|
137
|
+
wrappingKey,
|
|
138
|
+
"AES-KW",
|
|
139
|
+
{ name: "AES-GCM", length: 256 },
|
|
140
|
+
false,
|
|
141
|
+
["encrypt", "decrypt"]
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
const extractableKey = await crypto.subtle.generateKey(
|
|
145
|
+
{ name: "AES-GCM", length: 256 },
|
|
146
|
+
true,
|
|
147
|
+
["encrypt", "decrypt"]
|
|
148
|
+
);
|
|
149
|
+
const wrappedBuf = await crypto.subtle.wrapKey("raw", extractableKey, wrappingKey, "AES-KW");
|
|
150
|
+
wrap.onKeyWrapped?.(toBase64(wrappedBuf));
|
|
151
|
+
const rawKeyBuf = await crypto.subtle.exportKey("raw", extractableKey);
|
|
152
|
+
return crypto.subtle.importKey(
|
|
153
|
+
"raw",
|
|
154
|
+
rawKeyBuf,
|
|
155
|
+
{ name: "AES-GCM", length: 256 },
|
|
156
|
+
false,
|
|
157
|
+
["encrypt", "decrypt"]
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const { password, salt } = spec;
|
|
162
|
+
return deriveAesGcmKey(password, salt, iterations);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function encrypted(inner, config) {
|
|
166
|
+
if (inner instanceof EncryptedStorageAdapter) {
|
|
167
|
+
throw new Error("EncryptedStorageAdapter: double-encryption is not allowed");
|
|
168
|
+
}
|
|
169
|
+
if (!config.key && !config.keychain) {
|
|
170
|
+
throw new Error("EncryptedStorageAdapter: either key or keychain must be provided");
|
|
171
|
+
}
|
|
172
|
+
const iterations = config.iterations ?? 6e5;
|
|
173
|
+
let keychain;
|
|
174
|
+
let activeVersion;
|
|
175
|
+
if (config.keychain) {
|
|
176
|
+
const { keys, activeVersion: av } = config.keychain;
|
|
177
|
+
if (!(av in keys)) {
|
|
178
|
+
throw new Error(`EncryptedStorageAdapter: keychain.activeVersion ${av} not found in keychain.keys`);
|
|
179
|
+
}
|
|
180
|
+
activeVersion = av;
|
|
181
|
+
keychain = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const [ver, spec] of Object.entries(keys)) {
|
|
183
|
+
const version = Number(ver);
|
|
184
|
+
const resolved = await resolveKeySpec(spec, iterations);
|
|
185
|
+
keychain.set(version, resolved);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const resolved = await resolveKeySpec(config.key, iterations, config.wrap);
|
|
189
|
+
activeVersion = 1;
|
|
190
|
+
keychain = /* @__PURE__ */ new Map([[1, resolved]]);
|
|
191
|
+
}
|
|
192
|
+
await verifySentinel(keychain.get(activeVersion));
|
|
193
|
+
return new EncryptedStorageAdapter(inner, keychain, activeVersion);
|
|
194
|
+
}
|
|
195
|
+
var EncryptedStorageAdapter = class {
|
|
196
|
+
constructor(inner, keychain, activeVersion) {
|
|
197
|
+
this.inner = inner;
|
|
198
|
+
this.keychain = keychain;
|
|
199
|
+
this.activeVersion = activeVersion;
|
|
200
|
+
}
|
|
201
|
+
isEncryptedDoc(doc) {
|
|
202
|
+
return "_encrypted" in doc && "_iv" in doc;
|
|
203
|
+
}
|
|
204
|
+
async encryptDoc(doc) {
|
|
205
|
+
const raw = doc;
|
|
206
|
+
const metadata = {};
|
|
207
|
+
for (const field of METADATA_FIELDS) {
|
|
208
|
+
if (field in raw) metadata[field] = raw[field];
|
|
209
|
+
}
|
|
210
|
+
const userFields = {};
|
|
211
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
212
|
+
if (!METADATA_FIELDS.has(k) && !ENCRYPTED_FIELDS.has(k)) {
|
|
213
|
+
userFields[k] = v;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
217
|
+
const aad = encode(JSON.stringify({ _id: doc._id, _collection: doc._collection }));
|
|
218
|
+
const plaintext = encode(JSON.stringify(userFields));
|
|
219
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
220
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
221
|
+
this.keychain.get(this.activeVersion),
|
|
222
|
+
plaintext
|
|
223
|
+
);
|
|
224
|
+
return {
|
|
225
|
+
...metadata,
|
|
226
|
+
_encrypted: toBase64(ciphertext),
|
|
227
|
+
_iv: toBase64(iv),
|
|
228
|
+
_keyVersion: this.activeVersion
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async decryptDoc(doc) {
|
|
232
|
+
if (!this.isEncryptedDoc(doc)) return doc;
|
|
233
|
+
const raw = doc;
|
|
234
|
+
const version = typeof raw._keyVersion === "number" ? raw._keyVersion : 1;
|
|
235
|
+
const key = this.keychain.get(version);
|
|
236
|
+
if (!key) {
|
|
237
|
+
throw new Error(`EncryptedStorageAdapter: no key found for _keyVersion ${version}`);
|
|
238
|
+
}
|
|
239
|
+
const aad = encode(JSON.stringify({ _id: doc._id, _collection: doc._collection }));
|
|
240
|
+
const iv = fromBase64(raw._iv);
|
|
241
|
+
const ciphertext = fromBase64(raw._encrypted);
|
|
242
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
243
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
244
|
+
key,
|
|
245
|
+
ciphertext
|
|
246
|
+
);
|
|
247
|
+
const userFields = JSON.parse(new TextDecoder().decode(plaintext));
|
|
248
|
+
const metadata = {};
|
|
249
|
+
for (const field of METADATA_FIELDS) {
|
|
250
|
+
if (field in raw) metadata[field] = raw[field];
|
|
251
|
+
}
|
|
252
|
+
return { ...metadata, ...userFields };
|
|
253
|
+
}
|
|
254
|
+
async get(collection2, id) {
|
|
255
|
+
const doc = await this.inner.get(collection2, id);
|
|
256
|
+
if (!doc) return null;
|
|
257
|
+
return this.decryptDoc(doc);
|
|
258
|
+
}
|
|
259
|
+
async getRaw(collection2, id) {
|
|
260
|
+
const fn = this.inner.getRaw?.bind(this.inner) ?? this.inner.get.bind(this.inner);
|
|
261
|
+
const doc = await fn(collection2, id);
|
|
262
|
+
if (!doc) return null;
|
|
263
|
+
return this.decryptDoc(doc);
|
|
264
|
+
}
|
|
265
|
+
async getMany(collection2, filter) {
|
|
266
|
+
const docs = await this.inner.getMany(collection2);
|
|
267
|
+
const decrypted = await Promise.all(docs.map((doc) => this.decryptDoc(doc)));
|
|
268
|
+
if (!filter) return decrypted;
|
|
269
|
+
return decrypted.filter(
|
|
270
|
+
(doc) => Object.entries(filter).every(([k, v]) => doc[k] === v)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
async put(collection2, id, doc) {
|
|
274
|
+
if (this.isEncryptedDoc(doc)) {
|
|
275
|
+
return this.inner.put(collection2, id, doc);
|
|
276
|
+
}
|
|
277
|
+
const encryptedDoc = await this.encryptDoc(doc);
|
|
278
|
+
return this.inner.put(collection2, id, encryptedDoc);
|
|
279
|
+
}
|
|
280
|
+
async delete(collection2, id) {
|
|
281
|
+
return this.inner.delete(collection2, id);
|
|
282
|
+
}
|
|
283
|
+
/** E2E: encrypted blobs travel to the server as-is — no decryption here. */
|
|
284
|
+
async getChangesSince(timestamp) {
|
|
285
|
+
return this.inner.getChangesSince(timestamp);
|
|
286
|
+
}
|
|
287
|
+
/** Returns raw encrypted storage representation. Do not use in production data flows. */
|
|
288
|
+
async getEncrypted(collection2, id) {
|
|
289
|
+
return this.inner.get(collection2, id);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/core/storage/key-rotation.ts
|
|
294
|
+
var BATCH_SIZE = 100;
|
|
295
|
+
async function reEncryptAll(adapter, innerStorage, activeVersion) {
|
|
296
|
+
const result = { total: 0, reEncrypted: 0, skipped: 0, errors: [] };
|
|
297
|
+
const changes = await innerStorage.getChangesSince(0);
|
|
298
|
+
result.total = changes.length;
|
|
299
|
+
for (let i = 0; i < changes.length; i += BATCH_SIZE) {
|
|
300
|
+
const batch = changes.slice(i, i + BATCH_SIZE);
|
|
301
|
+
await Promise.all(
|
|
302
|
+
batch.map(async (change) => {
|
|
303
|
+
const raw = change.doc;
|
|
304
|
+
const keyVersion = raw._keyVersion;
|
|
305
|
+
if (keyVersion === activeVersion) {
|
|
306
|
+
result.skipped++;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const decrypted = await adapter.get(change.collection, change.id);
|
|
311
|
+
if (!decrypted) {
|
|
312
|
+
result.skipped++;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await adapter.put(change.collection, change.id, decrypted);
|
|
316
|
+
result.reEncrypted++;
|
|
317
|
+
} catch (err) {
|
|
318
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
319
|
+
result.errors.push(`${change.collection}/${change.id}: ${msg}`);
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
async function rewrapKey(oldPassword, newPassword, wrappedKey, salt, iterations = 6e5) {
|
|
327
|
+
const oldWrappingKey = await deriveAesKwKey(oldPassword, salt, iterations);
|
|
328
|
+
const newWrappingKey = await deriveAesKwKey(newPassword, salt, iterations);
|
|
329
|
+
const dataKey = await crypto.subtle.unwrapKey(
|
|
330
|
+
"raw",
|
|
331
|
+
fromBase64(wrappedKey),
|
|
332
|
+
oldWrappingKey,
|
|
333
|
+
"AES-KW",
|
|
334
|
+
{ name: "AES-GCM", length: 256 },
|
|
335
|
+
true,
|
|
336
|
+
["encrypt", "decrypt"]
|
|
337
|
+
);
|
|
338
|
+
const newWrappedBuf = await crypto.subtle.wrapKey("raw", dataKey, newWrappingKey, "AES-KW");
|
|
339
|
+
return toBase64(newWrappedBuf);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/core/storage/migrate-encrypted.ts
|
|
343
|
+
async function migrateToEncrypted(storage, encryptedStorage) {
|
|
344
|
+
const changes = await storage.getChangesSince(0);
|
|
345
|
+
const result = {
|
|
346
|
+
total: changes.length,
|
|
347
|
+
encrypted: 0,
|
|
348
|
+
skipped: 0,
|
|
349
|
+
errors: []
|
|
350
|
+
};
|
|
351
|
+
for (const change of changes) {
|
|
352
|
+
const { collection: collection2, id, doc } = change;
|
|
353
|
+
if ("_encrypted" in doc && "_iv" in doc) {
|
|
354
|
+
result.skipped++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
await encryptedStorage.put(collection2, id, doc);
|
|
359
|
+
result.encrypted++;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
result.errors.push(
|
|
362
|
+
`${collection2}/${id}: ${err instanceof Error ? err.message : String(err)}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/core/sync/conflict.ts
|
|
370
|
+
function resolveConflict(local, remote) {
|
|
371
|
+
return remote._updatedAt >= local._updatedAt ? remote : local;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/core/sync/transport.ts
|
|
375
|
+
var HttpTransport = class {
|
|
376
|
+
constructor(endpoint) {
|
|
377
|
+
this.endpoint = endpoint;
|
|
378
|
+
}
|
|
379
|
+
token = null;
|
|
380
|
+
setToken(token) {
|
|
381
|
+
this.token = token;
|
|
382
|
+
}
|
|
383
|
+
async push(payload) {
|
|
384
|
+
const response = await this.fetchWithRetry(`${this.endpoint}/sync/push`, {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: this.buildHeaders(),
|
|
387
|
+
body: JSON.stringify(payload)
|
|
388
|
+
});
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
throw new Error(`Push failed: ${response.status} ${response.statusText}`);
|
|
391
|
+
}
|
|
392
|
+
return payload.changes.length;
|
|
393
|
+
}
|
|
394
|
+
async pull(payload) {
|
|
395
|
+
const response = await this.fetchWithRetry(`${this.endpoint}/sync/pull?since=${payload.since}`, {
|
|
396
|
+
headers: this.buildHeaders()
|
|
397
|
+
});
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
throw new Error(`Pull failed: ${response.status} ${response.statusText}`);
|
|
400
|
+
}
|
|
401
|
+
const { changes } = await response.json();
|
|
402
|
+
return changes;
|
|
403
|
+
}
|
|
404
|
+
destroy() {
|
|
405
|
+
}
|
|
406
|
+
async fetchWithRetry(url, init, retries = 3) {
|
|
407
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
408
|
+
try {
|
|
409
|
+
const res = await fetch(url, init);
|
|
410
|
+
if (res.ok || res.status < 500) return res;
|
|
411
|
+
if (attempt === retries) return res;
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (attempt === retries) throw err;
|
|
414
|
+
}
|
|
415
|
+
const delay = Math.pow(2, attempt) * 1e3 + Math.random() * 500;
|
|
416
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
417
|
+
}
|
|
418
|
+
throw new Error("fetchWithRetry: unreachable");
|
|
419
|
+
}
|
|
420
|
+
buildHeaders() {
|
|
421
|
+
const headers = { "Content-Type": "application/json" };
|
|
422
|
+
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
423
|
+
return headers;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// src/core/sync/ws-transport.ts
|
|
428
|
+
var WebSocketTransport = class {
|
|
429
|
+
constructor(config) {
|
|
430
|
+
this.config = config;
|
|
431
|
+
this.token = config.token ?? null;
|
|
432
|
+
if (config.url.startsWith("ws://") && typeof location !== "undefined" && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
|
|
433
|
+
console.warn("[OfflineKit] WebSocket connection uses insecure ws:// protocol. Use wss:// in production.");
|
|
434
|
+
}
|
|
435
|
+
this.connect();
|
|
436
|
+
}
|
|
437
|
+
ws = null;
|
|
438
|
+
pending = /* @__PURE__ */ new Map();
|
|
439
|
+
reconnectAttempt = 0;
|
|
440
|
+
reconnectTimer = null;
|
|
441
|
+
destroyed = false;
|
|
442
|
+
token;
|
|
443
|
+
setToken(token) {
|
|
444
|
+
this.token = token;
|
|
445
|
+
}
|
|
446
|
+
async push(payload) {
|
|
447
|
+
const id = this.generateId();
|
|
448
|
+
await this.send({ type: "push", id, payload });
|
|
449
|
+
return payload.changes.length;
|
|
450
|
+
}
|
|
451
|
+
async pull(payload) {
|
|
452
|
+
const id = this.generateId();
|
|
453
|
+
const result = await this.send({ type: "pull", id, since: payload.since });
|
|
454
|
+
const { changes } = result;
|
|
455
|
+
return changes;
|
|
456
|
+
}
|
|
457
|
+
destroy() {
|
|
458
|
+
this.destroyed = true;
|
|
459
|
+
if (this.reconnectTimer !== null) {
|
|
460
|
+
clearTimeout(this.reconnectTimer);
|
|
461
|
+
this.reconnectTimer = null;
|
|
462
|
+
}
|
|
463
|
+
for (const req of this.pending.values()) {
|
|
464
|
+
req.reject(new Error("Transport destroyed"));
|
|
465
|
+
}
|
|
466
|
+
this.pending.clear();
|
|
467
|
+
if (this.ws) {
|
|
468
|
+
this.ws.close();
|
|
469
|
+
this.ws = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
connect() {
|
|
473
|
+
if (this.destroyed) return;
|
|
474
|
+
this.ws = new WebSocket(this.config.url);
|
|
475
|
+
this.ws.onopen = () => {
|
|
476
|
+
this.reconnectAttempt = 0;
|
|
477
|
+
if (this.token) {
|
|
478
|
+
this.ws.send(JSON.stringify({ type: "auth", token: this.token }));
|
|
479
|
+
}
|
|
480
|
+
this.config.onConnected?.();
|
|
481
|
+
};
|
|
482
|
+
this.ws.onmessage = (event) => {
|
|
483
|
+
let msg;
|
|
484
|
+
try {
|
|
485
|
+
msg = JSON.parse(event.data);
|
|
486
|
+
} catch {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (msg.type === "push_ack" || msg.type === "pull_response") {
|
|
490
|
+
const pending = this.pending.get(msg.id);
|
|
491
|
+
if (pending) {
|
|
492
|
+
this.pending.delete(msg.id);
|
|
493
|
+
pending.resolve(msg.payload);
|
|
494
|
+
}
|
|
495
|
+
} else if (msg.type === "remote_changes") {
|
|
496
|
+
this.config.onRemoteChanges?.(msg.changes ?? []);
|
|
497
|
+
} else if (msg.type === "error") {
|
|
498
|
+
const pending = this.pending.get(msg.id);
|
|
499
|
+
if (pending) {
|
|
500
|
+
this.pending.delete(msg.id);
|
|
501
|
+
const errPayload = msg.payload;
|
|
502
|
+
pending.reject(new Error(errPayload?.message ?? "WebSocket error"));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
this.ws.onclose = () => {
|
|
507
|
+
for (const req of this.pending.values()) {
|
|
508
|
+
req.reject(new Error("WebSocket disconnected"));
|
|
509
|
+
}
|
|
510
|
+
this.pending.clear();
|
|
511
|
+
this.scheduleReconnect();
|
|
512
|
+
};
|
|
513
|
+
this.ws.onerror = () => {
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
scheduleReconnect() {
|
|
517
|
+
if (this.destroyed || this.config.reconnect === false) return;
|
|
518
|
+
const max = this.config.maxReconnectAttempts ?? Infinity;
|
|
519
|
+
if (this.reconnectAttempt >= max) return;
|
|
520
|
+
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempt), 3e4);
|
|
521
|
+
const jitter = Math.random() * 200;
|
|
522
|
+
this.reconnectTimer = setTimeout(() => {
|
|
523
|
+
this.reconnectAttempt++;
|
|
524
|
+
this.connect();
|
|
525
|
+
}, delay + jitter);
|
|
526
|
+
}
|
|
527
|
+
send(msg) {
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
530
|
+
reject(new Error("WebSocket not connected"));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const id = msg.id;
|
|
534
|
+
const timeout = this.config.requestTimeout ?? 3e4;
|
|
535
|
+
const timer = setTimeout(() => {
|
|
536
|
+
if (this.pending.has(id)) {
|
|
537
|
+
this.pending.delete(id);
|
|
538
|
+
reject(new Error("WebSocket request timed out"));
|
|
539
|
+
}
|
|
540
|
+
}, timeout);
|
|
541
|
+
this.pending.set(id, {
|
|
542
|
+
resolve: (value) => {
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
resolve(value);
|
|
545
|
+
},
|
|
546
|
+
reject: (reason) => {
|
|
547
|
+
clearTimeout(timer);
|
|
548
|
+
reject(reason);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
this.ws.send(JSON.stringify(msg));
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
generateId() {
|
|
555
|
+
const bytes = new Uint8Array(16);
|
|
556
|
+
crypto.getRandomValues(bytes);
|
|
557
|
+
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
558
|
+
return `${Date.now()}-${hex}`;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// src/core/sync/auto-transport.ts
|
|
563
|
+
var AutoTransport = class {
|
|
564
|
+
ws;
|
|
565
|
+
http;
|
|
566
|
+
useWs = true;
|
|
567
|
+
constructor(config) {
|
|
568
|
+
this.http = new HttpTransport(config.httpEndpoint);
|
|
569
|
+
if (config.token) {
|
|
570
|
+
this.http.setToken(config.token);
|
|
571
|
+
}
|
|
572
|
+
this.ws = new WebSocketTransport({
|
|
573
|
+
url: config.wsUrl,
|
|
574
|
+
token: config.token,
|
|
575
|
+
reconnect: true,
|
|
576
|
+
onRemoteChanges: config.onRemoteChanges,
|
|
577
|
+
onConnected: () => {
|
|
578
|
+
this.useWs = true;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
setToken(token) {
|
|
583
|
+
this.ws.setToken(token);
|
|
584
|
+
this.http.setToken(token);
|
|
585
|
+
}
|
|
586
|
+
async push(payload) {
|
|
587
|
+
if (this.useWs) {
|
|
588
|
+
try {
|
|
589
|
+
return await this.ws.push(payload);
|
|
590
|
+
} catch {
|
|
591
|
+
this.useWs = false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return this.http.push(payload);
|
|
595
|
+
}
|
|
596
|
+
async pull(payload) {
|
|
597
|
+
if (this.useWs) {
|
|
598
|
+
try {
|
|
599
|
+
return await this.ws.pull(payload);
|
|
600
|
+
} catch {
|
|
601
|
+
this.useWs = false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return this.http.pull(payload);
|
|
605
|
+
}
|
|
606
|
+
destroy() {
|
|
607
|
+
this.ws.destroy();
|
|
608
|
+
this.http.destroy();
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// src/core/sync/engine.ts
|
|
613
|
+
var SYNC_META_COLLECTION = "_sync_meta";
|
|
614
|
+
var SyncEngine = class {
|
|
615
|
+
constructor(storage, config) {
|
|
616
|
+
this.storage = storage;
|
|
617
|
+
this.config = config;
|
|
618
|
+
this.lastSyncKey = `last_sync_at_${config.endpoint}`;
|
|
619
|
+
this.transport = this.resolveTransport(config);
|
|
620
|
+
}
|
|
621
|
+
status = "idle";
|
|
622
|
+
lastSyncAt = 0;
|
|
623
|
+
lastSyncAtInitialized = false;
|
|
624
|
+
/** Typed listener map: values are SyncEventMap[E][] per event key. */
|
|
625
|
+
listenerMap = /* @__PURE__ */ new Map();
|
|
626
|
+
transport;
|
|
627
|
+
lastSyncKey;
|
|
628
|
+
/**
|
|
629
|
+
* Return the current sync status.
|
|
630
|
+
*
|
|
631
|
+
* @returns The current {@link SyncStatus} (`'idle'`, `'syncing'`, `'error'`, or `'offline'`).
|
|
632
|
+
*/
|
|
633
|
+
getStatus() {
|
|
634
|
+
return this.status;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Return the timestamp of the last successful sync.
|
|
638
|
+
*
|
|
639
|
+
* @returns Epoch timestamp (ms), or `0` if no sync has completed yet.
|
|
640
|
+
*/
|
|
641
|
+
getLastSyncAt() {
|
|
642
|
+
return this.lastSyncAt;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Set the bearer token used by the transport for authenticated requests.
|
|
646
|
+
*
|
|
647
|
+
* @param token - The bearer token string, or `null` to clear it.
|
|
648
|
+
*/
|
|
649
|
+
setToken(token) {
|
|
650
|
+
this.transport.setToken?.(token);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Register a listener for sync events.
|
|
654
|
+
*
|
|
655
|
+
* @param event - The event type to listen for.
|
|
656
|
+
* @param listener - The callback to invoke when the event fires.
|
|
657
|
+
*/
|
|
658
|
+
on(event, listener) {
|
|
659
|
+
const existing = this.listenerMap.get(event) ?? [];
|
|
660
|
+
this.listenerMap.set(event, [...existing, listener]);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Remove a previously registered sync event listener.
|
|
664
|
+
*
|
|
665
|
+
* @param event - The event type.
|
|
666
|
+
* @param listener - The callback to remove.
|
|
667
|
+
*/
|
|
668
|
+
off(event, listener) {
|
|
669
|
+
const existing = this.listenerMap.get(event) ?? [];
|
|
670
|
+
this.listenerMap.set(event, existing.filter((l) => l !== listener));
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Push local changes to the server.
|
|
674
|
+
*
|
|
675
|
+
* @returns The number of documents pushed.
|
|
676
|
+
*/
|
|
677
|
+
async push() {
|
|
678
|
+
const changes = await this.storage.getChangesSince(this.lastSyncAt);
|
|
679
|
+
if (changes.length === 0) return 0;
|
|
680
|
+
return this.transport.push({ changes, lastSyncAt: this.lastSyncAt });
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Pull remote changes from the server. Applies conflict resolution for
|
|
684
|
+
* documents that exist both locally and remotely.
|
|
685
|
+
*
|
|
686
|
+
* @returns An object with `pulled` (total remote docs received) and `conflicts` (local-wins count).
|
|
687
|
+
*/
|
|
688
|
+
async pull() {
|
|
689
|
+
const remoteChanges = await this.transport.pull({ since: this.lastSyncAt });
|
|
690
|
+
let conflicts = 0;
|
|
691
|
+
const validRemote = remoteChanges.filter((doc) => {
|
|
692
|
+
if (typeof doc._id !== "string" || typeof doc._collection !== "string" || typeof doc._updatedAt !== "number" || typeof doc._deleted !== "boolean") {
|
|
693
|
+
console.warn("[OfflineKit] Skipping invalid remote document:", doc._id);
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
return true;
|
|
697
|
+
});
|
|
698
|
+
for (const remote of validRemote) {
|
|
699
|
+
const local = this.storage.getRaw ? await this.storage.getRaw(remote._collection, remote._id) : await this.storage.get(remote._collection, remote._id);
|
|
700
|
+
let docToStore = remote;
|
|
701
|
+
if (local) {
|
|
702
|
+
const resolver = this.config.conflictResolver ?? resolveConflict;
|
|
703
|
+
const resolved = resolver(local, remote);
|
|
704
|
+
if (resolved === local) {
|
|
705
|
+
conflicts++;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
docToStore = resolved;
|
|
709
|
+
}
|
|
710
|
+
if (docToStore._deleted) {
|
|
711
|
+
await this.storage.delete(docToStore._collection, docToStore._id);
|
|
712
|
+
} else {
|
|
713
|
+
await this.storage.put(docToStore._collection, docToStore._id, docToStore);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return { pulled: validRemote.length, conflicts };
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Run a full push+pull sync cycle. Pushes local changes first, then pulls
|
|
720
|
+
* remote changes. Updates the last-sync timestamp on success.
|
|
721
|
+
*
|
|
722
|
+
* @returns A {@link SyncResult} with pushed, pulled, and conflicts counts.
|
|
723
|
+
*/
|
|
724
|
+
async sync() {
|
|
725
|
+
if (!this.config.enabled) return { pushed: 0, pulled: 0, conflicts: 0 };
|
|
726
|
+
if (this.status === "syncing") return { pushed: 0, pulled: 0, conflicts: 0 };
|
|
727
|
+
const isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
|
|
728
|
+
if (!isOnline) {
|
|
729
|
+
this.setStatus("offline");
|
|
730
|
+
return { pushed: 0, pulled: 0, conflicts: 0 };
|
|
731
|
+
}
|
|
732
|
+
this.setStatus("syncing");
|
|
733
|
+
this.emit("sync:start");
|
|
734
|
+
try {
|
|
735
|
+
if (!this.lastSyncAtInitialized) {
|
|
736
|
+
this.lastSyncAt = await this.loadLastSyncAt();
|
|
737
|
+
this.lastSyncAtInitialized = true;
|
|
738
|
+
}
|
|
739
|
+
const pushed = await this.push();
|
|
740
|
+
const { pulled, conflicts } = await this.pull();
|
|
741
|
+
this.lastSyncAt = Date.now();
|
|
742
|
+
await this.saveLastSyncAt(this.lastSyncAt);
|
|
743
|
+
this.setStatus("idle");
|
|
744
|
+
const result = { pushed, pulled, conflicts };
|
|
745
|
+
this.emit("sync:complete", result);
|
|
746
|
+
return result;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
const browserOffline = typeof navigator !== "undefined" ? !navigator.onLine : false;
|
|
749
|
+
const isOffline2 = browserOffline || err instanceof TypeError && err.message.includes("fetch");
|
|
750
|
+
this.setStatus(isOffline2 ? "offline" : "error");
|
|
751
|
+
this.emit("sync:error", err);
|
|
752
|
+
return { pushed: 0, pulled: 0, conflicts: 0, error: err instanceof Error ? err : new Error(String(err)) };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Clean up the transport (close WebSocket connections, etc.).
|
|
757
|
+
* Call this when the sync engine is no longer needed.
|
|
758
|
+
*/
|
|
759
|
+
destroy() {
|
|
760
|
+
this.transport.destroy();
|
|
761
|
+
}
|
|
762
|
+
resolveTransport(config) {
|
|
763
|
+
const t = config.transport;
|
|
764
|
+
if (!t || t === "http") return new HttpTransport(config.endpoint);
|
|
765
|
+
if (t === "websocket") {
|
|
766
|
+
const wsUrl = config.endpoint.replace(/^http/, "ws");
|
|
767
|
+
return new WebSocketTransport({ url: wsUrl });
|
|
768
|
+
}
|
|
769
|
+
if (t === "auto") {
|
|
770
|
+
const wsUrl = config.endpoint.replace(/^http/, "ws");
|
|
771
|
+
return new AutoTransport({ wsUrl, httpEndpoint: config.endpoint });
|
|
772
|
+
}
|
|
773
|
+
return t;
|
|
774
|
+
}
|
|
775
|
+
setStatus(status) {
|
|
776
|
+
this.status = status;
|
|
777
|
+
}
|
|
778
|
+
emit(event, ...args) {
|
|
779
|
+
const listeners = this.listenerMap.get(event) ?? [];
|
|
780
|
+
for (const listener of listeners) {
|
|
781
|
+
listener(...args);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async loadLastSyncAt() {
|
|
785
|
+
try {
|
|
786
|
+
const doc = await this.storage.get(SYNC_META_COLLECTION, this.lastSyncKey);
|
|
787
|
+
const val = doc !== null ? doc["value"] : void 0;
|
|
788
|
+
return typeof val === "number" ? val : 0;
|
|
789
|
+
} catch {
|
|
790
|
+
return 0;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async saveLastSyncAt(ts) {
|
|
794
|
+
try {
|
|
795
|
+
await this.storage.put(SYNC_META_COLLECTION, this.lastSyncKey, {
|
|
796
|
+
_id: this.lastSyncKey,
|
|
797
|
+
_collection: SYNC_META_COLLECTION,
|
|
798
|
+
_updatedAt: ts,
|
|
799
|
+
_deleted: false,
|
|
800
|
+
value: ts
|
|
801
|
+
});
|
|
802
|
+
} catch {
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// src/core/sync/triggers.ts
|
|
808
|
+
function setupFocusTrigger(engine, config) {
|
|
809
|
+
const handler = () => {
|
|
810
|
+
if (document.visibilityState === "visible" && config.enabled) {
|
|
811
|
+
void engine.sync();
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
document.addEventListener("visibilitychange", handler);
|
|
815
|
+
return () => document.removeEventListener("visibilitychange", handler);
|
|
816
|
+
}
|
|
817
|
+
function setupReconnectTrigger(engine, config) {
|
|
818
|
+
const handler = () => {
|
|
819
|
+
if (config.enabled) void engine.sync();
|
|
820
|
+
};
|
|
821
|
+
window.addEventListener("online", handler);
|
|
822
|
+
return () => window.removeEventListener("online", handler);
|
|
823
|
+
}
|
|
824
|
+
function setupIntervalTrigger(engine, config) {
|
|
825
|
+
const ms = config.interval > 0 ? config.interval : 3e4;
|
|
826
|
+
const id = setInterval(() => {
|
|
827
|
+
if (config.enabled && navigator.onLine) void engine.sync();
|
|
828
|
+
}, ms);
|
|
829
|
+
return () => clearInterval(id);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/core/auth/pbkdf2.ts
|
|
833
|
+
var ITERATIONS = 6e5;
|
|
834
|
+
var HASH_ALGO = "SHA-256";
|
|
835
|
+
var KEY_LENGTH = 32;
|
|
836
|
+
var SALT_LENGTH = 16;
|
|
837
|
+
function toHex(bytes) {
|
|
838
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
839
|
+
}
|
|
840
|
+
async function deriveKey(password, salt) {
|
|
841
|
+
const enc = new TextEncoder();
|
|
842
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
843
|
+
"raw",
|
|
844
|
+
enc.encode(password),
|
|
845
|
+
"PBKDF2",
|
|
846
|
+
false,
|
|
847
|
+
["deriveBits"]
|
|
848
|
+
);
|
|
849
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
850
|
+
{
|
|
851
|
+
name: "PBKDF2",
|
|
852
|
+
salt,
|
|
853
|
+
iterations: ITERATIONS,
|
|
854
|
+
hash: HASH_ALGO
|
|
855
|
+
},
|
|
856
|
+
keyMaterial,
|
|
857
|
+
KEY_LENGTH * 8
|
|
858
|
+
);
|
|
859
|
+
return toHex(new Uint8Array(derivedBits));
|
|
860
|
+
}
|
|
861
|
+
async function hashPassword(password, _email) {
|
|
862
|
+
if (!password) throw new Error("Password must not be empty");
|
|
863
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
864
|
+
const hashHex = await deriveKey(password, salt);
|
|
865
|
+
return `${toHex(salt)}:${hashHex}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/core/auth/session.ts
|
|
869
|
+
var TOKEN_KEY = "__offlinekit_token";
|
|
870
|
+
var USER_KEY = "__offlinekit_user";
|
|
871
|
+
function saveSession(session) {
|
|
872
|
+
try {
|
|
873
|
+
localStorage.setItem(TOKEN_KEY, session.token);
|
|
874
|
+
localStorage.setItem(USER_KEY, JSON.stringify(session.user));
|
|
875
|
+
} catch {
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function loadSession() {
|
|
879
|
+
try {
|
|
880
|
+
const token = localStorage.getItem(TOKEN_KEY);
|
|
881
|
+
const userRaw = localStorage.getItem(USER_KEY);
|
|
882
|
+
if (!token || !userRaw) return null;
|
|
883
|
+
const parsed = JSON.parse(userRaw);
|
|
884
|
+
if (typeof parsed.id !== "string" || typeof parsed.email !== "string") return null;
|
|
885
|
+
return { token, user: { id: parsed.id, email: parsed.email } };
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function clearSession() {
|
|
891
|
+
try {
|
|
892
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
893
|
+
localStorage.removeItem(USER_KEY);
|
|
894
|
+
} catch {
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// src/core/auth/adapter.ts
|
|
899
|
+
var EmailPasswordAuth = class {
|
|
900
|
+
constructor(config) {
|
|
901
|
+
this.config = config;
|
|
902
|
+
}
|
|
903
|
+
session = loadSession();
|
|
904
|
+
async signUp(credentials) {
|
|
905
|
+
if (!credentials.email) throw new Error("Email is required");
|
|
906
|
+
if (!credentials.password) throw new Error("Password is required");
|
|
907
|
+
const passwordHash = await hashPassword(credentials.password);
|
|
908
|
+
const res = await fetch(`${this.config.endpoint}/auth/signup`, {
|
|
909
|
+
method: "POST",
|
|
910
|
+
headers: { "Content-Type": "application/json" },
|
|
911
|
+
body: JSON.stringify({ email: credentials.email, passwordHash })
|
|
912
|
+
});
|
|
913
|
+
if (!res.ok) {
|
|
914
|
+
const body = await res.json().catch(() => ({}));
|
|
915
|
+
throw new Error(body.error ?? `Sign-up failed: ${res.status}`);
|
|
916
|
+
}
|
|
917
|
+
const data = await res.json();
|
|
918
|
+
this.session = { user: data.user, token: data.token };
|
|
919
|
+
saveSession(this.session);
|
|
920
|
+
return data.user;
|
|
921
|
+
}
|
|
922
|
+
async signIn(credentials) {
|
|
923
|
+
if (!credentials.email) throw new Error("Email is required");
|
|
924
|
+
if (!credentials.password) throw new Error("Password is required");
|
|
925
|
+
const passwordHash = await hashPassword(credentials.password);
|
|
926
|
+
const res = await fetch(`${this.config.endpoint}/auth/signin`, {
|
|
927
|
+
method: "POST",
|
|
928
|
+
headers: { "Content-Type": "application/json" },
|
|
929
|
+
body: JSON.stringify({ email: credentials.email, passwordHash })
|
|
930
|
+
});
|
|
931
|
+
if (!res.ok) {
|
|
932
|
+
const body = await res.json().catch(() => ({}));
|
|
933
|
+
throw new Error(body.error ?? `Sign-in failed: ${res.status}`);
|
|
934
|
+
}
|
|
935
|
+
const data = await res.json();
|
|
936
|
+
this.session = { user: data.user, token: data.token };
|
|
937
|
+
saveSession(this.session);
|
|
938
|
+
return data.user;
|
|
939
|
+
}
|
|
940
|
+
async signOut() {
|
|
941
|
+
const token = this.session?.token;
|
|
942
|
+
this.session = null;
|
|
943
|
+
clearSession();
|
|
944
|
+
if (token) {
|
|
945
|
+
await fetch(`${this.config.endpoint}/auth/signout`, {
|
|
946
|
+
method: "POST",
|
|
947
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
|
948
|
+
}).catch(() => {
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
currentUser() {
|
|
953
|
+
return this.session?.user ?? null;
|
|
954
|
+
}
|
|
955
|
+
getToken() {
|
|
956
|
+
return this.session?.token ?? null;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// src/core/auth/better-auth-adapter.ts
|
|
961
|
+
function unwrap(result, context) {
|
|
962
|
+
if (result.error) {
|
|
963
|
+
throw new Error(result.error.message ?? `Better Auth ${context} failed`);
|
|
964
|
+
}
|
|
965
|
+
if (!result.data) {
|
|
966
|
+
throw new Error(`Better Auth ${context}: no data returned`);
|
|
967
|
+
}
|
|
968
|
+
return result.data;
|
|
969
|
+
}
|
|
970
|
+
function toUser(baUser) {
|
|
971
|
+
return { id: baUser.id, email: baUser.email };
|
|
972
|
+
}
|
|
973
|
+
var BetterAuthAdapter = class {
|
|
974
|
+
constructor(config) {
|
|
975
|
+
this.config = config;
|
|
976
|
+
}
|
|
977
|
+
session = loadSession();
|
|
978
|
+
/**
|
|
979
|
+
* Revalidate the stored session against the server.
|
|
980
|
+
* Call on app startup to verify a cached bearer token is still valid.
|
|
981
|
+
* On network failure the cached session is kept for offline use.
|
|
982
|
+
*/
|
|
983
|
+
async revalidateSession() {
|
|
984
|
+
if (!this.session?.token) return null;
|
|
985
|
+
try {
|
|
986
|
+
const result = await this.config.client.getSession();
|
|
987
|
+
if (result.error || !result.data) {
|
|
988
|
+
this.session = null;
|
|
989
|
+
clearSession();
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
const user = toUser(result.data.user);
|
|
993
|
+
const token = result.data.session.token;
|
|
994
|
+
this.session = { user, token };
|
|
995
|
+
saveSession(this.session);
|
|
996
|
+
return user;
|
|
997
|
+
} catch {
|
|
998
|
+
return this.session?.user ?? null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async signUp(credentials) {
|
|
1002
|
+
const name = credentials.name ?? credentials.email.split("@")[0];
|
|
1003
|
+
const result = await this.config.client.signUp.email({
|
|
1004
|
+
email: credentials.email,
|
|
1005
|
+
password: credentials.password,
|
|
1006
|
+
name
|
|
1007
|
+
});
|
|
1008
|
+
const data = unwrap(result, "signUp");
|
|
1009
|
+
const user = toUser(data.user);
|
|
1010
|
+
const token = data.token;
|
|
1011
|
+
if (token) {
|
|
1012
|
+
this.session = { user, token };
|
|
1013
|
+
saveSession(this.session);
|
|
1014
|
+
}
|
|
1015
|
+
return user;
|
|
1016
|
+
}
|
|
1017
|
+
async signIn(credentials) {
|
|
1018
|
+
const result = await this.config.client.signIn.email({
|
|
1019
|
+
email: credentials.email,
|
|
1020
|
+
password: credentials.password
|
|
1021
|
+
});
|
|
1022
|
+
const data = unwrap(result, "signIn");
|
|
1023
|
+
const user = toUser(data.user);
|
|
1024
|
+
this.session = { user, token: data.token };
|
|
1025
|
+
saveSession(this.session);
|
|
1026
|
+
return user;
|
|
1027
|
+
}
|
|
1028
|
+
async signInWithSocial(provider) {
|
|
1029
|
+
const result = await this.config.client.signIn.social({ provider });
|
|
1030
|
+
const data = unwrap(result, "signInWithSocial");
|
|
1031
|
+
if (data.user && data.token) {
|
|
1032
|
+
const user = toUser(data.user);
|
|
1033
|
+
this.session = { user, token: data.token };
|
|
1034
|
+
saveSession(this.session);
|
|
1035
|
+
return user;
|
|
1036
|
+
}
|
|
1037
|
+
if (data.url && typeof window !== "undefined") {
|
|
1038
|
+
const url = new URL(data.url);
|
|
1039
|
+
const trusted = this.config.trustedOrigins ?? [window.location.origin];
|
|
1040
|
+
if (!trusted.includes(url.origin)) {
|
|
1041
|
+
throw new Error(`Untrusted redirect URL origin: ${url.origin}`);
|
|
1042
|
+
}
|
|
1043
|
+
window.location.href = data.url;
|
|
1044
|
+
}
|
|
1045
|
+
throw new Error(
|
|
1046
|
+
"Social sign-in initiated OAuth redirect. Call revalidateSession() after the callback to load the session."
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
async signOut() {
|
|
1050
|
+
this.session = null;
|
|
1051
|
+
clearSession();
|
|
1052
|
+
try {
|
|
1053
|
+
await this.config.client.signOut();
|
|
1054
|
+
} catch {
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
currentUser() {
|
|
1058
|
+
return this.session?.user ?? null;
|
|
1059
|
+
}
|
|
1060
|
+
getToken() {
|
|
1061
|
+
return this.session?.token ?? null;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
// src/core/auth/offline-adapter.ts
|
|
1066
|
+
var OFFLINE_SESSION_KEY = "__offlinekit_offline_session";
|
|
1067
|
+
function saveOfflineSession(session) {
|
|
1068
|
+
try {
|
|
1069
|
+
localStorage.setItem(OFFLINE_SESSION_KEY, JSON.stringify(session));
|
|
1070
|
+
} catch {
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function loadOfflineSession() {
|
|
1074
|
+
try {
|
|
1075
|
+
const raw = localStorage.getItem(OFFLINE_SESSION_KEY);
|
|
1076
|
+
if (!raw) return null;
|
|
1077
|
+
const parsed = JSON.parse(raw);
|
|
1078
|
+
if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number" || !parsed.user || typeof parsed.user.id !== "string") return null;
|
|
1079
|
+
return parsed;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function clearOfflineSession() {
|
|
1085
|
+
try {
|
|
1086
|
+
localStorage.removeItem(OFFLINE_SESSION_KEY);
|
|
1087
|
+
} catch {
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function isOffline() {
|
|
1091
|
+
return typeof navigator !== "undefined" && !navigator.onLine;
|
|
1092
|
+
}
|
|
1093
|
+
var OfflineSessionAdapter = class {
|
|
1094
|
+
constructor(inner, cacheDuration = 7 * 24 * 60 * 60 * 1e3) {
|
|
1095
|
+
this.inner = inner;
|
|
1096
|
+
this.cacheDuration = cacheDuration;
|
|
1097
|
+
}
|
|
1098
|
+
async signUp(credentials) {
|
|
1099
|
+
const user = await this.inner.signUp(credentials);
|
|
1100
|
+
const token = this.inner.getToken() ?? "";
|
|
1101
|
+
saveOfflineSession({ user, token, expiresAt: Date.now() + this.cacheDuration, provider: "email" });
|
|
1102
|
+
return user;
|
|
1103
|
+
}
|
|
1104
|
+
async signIn(credentials) {
|
|
1105
|
+
try {
|
|
1106
|
+
const user = await this.inner.signIn(credentials);
|
|
1107
|
+
const token = this.inner.getToken() ?? "";
|
|
1108
|
+
saveOfflineSession({ user, token, expiresAt: Date.now() + this.cacheDuration, provider: "email" });
|
|
1109
|
+
return user;
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
if (isOffline()) {
|
|
1112
|
+
const cached = loadOfflineSession();
|
|
1113
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
1114
|
+
return cached.user;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
throw err;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async signOut() {
|
|
1121
|
+
clearOfflineSession();
|
|
1122
|
+
await this.inner.signOut();
|
|
1123
|
+
}
|
|
1124
|
+
currentUser() {
|
|
1125
|
+
const inner = this.inner.currentUser();
|
|
1126
|
+
if (inner) return inner;
|
|
1127
|
+
const cached = loadOfflineSession();
|
|
1128
|
+
if (cached && cached.expiresAt > Date.now()) return cached.user;
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
getToken() {
|
|
1132
|
+
const inner = this.inner.getToken();
|
|
1133
|
+
if (inner) return inner;
|
|
1134
|
+
const cached = loadOfflineSession();
|
|
1135
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
// src/core/errors/tracker.ts
|
|
1141
|
+
var ERRORS_COLLECTION = "_errors";
|
|
1142
|
+
var ErrorTracker = class {
|
|
1143
|
+
constructor(storage, config) {
|
|
1144
|
+
this.storage = storage;
|
|
1145
|
+
this.config = config;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Retrieve all stored error entries.
|
|
1149
|
+
*
|
|
1150
|
+
* @returns An array of all persisted {@link ErrorEntry} records.
|
|
1151
|
+
*/
|
|
1152
|
+
async getAll() {
|
|
1153
|
+
const docs = await this.storage.getMany(ERRORS_COLLECTION);
|
|
1154
|
+
return docs;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Clear all stored error entries (soft-deletes them from storage).
|
|
1158
|
+
*/
|
|
1159
|
+
async clear() {
|
|
1160
|
+
const all = await this.storage.getMany(ERRORS_COLLECTION);
|
|
1161
|
+
for (const entry of all) {
|
|
1162
|
+
await this.storage.delete(ERRORS_COLLECTION, entry._id);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Capture an error and persist it to storage. Triggers the `onError` callback
|
|
1167
|
+
* and webhook (if configured), then prunes old entries if over the limit.
|
|
1168
|
+
*
|
|
1169
|
+
* @param error - The Error instance to capture.
|
|
1170
|
+
* @param type - The error category (e.g. `'unhandled_exception'`, `'sync_error'`).
|
|
1171
|
+
* @param context - Optional additional context merged with auto-detected fields.
|
|
1172
|
+
*/
|
|
1173
|
+
async capture(error, type, context) {
|
|
1174
|
+
if (!this.config.enabled) return;
|
|
1175
|
+
const now = Date.now();
|
|
1176
|
+
const entry = {
|
|
1177
|
+
_id: generateId(),
|
|
1178
|
+
_collection: ERRORS_COLLECTION,
|
|
1179
|
+
_updatedAt: now,
|
|
1180
|
+
_deleted: false,
|
|
1181
|
+
timestamp: now,
|
|
1182
|
+
type,
|
|
1183
|
+
message: error.message,
|
|
1184
|
+
stack: error.stack,
|
|
1185
|
+
context: {
|
|
1186
|
+
online: typeof navigator !== "undefined" ? navigator.onLine : true,
|
|
1187
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
|
|
1188
|
+
...context
|
|
1189
|
+
},
|
|
1190
|
+
...this.config.snapshot ? { snapshot: await this.captureSnapshot() } : {}
|
|
1191
|
+
};
|
|
1192
|
+
await this.storage.put(ERRORS_COLLECTION, entry._id, entry);
|
|
1193
|
+
this.config.onError?.(entry);
|
|
1194
|
+
if (this.config.webhookUrl) {
|
|
1195
|
+
this.sendWebhook(this.config.webhookUrl, entry);
|
|
1196
|
+
}
|
|
1197
|
+
await this.pruneIfNeeded();
|
|
1198
|
+
}
|
|
1199
|
+
sendWebhook(url, entry) {
|
|
1200
|
+
const send = async (attempt) => {
|
|
1201
|
+
try {
|
|
1202
|
+
const res = await fetch(url, {
|
|
1203
|
+
method: "POST",
|
|
1204
|
+
headers: {
|
|
1205
|
+
"Content-Type": "application/json",
|
|
1206
|
+
"User-Agent": "mpb-localkit-error-tracker"
|
|
1207
|
+
},
|
|
1208
|
+
body: JSON.stringify(entry)
|
|
1209
|
+
});
|
|
1210
|
+
if (res.ok) return;
|
|
1211
|
+
if (res.status === 429) {
|
|
1212
|
+
this.reportWebhookFailure(url);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (attempt < 3) {
|
|
1216
|
+
const jitter = Math.random() * 500;
|
|
1217
|
+
const delay = Math.pow(2, attempt) * 1e3 + jitter;
|
|
1218
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1219
|
+
return send(attempt + 1);
|
|
1220
|
+
}
|
|
1221
|
+
this.reportWebhookFailure(url);
|
|
1222
|
+
} catch {
|
|
1223
|
+
if (attempt < 3) {
|
|
1224
|
+
const jitter = Math.random() * 500;
|
|
1225
|
+
const delay = Math.pow(2, attempt) * 1e3 + jitter;
|
|
1226
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1227
|
+
return send(attempt + 1);
|
|
1228
|
+
}
|
|
1229
|
+
this.reportWebhookFailure(url);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
send(0);
|
|
1233
|
+
}
|
|
1234
|
+
reportWebhookFailure(url) {
|
|
1235
|
+
if (this.config.onError) {
|
|
1236
|
+
const now = Date.now();
|
|
1237
|
+
this.config.onError({
|
|
1238
|
+
_id: "",
|
|
1239
|
+
_collection: "_errors",
|
|
1240
|
+
_updatedAt: now,
|
|
1241
|
+
_deleted: false,
|
|
1242
|
+
type: "runtime",
|
|
1243
|
+
message: `Webhook delivery failed after retries: ${url}`,
|
|
1244
|
+
timestamp: now,
|
|
1245
|
+
context: { online: typeof navigator !== "undefined" ? navigator.onLine : true }
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
async captureSnapshot() {
|
|
1250
|
+
const snapshot = {};
|
|
1251
|
+
try {
|
|
1252
|
+
let total = 0;
|
|
1253
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1254
|
+
const k = localStorage.key(i) ?? "";
|
|
1255
|
+
total += k.length + (localStorage.getItem(k)?.length ?? 0);
|
|
1256
|
+
}
|
|
1257
|
+
snapshot.localStorageSize = total;
|
|
1258
|
+
} catch {
|
|
1259
|
+
}
|
|
1260
|
+
return snapshot;
|
|
1261
|
+
}
|
|
1262
|
+
async pruneIfNeeded() {
|
|
1263
|
+
const all = await this.storage.getMany(ERRORS_COLLECTION);
|
|
1264
|
+
if (all.length <= this.config.maxLocalErrors) return;
|
|
1265
|
+
const sorted = [...all].sort((a, b) => (a._updatedAt ?? 0) - (b._updatedAt ?? 0));
|
|
1266
|
+
const toDelete = sorted.slice(0, all.length - this.config.maxLocalErrors);
|
|
1267
|
+
for (const entry of toDelete) {
|
|
1268
|
+
await this.storage.delete(ERRORS_COLLECTION, entry._id);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
// src/core/errors/index.ts
|
|
1274
|
+
var DEFAULT_CONFIG = {
|
|
1275
|
+
enabled: true,
|
|
1276
|
+
snapshot: true,
|
|
1277
|
+
maxLocalErrors: 100
|
|
1278
|
+
};
|
|
1279
|
+
var ErrorManager = class {
|
|
1280
|
+
tracker;
|
|
1281
|
+
constructor(storage, config = {}) {
|
|
1282
|
+
this.tracker = new ErrorTracker(storage, { ...DEFAULT_CONFIG, ...config });
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Capture an error and persist it to storage. Triggers the `onError` callback
|
|
1286
|
+
* and webhook (if configured), then prunes old entries if over the limit.
|
|
1287
|
+
*
|
|
1288
|
+
* @param error - The Error instance to capture.
|
|
1289
|
+
* @param type - The error category (defaults to `'unhandled_exception'`).
|
|
1290
|
+
* @param context - Optional additional context (online status, user agent, etc.).
|
|
1291
|
+
*/
|
|
1292
|
+
async capture(error, type = "unhandled_exception", context) {
|
|
1293
|
+
return this.tracker.capture(error, type, context);
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Retrieve all stored error entries.
|
|
1297
|
+
*
|
|
1298
|
+
* @returns An array of all persisted {@link ErrorEntry} records.
|
|
1299
|
+
*/
|
|
1300
|
+
async getAll() {
|
|
1301
|
+
return this.tracker.getAll();
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Clear all stored error entries (soft-deletes them from storage).
|
|
1305
|
+
*/
|
|
1306
|
+
async clear() {
|
|
1307
|
+
return this.tracker.clear();
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Expose the underlying tracker for handler wiring (e.g. global error handlers).
|
|
1311
|
+
*
|
|
1312
|
+
* @returns The {@link ErrorTracker} instance.
|
|
1313
|
+
*/
|
|
1314
|
+
getTracker() {
|
|
1315
|
+
return this.tracker;
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// src/core/storage/query.ts
|
|
1320
|
+
function isCondition(value) {
|
|
1321
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1322
|
+
const keys = Object.keys(value);
|
|
1323
|
+
return keys.some((k) => k === "$gt" || k === "$lt" || k === "$gte" || k === "$lte" || k === "$in");
|
|
1324
|
+
}
|
|
1325
|
+
function matchesWhere(doc, where) {
|
|
1326
|
+
for (const key of Object.keys(where)) {
|
|
1327
|
+
const condition = where[key];
|
|
1328
|
+
const docValue = doc[key];
|
|
1329
|
+
if (isCondition(condition)) {
|
|
1330
|
+
if (condition.$gt != null && !(docValue > condition.$gt)) return false;
|
|
1331
|
+
if (condition.$lt != null && !(docValue < condition.$lt)) return false;
|
|
1332
|
+
if (condition.$gte != null && !(docValue >= condition.$gte)) return false;
|
|
1333
|
+
if (condition.$lte != null && !(docValue <= condition.$lte)) return false;
|
|
1334
|
+
if (condition.$in !== void 0 && !condition.$in.includes(docValue)) return false;
|
|
1335
|
+
} else {
|
|
1336
|
+
if (docValue !== condition) return false;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
function applySort(docs, sort) {
|
|
1342
|
+
const entries = Object.entries(sort);
|
|
1343
|
+
if (entries.length === 0) return docs;
|
|
1344
|
+
return [...docs].sort((a, b) => {
|
|
1345
|
+
for (const [field, direction] of entries) {
|
|
1346
|
+
const av = a[field];
|
|
1347
|
+
const bv = b[field];
|
|
1348
|
+
if (av === bv) continue;
|
|
1349
|
+
const cmp = av < bv ? -1 : 1;
|
|
1350
|
+
return direction === "asc" ? cmp : -cmp;
|
|
1351
|
+
}
|
|
1352
|
+
return 0;
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
function applyQuery(docs, options) {
|
|
1356
|
+
let result = docs;
|
|
1357
|
+
if (options.where) {
|
|
1358
|
+
const where = options.where;
|
|
1359
|
+
result = result.filter((doc) => matchesWhere(doc, where));
|
|
1360
|
+
}
|
|
1361
|
+
if (options.sort) {
|
|
1362
|
+
result = applySort(result, options.sort);
|
|
1363
|
+
}
|
|
1364
|
+
const offset = options.offset ?? 0;
|
|
1365
|
+
if (offset > 0) {
|
|
1366
|
+
result = result.slice(offset);
|
|
1367
|
+
}
|
|
1368
|
+
if (options.limit !== void 0) {
|
|
1369
|
+
result = result.slice(0, options.limit);
|
|
1370
|
+
}
|
|
1371
|
+
return result;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/core/client/collection.ts
|
|
1375
|
+
var CollectionImpl = class {
|
|
1376
|
+
schema;
|
|
1377
|
+
collectionName;
|
|
1378
|
+
storage;
|
|
1379
|
+
version;
|
|
1380
|
+
migrate;
|
|
1381
|
+
constructor(schema, collectionName, storage, version, migrate) {
|
|
1382
|
+
this.schema = schema;
|
|
1383
|
+
this.collectionName = collectionName;
|
|
1384
|
+
this.storage = storage;
|
|
1385
|
+
this.version = version;
|
|
1386
|
+
this.migrate = migrate;
|
|
1387
|
+
}
|
|
1388
|
+
get name() {
|
|
1389
|
+
return this.collectionName;
|
|
1390
|
+
}
|
|
1391
|
+
async applyMigration(doc) {
|
|
1392
|
+
if (this.version === void 0 || doc._schemaVersion === this.version) {
|
|
1393
|
+
return doc;
|
|
1394
|
+
}
|
|
1395
|
+
if (this.migrate) {
|
|
1396
|
+
const migrated = this.migrate(doc);
|
|
1397
|
+
const updated = { ...migrated, _schemaVersion: this.version };
|
|
1398
|
+
await this.storage.put(this.collectionName, updated._id, updated);
|
|
1399
|
+
return updated;
|
|
1400
|
+
}
|
|
1401
|
+
return doc;
|
|
1402
|
+
}
|
|
1403
|
+
async create(data) {
|
|
1404
|
+
const validated = this.schema.parse(data);
|
|
1405
|
+
const id = generateId();
|
|
1406
|
+
const doc = {
|
|
1407
|
+
...validated,
|
|
1408
|
+
_id: id,
|
|
1409
|
+
_collection: this.collectionName,
|
|
1410
|
+
_updatedAt: Date.now(),
|
|
1411
|
+
_deleted: false,
|
|
1412
|
+
...this.version !== void 0 ? { _schemaVersion: this.version } : {}
|
|
1413
|
+
};
|
|
1414
|
+
await this.storage.put(this.collectionName, id, doc);
|
|
1415
|
+
return doc;
|
|
1416
|
+
}
|
|
1417
|
+
async findMany(options) {
|
|
1418
|
+
const docs = await this.storage.getMany(this.collectionName);
|
|
1419
|
+
const typed = await Promise.all(
|
|
1420
|
+
docs.filter((doc) => !doc._deleted).map((doc) => this.applyMigration(doc))
|
|
1421
|
+
);
|
|
1422
|
+
if (!options) return typed;
|
|
1423
|
+
return applyQuery(typed, options);
|
|
1424
|
+
}
|
|
1425
|
+
async findOne(id) {
|
|
1426
|
+
const doc = await this.storage.get(this.collectionName, id);
|
|
1427
|
+
if (!doc || doc._deleted) return null;
|
|
1428
|
+
return this.applyMigration(doc);
|
|
1429
|
+
}
|
|
1430
|
+
async update(id, data) {
|
|
1431
|
+
const existing = await this.storage.get(this.collectionName, id);
|
|
1432
|
+
if (!existing || existing._deleted) {
|
|
1433
|
+
throw new Error(`Document not found: ${id}`);
|
|
1434
|
+
}
|
|
1435
|
+
const merged = {
|
|
1436
|
+
...existing,
|
|
1437
|
+
...data
|
|
1438
|
+
};
|
|
1439
|
+
const userFields = {};
|
|
1440
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
1441
|
+
if (!k.startsWith("_")) userFields[k] = v;
|
|
1442
|
+
}
|
|
1443
|
+
this.schema.parse(userFields);
|
|
1444
|
+
const updated = {
|
|
1445
|
+
...merged,
|
|
1446
|
+
_updatedAt: Date.now()
|
|
1447
|
+
};
|
|
1448
|
+
await this.storage.put(this.collectionName, id, updated);
|
|
1449
|
+
return updated;
|
|
1450
|
+
}
|
|
1451
|
+
async delete(id) {
|
|
1452
|
+
const existing = await this.storage.get(this.collectionName, id);
|
|
1453
|
+
if (!existing) return;
|
|
1454
|
+
const deleted = {
|
|
1455
|
+
...existing,
|
|
1456
|
+
_deleted: true,
|
|
1457
|
+
_updatedAt: Date.now()
|
|
1458
|
+
};
|
|
1459
|
+
await this.storage.put(this.collectionName, id, deleted);
|
|
1460
|
+
}
|
|
1461
|
+
async count(options) {
|
|
1462
|
+
const docs = await this.findMany(options);
|
|
1463
|
+
return docs.length;
|
|
1464
|
+
}
|
|
1465
|
+
async exists(id) {
|
|
1466
|
+
const doc = await this.storage.get(this.collectionName, id);
|
|
1467
|
+
return doc !== null && !doc._deleted;
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
var ObservableCollectionImpl = class extends CollectionImpl {
|
|
1471
|
+
snapshot = [];
|
|
1472
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1473
|
+
notify() {
|
|
1474
|
+
for (const sub of this.subscribers) sub();
|
|
1475
|
+
}
|
|
1476
|
+
async refreshSnapshot() {
|
|
1477
|
+
const data = await this.findMany();
|
|
1478
|
+
this.snapshot = data;
|
|
1479
|
+
this.notify();
|
|
1480
|
+
}
|
|
1481
|
+
subscribe(listener) {
|
|
1482
|
+
this.subscribers.add(listener);
|
|
1483
|
+
this.refreshSnapshot().catch((err) => console.error("[MPB LocalKit] subscribe refreshSnapshot failed:", err));
|
|
1484
|
+
return () => {
|
|
1485
|
+
this.subscribers.delete(listener);
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
getSnapshot() {
|
|
1489
|
+
return this.snapshot;
|
|
1490
|
+
}
|
|
1491
|
+
async create(data) {
|
|
1492
|
+
const doc = await super.create(data);
|
|
1493
|
+
await this.refreshSnapshot();
|
|
1494
|
+
return doc;
|
|
1495
|
+
}
|
|
1496
|
+
async update(id, data) {
|
|
1497
|
+
const doc = await super.update(id, data);
|
|
1498
|
+
await this.refreshSnapshot();
|
|
1499
|
+
return doc;
|
|
1500
|
+
}
|
|
1501
|
+
async delete(id) {
|
|
1502
|
+
await super.delete(id);
|
|
1503
|
+
await this.refreshSnapshot();
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
// src/core/client/events.ts
|
|
1508
|
+
var AuthStore = class {
|
|
1509
|
+
constructor(adapter) {
|
|
1510
|
+
this.adapter = adapter;
|
|
1511
|
+
this.state = {
|
|
1512
|
+
user: adapter.currentUser(),
|
|
1513
|
+
token: adapter.getToken(),
|
|
1514
|
+
isLoading: false
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
state;
|
|
1518
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1519
|
+
subscribe(listener) {
|
|
1520
|
+
this.listeners.add(listener);
|
|
1521
|
+
return () => {
|
|
1522
|
+
this.listeners.delete(listener);
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
getSnapshot() {
|
|
1526
|
+
return this.state;
|
|
1527
|
+
}
|
|
1528
|
+
notify() {
|
|
1529
|
+
for (const l of this.listeners) l();
|
|
1530
|
+
}
|
|
1531
|
+
async signUp(credentials) {
|
|
1532
|
+
this.state = { ...this.state, isLoading: true };
|
|
1533
|
+
this.notify();
|
|
1534
|
+
try {
|
|
1535
|
+
const user = await this.adapter.signUp(credentials);
|
|
1536
|
+
this.state = { user, token: this.adapter.getToken(), isLoading: false };
|
|
1537
|
+
this.notify();
|
|
1538
|
+
return user;
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
this.state = { ...this.state, isLoading: false };
|
|
1541
|
+
this.notify();
|
|
1542
|
+
throw err;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
async signIn(credentials) {
|
|
1546
|
+
this.state = { ...this.state, isLoading: true };
|
|
1547
|
+
this.notify();
|
|
1548
|
+
try {
|
|
1549
|
+
const user = await this.adapter.signIn(credentials);
|
|
1550
|
+
this.state = { user, token: this.adapter.getToken(), isLoading: false };
|
|
1551
|
+
this.notify();
|
|
1552
|
+
return user;
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
this.state = { ...this.state, isLoading: false };
|
|
1555
|
+
this.notify();
|
|
1556
|
+
throw err;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
async signOut() {
|
|
1560
|
+
await this.adapter.signOut();
|
|
1561
|
+
this.state = { user: null, token: null, isLoading: false };
|
|
1562
|
+
this.notify();
|
|
1563
|
+
}
|
|
1564
|
+
currentUser() {
|
|
1565
|
+
return this.state.user;
|
|
1566
|
+
}
|
|
1567
|
+
getToken() {
|
|
1568
|
+
return this.state.token;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Revalidate a cached session against the server.
|
|
1572
|
+
* Delegates to the inner adapter's `revalidateSession` if it supports it.
|
|
1573
|
+
*
|
|
1574
|
+
* @returns The revalidated user, or `null` if the session is invalid or the adapter does not support revalidation.
|
|
1575
|
+
*/
|
|
1576
|
+
async revalidateSession() {
|
|
1577
|
+
if (!this.adapter.revalidateSession) return null;
|
|
1578
|
+
const user = await this.adapter.revalidateSession();
|
|
1579
|
+
this.state = {
|
|
1580
|
+
user,
|
|
1581
|
+
token: this.adapter.getToken(),
|
|
1582
|
+
isLoading: false
|
|
1583
|
+
};
|
|
1584
|
+
this.notify();
|
|
1585
|
+
return user;
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
var SyncStore = class {
|
|
1589
|
+
state = { status: "idle", lastSyncAt: null };
|
|
1590
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1591
|
+
subscribe(listener) {
|
|
1592
|
+
this.listeners.add(listener);
|
|
1593
|
+
return () => {
|
|
1594
|
+
this.listeners.delete(listener);
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
getSnapshot() {
|
|
1598
|
+
return this.state;
|
|
1599
|
+
}
|
|
1600
|
+
setStatus(status, lastSyncAt) {
|
|
1601
|
+
this.state = {
|
|
1602
|
+
status,
|
|
1603
|
+
lastSyncAt: lastSyncAt !== void 0 ? lastSyncAt : this.state.lastSyncAt
|
|
1604
|
+
};
|
|
1605
|
+
for (const l of this.listeners) l();
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// src/core/client/app.ts
|
|
1610
|
+
function createDeferredBetterAuthClient(baseURL) {
|
|
1611
|
+
let clientPromise = null;
|
|
1612
|
+
function getClient() {
|
|
1613
|
+
if (!clientPromise) {
|
|
1614
|
+
clientPromise = (async () => {
|
|
1615
|
+
try {
|
|
1616
|
+
const mod = await import('better-auth/client');
|
|
1617
|
+
return mod.createAuthClient({
|
|
1618
|
+
baseURL,
|
|
1619
|
+
fetchOptions: {
|
|
1620
|
+
auth: {
|
|
1621
|
+
type: "Bearer",
|
|
1622
|
+
token: () => {
|
|
1623
|
+
try {
|
|
1624
|
+
return loadSession()?.token ?? "";
|
|
1625
|
+
} catch {
|
|
1626
|
+
return "";
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
} catch {
|
|
1633
|
+
throw new Error(
|
|
1634
|
+
'better-auth is not installed. Run: npm install better-auth\nThe server must enable the bearer plugin: import { bearer } from "better-auth/plugins"'
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
})();
|
|
1638
|
+
}
|
|
1639
|
+
return clientPromise;
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
signUp: {
|
|
1643
|
+
email: async (opts) => (await getClient()).signUp.email(opts)
|
|
1644
|
+
},
|
|
1645
|
+
signIn: {
|
|
1646
|
+
email: async (opts) => (await getClient()).signIn.email(opts),
|
|
1647
|
+
social: async (opts) => (await getClient()).signIn.social(opts)
|
|
1648
|
+
},
|
|
1649
|
+
signOut: async () => (await getClient()).signOut(),
|
|
1650
|
+
getSession: async () => (await getClient()).getSession()
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
function resolveAuth(config) {
|
|
1654
|
+
if (!config) {
|
|
1655
|
+
return {
|
|
1656
|
+
signUp: async () => {
|
|
1657
|
+
throw new Error("Auth not configured. Implement AuthAdapter.");
|
|
1658
|
+
},
|
|
1659
|
+
signIn: async () => {
|
|
1660
|
+
throw new Error("Auth not configured. Implement AuthAdapter.");
|
|
1661
|
+
},
|
|
1662
|
+
signOut: async () => {
|
|
1663
|
+
throw new Error("Auth not configured. Implement AuthAdapter.");
|
|
1664
|
+
},
|
|
1665
|
+
currentUser: () => null,
|
|
1666
|
+
getToken: () => null
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
if ("type" in config) {
|
|
1670
|
+
if (config.type === "email-password") {
|
|
1671
|
+
return new EmailPasswordAuth({ endpoint: config.endpoint });
|
|
1672
|
+
}
|
|
1673
|
+
if (config.type === "better-auth") {
|
|
1674
|
+
return new BetterAuthAdapter({ client: createDeferredBetterAuthClient(config.baseURL) });
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const adapter = config;
|
|
1678
|
+
if (typeof adapter.signUp !== "function" || typeof adapter.signIn !== "function" || typeof adapter.signOut !== "function" || typeof adapter.currentUser !== "function" || typeof adapter.getToken !== "function") {
|
|
1679
|
+
throw new Error("Invalid auth config: must implement AuthAdapter interface (signUp, signIn, signOut, currentUser, getToken)");
|
|
1680
|
+
}
|
|
1681
|
+
return adapter;
|
|
1682
|
+
}
|
|
1683
|
+
function createApp(config) {
|
|
1684
|
+
const storage = config.storage ?? new MemoryAdapter();
|
|
1685
|
+
const RESERVED_KEYS = ["auth", "errors", "syncStore", "sync"];
|
|
1686
|
+
for (const name of Object.keys(config.collections)) {
|
|
1687
|
+
if (RESERVED_KEYS.includes(name)) {
|
|
1688
|
+
throw new Error(`Collection name "${name}" conflicts with reserved key`);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
const collections = {};
|
|
1692
|
+
for (const [name, descriptor] of Object.entries(config.collections)) {
|
|
1693
|
+
collections[name] = new ObservableCollectionImpl(descriptor.schema, name, storage, descriptor.version, descriptor.migrate);
|
|
1694
|
+
}
|
|
1695
|
+
const errors = new ErrorManager(storage, config.errorTracking);
|
|
1696
|
+
const authStore = new AuthStore(resolveAuth(config.auth));
|
|
1697
|
+
const syncStore = new SyncStore();
|
|
1698
|
+
let syncAPI;
|
|
1699
|
+
if (config.sync?.endpoint) {
|
|
1700
|
+
const syncConfig = {
|
|
1701
|
+
endpoint: config.sync.endpoint,
|
|
1702
|
+
interval: config.sync.interval ?? 3e4,
|
|
1703
|
+
enabled: config.sync.enabled ?? true,
|
|
1704
|
+
conflictResolver: config.sync.conflictResolver,
|
|
1705
|
+
transport: config.sync.transport
|
|
1706
|
+
};
|
|
1707
|
+
const engine = new SyncEngine(storage, syncConfig);
|
|
1708
|
+
engine.setToken(authStore.getToken());
|
|
1709
|
+
const unsubAuth = authStore.subscribe(() => engine.setToken(authStore.getToken()));
|
|
1710
|
+
engine.on("sync:start", () => syncStore.setStatus("syncing"));
|
|
1711
|
+
engine.on("sync:complete", () => syncStore.setStatus("idle", engine.getLastSyncAt()));
|
|
1712
|
+
engine.on("sync:error", () => syncStore.setStatus(engine.getStatus()));
|
|
1713
|
+
let intervalId = null;
|
|
1714
|
+
const onOnline = () => {
|
|
1715
|
+
void engine.sync();
|
|
1716
|
+
};
|
|
1717
|
+
syncAPI = {
|
|
1718
|
+
push: () => engine.push(),
|
|
1719
|
+
pull: () => engine.pull(),
|
|
1720
|
+
start() {
|
|
1721
|
+
if (!syncConfig.enabled) return;
|
|
1722
|
+
intervalId = setInterval(() => {
|
|
1723
|
+
void engine.sync();
|
|
1724
|
+
}, syncConfig.interval);
|
|
1725
|
+
if (typeof window !== "undefined") {
|
|
1726
|
+
window.addEventListener("online", onOnline);
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
stop() {
|
|
1730
|
+
if (intervalId !== null) {
|
|
1731
|
+
clearInterval(intervalId);
|
|
1732
|
+
intervalId = null;
|
|
1733
|
+
}
|
|
1734
|
+
if (typeof window !== "undefined") {
|
|
1735
|
+
window.removeEventListener("online", onOnline);
|
|
1736
|
+
}
|
|
1737
|
+
unsubAuth();
|
|
1738
|
+
},
|
|
1739
|
+
get status() {
|
|
1740
|
+
return engine.getStatus();
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
return {
|
|
1745
|
+
...collections,
|
|
1746
|
+
auth: authStore,
|
|
1747
|
+
errors,
|
|
1748
|
+
syncStore,
|
|
1749
|
+
...syncAPI !== void 0 ? { sync: syncAPI } : {}
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// src/core/query.ts
|
|
1754
|
+
var localkitKeys = {
|
|
1755
|
+
all: ["localkit"],
|
|
1756
|
+
collection: (name) => ["localkit", name],
|
|
1757
|
+
collectionQuery: (name, opts) => ["localkit", name, JSON.stringify(opts)]
|
|
1758
|
+
};
|
|
1759
|
+
function collectionQueryOptions(collection2, queryOptions) {
|
|
1760
|
+
const queryKey = queryOptions ? localkitKeys.collectionQuery(collection2.name, queryOptions) : localkitKeys.collection(collection2.name);
|
|
1761
|
+
return {
|
|
1762
|
+
queryKey,
|
|
1763
|
+
queryFn: () => collection2.findMany(queryOptions)
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
function subscribeToCollection(collection2, queryClient) {
|
|
1767
|
+
const queryKey = localkitKeys.collection(collection2.name);
|
|
1768
|
+
if (!("subscribe" in collection2)) {
|
|
1769
|
+
return () => {
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
const obs = collection2;
|
|
1773
|
+
return obs.subscribe(() => {
|
|
1774
|
+
void queryClient.invalidateQueries({ queryKey });
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
export { AutoTransport, BetterAuthAdapter, EmailPasswordAuth, EncryptedStorageAdapter, HttpTransport, OfflineSessionAdapter, SyncEngine, WebSocketTransport, collection, collectionQueryOptions, createApp, encrypted, generateId, hashPassword, localkitKeys, migrateToEncrypted, reEncryptAll, resolveConflict, rewrapKey, setupFocusTrigger, setupIntervalTrigger, setupReconnectTrigger, subscribeToCollection };
|
|
1779
|
+
//# sourceMappingURL=index.js.map
|
|
1780
|
+
//# sourceMappingURL=index.js.map
|