@noy-db/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1630 @@
1
+ // src/env-check.ts
2
+ function checkEnvironment() {
3
+ if (typeof process !== "undefined" && process.versions?.node) {
4
+ const major = parseInt(process.versions.node.split(".")[0], 10);
5
+ if (major < 18) {
6
+ throw new Error(
7
+ `@noy-db/core requires Node.js 18 or later (found ${process.versions.node}). Node.js 18+ is required for the Web Crypto API (crypto.subtle).`
8
+ );
9
+ }
10
+ }
11
+ if (typeof globalThis.crypto?.subtle === "undefined") {
12
+ throw new Error(
13
+ "@noy-db/core requires the Web Crypto API (crypto.subtle). Ensure you are running Node.js 18+ or a modern browser (Chrome 63+, Firefox 57+, Safari 13+)."
14
+ );
15
+ }
16
+ }
17
+ checkEnvironment();
18
+
19
+ // src/types.ts
20
+ var NOYDB_FORMAT_VERSION = 1;
21
+ var NOYDB_KEYRING_VERSION = 1;
22
+ var NOYDB_BACKUP_VERSION = 1;
23
+ var NOYDB_SYNC_VERSION = 1;
24
+ function defineAdapter(factory) {
25
+ return factory;
26
+ }
27
+
28
+ // src/errors.ts
29
+ var NoydbError = class extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.name = "NoydbError";
34
+ this.code = code;
35
+ }
36
+ };
37
+ var DecryptionError = class extends NoydbError {
38
+ constructor(message = "Decryption failed") {
39
+ super("DECRYPTION_FAILED", message);
40
+ this.name = "DecryptionError";
41
+ }
42
+ };
43
+ var TamperedError = class extends NoydbError {
44
+ constructor(message = "Data integrity check failed \u2014 record may have been tampered with") {
45
+ super("TAMPERED", message);
46
+ this.name = "TamperedError";
47
+ }
48
+ };
49
+ var InvalidKeyError = class extends NoydbError {
50
+ constructor(message = "Invalid key \u2014 wrong passphrase or corrupted keyring") {
51
+ super("INVALID_KEY", message);
52
+ this.name = "InvalidKeyError";
53
+ }
54
+ };
55
+ var NoAccessError = class extends NoydbError {
56
+ constructor(message = "No access \u2014 user does not have a key for this collection") {
57
+ super("NO_ACCESS", message);
58
+ this.name = "NoAccessError";
59
+ }
60
+ };
61
+ var ReadOnlyError = class extends NoydbError {
62
+ constructor(message = "Read-only \u2014 user has ro permission on this collection") {
63
+ super("READ_ONLY", message);
64
+ this.name = "ReadOnlyError";
65
+ }
66
+ };
67
+ var PermissionDeniedError = class extends NoydbError {
68
+ constructor(message = "Permission denied \u2014 insufficient role for this operation") {
69
+ super("PERMISSION_DENIED", message);
70
+ this.name = "PermissionDeniedError";
71
+ }
72
+ };
73
+ var ConflictError = class extends NoydbError {
74
+ version;
75
+ constructor(version, message = "Version conflict") {
76
+ super("CONFLICT", message);
77
+ this.name = "ConflictError";
78
+ this.version = version;
79
+ }
80
+ };
81
+ var NetworkError = class extends NoydbError {
82
+ constructor(message = "Network error") {
83
+ super("NETWORK_ERROR", message);
84
+ this.name = "NetworkError";
85
+ }
86
+ };
87
+ var NotFoundError = class extends NoydbError {
88
+ constructor(message = "Record not found") {
89
+ super("NOT_FOUND", message);
90
+ this.name = "NotFoundError";
91
+ }
92
+ };
93
+ var ValidationError = class extends NoydbError {
94
+ constructor(message = "Validation error") {
95
+ super("VALIDATION_ERROR", message);
96
+ this.name = "ValidationError";
97
+ }
98
+ };
99
+
100
+ // src/crypto.ts
101
+ var PBKDF2_ITERATIONS = 6e5;
102
+ var SALT_BYTES = 32;
103
+ var IV_BYTES = 12;
104
+ var KEY_BITS = 256;
105
+ var subtle = globalThis.crypto.subtle;
106
+ async function deriveKey(passphrase, salt) {
107
+ const keyMaterial = await subtle.importKey(
108
+ "raw",
109
+ new TextEncoder().encode(passphrase),
110
+ "PBKDF2",
111
+ false,
112
+ ["deriveKey"]
113
+ );
114
+ return subtle.deriveKey(
115
+ {
116
+ name: "PBKDF2",
117
+ salt,
118
+ iterations: PBKDF2_ITERATIONS,
119
+ hash: "SHA-256"
120
+ },
121
+ keyMaterial,
122
+ { name: "AES-KW", length: KEY_BITS },
123
+ false,
124
+ ["wrapKey", "unwrapKey"]
125
+ );
126
+ }
127
+ async function generateDEK() {
128
+ return subtle.generateKey(
129
+ { name: "AES-GCM", length: KEY_BITS },
130
+ true,
131
+ // extractable — needed for AES-KW wrapping
132
+ ["encrypt", "decrypt"]
133
+ );
134
+ }
135
+ async function wrapKey(dek, kek) {
136
+ const wrapped = await subtle.wrapKey("raw", dek, kek, "AES-KW");
137
+ return bufferToBase64(wrapped);
138
+ }
139
+ async function unwrapKey(wrappedBase64, kek) {
140
+ try {
141
+ return await subtle.unwrapKey(
142
+ "raw",
143
+ base64ToBuffer(wrappedBase64),
144
+ kek,
145
+ "AES-KW",
146
+ { name: "AES-GCM", length: KEY_BITS },
147
+ true,
148
+ ["encrypt", "decrypt"]
149
+ );
150
+ } catch {
151
+ throw new InvalidKeyError();
152
+ }
153
+ }
154
+ async function encrypt(plaintext, dek) {
155
+ const iv = generateIV();
156
+ const encoded = new TextEncoder().encode(plaintext);
157
+ const ciphertext = await subtle.encrypt(
158
+ { name: "AES-GCM", iv },
159
+ dek,
160
+ encoded
161
+ );
162
+ return {
163
+ iv: bufferToBase64(iv),
164
+ data: bufferToBase64(ciphertext)
165
+ };
166
+ }
167
+ async function decrypt(ivBase64, dataBase64, dek) {
168
+ const iv = base64ToBuffer(ivBase64);
169
+ const ciphertext = base64ToBuffer(dataBase64);
170
+ try {
171
+ const plaintext = await subtle.decrypt(
172
+ { name: "AES-GCM", iv },
173
+ dek,
174
+ ciphertext
175
+ );
176
+ return new TextDecoder().decode(plaintext);
177
+ } catch (err) {
178
+ if (err instanceof Error && err.name === "OperationError") {
179
+ throw new TamperedError();
180
+ }
181
+ throw new DecryptionError(
182
+ err instanceof Error ? err.message : "Decryption failed"
183
+ );
184
+ }
185
+ }
186
+ function generateIV() {
187
+ return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
188
+ }
189
+ function generateSalt() {
190
+ return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES));
191
+ }
192
+ function bufferToBase64(buffer) {
193
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
194
+ let binary = "";
195
+ for (let i = 0; i < bytes.length; i++) {
196
+ binary += String.fromCharCode(bytes[i]);
197
+ }
198
+ return btoa(binary);
199
+ }
200
+ function base64ToBuffer(base64) {
201
+ const binary = atob(base64);
202
+ const bytes = new Uint8Array(binary.length);
203
+ for (let i = 0; i < binary.length; i++) {
204
+ bytes[i] = binary.charCodeAt(i);
205
+ }
206
+ return bytes;
207
+ }
208
+
209
+ // src/keyring.ts
210
+ var GRANTABLE_BY_ADMIN = ["operator", "viewer", "client"];
211
+ function canGrant(callerRole, targetRole) {
212
+ if (callerRole === "owner") return true;
213
+ if (callerRole === "admin") return GRANTABLE_BY_ADMIN.includes(targetRole);
214
+ return false;
215
+ }
216
+ function canRevoke(callerRole, targetRole) {
217
+ if (targetRole === "owner") return false;
218
+ if (callerRole === "owner") return true;
219
+ if (callerRole === "admin") return GRANTABLE_BY_ADMIN.includes(targetRole);
220
+ return false;
221
+ }
222
+ async function loadKeyring(adapter, compartment, userId, passphrase) {
223
+ const envelope = await adapter.get(compartment, "_keyring", userId);
224
+ if (!envelope) {
225
+ throw new NoAccessError(`No keyring found for user "${userId}" in compartment "${compartment}"`);
226
+ }
227
+ const keyringFile = JSON.parse(envelope._data);
228
+ const salt = base64ToBuffer(keyringFile.salt);
229
+ const kek = await deriveKey(passphrase, salt);
230
+ const deks = /* @__PURE__ */ new Map();
231
+ for (const [collName, wrappedDek] of Object.entries(keyringFile.deks)) {
232
+ const dek = await unwrapKey(wrappedDek, kek);
233
+ deks.set(collName, dek);
234
+ }
235
+ return {
236
+ userId: keyringFile.user_id,
237
+ displayName: keyringFile.display_name,
238
+ role: keyringFile.role,
239
+ permissions: keyringFile.permissions,
240
+ deks,
241
+ kek,
242
+ salt
243
+ };
244
+ }
245
+ async function createOwnerKeyring(adapter, compartment, userId, passphrase) {
246
+ const salt = generateSalt();
247
+ const kek = await deriveKey(passphrase, salt);
248
+ const keyringFile = {
249
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
250
+ user_id: userId,
251
+ display_name: userId,
252
+ role: "owner",
253
+ permissions: {},
254
+ deks: {},
255
+ salt: bufferToBase64(salt),
256
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
257
+ granted_by: userId
258
+ };
259
+ await writeKeyringFile(adapter, compartment, userId, keyringFile);
260
+ return {
261
+ userId,
262
+ displayName: userId,
263
+ role: "owner",
264
+ permissions: {},
265
+ deks: /* @__PURE__ */ new Map(),
266
+ kek,
267
+ salt
268
+ };
269
+ }
270
+ async function grant(adapter, compartment, callerKeyring, options) {
271
+ if (!canGrant(callerKeyring.role, options.role)) {
272
+ throw new PermissionDeniedError(
273
+ `Role "${callerKeyring.role}" cannot grant role "${options.role}"`
274
+ );
275
+ }
276
+ const permissions = resolvePermissions(options.role, options.permissions);
277
+ const newSalt = generateSalt();
278
+ const newKek = await deriveKey(options.passphrase, newSalt);
279
+ const wrappedDeks = {};
280
+ for (const collName of Object.keys(permissions)) {
281
+ const dek = callerKeyring.deks.get(collName);
282
+ if (dek) {
283
+ wrappedDeks[collName] = await wrapKey(dek, newKek);
284
+ }
285
+ }
286
+ if (options.role === "owner" || options.role === "admin" || options.role === "viewer") {
287
+ for (const [collName, dek] of callerKeyring.deks) {
288
+ if (!(collName in wrappedDeks)) {
289
+ wrappedDeks[collName] = await wrapKey(dek, newKek);
290
+ }
291
+ }
292
+ }
293
+ const keyringFile = {
294
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
295
+ user_id: options.userId,
296
+ display_name: options.displayName,
297
+ role: options.role,
298
+ permissions,
299
+ deks: wrappedDeks,
300
+ salt: bufferToBase64(newSalt),
301
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
302
+ granted_by: callerKeyring.userId
303
+ };
304
+ await writeKeyringFile(adapter, compartment, options.userId, keyringFile);
305
+ }
306
+ async function revoke(adapter, compartment, callerKeyring, options) {
307
+ const targetEnvelope = await adapter.get(compartment, "_keyring", options.userId);
308
+ if (!targetEnvelope) {
309
+ throw new NoAccessError(`User "${options.userId}" has no keyring in compartment "${compartment}"`);
310
+ }
311
+ const targetKeyring = JSON.parse(targetEnvelope._data);
312
+ if (!canRevoke(callerKeyring.role, targetKeyring.role)) {
313
+ throw new PermissionDeniedError(
314
+ `Role "${callerKeyring.role}" cannot revoke role "${targetKeyring.role}"`
315
+ );
316
+ }
317
+ const affectedCollections = Object.keys(targetKeyring.deks);
318
+ await adapter.delete(compartment, "_keyring", options.userId);
319
+ if (options.rotateKeys !== false && affectedCollections.length > 0) {
320
+ await rotateKeys(adapter, compartment, callerKeyring, affectedCollections);
321
+ }
322
+ }
323
+ async function rotateKeys(adapter, compartment, callerKeyring, collections) {
324
+ const newDeks = /* @__PURE__ */ new Map();
325
+ for (const collName of collections) {
326
+ newDeks.set(collName, await generateDEK());
327
+ }
328
+ for (const collName of collections) {
329
+ const oldDek = callerKeyring.deks.get(collName);
330
+ const newDek = newDeks.get(collName);
331
+ if (!oldDek) continue;
332
+ const ids = await adapter.list(compartment, collName);
333
+ for (const id of ids) {
334
+ const envelope = await adapter.get(compartment, collName, id);
335
+ if (!envelope || !envelope._iv) continue;
336
+ const plaintext = await decrypt(envelope._iv, envelope._data, oldDek);
337
+ const { iv, data } = await encrypt(plaintext, newDek);
338
+ const newEnvelope = {
339
+ _noydb: NOYDB_FORMAT_VERSION,
340
+ _v: envelope._v,
341
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
342
+ _iv: iv,
343
+ _data: data
344
+ };
345
+ await adapter.put(compartment, collName, id, newEnvelope);
346
+ }
347
+ }
348
+ for (const [collName, newDek] of newDeks) {
349
+ callerKeyring.deks.set(collName, newDek);
350
+ }
351
+ await persistKeyring(adapter, compartment, callerKeyring);
352
+ const userIds = await adapter.list(compartment, "_keyring");
353
+ for (const userId of userIds) {
354
+ if (userId === callerKeyring.userId) continue;
355
+ const userEnvelope = await adapter.get(compartment, "_keyring", userId);
356
+ if (!userEnvelope) continue;
357
+ const userKeyringFile = JSON.parse(userEnvelope._data);
358
+ const userKek = null;
359
+ const updatedDeks = { ...userKeyringFile.deks };
360
+ for (const collName of collections) {
361
+ delete updatedDeks[collName];
362
+ }
363
+ const updatedPermissions = { ...userKeyringFile.permissions };
364
+ for (const collName of collections) {
365
+ delete updatedPermissions[collName];
366
+ }
367
+ const updatedKeyring = {
368
+ ...userKeyringFile,
369
+ deks: updatedDeks,
370
+ permissions: updatedPermissions
371
+ };
372
+ await writeKeyringFile(adapter, compartment, userId, updatedKeyring);
373
+ }
374
+ }
375
+ async function changeSecret(adapter, compartment, keyring, newPassphrase) {
376
+ const newSalt = generateSalt();
377
+ const newKek = await deriveKey(newPassphrase, newSalt);
378
+ const wrappedDeks = {};
379
+ for (const [collName, dek] of keyring.deks) {
380
+ wrappedDeks[collName] = await wrapKey(dek, newKek);
381
+ }
382
+ const keyringFile = {
383
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
384
+ user_id: keyring.userId,
385
+ display_name: keyring.displayName,
386
+ role: keyring.role,
387
+ permissions: keyring.permissions,
388
+ deks: wrappedDeks,
389
+ salt: bufferToBase64(newSalt),
390
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
391
+ granted_by: keyring.userId
392
+ };
393
+ await writeKeyringFile(adapter, compartment, keyring.userId, keyringFile);
394
+ return {
395
+ userId: keyring.userId,
396
+ displayName: keyring.displayName,
397
+ role: keyring.role,
398
+ permissions: keyring.permissions,
399
+ deks: keyring.deks,
400
+ // Same DEKs, different wrapping
401
+ kek: newKek,
402
+ salt: newSalt
403
+ };
404
+ }
405
+ async function listUsers(adapter, compartment) {
406
+ const userIds = await adapter.list(compartment, "_keyring");
407
+ const users = [];
408
+ for (const userId of userIds) {
409
+ const envelope = await adapter.get(compartment, "_keyring", userId);
410
+ if (!envelope) continue;
411
+ const kf = JSON.parse(envelope._data);
412
+ users.push({
413
+ userId: kf.user_id,
414
+ displayName: kf.display_name,
415
+ role: kf.role,
416
+ permissions: kf.permissions,
417
+ createdAt: kf.created_at,
418
+ grantedBy: kf.granted_by
419
+ });
420
+ }
421
+ return users;
422
+ }
423
+ async function ensureCollectionDEK(adapter, compartment, keyring) {
424
+ return async (collectionName) => {
425
+ const existing = keyring.deks.get(collectionName);
426
+ if (existing) return existing;
427
+ const dek = await generateDEK();
428
+ keyring.deks.set(collectionName, dek);
429
+ await persistKeyring(adapter, compartment, keyring);
430
+ return dek;
431
+ };
432
+ }
433
+ function hasWritePermission(keyring, collectionName) {
434
+ if (keyring.role === "owner" || keyring.role === "admin") return true;
435
+ if (keyring.role === "viewer" || keyring.role === "client") return false;
436
+ return keyring.permissions[collectionName] === "rw";
437
+ }
438
+ async function persistKeyring(adapter, compartment, keyring) {
439
+ const wrappedDeks = {};
440
+ for (const [collName, dek] of keyring.deks) {
441
+ wrappedDeks[collName] = await wrapKey(dek, keyring.kek);
442
+ }
443
+ const keyringFile = {
444
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
445
+ user_id: keyring.userId,
446
+ display_name: keyring.displayName,
447
+ role: keyring.role,
448
+ permissions: keyring.permissions,
449
+ deks: wrappedDeks,
450
+ salt: bufferToBase64(keyring.salt),
451
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
452
+ granted_by: keyring.userId
453
+ };
454
+ await writeKeyringFile(adapter, compartment, keyring.userId, keyringFile);
455
+ }
456
+ function resolvePermissions(role, explicit) {
457
+ if (role === "owner" || role === "admin" || role === "viewer") return {};
458
+ return explicit ?? {};
459
+ }
460
+ async function writeKeyringFile(adapter, compartment, userId, keyringFile) {
461
+ const envelope = {
462
+ _noydb: 1,
463
+ _v: 1,
464
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
465
+ _iv: "",
466
+ _data: JSON.stringify(keyringFile)
467
+ };
468
+ await adapter.put(compartment, "_keyring", userId, envelope);
469
+ }
470
+
471
+ // src/history.ts
472
+ var HISTORY_COLLECTION = "_history";
473
+ var VERSION_PAD = 10;
474
+ function historyId(collection, recordId, version) {
475
+ return `${collection}:${recordId}:${String(version).padStart(VERSION_PAD, "0")}`;
476
+ }
477
+ function matchesPrefix(id, collection, recordId) {
478
+ if (recordId) {
479
+ return id.startsWith(`${collection}:${recordId}:`);
480
+ }
481
+ return id.startsWith(`${collection}:`);
482
+ }
483
+ async function saveHistory(adapter, compartment, collection, recordId, envelope) {
484
+ const id = historyId(collection, recordId, envelope._v);
485
+ await adapter.put(compartment, HISTORY_COLLECTION, id, envelope);
486
+ }
487
+ async function getHistory(adapter, compartment, collection, recordId, options) {
488
+ const allIds = await adapter.list(compartment, HISTORY_COLLECTION);
489
+ const matchingIds = allIds.filter((id) => matchesPrefix(id, collection, recordId)).sort().reverse();
490
+ let entries = [];
491
+ for (const id of matchingIds) {
492
+ const envelope = await adapter.get(compartment, HISTORY_COLLECTION, id);
493
+ if (!envelope) continue;
494
+ if (options?.from && envelope._ts < options.from) continue;
495
+ if (options?.to && envelope._ts > options.to) continue;
496
+ entries.push(envelope);
497
+ if (options?.limit && entries.length >= options.limit) break;
498
+ }
499
+ return entries;
500
+ }
501
+ async function getVersionEnvelope(adapter, compartment, collection, recordId, version) {
502
+ const id = historyId(collection, recordId, version);
503
+ return adapter.get(compartment, HISTORY_COLLECTION, id);
504
+ }
505
+ async function pruneHistory(adapter, compartment, collection, recordId, options) {
506
+ const allIds = await adapter.list(compartment, HISTORY_COLLECTION);
507
+ const matchingIds = allIds.filter((id) => recordId ? matchesPrefix(id, collection, recordId) : matchesPrefix(id, collection)).sort();
508
+ let toDelete = [];
509
+ if (options.keepVersions !== void 0) {
510
+ const keep = options.keepVersions;
511
+ if (matchingIds.length > keep) {
512
+ toDelete = matchingIds.slice(0, matchingIds.length - keep);
513
+ }
514
+ }
515
+ if (options.beforeDate) {
516
+ for (const id of matchingIds) {
517
+ if (toDelete.includes(id)) continue;
518
+ const envelope = await adapter.get(compartment, HISTORY_COLLECTION, id);
519
+ if (envelope && envelope._ts < options.beforeDate) {
520
+ toDelete.push(id);
521
+ }
522
+ }
523
+ }
524
+ const uniqueDeletes = [...new Set(toDelete)];
525
+ for (const id of uniqueDeletes) {
526
+ await adapter.delete(compartment, HISTORY_COLLECTION, id);
527
+ }
528
+ return uniqueDeletes.length;
529
+ }
530
+ async function clearHistory(adapter, compartment, collection, recordId) {
531
+ const allIds = await adapter.list(compartment, HISTORY_COLLECTION);
532
+ let toDelete;
533
+ if (collection && recordId) {
534
+ toDelete = allIds.filter((id) => matchesPrefix(id, collection, recordId));
535
+ } else if (collection) {
536
+ toDelete = allIds.filter((id) => matchesPrefix(id, collection));
537
+ } else {
538
+ toDelete = allIds;
539
+ }
540
+ for (const id of toDelete) {
541
+ await adapter.delete(compartment, HISTORY_COLLECTION, id);
542
+ }
543
+ return toDelete.length;
544
+ }
545
+
546
+ // src/diff.ts
547
+ function diff(oldObj, newObj, basePath = "") {
548
+ const changes = [];
549
+ if (oldObj === newObj) return changes;
550
+ if (oldObj == null && newObj != null) {
551
+ return [{ path: basePath || "(root)", type: "added", to: newObj }];
552
+ }
553
+ if (oldObj != null && newObj == null) {
554
+ return [{ path: basePath || "(root)", type: "removed", from: oldObj }];
555
+ }
556
+ if (typeof oldObj !== typeof newObj) {
557
+ return [{ path: basePath || "(root)", type: "changed", from: oldObj, to: newObj }];
558
+ }
559
+ if (typeof oldObj !== "object") {
560
+ return [{ path: basePath || "(root)", type: "changed", from: oldObj, to: newObj }];
561
+ }
562
+ if (Array.isArray(oldObj) && Array.isArray(newObj)) {
563
+ const maxLen = Math.max(oldObj.length, newObj.length);
564
+ for (let i = 0; i < maxLen; i++) {
565
+ const p = basePath ? `${basePath}[${i}]` : `[${i}]`;
566
+ if (i >= oldObj.length) {
567
+ changes.push({ path: p, type: "added", to: newObj[i] });
568
+ } else if (i >= newObj.length) {
569
+ changes.push({ path: p, type: "removed", from: oldObj[i] });
570
+ } else {
571
+ changes.push(...diff(oldObj[i], newObj[i], p));
572
+ }
573
+ }
574
+ return changes;
575
+ }
576
+ const oldRecord = oldObj;
577
+ const newRecord = newObj;
578
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldRecord), ...Object.keys(newRecord)]);
579
+ for (const key of allKeys) {
580
+ const p = basePath ? `${basePath}.${key}` : key;
581
+ if (!(key in oldRecord)) {
582
+ changes.push({ path: p, type: "added", to: newRecord[key] });
583
+ } else if (!(key in newRecord)) {
584
+ changes.push({ path: p, type: "removed", from: oldRecord[key] });
585
+ } else {
586
+ changes.push(...diff(oldRecord[key], newRecord[key], p));
587
+ }
588
+ }
589
+ return changes;
590
+ }
591
+ function formatDiff(changes) {
592
+ if (changes.length === 0) return "(no changes)";
593
+ return changes.map((c) => {
594
+ switch (c.type) {
595
+ case "added":
596
+ return `+ ${c.path}: ${JSON.stringify(c.to)}`;
597
+ case "removed":
598
+ return `- ${c.path}: ${JSON.stringify(c.from)}`;
599
+ case "changed":
600
+ return `~ ${c.path}: ${JSON.stringify(c.from)} \u2192 ${JSON.stringify(c.to)}`;
601
+ }
602
+ }).join("\n");
603
+ }
604
+
605
+ // src/collection.ts
606
+ var Collection = class {
607
+ adapter;
608
+ compartment;
609
+ name;
610
+ keyring;
611
+ encrypted;
612
+ emitter;
613
+ getDEK;
614
+ onDirty;
615
+ historyConfig;
616
+ // In-memory cache of decrypted records
617
+ cache = /* @__PURE__ */ new Map();
618
+ hydrated = false;
619
+ constructor(opts) {
620
+ this.adapter = opts.adapter;
621
+ this.compartment = opts.compartment;
622
+ this.name = opts.name;
623
+ this.keyring = opts.keyring;
624
+ this.encrypted = opts.encrypted;
625
+ this.emitter = opts.emitter;
626
+ this.getDEK = opts.getDEK;
627
+ this.onDirty = opts.onDirty;
628
+ this.historyConfig = opts.historyConfig ?? { enabled: true };
629
+ }
630
+ /** Get a single record by ID. Returns null if not found. */
631
+ async get(id) {
632
+ await this.ensureHydrated();
633
+ const entry = this.cache.get(id);
634
+ return entry ? entry.record : null;
635
+ }
636
+ /** Create or update a record. */
637
+ async put(id, record) {
638
+ if (!hasWritePermission(this.keyring, this.name)) {
639
+ throw new ReadOnlyError();
640
+ }
641
+ await this.ensureHydrated();
642
+ const existing = this.cache.get(id);
643
+ const version = existing ? existing.version + 1 : 1;
644
+ if (existing && this.historyConfig.enabled !== false) {
645
+ const historyEnvelope = await this.encryptRecord(existing.record, existing.version);
646
+ await saveHistory(this.adapter, this.compartment, this.name, id, historyEnvelope);
647
+ this.emitter.emit("history:save", {
648
+ compartment: this.compartment,
649
+ collection: this.name,
650
+ id,
651
+ version: existing.version
652
+ });
653
+ if (this.historyConfig.maxVersions) {
654
+ await pruneHistory(this.adapter, this.compartment, this.name, id, {
655
+ keepVersions: this.historyConfig.maxVersions
656
+ });
657
+ }
658
+ }
659
+ const envelope = await this.encryptRecord(record, version);
660
+ await this.adapter.put(this.compartment, this.name, id, envelope);
661
+ this.cache.set(id, { record, version });
662
+ await this.onDirty?.(this.name, id, "put", version);
663
+ this.emitter.emit("change", {
664
+ compartment: this.compartment,
665
+ collection: this.name,
666
+ id,
667
+ action: "put"
668
+ });
669
+ }
670
+ /** Delete a record by ID. */
671
+ async delete(id) {
672
+ if (!hasWritePermission(this.keyring, this.name)) {
673
+ throw new ReadOnlyError();
674
+ }
675
+ const existing = this.cache.get(id);
676
+ if (existing && this.historyConfig.enabled !== false) {
677
+ const historyEnvelope = await this.encryptRecord(existing.record, existing.version);
678
+ await saveHistory(this.adapter, this.compartment, this.name, id, historyEnvelope);
679
+ }
680
+ await this.adapter.delete(this.compartment, this.name, id);
681
+ this.cache.delete(id);
682
+ await this.onDirty?.(this.name, id, "delete", existing?.version ?? 0);
683
+ this.emitter.emit("change", {
684
+ compartment: this.compartment,
685
+ collection: this.name,
686
+ id,
687
+ action: "delete"
688
+ });
689
+ }
690
+ /** List all records in the collection. */
691
+ async list() {
692
+ await this.ensureHydrated();
693
+ return [...this.cache.values()].map((e) => e.record);
694
+ }
695
+ /** Filter records by a predicate. */
696
+ query(predicate) {
697
+ return [...this.cache.values()].map((e) => e.record).filter(predicate);
698
+ }
699
+ // ─── History Methods ────────────────────────────────────────────
700
+ /** Get version history for a record, newest first. */
701
+ async history(id, options) {
702
+ const envelopes = await getHistory(
703
+ this.adapter,
704
+ this.compartment,
705
+ this.name,
706
+ id,
707
+ options
708
+ );
709
+ const entries = [];
710
+ for (const env of envelopes) {
711
+ const record = await this.decryptRecord(env);
712
+ entries.push({
713
+ version: env._v,
714
+ timestamp: env._ts,
715
+ userId: env._by ?? "",
716
+ record
717
+ });
718
+ }
719
+ return entries;
720
+ }
721
+ /** Get a specific past version of a record. */
722
+ async getVersion(id, version) {
723
+ const envelope = await getVersionEnvelope(
724
+ this.adapter,
725
+ this.compartment,
726
+ this.name,
727
+ id,
728
+ version
729
+ );
730
+ if (!envelope) return null;
731
+ return this.decryptRecord(envelope);
732
+ }
733
+ /** Revert a record to a past version. Creates a new version with the old content. */
734
+ async revert(id, version) {
735
+ const oldRecord = await this.getVersion(id, version);
736
+ if (!oldRecord) {
737
+ throw new Error(`Version ${version} not found for record "${id}"`);
738
+ }
739
+ await this.put(id, oldRecord);
740
+ }
741
+ /**
742
+ * Compare two versions of a record and return the differences.
743
+ * Use version 0 to represent "before creation" (empty).
744
+ * Omit versionB to compare against the current version.
745
+ */
746
+ async diff(id, versionA, versionB) {
747
+ const recordA = versionA === 0 ? null : await this.resolveVersion(id, versionA);
748
+ const recordB = versionB === void 0 || versionB === 0 ? versionB === 0 ? null : await this.resolveCurrentOrVersion(id) : await this.resolveVersion(id, versionB);
749
+ return diff(recordA, recordB);
750
+ }
751
+ /** Resolve a version: try history first, then check if it's the current version. */
752
+ async resolveVersion(id, version) {
753
+ const fromHistory = await this.getVersion(id, version);
754
+ if (fromHistory) return fromHistory;
755
+ await this.ensureHydrated();
756
+ const current = this.cache.get(id);
757
+ if (current && current.version === version) return current.record;
758
+ return null;
759
+ }
760
+ async resolveCurrentOrVersion(id) {
761
+ await this.ensureHydrated();
762
+ return this.cache.get(id)?.record ?? null;
763
+ }
764
+ /** Prune history entries for a record (or all records if id is undefined). */
765
+ async pruneRecordHistory(id, options) {
766
+ const pruned = await pruneHistory(
767
+ this.adapter,
768
+ this.compartment,
769
+ this.name,
770
+ id,
771
+ options
772
+ );
773
+ if (pruned > 0) {
774
+ this.emitter.emit("history:prune", {
775
+ compartment: this.compartment,
776
+ collection: this.name,
777
+ id: id ?? "*",
778
+ pruned
779
+ });
780
+ }
781
+ return pruned;
782
+ }
783
+ /** Clear all history for this collection (or a specific record). */
784
+ async clearHistory(id) {
785
+ return clearHistory(this.adapter, this.compartment, this.name, id);
786
+ }
787
+ // ─── Core Methods ─────────────────────────────────────────────
788
+ /** Count records in the collection. */
789
+ async count() {
790
+ await this.ensureHydrated();
791
+ return this.cache.size;
792
+ }
793
+ // ─── Internal ──────────────────────────────────────────────────
794
+ /** Load all records from adapter into memory cache. */
795
+ async ensureHydrated() {
796
+ if (this.hydrated) return;
797
+ const ids = await this.adapter.list(this.compartment, this.name);
798
+ for (const id of ids) {
799
+ const envelope = await this.adapter.get(this.compartment, this.name, id);
800
+ if (envelope) {
801
+ const record = await this.decryptRecord(envelope);
802
+ this.cache.set(id, { record, version: envelope._v });
803
+ }
804
+ }
805
+ this.hydrated = true;
806
+ }
807
+ /** Hydrate from a pre-loaded snapshot (used by Compartment). */
808
+ async hydrateFromSnapshot(records) {
809
+ for (const [id, envelope] of Object.entries(records)) {
810
+ const record = await this.decryptRecord(envelope);
811
+ this.cache.set(id, { record, version: envelope._v });
812
+ }
813
+ this.hydrated = true;
814
+ }
815
+ /** Get all records as encrypted envelopes (for dump). */
816
+ async dumpEnvelopes() {
817
+ await this.ensureHydrated();
818
+ const result = {};
819
+ for (const [id, entry] of this.cache) {
820
+ result[id] = await this.encryptRecord(entry.record, entry.version);
821
+ }
822
+ return result;
823
+ }
824
+ async encryptRecord(record, version) {
825
+ const json = JSON.stringify(record);
826
+ const by = this.keyring.userId;
827
+ if (!this.encrypted) {
828
+ return {
829
+ _noydb: NOYDB_FORMAT_VERSION,
830
+ _v: version,
831
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
832
+ _iv: "",
833
+ _data: json,
834
+ _by: by
835
+ };
836
+ }
837
+ const dek = await this.getDEK(this.name);
838
+ const { iv, data } = await encrypt(json, dek);
839
+ return {
840
+ _noydb: NOYDB_FORMAT_VERSION,
841
+ _v: version,
842
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
843
+ _iv: iv,
844
+ _data: data,
845
+ _by: by
846
+ };
847
+ }
848
+ async decryptRecord(envelope) {
849
+ if (!this.encrypted) {
850
+ return JSON.parse(envelope._data);
851
+ }
852
+ const dek = await this.getDEK(this.name);
853
+ const json = await decrypt(envelope._iv, envelope._data, dek);
854
+ return JSON.parse(json);
855
+ }
856
+ };
857
+
858
+ // src/compartment.ts
859
+ var Compartment = class {
860
+ adapter;
861
+ name;
862
+ keyring;
863
+ encrypted;
864
+ emitter;
865
+ onDirty;
866
+ historyConfig;
867
+ getDEK;
868
+ collectionCache = /* @__PURE__ */ new Map();
869
+ constructor(opts) {
870
+ this.adapter = opts.adapter;
871
+ this.name = opts.name;
872
+ this.keyring = opts.keyring;
873
+ this.encrypted = opts.encrypted;
874
+ this.emitter = opts.emitter;
875
+ this.onDirty = opts.onDirty;
876
+ this.historyConfig = opts.historyConfig ?? { enabled: true };
877
+ let getDEKFn = null;
878
+ this.getDEK = async (collectionName) => {
879
+ if (!getDEKFn) {
880
+ getDEKFn = await ensureCollectionDEK(this.adapter, this.name, this.keyring);
881
+ }
882
+ return getDEKFn(collectionName);
883
+ };
884
+ }
885
+ /** Open a typed collection within this compartment. */
886
+ collection(collectionName) {
887
+ let coll = this.collectionCache.get(collectionName);
888
+ if (!coll) {
889
+ coll = new Collection({
890
+ adapter: this.adapter,
891
+ compartment: this.name,
892
+ name: collectionName,
893
+ keyring: this.keyring,
894
+ encrypted: this.encrypted,
895
+ emitter: this.emitter,
896
+ getDEK: this.getDEK,
897
+ onDirty: this.onDirty,
898
+ historyConfig: this.historyConfig
899
+ });
900
+ this.collectionCache.set(collectionName, coll);
901
+ }
902
+ return coll;
903
+ }
904
+ /** List all collection names in this compartment. */
905
+ async collections() {
906
+ const snapshot = await this.adapter.loadAll(this.name);
907
+ return Object.keys(snapshot);
908
+ }
909
+ /** Dump compartment as encrypted JSON backup string. */
910
+ async dump() {
911
+ const snapshot = await this.adapter.loadAll(this.name);
912
+ const keyringIds = await this.adapter.list(this.name, "_keyring");
913
+ const keyrings = {};
914
+ for (const keyringId of keyringIds) {
915
+ const envelope = await this.adapter.get(this.name, "_keyring", keyringId);
916
+ if (envelope) {
917
+ keyrings[keyringId] = JSON.parse(envelope._data);
918
+ }
919
+ }
920
+ const backup = {
921
+ _noydb_backup: NOYDB_BACKUP_VERSION,
922
+ _compartment: this.name,
923
+ _exported_at: (/* @__PURE__ */ new Date()).toISOString(),
924
+ _exported_by: this.keyring.userId,
925
+ keyrings,
926
+ collections: snapshot
927
+ };
928
+ return JSON.stringify(backup);
929
+ }
930
+ /** Restore compartment from an encrypted JSON backup string. */
931
+ async load(backupJson) {
932
+ const backup = JSON.parse(backupJson);
933
+ await this.adapter.saveAll(this.name, backup.collections);
934
+ for (const [userId, keyringFile] of Object.entries(backup.keyrings)) {
935
+ const envelope = {
936
+ _noydb: 1,
937
+ _v: 1,
938
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
939
+ _iv: "",
940
+ _data: JSON.stringify(keyringFile)
941
+ };
942
+ await this.adapter.put(this.name, "_keyring", userId, envelope);
943
+ }
944
+ this.collectionCache.clear();
945
+ }
946
+ /** Export compartment as decrypted JSON (owner only). */
947
+ async export() {
948
+ if (this.keyring.role !== "owner") {
949
+ throw new PermissionDeniedError("Only the owner can export decrypted data");
950
+ }
951
+ const result = {};
952
+ const snapshot = await this.adapter.loadAll(this.name);
953
+ for (const [collName, records] of Object.entries(snapshot)) {
954
+ const coll = this.collection(collName);
955
+ const decrypted = {};
956
+ for (const id of Object.keys(records)) {
957
+ decrypted[id] = await coll.get(id);
958
+ }
959
+ result[collName] = decrypted;
960
+ }
961
+ return JSON.stringify(result);
962
+ }
963
+ };
964
+
965
+ // src/events.ts
966
+ var NoydbEventEmitter = class {
967
+ listeners = /* @__PURE__ */ new Map();
968
+ on(event, handler) {
969
+ let set = this.listeners.get(event);
970
+ if (!set) {
971
+ set = /* @__PURE__ */ new Set();
972
+ this.listeners.set(event, set);
973
+ }
974
+ set.add(handler);
975
+ }
976
+ off(event, handler) {
977
+ this.listeners.get(event)?.delete(handler);
978
+ }
979
+ emit(event, data) {
980
+ const set = this.listeners.get(event);
981
+ if (set) {
982
+ for (const handler of set) {
983
+ handler(data);
984
+ }
985
+ }
986
+ }
987
+ removeAllListeners() {
988
+ this.listeners.clear();
989
+ }
990
+ };
991
+
992
+ // src/sync.ts
993
+ var SyncEngine = class {
994
+ local;
995
+ remote;
996
+ strategy;
997
+ emitter;
998
+ compartment;
999
+ dirty = [];
1000
+ lastPush = null;
1001
+ lastPull = null;
1002
+ loaded = false;
1003
+ autoSyncInterval = null;
1004
+ isOnline = true;
1005
+ constructor(opts) {
1006
+ this.local = opts.local;
1007
+ this.remote = opts.remote;
1008
+ this.compartment = opts.compartment;
1009
+ this.strategy = opts.strategy;
1010
+ this.emitter = opts.emitter;
1011
+ }
1012
+ /** Record a local change for later push. */
1013
+ async trackChange(collection, id, action, version) {
1014
+ await this.ensureLoaded();
1015
+ const idx = this.dirty.findIndex((d) => d.collection === collection && d.id === id);
1016
+ const entry = {
1017
+ compartment: this.compartment,
1018
+ collection,
1019
+ id,
1020
+ action,
1021
+ version,
1022
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1023
+ };
1024
+ if (idx >= 0) {
1025
+ this.dirty[idx] = entry;
1026
+ } else {
1027
+ this.dirty.push(entry);
1028
+ }
1029
+ await this.persistMeta();
1030
+ }
1031
+ /** Push dirty records to remote adapter. */
1032
+ async push() {
1033
+ await this.ensureLoaded();
1034
+ let pushed = 0;
1035
+ const conflicts = [];
1036
+ const errors = [];
1037
+ const completed = [];
1038
+ for (let i = 0; i < this.dirty.length; i++) {
1039
+ const entry = this.dirty[i];
1040
+ try {
1041
+ if (entry.action === "delete") {
1042
+ await this.remote.delete(this.compartment, entry.collection, entry.id);
1043
+ completed.push(i);
1044
+ pushed++;
1045
+ } else {
1046
+ const envelope = await this.local.get(this.compartment, entry.collection, entry.id);
1047
+ if (!envelope) {
1048
+ completed.push(i);
1049
+ continue;
1050
+ }
1051
+ try {
1052
+ await this.remote.put(
1053
+ this.compartment,
1054
+ entry.collection,
1055
+ entry.id,
1056
+ envelope,
1057
+ entry.version > 1 ? entry.version - 1 : void 0
1058
+ );
1059
+ completed.push(i);
1060
+ pushed++;
1061
+ } catch (err) {
1062
+ if (err instanceof ConflictError) {
1063
+ const remoteEnvelope = await this.remote.get(this.compartment, entry.collection, entry.id);
1064
+ if (remoteEnvelope) {
1065
+ const conflict = {
1066
+ compartment: this.compartment,
1067
+ collection: entry.collection,
1068
+ id: entry.id,
1069
+ local: envelope,
1070
+ remote: remoteEnvelope,
1071
+ localVersion: envelope._v,
1072
+ remoteVersion: remoteEnvelope._v
1073
+ };
1074
+ conflicts.push(conflict);
1075
+ this.emitter.emit("sync:conflict", conflict);
1076
+ const resolution = this.resolveConflict(conflict);
1077
+ if (resolution === "local") {
1078
+ await this.remote.put(this.compartment, entry.collection, entry.id, envelope);
1079
+ completed.push(i);
1080
+ pushed++;
1081
+ } else if (resolution === "remote") {
1082
+ await this.local.put(this.compartment, entry.collection, entry.id, remoteEnvelope);
1083
+ completed.push(i);
1084
+ }
1085
+ }
1086
+ } else {
1087
+ throw err;
1088
+ }
1089
+ }
1090
+ }
1091
+ } catch (err) {
1092
+ errors.push(err instanceof Error ? err : new Error(String(err)));
1093
+ }
1094
+ }
1095
+ for (const i of completed.sort((a, b) => b - a)) {
1096
+ this.dirty.splice(i, 1);
1097
+ }
1098
+ this.lastPush = (/* @__PURE__ */ new Date()).toISOString();
1099
+ await this.persistMeta();
1100
+ const result = { pushed, conflicts, errors };
1101
+ this.emitter.emit("sync:push", result);
1102
+ return result;
1103
+ }
1104
+ /** Pull remote records to local adapter. */
1105
+ async pull() {
1106
+ await this.ensureLoaded();
1107
+ let pulled = 0;
1108
+ const conflicts = [];
1109
+ const errors = [];
1110
+ try {
1111
+ const remoteSnapshot = await this.remote.loadAll(this.compartment);
1112
+ for (const [collName, records] of Object.entries(remoteSnapshot)) {
1113
+ for (const [id, remoteEnvelope] of Object.entries(records)) {
1114
+ try {
1115
+ const localEnvelope = await this.local.get(this.compartment, collName, id);
1116
+ if (!localEnvelope) {
1117
+ await this.local.put(this.compartment, collName, id, remoteEnvelope);
1118
+ pulled++;
1119
+ } else if (remoteEnvelope._v > localEnvelope._v) {
1120
+ const isDirty = this.dirty.some((d) => d.collection === collName && d.id === id);
1121
+ if (isDirty) {
1122
+ const conflict = {
1123
+ compartment: this.compartment,
1124
+ collection: collName,
1125
+ id,
1126
+ local: localEnvelope,
1127
+ remote: remoteEnvelope,
1128
+ localVersion: localEnvelope._v,
1129
+ remoteVersion: remoteEnvelope._v
1130
+ };
1131
+ conflicts.push(conflict);
1132
+ this.emitter.emit("sync:conflict", conflict);
1133
+ const resolution = this.resolveConflict(conflict);
1134
+ if (resolution === "remote") {
1135
+ await this.local.put(this.compartment, collName, id, remoteEnvelope);
1136
+ this.dirty = this.dirty.filter((d) => !(d.collection === collName && d.id === id));
1137
+ pulled++;
1138
+ }
1139
+ } else {
1140
+ await this.local.put(this.compartment, collName, id, remoteEnvelope);
1141
+ pulled++;
1142
+ }
1143
+ }
1144
+ } catch (err) {
1145
+ errors.push(err instanceof Error ? err : new Error(String(err)));
1146
+ }
1147
+ }
1148
+ }
1149
+ } catch (err) {
1150
+ errors.push(err instanceof Error ? err : new Error(String(err)));
1151
+ }
1152
+ this.lastPull = (/* @__PURE__ */ new Date()).toISOString();
1153
+ await this.persistMeta();
1154
+ const result = { pulled, conflicts, errors };
1155
+ this.emitter.emit("sync:pull", result);
1156
+ return result;
1157
+ }
1158
+ /** Bidirectional sync: pull then push. */
1159
+ async sync() {
1160
+ const pullResult = await this.pull();
1161
+ const pushResult = await this.push();
1162
+ return { pull: pullResult, push: pushResult };
1163
+ }
1164
+ /** Get current sync status. */
1165
+ status() {
1166
+ return {
1167
+ dirty: this.dirty.length,
1168
+ lastPush: this.lastPush,
1169
+ lastPull: this.lastPull,
1170
+ online: this.isOnline
1171
+ };
1172
+ }
1173
+ // ─── Auto-Sync ───────────────────────────────────────────────────
1174
+ /** Start auto-sync: listen for online/offline events, optional periodic sync. */
1175
+ startAutoSync(intervalMs) {
1176
+ if (typeof globalThis.addEventListener === "function") {
1177
+ globalThis.addEventListener("online", this.handleOnline);
1178
+ globalThis.addEventListener("offline", this.handleOffline);
1179
+ }
1180
+ if (intervalMs && intervalMs > 0) {
1181
+ this.autoSyncInterval = setInterval(() => {
1182
+ if (this.isOnline) {
1183
+ void this.sync();
1184
+ }
1185
+ }, intervalMs);
1186
+ }
1187
+ }
1188
+ /** Stop auto-sync. */
1189
+ stopAutoSync() {
1190
+ if (typeof globalThis.removeEventListener === "function") {
1191
+ globalThis.removeEventListener("online", this.handleOnline);
1192
+ globalThis.removeEventListener("offline", this.handleOffline);
1193
+ }
1194
+ if (this.autoSyncInterval) {
1195
+ clearInterval(this.autoSyncInterval);
1196
+ this.autoSyncInterval = null;
1197
+ }
1198
+ }
1199
+ handleOnline = () => {
1200
+ this.isOnline = true;
1201
+ this.emitter.emit("sync:online", void 0);
1202
+ void this.sync();
1203
+ };
1204
+ handleOffline = () => {
1205
+ this.isOnline = false;
1206
+ this.emitter.emit("sync:offline", void 0);
1207
+ };
1208
+ /** Resolve a conflict using the configured strategy. */
1209
+ resolveConflict(conflict) {
1210
+ if (typeof this.strategy === "function") {
1211
+ return this.strategy(conflict);
1212
+ }
1213
+ switch (this.strategy) {
1214
+ case "local-wins":
1215
+ return "local";
1216
+ case "remote-wins":
1217
+ return "remote";
1218
+ case "version":
1219
+ default:
1220
+ return conflict.localVersion >= conflict.remoteVersion ? "local" : "remote";
1221
+ }
1222
+ }
1223
+ // ─── Persistence ─────────────────────────────────────────────────
1224
+ async ensureLoaded() {
1225
+ if (this.loaded) return;
1226
+ const envelope = await this.local.get(this.compartment, "_sync", "meta");
1227
+ if (envelope) {
1228
+ const meta = JSON.parse(envelope._data);
1229
+ this.dirty = [...meta.dirty];
1230
+ this.lastPush = meta.last_push;
1231
+ this.lastPull = meta.last_pull;
1232
+ }
1233
+ this.loaded = true;
1234
+ }
1235
+ async persistMeta() {
1236
+ const meta = {
1237
+ _noydb_sync: NOYDB_SYNC_VERSION,
1238
+ last_push: this.lastPush,
1239
+ last_pull: this.lastPull,
1240
+ dirty: this.dirty
1241
+ };
1242
+ const envelope = {
1243
+ _noydb: 1,
1244
+ _v: 1,
1245
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
1246
+ _iv: "",
1247
+ _data: JSON.stringify(meta)
1248
+ };
1249
+ await this.local.put(this.compartment, "_sync", "meta", envelope);
1250
+ }
1251
+ };
1252
+
1253
+ // src/noydb.ts
1254
+ function createPlaintextKeyring(userId) {
1255
+ return {
1256
+ userId,
1257
+ displayName: userId,
1258
+ role: "owner",
1259
+ permissions: {},
1260
+ deks: /* @__PURE__ */ new Map(),
1261
+ kek: null,
1262
+ salt: new Uint8Array(0)
1263
+ };
1264
+ }
1265
+ var Noydb = class {
1266
+ options;
1267
+ emitter = new NoydbEventEmitter();
1268
+ compartmentCache = /* @__PURE__ */ new Map();
1269
+ keyringCache = /* @__PURE__ */ new Map();
1270
+ syncEngines = /* @__PURE__ */ new Map();
1271
+ closed = false;
1272
+ sessionTimer = null;
1273
+ constructor(options) {
1274
+ this.options = options;
1275
+ this.resetSessionTimer();
1276
+ }
1277
+ resetSessionTimer() {
1278
+ if (this.sessionTimer) clearTimeout(this.sessionTimer);
1279
+ if (this.options.sessionTimeout && this.options.sessionTimeout > 0) {
1280
+ this.sessionTimer = setTimeout(() => {
1281
+ this.close();
1282
+ }, this.options.sessionTimeout);
1283
+ }
1284
+ }
1285
+ /** Open a compartment by name. */
1286
+ async openCompartment(name) {
1287
+ if (this.closed) throw new ValidationError("Instance is closed");
1288
+ this.resetSessionTimer();
1289
+ let comp = this.compartmentCache.get(name);
1290
+ if (comp) return comp;
1291
+ const keyring = await this.getKeyring(name);
1292
+ let syncEngine;
1293
+ if (this.options.sync) {
1294
+ syncEngine = new SyncEngine({
1295
+ local: this.options.adapter,
1296
+ remote: this.options.sync,
1297
+ compartment: name,
1298
+ strategy: this.options.conflict ?? "version",
1299
+ emitter: this.emitter
1300
+ });
1301
+ this.syncEngines.set(name, syncEngine);
1302
+ }
1303
+ comp = new Compartment({
1304
+ adapter: this.options.adapter,
1305
+ name,
1306
+ keyring,
1307
+ encrypted: this.options.encrypt !== false,
1308
+ emitter: this.emitter,
1309
+ onDirty: syncEngine ? (coll, id, action, version) => syncEngine.trackChange(coll, id, action, version) : void 0,
1310
+ historyConfig: this.options.history
1311
+ });
1312
+ this.compartmentCache.set(name, comp);
1313
+ return comp;
1314
+ }
1315
+ /** Synchronous compartment access (must call openCompartment first, or auto-opens). */
1316
+ compartment(name) {
1317
+ const cached = this.compartmentCache.get(name);
1318
+ if (cached) return cached;
1319
+ if (this.options.encrypt === false) {
1320
+ const keyring2 = createPlaintextKeyring(this.options.user);
1321
+ const comp2 = new Compartment({
1322
+ adapter: this.options.adapter,
1323
+ name,
1324
+ keyring: keyring2,
1325
+ encrypted: false,
1326
+ emitter: this.emitter,
1327
+ historyConfig: this.options.history
1328
+ });
1329
+ this.compartmentCache.set(name, comp2);
1330
+ return comp2;
1331
+ }
1332
+ const keyring = this.keyringCache.get(name);
1333
+ if (!keyring) {
1334
+ throw new ValidationError(
1335
+ `Compartment "${name}" not opened. Use await db.openCompartment("${name}") first.`
1336
+ );
1337
+ }
1338
+ const comp = new Compartment({
1339
+ adapter: this.options.adapter,
1340
+ name,
1341
+ keyring,
1342
+ encrypted: true,
1343
+ historyConfig: this.options.history,
1344
+ emitter: this.emitter
1345
+ });
1346
+ this.compartmentCache.set(name, comp);
1347
+ return comp;
1348
+ }
1349
+ /** Grant access to a user for a compartment. */
1350
+ async grant(compartment, options) {
1351
+ const keyring = await this.getKeyring(compartment);
1352
+ await grant(this.options.adapter, compartment, keyring, options);
1353
+ }
1354
+ /** Revoke a user's access to a compartment. */
1355
+ async revoke(compartment, options) {
1356
+ const keyring = await this.getKeyring(compartment);
1357
+ await revoke(this.options.adapter, compartment, keyring, options);
1358
+ }
1359
+ /** List all users with access to a compartment. */
1360
+ async listUsers(compartment) {
1361
+ return listUsers(this.options.adapter, compartment);
1362
+ }
1363
+ /** Change the current user's passphrase for a compartment. */
1364
+ async changeSecret(compartment, newPassphrase) {
1365
+ const keyring = await this.getKeyring(compartment);
1366
+ const updated = await changeSecret(
1367
+ this.options.adapter,
1368
+ compartment,
1369
+ keyring,
1370
+ newPassphrase
1371
+ );
1372
+ this.keyringCache.set(compartment, updated);
1373
+ }
1374
+ // ─── Sync ──────────────────────────────────────────────────────
1375
+ /** Push local changes to remote for a compartment. */
1376
+ async push(compartment) {
1377
+ const engine = this.getSyncEngine(compartment);
1378
+ return engine.push();
1379
+ }
1380
+ /** Pull remote changes to local for a compartment. */
1381
+ async pull(compartment) {
1382
+ const engine = this.getSyncEngine(compartment);
1383
+ return engine.pull();
1384
+ }
1385
+ /** Bidirectional sync: pull then push. */
1386
+ async sync(compartment) {
1387
+ const engine = this.getSyncEngine(compartment);
1388
+ return engine.sync();
1389
+ }
1390
+ /** Get sync status for a compartment. */
1391
+ syncStatus(compartment) {
1392
+ const engine = this.syncEngines.get(compartment);
1393
+ if (!engine) {
1394
+ return { dirty: 0, lastPush: null, lastPull: null, online: true };
1395
+ }
1396
+ return engine.status();
1397
+ }
1398
+ getSyncEngine(compartment) {
1399
+ const engine = this.syncEngines.get(compartment);
1400
+ if (!engine) {
1401
+ throw new ValidationError("No sync adapter configured. Pass a `sync` adapter to createNoydb().");
1402
+ }
1403
+ return engine;
1404
+ }
1405
+ // ─── Events ────────────────────────────────────────────────────
1406
+ on(event, handler) {
1407
+ this.emitter.on(event, handler);
1408
+ }
1409
+ off(event, handler) {
1410
+ this.emitter.off(event, handler);
1411
+ }
1412
+ close() {
1413
+ this.closed = true;
1414
+ if (this.sessionTimer) {
1415
+ clearTimeout(this.sessionTimer);
1416
+ this.sessionTimer = null;
1417
+ }
1418
+ for (const engine of this.syncEngines.values()) {
1419
+ engine.stopAutoSync();
1420
+ }
1421
+ this.syncEngines.clear();
1422
+ this.keyringCache.clear();
1423
+ this.compartmentCache.clear();
1424
+ this.emitter.removeAllListeners();
1425
+ }
1426
+ /** Get or load the keyring for a compartment. */
1427
+ async getKeyring(compartment) {
1428
+ if (this.options.encrypt === false) {
1429
+ return createPlaintextKeyring(this.options.user);
1430
+ }
1431
+ const cached = this.keyringCache.get(compartment);
1432
+ if (cached) return cached;
1433
+ if (!this.options.secret) {
1434
+ throw new ValidationError("A secret (passphrase) is required when encryption is enabled");
1435
+ }
1436
+ let keyring;
1437
+ try {
1438
+ keyring = await loadKeyring(this.options.adapter, compartment, this.options.user, this.options.secret);
1439
+ } catch (err) {
1440
+ if (err instanceof NoAccessError) {
1441
+ keyring = await createOwnerKeyring(this.options.adapter, compartment, this.options.user, this.options.secret);
1442
+ } else {
1443
+ throw err;
1444
+ }
1445
+ }
1446
+ this.keyringCache.set(compartment, keyring);
1447
+ return keyring;
1448
+ }
1449
+ };
1450
+ async function createNoydb(options) {
1451
+ const encrypted = options.encrypt !== false;
1452
+ if (encrypted && !options.secret) {
1453
+ throw new ValidationError("A secret (passphrase) is required when encryption is enabled");
1454
+ }
1455
+ return new Noydb(options);
1456
+ }
1457
+
1458
+ // src/biometric.ts
1459
+ function isBiometricAvailable() {
1460
+ return typeof window !== "undefined" && typeof window.PublicKeyCredential !== "undefined" && typeof navigator.credentials !== "undefined";
1461
+ }
1462
+ async function enrollBiometric(userId, kek) {
1463
+ if (!isBiometricAvailable()) {
1464
+ throw new ValidationError("WebAuthn is not available in this environment");
1465
+ }
1466
+ const challenge = globalThis.crypto.getRandomValues(new Uint8Array(32));
1467
+ const userIdBytes = new TextEncoder().encode(userId);
1468
+ const credential = await navigator.credentials.create({
1469
+ publicKey: {
1470
+ challenge,
1471
+ rp: { name: "NOYDB" },
1472
+ user: {
1473
+ id: userIdBytes,
1474
+ name: userId,
1475
+ displayName: userId
1476
+ },
1477
+ pubKeyCredParams: [
1478
+ { type: "public-key", alg: -7 },
1479
+ // ES256
1480
+ { type: "public-key", alg: -257 }
1481
+ // RS256
1482
+ ],
1483
+ authenticatorSelection: {
1484
+ authenticatorAttachment: "platform",
1485
+ userVerification: "required",
1486
+ residentKey: "preferred"
1487
+ },
1488
+ timeout: 6e4
1489
+ }
1490
+ });
1491
+ if (!credential) {
1492
+ throw new ValidationError("Biometric enrollment was cancelled");
1493
+ }
1494
+ const rawKek = await globalThis.crypto.subtle.exportKey("raw", kek);
1495
+ const wrappingKey = await deriveWrappingKey(credential.rawId);
1496
+ const iv = new Uint8Array(12);
1497
+ const wrappedKek = await globalThis.crypto.subtle.encrypt(
1498
+ { name: "AES-GCM", iv },
1499
+ wrappingKey,
1500
+ rawKek
1501
+ );
1502
+ return {
1503
+ credentialId: bufferToBase64(credential.rawId),
1504
+ wrappedKek: bufferToBase64(wrappedKek),
1505
+ salt: bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(32)))
1506
+ };
1507
+ }
1508
+ async function unlockBiometric(storedCredential) {
1509
+ if (!isBiometricAvailable()) {
1510
+ throw new ValidationError("WebAuthn is not available in this environment");
1511
+ }
1512
+ const credentialId = base64ToBuffer(storedCredential.credentialId);
1513
+ const assertion = await navigator.credentials.get({
1514
+ publicKey: {
1515
+ challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),
1516
+ allowCredentials: [{
1517
+ type: "public-key",
1518
+ id: credentialId
1519
+ }],
1520
+ userVerification: "required",
1521
+ timeout: 6e4
1522
+ }
1523
+ });
1524
+ if (!assertion) {
1525
+ throw new ValidationError("Biometric authentication was cancelled");
1526
+ }
1527
+ const wrappingKey = await deriveWrappingKey(assertion.rawId);
1528
+ const unlockIv = new Uint8Array(12);
1529
+ const rawKek = await globalThis.crypto.subtle.decrypt(
1530
+ { name: "AES-GCM", iv: unlockIv },
1531
+ wrappingKey,
1532
+ base64ToBuffer(storedCredential.wrappedKek)
1533
+ );
1534
+ return globalThis.crypto.subtle.importKey(
1535
+ "raw",
1536
+ rawKek,
1537
+ { name: "AES-KW", length: 256 },
1538
+ false,
1539
+ ["wrapKey", "unwrapKey"]
1540
+ );
1541
+ }
1542
+ function removeBiometric(storage, userId) {
1543
+ storage.removeItem(`noydb:biometric:${userId}`);
1544
+ }
1545
+ function saveBiometric(storage, userId, credential) {
1546
+ storage.setItem(`noydb:biometric:${userId}`, JSON.stringify(credential));
1547
+ }
1548
+ function loadBiometric(storage, userId) {
1549
+ const data = storage.getItem(`noydb:biometric:${userId}`);
1550
+ return data ? JSON.parse(data) : null;
1551
+ }
1552
+ async function deriveWrappingKey(rawId) {
1553
+ const keyMaterial = await globalThis.crypto.subtle.importKey(
1554
+ "raw",
1555
+ rawId,
1556
+ "HKDF",
1557
+ false,
1558
+ ["deriveKey"]
1559
+ );
1560
+ return globalThis.crypto.subtle.deriveKey(
1561
+ {
1562
+ name: "HKDF",
1563
+ hash: "SHA-256",
1564
+ salt: new TextEncoder().encode("noydb-biometric-wrapping"),
1565
+ info: new TextEncoder().encode("kek-wrap")
1566
+ },
1567
+ keyMaterial,
1568
+ { name: "AES-GCM", length: 256 },
1569
+ false,
1570
+ ["encrypt", "decrypt"]
1571
+ );
1572
+ }
1573
+
1574
+ // src/validation.ts
1575
+ function validatePassphrase(passphrase) {
1576
+ if (passphrase.length < 8) {
1577
+ throw new ValidationError(
1578
+ "Passphrase too short \u2014 minimum 8 characters. Recommended: 12+ characters or a 4+ word passphrase."
1579
+ );
1580
+ }
1581
+ const entropy = estimateEntropy(passphrase);
1582
+ if (entropy < 28) {
1583
+ throw new ValidationError(
1584
+ "Passphrase too weak \u2014 too little entropy. Use a mix of uppercase, lowercase, numbers, and symbols, or use a 4+ word passphrase."
1585
+ );
1586
+ }
1587
+ }
1588
+ function estimateEntropy(passphrase) {
1589
+ let charsetSize = 0;
1590
+ if (/[a-z]/.test(passphrase)) charsetSize += 26;
1591
+ if (/[A-Z]/.test(passphrase)) charsetSize += 26;
1592
+ if (/[0-9]/.test(passphrase)) charsetSize += 10;
1593
+ if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 32;
1594
+ if (charsetSize === 0) charsetSize = 26;
1595
+ return Math.floor(passphrase.length * Math.log2(charsetSize));
1596
+ }
1597
+ export {
1598
+ Collection,
1599
+ Compartment,
1600
+ ConflictError,
1601
+ DecryptionError,
1602
+ InvalidKeyError,
1603
+ NOYDB_BACKUP_VERSION,
1604
+ NOYDB_FORMAT_VERSION,
1605
+ NOYDB_KEYRING_VERSION,
1606
+ NOYDB_SYNC_VERSION,
1607
+ NetworkError,
1608
+ NoAccessError,
1609
+ NotFoundError,
1610
+ Noydb,
1611
+ NoydbError,
1612
+ PermissionDeniedError,
1613
+ ReadOnlyError,
1614
+ SyncEngine,
1615
+ TamperedError,
1616
+ ValidationError,
1617
+ createNoydb,
1618
+ defineAdapter,
1619
+ diff,
1620
+ enrollBiometric,
1621
+ estimateEntropy,
1622
+ formatDiff,
1623
+ isBiometricAvailable,
1624
+ loadBiometric,
1625
+ removeBiometric,
1626
+ saveBiometric,
1627
+ unlockBiometric,
1628
+ validatePassphrase
1629
+ };
1630
+ //# sourceMappingURL=index.js.map