@pixagram/lacerta-db 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * LacertaDB V0.9.0 - Production Library with QuickStore (Optimized)
2
+ * LacertaDB V0.9.2 - Production Library with QuickStore (Optimized)
3
3
  * * A high-performance, browser-based document database with support for:
4
4
  * - IndexedDB storage with connection pooling
5
5
  * - Multiple caching strategies (LRU, LFU, TTL)
@@ -9,17 +9,35 @@
9
9
  * - MongoDB-like query syntax and aggregation pipeline
10
10
  * - Schema migrations and versioning
11
11
  * - QuickStore for fast localStorage-based operations
12
- * * Changelog V0.9.0:
12
+ * * Changelog V0.9.2:
13
+ * - CRITICAL: Fixed non-extractable CryptoKey in _importMasterKeys (changePin was broken).
14
+ * - CRITICAL: Fixed TransactionInactiveError in batchOperation (sync request queueing).
15
+ * - CRITICAL: Fixed batchAdd crash when called without options (from import()).
16
+ * - SECURITY: Constant-time comparison in _arrayEquals (timing attack prevention).
17
+ * - SECURITY: Unbiased PIN generation via rejection sampling.
18
+ * - SECURITY: changePin now verifies oldPin before allowing change.
19
+ * - SECURITY: Standardized AES-GCM IV to 12 bytes in encryptPrivateKey/decryptPrivateKey.
20
+ * - FIX: Environment-safe polyfills (no bare `window` at module scope).
21
+ * - FIX: BTreeIndex.remove only decrements _size when key/value actually existed.
22
+ * - FIX: LFUCache.has() now checks TTL expiration.
23
+ * - FIX: GeoIndex._size cannot go negative on redundant removePoint calls.
24
+ * - FIX: $group aggregation uses JSON.stringify for object keys (Map comparison).
25
+ * - FIX: TextIndex tokenizer minimum length normalized across code paths.
26
+ * - FIX: QuickStore properly cleans up beforeunload listener via destroy().
27
+ * - FIX: Consistent window.requestIdleCallback usage in _saveIndexMetadata.
28
+ * - FIX: Block-scoped const in $avg aggregation case.
29
+ * * Changelog V0.9.1:
13
30
  * - CRITICAL: Implemented Master Key Wrapping for encryption (fixes data loss on PIN change).
14
31
  * - CRITICAL: Fixed UI freezing in QuickStore using in-memory caching and async persistence.
15
32
  * - CRITICAL: Replaced O(N) GeoIndex with O(log N) QuadTree.
33
+ * - CRITICAL: Fixed TransactionInactiveError by implementing Batch Processing for Indexes.
16
34
  * - SECURITY: Increased PBKDF2 iterations to 600,000 (OWASP standard).
17
35
  * - OPTIMIZATION: Removed Global Mutex for read operations (concurrency fix).
18
36
  * - OPTIMIZATION: Implemented Cursor-based indexing (OOM fix).
19
37
  * - FIX: Added Magic Byte check for robust compression detection.
20
38
  * - FIX: Use Intl.Segmenter for proper CJK text tokenization.
21
39
  * * @module LacertaDB
22
- * @version 0.9.0
40
+ * @version 0.9.2
23
41
  * @license MIT
24
42
  * @author Pixagram SA
25
43
  */
@@ -30,7 +48,7 @@
30
48
  // Polyfills
31
49
  // ========================
32
50
 
33
- if (!window.requestIdleCallback) {
51
+ if (typeof window !== 'undefined' && !window.requestIdleCallback) {
34
52
  window.requestIdleCallback = function(callback) {
35
53
  return setTimeout(callback, 0);
36
54
  };
@@ -76,8 +94,25 @@ class QuickStore {
76
94
  this._dirty = false;
77
95
 
78
96
  // Safety: Flush on unload to prevent data loss
97
+ this._flushHandler = () => this._flushSync();
79
98
  if (typeof window !== 'undefined') {
80
- window.addEventListener('beforeunload', () => this._flushSync());
99
+ window.addEventListener('beforeunload', this._flushHandler);
100
+ }
101
+ }
102
+
103
+ destroy() {
104
+ this._flushSync();
105
+ if (typeof window !== 'undefined' && this._flushHandler) {
106
+ window.removeEventListener('beforeunload', this._flushHandler);
107
+ this._flushHandler = null;
108
+ }
109
+ if (this._saveIndexTimer) {
110
+ if (typeof window !== 'undefined' && window.cancelIdleCallback) {
111
+ window.cancelIdleCallback(this._saveIndexTimer);
112
+ } else {
113
+ clearTimeout(this._saveIndexTimer);
114
+ }
115
+ this._saveIndexTimer = null;
81
116
  }
82
117
  }
83
118
 
@@ -451,7 +486,7 @@ class LFUCache {
451
486
  }
452
487
 
453
488
  has(key) {
454
- return this._cache.has(key);
489
+ return this.get(key) !== null;
455
490
  }
456
491
 
457
492
  get size() {
@@ -676,7 +711,7 @@ class BrowserEncryptionUtility {
676
711
  {
677
712
  name: 'PBKDF2',
678
713
  salt: salt,
679
- iterations: 100000,
714
+ iterations: 600000,
680
715
  hash: 'SHA-256'
681
716
  },
682
717
  keyMaterial,
@@ -718,7 +753,7 @@ class BrowserEncryptionUtility {
718
753
  {
719
754
  name: 'PBKDF2',
720
755
  salt: salt,
721
- iterations: 100000,
756
+ iterations: 600000,
722
757
  hash: 'SHA-256'
723
758
  },
724
759
  keyMaterial,
@@ -833,10 +868,10 @@ class SecureDatabaseEncryption {
833
868
  const hmacBytes = rawBytes.slice(32, 64);
834
869
 
835
870
  this._masterKey = await crypto.subtle.importKey(
836
- 'raw', encBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
871
+ 'raw', encBytes, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
837
872
  );
838
873
  this._hmacKey = await crypto.subtle.importKey(
839
- 'raw', hmacBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']
874
+ 'raw', hmacBytes, { name: 'HMAC', hash: 'SHA-256' }, true, ['sign', 'verify']
840
875
  );
841
876
  }
842
877
 
@@ -854,6 +889,17 @@ class SecureDatabaseEncryption {
854
889
  throw new Error('Database encryption not initialized');
855
890
  }
856
891
 
892
+ // Verify old PIN by attempting to unwrap current master key
893
+ const oldKek = await this._deriveKEK(new TextEncoder().encode(oldPin), this._salt);
894
+ const currentWrappedBytes = base64.decode(this._wrappedKeyBlob);
895
+ const currentIv = currentWrappedBytes.slice(0, 12);
896
+ const currentEncMK = currentWrappedBytes.slice(12);
897
+ try {
898
+ await crypto.subtle.decrypt({ name: 'AES-GCM', iv: currentIv }, oldKek, currentEncMK);
899
+ } catch (e) {
900
+ throw new Error('Invalid old PIN');
901
+ }
902
+
857
903
  // Derive new KEK
858
904
  const newSalt = crypto.getRandomValues(new Uint8Array(this._saltLength));
859
905
  const newKek = await this._deriveKEK(new TextEncoder().encode(newPin), newSalt);
@@ -973,7 +1019,7 @@ class SecureDatabaseEncryption {
973
1019
  keyData = serializer.serialize(privateKey);
974
1020
  }
975
1021
 
976
- const iv = crypto.getRandomValues(new Uint8Array(16));
1022
+ const iv = crypto.getRandomValues(new Uint8Array(12));
977
1023
 
978
1024
  const encryptedKey = await crypto.subtle.encrypt(
979
1025
  {
@@ -988,13 +1034,13 @@ class SecureDatabaseEncryption {
988
1034
 
989
1035
  const authLength = new Uint32Array([authData.length]);
990
1036
  const result = new Uint8Array(
991
- 16 + 4 + authData.length + encryptedKey.byteLength
1037
+ 12 + 4 + authData.length + encryptedKey.byteLength
992
1038
  );
993
1039
 
994
1040
  result.set(iv, 0);
995
- result.set(new Uint8Array(authLength.buffer), 16);
996
- result.set(authData, 20);
997
- result.set(new Uint8Array(encryptedKey), 20 + authData.length);
1041
+ result.set(new Uint8Array(authLength.buffer), 12);
1042
+ result.set(authData, 16);
1043
+ result.set(new Uint8Array(encryptedKey), 16 + authData.length);
998
1044
 
999
1045
  return base64.encode(result);
1000
1046
  }
@@ -1006,11 +1052,11 @@ class SecureDatabaseEncryption {
1006
1052
 
1007
1053
  const encryptedPackage = base64.decode(encryptedKeyString);
1008
1054
 
1009
- const iv = encryptedPackage.slice(0, 16);
1010
- const authLengthBytes = encryptedPackage.slice(16, 20);
1055
+ const iv = encryptedPackage.slice(0, 12);
1056
+ const authLengthBytes = encryptedPackage.slice(12, 16);
1011
1057
  const authLength = new Uint32Array(authLengthBytes.buffer)[0];
1012
- const authData = encryptedPackage.slice(20, 20 + authLength);
1013
- const encryptedKey = encryptedPackage.slice(20 + authLength);
1058
+ const authData = encryptedPackage.slice(16, 16 + authLength);
1059
+ const encryptedKey = encryptedPackage.slice(16 + authLength);
1014
1060
 
1015
1061
  const encoder = new TextEncoder();
1016
1062
  const expectedAuth = encoder.encode(additionalAuth);
@@ -1034,11 +1080,16 @@ class SecureDatabaseEncryption {
1034
1080
  }
1035
1081
 
1036
1082
  static generateSecurePIN(length = 6) {
1037
- const digits = new Uint8Array(length);
1038
- crypto.getRandomValues(digits);
1039
- return Array.from(digits)
1040
- .map(b => (b % 10).toString())
1041
- .join('');
1083
+ const digits = [];
1084
+ const buf = new Uint8Array(1);
1085
+ while (digits.length < length) {
1086
+ crypto.getRandomValues(buf);
1087
+ // Rejection sampling: only accept values 0-249 to avoid modulo bias
1088
+ if (buf[0] < 250) {
1089
+ digits.push((buf[0] % 10).toString());
1090
+ }
1091
+ }
1092
+ return digits.join('');
1042
1093
  }
1043
1094
 
1044
1095
  destroy() {
@@ -1049,10 +1100,12 @@ class SecureDatabaseEncryption {
1049
1100
 
1050
1101
  _arrayEquals(a, b) {
1051
1102
  if (a.length !== b.length) return false;
1103
+ // Constant-time comparison to prevent timing attacks
1104
+ let diff = 0;
1052
1105
  for (let i = 0; i < a.length; i++) {
1053
- if (a[i] !== b[i]) return false;
1106
+ diff |= a[i] ^ b[i];
1054
1107
  }
1055
- return true;
1108
+ return diff === 0;
1056
1109
  }
1057
1110
 
1058
1111
  exportMetadata() {
@@ -1385,13 +1438,14 @@ class BTreeIndex {
1385
1438
 
1386
1439
  remove(key, value) {
1387
1440
  if (!this._root) return;
1388
- this._root.remove(key, value);
1389
- if (this._root.n === 0) {
1390
- if (!this._root.leaf && this._root.children[0]) {
1441
+ const existing = this._root.search(key);
1442
+ if (existing && existing.has(value)) {
1443
+ this._root.remove(key, value);
1444
+ if (this._root.n === 0 && !this._root.leaf && this._root.children[0]) {
1391
1445
  this._root = this._root.children[0];
1392
1446
  }
1447
+ this._size--;
1393
1448
  }
1394
- this._size--;
1395
1449
  }
1396
1450
 
1397
1451
  verify() {
@@ -1492,7 +1546,7 @@ class TextIndex {
1492
1546
  return text.toLowerCase()
1493
1547
  .replace(/[^\w\s]/g, ' ')
1494
1548
  .split(/\s+/)
1495
- .filter(token => token.length > 2);
1549
+ .filter(token => token.length > 1);
1496
1550
  }
1497
1551
  }
1498
1552
 
@@ -1521,7 +1575,7 @@ class GeoIndex {
1521
1575
 
1522
1576
  removePoint(docId) {
1523
1577
  this._tree.remove(docId);
1524
- this._size--;
1578
+ if (this._size > 0) this._size--;
1525
1579
  }
1526
1580
 
1527
1581
  updatePoint(docId, newCoords) {
@@ -1636,36 +1690,58 @@ class IndexManager {
1636
1690
  const indexData = this._createIndexStructure(index.type);
1637
1691
  this._indexData.set(indexName, indexData);
1638
1692
 
1639
- // Optimization: Cursor-based iteration
1640
- await this._collection._indexedDB.iterate(this._collection._db, 'documents', async (docData) => {
1641
- let doc = docData;
1693
+ // Optimization: Use Batched Processing instead of single Cursor
1694
+ // This prevents transaction timeouts caused by async crypto operations inside the loop
1695
+ let lastKey = null;
1696
+ const batchSize = 100; // Keep batch small for responsiveness
1697
+
1698
+ while (true) {
1699
+ // 1. Fetch Batch (Transaction opens and closes here)
1700
+ const batch = await this._collection._indexedDB.getBatch(
1701
+ this._collection._db,
1702
+ 'documents',
1703
+ lastKey,
1704
+ batchSize
1705
+ );
1642
1706
 
1643
- if (docData.packedData) {
1644
- const d = new Document(docData, {
1645
- compressed: docData._compressed,
1646
- encrypted: docData._encrypted
1647
- });
1648
- await d.unpack(this._collection.database.encryption);
1649
- doc = d.objectOutput();
1650
- }
1707
+ if (batch.length === 0) break;
1708
+
1709
+ // 2. Process Batch (Async crypto operations safe here)
1710
+ for (const docData of batch) {
1711
+ lastKey = docData._id; // Update for next batch
1712
+ let doc = docData;
1713
+
1714
+ if (docData.packedData) {
1715
+ const d = new Document(docData, {
1716
+ compressed: docData._compressed,
1717
+ encrypted: docData._encrypted
1718
+ });
1719
+ // This await is what killed the transaction before
1720
+ await d.unpack(this._collection.database.encryption);
1721
+ doc = d.objectOutput();
1722
+ }
1651
1723
 
1652
- let value = this._getFieldValue(doc, index.fieldPath);
1724
+ let value = this._getFieldValue(doc, index.fieldPath);
1653
1725
 
1654
- if (index.sparse && (value === null || value === undefined)) {
1655
- return;
1656
- }
1726
+ if (index.sparse && (value === null || value === undefined)) {
1727
+ continue;
1728
+ }
1657
1729
 
1658
- if (index.unique && indexData.has && indexData.has(value)) {
1659
- console.error(`Unique constraint violation on index '${indexName}'`);
1660
- return;
1661
- }
1730
+ if (index.unique && indexData.has && indexData.has(value)) {
1731
+ console.error(`Unique constraint violation on index '${indexName}'`);
1732
+ continue;
1733
+ }
1662
1734
 
1663
- if (index.hashed && index.type === 'btree') {
1664
- value = await this._hashVal(value);
1665
- }
1735
+ if (index.hashed && index.type === 'btree') {
1736
+ value = await this._hashVal(value);
1737
+ }
1666
1738
 
1667
- this._addToIndex(indexData, value, doc._id, index.type);
1668
- });
1739
+ this._addToIndex(indexData, value, doc._id, index.type);
1740
+ }
1741
+
1742
+ // Optional: Yield to main thread briefly to prevent UI freeze
1743
+ if (window.requestIdleCallback) await new Promise(r => window.requestIdleCallback(r));
1744
+ }
1669
1745
  }
1670
1746
 
1671
1747
  _createIndexStructure(type) {
@@ -1883,7 +1959,7 @@ class IndexManager {
1883
1959
  async _saveIndexMetadata() {
1884
1960
  const key = `lacertadb_${this._collection.database.name}_${this._collection.name}_indexes`;
1885
1961
  return new Promise((resolve) => {
1886
- requestIdleCallback(() => {
1962
+ const save = () => {
1887
1963
  const metadata = {
1888
1964
  indexes: Array.from(this._indexes.entries()).map(([name, index]) => ({
1889
1965
  name,
@@ -1894,7 +1970,12 @@ class IndexManager {
1894
1970
  const encoded = base64.encode(serialized);
1895
1971
  localStorage.setItem(key, encoded);
1896
1972
  resolve();
1897
- });
1973
+ };
1974
+ if (typeof window !== 'undefined' && window.requestIdleCallback) {
1975
+ window.requestIdleCallback(save);
1976
+ } else {
1977
+ setTimeout(save, 0);
1978
+ }
1898
1979
  });
1899
1980
  }
1900
1981
 
@@ -2081,7 +2162,7 @@ class OPFSUtility {
2081
2162
  }
2082
2163
 
2083
2164
  // ========================
2084
- // IndexedDB Utility (Optimized with Cursors & Mutex Fix)
2165
+ // IndexedDB Utility (Optimized with Batches)
2085
2166
  // ========================
2086
2167
 
2087
2168
  class IndexedDBUtility {
@@ -2139,6 +2220,19 @@ class IndexedDBUtility {
2139
2220
  });
2140
2221
  }
2141
2222
 
2223
+ // New: Batched Retrieval for processing large datasets efficiently
2224
+ async getBatch(db, storeName, lastKey, limit) {
2225
+ return this.performTransaction(db, [storeName], 'readonly', tx => {
2226
+ const store = tx.objectStore(storeName);
2227
+ let range;
2228
+ if (lastKey !== null && lastKey !== undefined) {
2229
+ range = IDBKeyRange.lowerBound(lastKey, true); // true = open range (skip lastKey)
2230
+ }
2231
+ // Use getAll which is faster than cursor for batches
2232
+ return this._promisifyRequest(() => store.getAll(range, limit));
2233
+ });
2234
+ }
2235
+
2142
2236
  add(db, storeName, value, key) {
2143
2237
  return this.performTransaction(db, [storeName], 'readwrite', tx => {
2144
2238
  const store = tx.objectStore(storeName);
@@ -2165,23 +2259,6 @@ class IndexedDBUtility {
2165
2259
  });
2166
2260
  }
2167
2261
 
2168
- // Cursor iteration for memory efficiency
2169
- async iterate(db, storeName, callback) {
2170
- return this.performTransaction(db, [storeName], 'readonly', tx => {
2171
- return new Promise((resolve, reject) => {
2172
- const req = tx.objectStore(storeName).openCursor();
2173
- req.onsuccess = async (e) => {
2174
- const cursor = e.target.result;
2175
- if (cursor) {
2176
- await callback(cursor.value);
2177
- cursor.continue();
2178
- }
2179
- };
2180
- req.onerror = () => reject(req.error);
2181
- });
2182
- });
2183
- }
2184
-
2185
2262
  delete(db, storeName, key) {
2186
2263
  return this.performTransaction(db, [storeName], 'readwrite', tx => {
2187
2264
  return this._promisifyRequest(() => tx.objectStore(storeName).delete(key));
@@ -2201,33 +2278,32 @@ class IndexedDBUtility {
2201
2278
  }
2202
2279
 
2203
2280
  async batchOperation(db, operations) {
2204
- return this.performTransaction(db, ['documents'], 'readwrite', async tx => {
2281
+ return this.performTransaction(db, ['documents'], 'readwrite', tx => {
2205
2282
  const store = tx.objectStore('documents');
2206
- const results = [];
2207
2283
 
2208
- for (const op of operations) {
2284
+ // CRITICAL: Queue ALL IDB requests synchronously to prevent
2285
+ // TransactionInactiveError. Do NOT use await between requests.
2286
+ const promises = operations.map(op => {
2209
2287
  try {
2210
- let result;
2211
2288
  switch (op.type) {
2212
2289
  case 'add':
2213
- result = await this._promisifyRequest(() => store.add(op.data));
2214
- break;
2290
+ return this._promisifyRequest(() => store.add(op.data))
2291
+ .then(result => ({ success: true, result }));
2215
2292
  case 'put':
2216
- result = await this._promisifyRequest(() => store.put(op.data));
2217
- break;
2293
+ return this._promisifyRequest(() => store.put(op.data))
2294
+ .then(result => ({ success: true, result }));
2218
2295
  case 'delete':
2219
- result = await this._promisifyRequest(() => store.delete(op.key));
2220
- break;
2296
+ return this._promisifyRequest(() => store.delete(op.key))
2297
+ .then(result => ({ success: true, result }));
2221
2298
  default:
2222
- throw new Error(`Unknown operation type: ${op.type}`);
2299
+ return Promise.resolve({ success: false, error: `Unknown operation type: ${op.type}` });
2223
2300
  }
2224
- results.push({ success: true, result });
2225
2301
  } catch (error) {
2226
- results.push({ success: false, error: error.message });
2302
+ return Promise.resolve({ success: false, error: error.message });
2227
2303
  }
2228
- }
2304
+ });
2229
2305
 
2230
- return results;
2306
+ return Promise.all(promises);
2231
2307
  });
2232
2308
  }
2233
2309
  }
@@ -2654,7 +2730,7 @@ class AggregationPipeline {
2654
2730
  for (const doc of docs) {
2655
2731
  const groupKey = typeof idField === 'string' ?
2656
2732
  queryEngine.getFieldValue(doc, idField.replace('$', '')) :
2657
- serializer.serialize(idField);
2733
+ JSON.stringify(idField);
2658
2734
 
2659
2735
  if (!groups.has(groupKey)) {
2660
2736
  groups.set(groupKey, { _id: groupKey, docs: [] });
@@ -2675,10 +2751,11 @@ class AggregationPipeline {
2675
2751
  case '$sum':
2676
2752
  result[fieldKey] = group.docs.reduce((sum, d) => sum + (queryEngine.getFieldValue(d, field) || 0), 0);
2677
2753
  break;
2678
- case '$avg':
2754
+ case '$avg': {
2679
2755
  const sum = group.docs.reduce((s, d) => s + (queryEngine.getFieldValue(d, field) || 0), 0);
2680
2756
  result[fieldKey] = sum / group.docs.length;
2681
2757
  break;
2758
+ }
2682
2759
  case '$count':
2683
2760
  result[fieldKey] = group.docs.length;
2684
2761
  break;
@@ -3243,7 +3320,7 @@ class Collection {
3243
3320
  return result;
3244
3321
  }
3245
3322
 
3246
- async batchAdd(documents, options) {
3323
+ async batchAdd(documents, options = {}) {
3247
3324
  if (!this._initialized) await this.init();
3248
3325
 
3249
3326
  const startTime = performance.now();
@@ -3623,7 +3700,7 @@ class Database {
3623
3700
 
3624
3701
  async export(format = 'json', password = null) {
3625
3702
  const data = {
3626
- version: '0.9.0',
3703
+ version: '0.9.2',
3627
3704
  database: this.name,
3628
3705
  timestamp: Date.now(),
3629
3706
  collections: {}
@@ -3704,7 +3781,7 @@ class Database {
3704
3781
 
3705
3782
  // Clear quickstore
3706
3783
  if (this._quickStore) {
3707
- this._quickStore.clear();
3784
+ this._quickStore.destroy();
3708
3785
  }
3709
3786
 
3710
3787
  // Destroy encryption
@@ -3790,7 +3867,7 @@ class LacertaDB {
3790
3867
 
3791
3868
  async createBackup(password = null) {
3792
3869
  const backup = {
3793
- version: '0.9.0',
3870
+ version: '0.9.2',
3794
3871
  timestamp: Date.now(),
3795
3872
  databases: {}
3796
3873
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixagram/lacerta-db",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Lacerta-DB is a Javascript IndexedDB Database for Web Browsers. Simple, Fast, Secure.",
5
5
  "devDependencies": {
6
6
  "@babel/core": "^7.23.6",