Skip to content

Instantly share code, notes, and snippets.

@vipertechofficial
Last active October 1, 2024 00:24
Show Gist options
  • Save vipertechofficial/41bd6e8f1f7a22a4b0f94fa312e18bf8 to your computer and use it in GitHub Desktop.
Save vipertechofficial/41bd6e8f1f7a22a4b0f94fa312e18bf8 to your computer and use it in GitHub Desktop.
A database system for javascript
"use strict";
/*
The MIT License (MIT)
Copyright (c) 2024 Matias Affolter
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import JOYSON from "joyson";
import snappy from "snappyjs";
import * as triplesec from "triplesec";
triplesec.util.buffer_to_ui8a = function(b) {
var i, ret, _i, _ref;
ret = new Uint8Array(b.length);
for (i = _i = 0, _ref = b.length; 0 <= _ref ? _i < _ref : _i > _ref; i = 0 <= _ref ? ++_i : --_i) {
ret[i] = b.readUInt8(i);
}
return ret;
};
triplesec.util.ui8a_to_buffer = function(v) {
var i, ret, _i, _ref;
ret = triplesec.Buffer.alloc(v.length);
for (i = _i = 0, _ref = v.length; 0 <= _ref ? _i < _ref : _i > _ref; i = 0 <= _ref ? ++_i : --_i) {
ret.writeUInt8(v[i], i);
}
return ret;
};
class IndexedDBUtility {
/**
* Open or create a new IndexedDB database.
* @param {string} dbName - The name of the database.
* @param {number} version - The version of the database.
* @param {function} upgradeCallback - Callback function for handling upgrades.
* @returns {Promise<IDBDatabase>} - Resolves with the opened database.
*/
static openDatabase(dbName, version = null, upgradeCallback = null) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName); // Open without specifying a version initially
request.onupgradeneeded = event => {
const db = event.target.result;
console.log(`Upgrading or initializing database: ${dbName}, version: ${event.newVersion}`);
// Call the upgrade callback to create object stores if needed
if (upgradeCallback) {
upgradeCallback(db, event.oldVersion, event.newVersion);
}
};
request.onsuccess = event => {
const db = event.target.result;
if (version && version > db.version) {
// Close the current connection and reopen with the correct version
db.close();
const versionedRequest = indexedDB.open(dbName, version); // Reopen with the specified version
versionedRequest.onupgradeneeded = event => {
const db = event.target.result;
console.log(`Upgrading database to version ${version}`);
if (upgradeCallback) {
upgradeCallback(db, event.oldVersion, event.newVersion);
}
};
versionedRequest.onsuccess = event => resolve(event.target.result);
versionedRequest.onerror = event => reject(`Failed to open database: ${event.target.error.message}`);
} else {
// If no version update is needed, resolve the database
resolve(db);
}
};
request.onerror = event => {
reject(`Failed to open database: ${event.target.error.message}`);
};
});
}
/**
* Perform a transaction on the specified object store with retries.
* @param {IDBDatabase} db - The IndexedDB instance.
* @param {string|string[]} storeNames - List of store names or a single store name.
* @param {string} mode - Transaction mode ("readonly", "readwrite").
* @param {function} callback - Callback function to perform the transaction.
* @param {number} retries - Number of retry attempts in case of failure.
* @returns {Promise<any>} - Resolves with the result of the transaction.
*/
static async performTransaction(db, storeNames, mode, callback, retries = 3) {
try {
const tx = db.transaction(Array.isArray(storeNames) ? storeNames : [storeNames], mode);
const stores = Array.isArray(storeNames) ? storeNames.map(name => tx.objectStore(name)) : [tx.objectStore(storeNames)];
const result = await callback(...stores);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(new Error(`Transaction failed: ${tx.error ? tx.error.message : 'unknown error'}`));
});
} catch (error) {
if (retries > 0) {
console.warn(`Transaction failed, retrying... (${retries} attempts left)`);
return this.performTransaction(db, storeNames, mode, callback, retries - 1);
} else {
throw new Error(`Transaction ultimately failed after retries: ${error.message}`);
}
}
}
/**
* Add or update a record in a store.
* @param {IDBObjectStore} store - The object store to add or update the record in.
* @param {any} record - The record to be added or updated.
* @returns {Promise<void>} - Resolves when the record is added or updated.
*/
static add(store, record) {
return new Promise((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = event => reject(`Failed to add record: ${event.target.error.message}`);
});
}
/**
* Delete a record by its key from a store.
* @param {IDBObjectStore} store - The object store to delete the record from.
* @param {any} key - The key of the record to delete.
* @returns {Promise<void>} - Resolves when the record is deleted.
*/
static delete(store, key) {
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = event => reject(`Failed to delete record: ${event.target.error.message}`);
});
}
/**
* Get a record by its key from a store.
* @param {IDBObjectStore} store - The object store to get the record from.
* @param {any} key - The key of the record to retrieve.
* @returns {Promise<any>} - Resolves with the record if found.
*/
static get(store, key) {
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(`Failed to retrieve record with key ${key}: ${event.target.error.message}`);
});
}
/**
* Get all records from a store.
* @param {IDBObjectStore} store - The object store to query.
* @returns {Promise<any[]>} - Resolves with an array of all records.
*/
static getAll(store) {
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(`Failed to retrieve records: ${event.target.error.message}`);
});
}
/**
* Count the number of records in a store.
* @param {IDBObjectStore} store - The object store to count records.
* @returns {Promise<number>} - Resolves with the number of records.
*/
static count(store) {
return new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(`Failed to count records: ${event.target.error.message}`);
});
}
/**
* Clear all records from a store.
* @param {IDBObjectStore} store - The object store to clear.
* @returns {Promise<void>} - Resolves when the store is cleared.
*/
static clear(store) {
return new Promise((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = event => reject(`Failed to clear store: ${event.target.error.message}`);
});
}
/**
* Delete an IndexedDB database.
* @param {string} dbName - The name of the database to delete.
* @returns {Promise<void>} - Resolves when the database is deleted.
*/
static deleteDatabase(dbName) {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => resolve();
request.onerror = event => reject(`Failed to delete database: ${event.target.error.message}`);
});
}
/**
* Open a cursor to iterate over records in a store.
* @param {IDBObjectStore} store - The object store to open a cursor on.
* @param {function} processCallback - Callback to process each record.
* @returns {Promise<void>} - Resolves when the iteration is complete.
*/
static iterateCursor(store, processCallback) {
return new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
processCallback(cursor.value, cursor.key);
cursor.continue();
} else {
resolve();
}
};
request.onerror = event => reject(`Cursor iteration failed: ${event.target.error.message}`);
});
}
}
class PrivateDatabaseManager {
constructor(syncInterval = 10000) {
this._db = null;
this._dbName = 'kissdb-private';
this._storeName = 'database-list';
this._metadataCache = new Map();
this._syncInterval = syncInterval;
this._syncTimer = null;
this._isDirty = false;
}
async init() {
try {
this._db = await IndexedDBUtility.openDatabase(this._dbName, 1, (db) => {
if (!db.objectStoreNames.contains(this._storeName)) {
db.createObjectStore(this._storeName, { keyPath: 'name' });
}
});
console.log(`Database "${this._dbName}" initialized with version: ${this._db.version}`);
} catch (error) {
console.error(`Failed to initialize database: ${error}`);
throw new Error("Database initialization failed.");
}
this._syncTimer = setInterval(() => this._syncCacheToDatabase(), this._syncInterval);
}
/**
* Validate metadata to ensure it meets the required structure.
* @param {Object} metadata - The metadata object to validate.
* @returns {boolean} - True if valid, false otherwise.
*/
_validateMetadata(metadata) {
return (
metadata &&
typeof metadata.name === 'string' &&
Array.isArray(metadata.collections) &&
metadata.collections.every(collection => typeof collection.name === 'string' && typeof collection.sizeKB === 'number' && typeof collection.length === 'number')
);
}
/**
* Updates or adds metadata for a collection, ensuring no duplicates
* or invalid entries like the database name itself.
*/
async updateDatabaseMetadata(dbName, newCollectionMetadata) {
let dbMetadata = await this.getDatabaseMetadata(dbName);
if (!this._validateMetadata(dbMetadata)) {
dbMetadata = {
name: newCollectionMetadata.name,
collections: [], // Array of collection metadata
totalSizeKB: 0, // Total size of the database in KB
lastModified: null, // Timestamp of the last modification
}; // Initialize if metadata is empty
}
// Prevent adding the database name itself as a collection
if (newCollectionMetadata.name === dbName) {
console.warn(`Attempted to add the database itself "${dbName}" as a collection. Ignored.`);
return;
}
// Ensure that no duplicate collections are added and that missing fields are updated
dbMetadata.collections = this._deduplicateAndMergeCollections(
dbMetadata.collections,
newCollectionMetadata
);
// Update metadata in the cache and mark it as dirty
this._metadataCache.set(dbName, dbMetadata);
this._isDirty = true;
}
/**
* Deduplicate and merge collection entries, prioritizing entries with complete data.
*/
_deduplicateAndMergeCollections(existingCollections, newCollectionMetadata) {
const updatedCollections = existingCollections.filter(
(collection) => collection.name !== newCollectionMetadata.name
);
// Add or update the new collection metadata with priority to the new fields
updatedCollections.push({
...newCollectionMetadata,
lastModified: newCollectionMetadata.lastModified || (existingCollections.find(c => c.name === newCollectionMetadata.name) || {}).lastModified,
length: newCollectionMetadata.length || (existingCollections.find(c => c.name === newCollectionMetadata.name) || {}).length,
sizeKB: newCollectionMetadata.sizeKB || (existingCollections.find(c => c.name === newCollectionMetadata.name) || {}).sizeKB,
totalSizeKB: newCollectionMetadata.totalSizeKB || (existingCollections.find(c => c.name === newCollectionMetadata.name) || {}).totalSizeKB
});
return updatedCollections;
}
async getDatabaseMetadata(dbName) {
if (this._metadataCache.has(dbName)) {
return this._metadataCache.get(dbName);
}
try {
const metadata = await IndexedDBUtility.performTransaction(
this._db,
this._storeName,
'readonly',
(store) => IndexedDBUtility.get(store, dbName)
);
if (metadata) {
this._metadataCache.set(dbName, metadata);
}
return metadata || { name: dbName, collections: [] }; // Ensure proper structure if no metadata found
} catch (error) {
console.error(`Error while retrieving metadata for ${dbName}:`, error);
throw new Error(`Failed to retrieve metadata: ${error.message}`);
}
}
async _syncCacheToDatabase() {
if (!this._isDirty) return;
try {
for (let [dbName, metadata] of this._metadataCache.entries()) {
await IndexedDBUtility.performTransaction(this._db, this._storeName, 'readwrite', (store) => {
return IndexedDBUtility.add(store, { name: dbName, ...metadata });
});
}
this._isDirty = false;
} catch (error) {
console.error('Failed to sync cache to database:', error);
}
}
async close() {
if (this._syncTimer) {
clearInterval(this._syncTimer);
this._syncTimer = null;
}
await this._syncCacheToDatabase();
if (this._db) {
this._db.close();
this._db = null;
}
}
async destroy() {
await this.close();
this._metadataCache.clear();
await IndexedDBUtility.deleteDatabase(this._dbName);
console.log('Metadata database deleted successfully.');
}
}
class Document {
constructor(data, encryptionKey = null) {
this._id = data._id || this._generateId();
this._created = data._created || Date.now();
this._permanent = data._permanent || false;
this._encrypted = data._encrypted || (encryptionKey ? true : false);
this._compressed = data._compressed || false; // Compression flag inside data
// Check if packedData or raw data is provided
if (data.packedData) {
// If packedData is provided, preserve the original _modified field
this.packedData = data.packedData;
this._modified = data._modified || Date.now(); // Keep the original modified timestamp if available
this.data = null; // Unpacked data will be available after unpack
} else {
// If raw data is provided, use it directly
this.data = data.data || {};
this._modified = Date.now(); // Set _modified to now for new/modified data
this.packedData = new Uint8Array(0); // Packed data is empty initially
}
this.encryptionKey = encryptionKey || "";
}
/**
* Check if a document is encrypted.
* @param {object} documentData - The document data to check.
* @returns {boolean} - True if the document is encrypted, false otherwise.
*/
static isEncrypted(documentData) {
return documentData._encrypted && documentData.packedData;
}
/**
* Unpack and decrypt an encrypted document.
* @param {object} documentData - The encrypted document data.
* @param {string} encryptionKey - The key to decrypt the document.
* @returns {Promise<object>} - Returns the decrypted document.
*/
static async decryptDocument(documentData, encryptionKey) {
if (!Document.isEncrypted(documentData)) {
throw new Error('Document is not encrypted.');
}
const decryptedData = await new Promise(function (resolve, reject){
triplesec.decrypt({
key: triplesec.Buffer.from(encryptionKey),
data: triplesec.util.ui8a_to_buffer(documentData.packedData)
}, function (err, res){
if(!err){
resolve(triplesec.util.buffer_to_ui8a(res));
}else {
reject(err);
}
});
});
console.log(decryptedData)
// Unpack the decrypted data
const unpackedData = JOYSON.unpack(decryptedData);
return {
_id: documentData._id,
_created: documentData._created,
_modified: documentData._modified,
_encrypted: true,
_compressed: documentData._compressed,
data: unpackedData
};
}
/**
* Pack the document data using JOYSON, optionally encrypt and compress.
* @returns {Promise<Uint8Array>} - Packed data ready for storage.
*/
async pack() {
if (!this.data) {
throw new Error('No data to pack');
}
let packedData = JOYSON.pack(this.data); // Pack the raw data
if (this._compressed) {
packedData = await this._compressData(packedData);
}
if (this._encrypted) {
packedData = await this._encryptData(packedData);
}
this.packedData = packedData; // Store the packed data
return packedData;
}
/**
* Unpack the document data, decrypt and decompress if required.
* @returns {Promise<void>} - Resolves when the data is unpacked.
*/
async unpack() {
// Only unpack if there's packedData and data is still null
if (!this.data && this.packedData.length > 0) {
let unpackedData = this.packedData;
if (this._encrypted) {
unpackedData = await this._decryptData(unpackedData);
}
if (this._compressed) {
unpackedData = await this._decompressData(unpackedData);
}
this.data = JOYSON.unpack(unpackedData); // Store the unpacked data
}
return this.data;
}
// Private methods for encryption, decryption, compression, decompression.
async _encryptData(data) {
const encryptionKey = this.encryptionKey;
const result = new Promise(function (resolve, reject){
triplesec.encrypt({
key: triplesec.Buffer.from(encryptionKey),
data: triplesec.util.ui8a_to_buffer(data)
}, function (err, res){
if(!err){
resolve(triplesec.util.buffer_to_ui8a(res));
}else {
reject(err);
}
});
});
return await result;
}
async _decryptData(data) {
const encryptionKey = this.encryptionKey;
const result = new Promise(function (resolve, reject){
triplesec.decrypt({
key: triplesec.Buffer.from(encryptionKey),
data: triplesec.util.ui8a_to_buffer(data)
}, function (err, res){
if(!err){
resolve(triplesec.util.buffer_to_ui8a(res));
}else {
reject(err);
}
});
});
return await result;
}
async _compressData(data) {
return snappy.compress(data);
}
async _decompressData(data) {
return snappy.uncompress(data);
}
_generateId() {
return 'xxxx-xxxx-xxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16));
}
/**
* Return the document in a format for general object use (readable format).
* @returns {Promise<object>} - Unpacked document data.
*/
async objectOutput() {
if (!this.data) {
await this.unpack(); // Ensure data is unpacked before returning
}
return {
_id: this._id,
_created: this._created,
_modified: this._modified,
_permanent: this._permanent,
_encrypted: this._encrypted,
_compressed: this._compressed,
data: this.data
};
}
/**
* Return the document in a format for storage in the database (packed format).
* @returns {Promise<object>} - Packed document data.
*/
async databaseOutput() {
if (!this.packedData || this.packedData.length === 0) {
await this.pack(); // Ensure the data is packed before returning
}
return {
_id: this._id,
_created: this._created,
_modified: this._modified, // Keep original _modified if packedData was used
_permanent: this._permanent,
_compressed: this._compressed,
_encrypted: this._encrypted,
packedData: this.packedData // Packed and ready for storage
};
}
}
class Collection {
constructor(db, name, privateDbManager) {
this._db = db; // Instance of the database this collection belongs to
this._name = name;
this._privateDbManager = privateDbManager;
this._metadata = {
sizeKB: 0,
length: 0,
lastModified: null
};
}
/**
* Initialize the collection and fetch metadata from the private database.
* Ensure that the collection (object store) exists.
* @returns {Promise<void>}
*/
async init() {
const dbMetadata = await this._privateDbManager.getDatabaseMetadata(this._db.name);
// Ensure collections array exists in metadata
if (!dbMetadata || !dbMetadata.collections) {
dbMetadata.collections = [];
}
const collectionMetadata = dbMetadata.collections.find(c => c.name === this._name);
if (collectionMetadata) {
this._metadata = collectionMetadata;
} else {
// If the collection doesn't exist, create it
if (!this._db.objectStoreNames.contains(this._name)) {
console.log(`Collection "${this._name}" does not exist. Creating it.`);
await this._createObjectStore();
}
this._metadata = { name: this._name, sizeKB: 0, length: 0, lastModified: null };
dbMetadata.collections.push(this._metadata);
await this._privateDbManager.updateDatabaseMetadata(this._db.name, this._metadata);
}
}
/**
* Create the object store (collection) dynamically.
* @private
*/
async _createObjectStore() {
const newVersion = this._db.version + 1;
this._db.close(); // Close the current connection
this._db = await IndexedDBUtility.openDatabase(this._db.name, newVersion, (db) => {
if (!db.objectStoreNames.contains(this._name)) {
db.createObjectStore(this._name, { keyPath: '_id' });
console.log(`Collection "${this._name}" created.`);
}
});
this._db = await IndexedDBUtility.openDatabase(this._db.name, newVersion);
}
/**
* Add a document to the collection.
* @param {Document} document - The document to add.
* @returns {Promise<void>}
*/
async addDocument(data, encryptionKey = null) {
const documentData = await new Document(data, encryptionKey).databaseOutput();
if (!this._db.objectStoreNames.contains(this._name)) {
console.log(`Object store "${this._name}" does not exist. Reinitializing.`);
await this._createObjectStore();
}
await IndexedDBUtility.performTransaction(this._db, this._name, 'readwrite', (store) => {
return IndexedDBUtility.add(store, documentData);
});
// Update metadata using the document's packedData size
this._metadata.length += 1;
this._metadata.sizeKB += documentData.packedData.byteLength / 1024; // Convert bytes to KB
this._metadata.lastModified = new Date().toISOString();
await this._updateMetadata();
}
/**
* Delete a document from the collection by its ID.
* @param {string} docId - The ID of the document to delete.
* @returns {Promise<void>}
*/
async deleteDocument(docId = 0, strict = false) {
// Get the document first to retrieve its size
const docData = await IndexedDBUtility.performTransaction(this._db, this._name, 'readonly', (store) => {
return IndexedDBUtility.get(store, docId);
});
if (!docData) {
if(strict) {
throw new Error(`Document with ID ${docId} not found.`);
}else {
return;
}
}
// Proceed with deletion
await IndexedDBUtility.performTransaction(this._db, this._name, 'readwrite', (store) => {
return IndexedDBUtility.delete(store, docId);
});
// Update metadata using the document's packedData size
this._metadata.length -= 1;
this._metadata.sizeKB -= docData.packedData.byteLength / 1024; // Directly using byte length to calculate size in KB
this._metadata.lastModified = Date.now();
await this._updateMetadata();
}
async addMultipleDocuments(documents) {
return new Promise((resolve, reject) => {
const tx = this._db.transaction(this._name, 'readwrite');
const store = tx.objectStore(this._name);
documents.forEach(doc => {
const documentData = new Document(doc).databaseOutput();
store.put(documentData);
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(`Failed to insert documents: ${tx.error ? tx.error.message : 'unknown error'}`);
});
}
/**
* Get a document by its ID from the collection.
* @param {string} docId - The ID of the document to retrieve.
* @returns {Promise<Document|null>} - The document or null if not found.
*/
async getDocument(docId, encryptionKey = null) {
// Ensure the collection (object store) exists before performing a transaction
if (!this._db.objectStoreNames.contains(this._name)) {
console.log(`Object store "${this._name}" does not exist. Reinitializing.`);
await this._createObjectStore(); // Create the store if missing
}
const docData = await IndexedDBUtility.performTransaction(this._db, this._name, 'readonly', (store) => {
return IndexedDBUtility.get(store, docId);
});
if (docData) {
if (Document.isEncrypted(docData)) {
// Return encrypted data if no key is provided
return new Document(docData).databaseOutput();
} else {
return new Document(docData).objectOutput();
}
}
return null;
}
async deleteOlderDocuments(quotaInMB) {
const bytesLimit = quotaInMB * 1024 * 1024; // Convert MB to bytes
let currentSize = await this._calculateTotalSize(); // Calculate current size
const store = this._db.transaction(this._name, 'readwrite').objectStore(this._name);
return new Promise((resolve, reject) => {
const request = store.openCursor(null, 'next'); // Open cursor to iterate in ascending order of key (_modified)
request.onsuccess = async (event) => {
const cursor = event.target.result;
if (cursor && currentSize > bytesLimit) {
await store.delete(cursor.key); // Delete the document
currentSize -= cursor.value.packedData.byteLength; // Reduce the size
cursor.continue(); // Continue to the next record
} else {
resolve();
}
};
request.onerror = event => reject(`Cursor failed: ${event.target.error.message}`);
});
}
/**
* Query the collection with optional filters.
* @param {object} filter - Optional filter to apply to the documents.
* @returns {Promise<Object[]>} - Resolves with an array of objects matching the filter.
*/
async query(filter = {}) {
const results = [];
await IndexedDBUtility.performTransaction(this._db, this._name, 'readonly', async (store) => {
await IndexedDBUtility.iterateCursor(store, (docData) => {
// Check if the document is encrypted
if (Document.isEncrypted(docData)) {
// Return encrypted data if no key is provided
const data = new Document(docData).databaseOutput();
results.push(data);
} else {
console.log(docData)
const data = new Document(docData).objectOutput();
let match = true;
for (const key in filter) {
if (data.data[key] !== filter[key]) {
match = false;
break;
}
}
if (match) {
// Return plain document if not encrypted
results.push(data);
}
}
});
});
return Promise.all(results);
}
onupgradeneeded(event) {
const db = event.target.result;
const store = db.createObjectStore('documents', { keyPath: '_id' });
store.createIndex('modifiedIndex', '_modified', { unique: false });
}
async getDocumentsSortedByModified() {
const tx = this._db.transaction(this._name, 'readonly');
const store = tx.objectStore(this._name);
const index = store.index('modifiedIndex');
return new Promise((resolve, reject) => {
const results = [];
const request = index.openCursor(null, 'prev'); // Open cursor to get latest modified documents first
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = (event) => reject(`Cursor failed: ${event.target.error.message}`);
});
}
async _calculateTotalSize() {
const tx = this._db.transaction(this._name, 'readonly');
const store = tx.objectStore(this._name);
return new Promise((resolve, reject) => {
let totalSize = 0;
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
totalSize += cursor.value.packedData.byteLength; // Accumulate the size of each document
cursor.continue();
} else {
resolve(totalSize);
}
};
request.onerror = (event) => reject(`Failed to calculate total size: ${event.target.error.message}`);
});
}
/**
* Update the metadata in the private database.
* @private
*/
async _updateMetadata() {
const dbMetadata = await this._privateDbManager.getDatabaseMetadata(this._db.name);
const collectionMetadata = dbMetadata.collections.find(c => c.name === this._name);
if (collectionMetadata) {
collectionMetadata.sizeKB = this._metadata.sizeKB;
collectionMetadata.length = this._metadata.length;
collectionMetadata.lastModified = this._metadata.lastModified;
}
// Update total size and last modification for the entire database
dbMetadata.totalSizeKB = dbMetadata.collections.reduce((total, coll) => total + coll.sizeKB, 0);
dbMetadata.lastModified = Date.now();
await this._privateDbManager.updateDatabaseMetadata(this._db.name, dbMetadata);
}
}
class Database {
constructor(name, privateDbManager) {
this._name = name;
this._privateDbManager = privateDbManager;
this._collections = new Map(); // Map of collection instances
this._db = null; // IndexedDB instance
}
/**
* Initialize the database by opening the IndexedDB instance and loading metadata.
* @returns {Promise<void>}
*/
async init() {
this._db = await IndexedDBUtility.openDatabase(this._name, 1, this._upgradeDatabase.bind(this));
// Load collections metadata from the private database
const metadata = await this._privateDbManager.getDatabaseMetadata(this._name);
if (!metadata) {
await this._privateDbManager.updateDatabaseMetadata(this._name, { collections: [] });
}
}
/**
* Upgrade the database (called when a new version is needed).
* @param {IDBDatabase} db - The IndexedDB instance.
* @private
*/
_upgradeDatabase(db) {
// In future, you can upgrade by adding object stores (collections) dynamically
console.log('Database upgrade triggered for', this._name);
}
/**
* Create a new collection in the database.
* @param {string} collectionName - The name of the new collection.
* @returns {Promise<void>}
*/
async createCollection(collectionName) {
if (this._collections.has(collectionName)) {
throw new Error(`Collection ${collectionName} already exists`);
}
// Increment the version to trigger the onupgradeneeded event
const newVersion = this._db.version + 1;
this._db.close(); // Close the current database connection
// Reopen the database with the new version to trigger an upgrade
await IndexedDBUtility.openDatabase(this._name, newVersion, (db) => {
if (!db.objectStoreNames.contains(collectionName)) {
db.createObjectStore(collectionName, { keyPath: '_id' });
console.log(`Collection "${collectionName}" created.`);
}
});
// Reopen the database with the new version
this._db = await IndexedDBUtility.openDatabase(this._name, newVersion);
// Create collection instance and store it
const collection = new Collection(this._db, collectionName, this._privateDbManager);
await collection.init();
this._collections.set(collectionName, collection);
// Update the database metadata
const metadata = await this._privateDbManager.getDatabaseMetadata(this._name);
metadata.collections.push({ name: collectionName, sizeKB: 0, length: 0 });
await this._privateDbManager.updateDatabaseMetadata(this._name, metadata);
}
/**
* Delete a collection from the database.
* @param {string} collectionName - The name of the collection to delete.
* @returns {Promise<void>}
*/
async deleteCollection(collectionName) {
if (!this._collections.has(collectionName)) {
throw new Error(`Collection ${collectionName} does not exist`);
}
// Delete the collection from IndexedDB
const tx = this._db.transaction([collectionName], 'readwrite');
const store = tx.objectStore(collectionName);
store.clear();
this._collections.delete(collectionName);
// Update metadata
const metadata = await this._privateDbManager.getDatabaseMetadata(this._name);
metadata.collections = metadata.collections.filter(c => c.name !== collectionName);
await this._privateDbManager.updateDatabaseMetadata(this._name, metadata);
}
/**
* Get an instance of a collection by name.
* @param {string} collectionName - The name of the collection.
* @returns {Promise<Collection>} - Resolves with the Collection instance.
*/
async getCollection(collectionName) {
if (!this._collections.has(collectionName)) {
const collection = new Collection(this._db, collectionName, this._privateDbManager);
await collection.init();
this._collections.set(collectionName, collection);
}
return this._collections.get(collectionName);
}
/**
* Delete the entire database and its metadata.
* @returns {Promise<void>}
*/
async deleteDatabase() {
await IndexedDBUtility.deleteDatabase(this._name);
await this._privateDbManager.deleteDatabaseMetadata(this._name);
}
}
module.exports = {
PrivateDatabaseManager,
Collection,
Database,
Document
}
@vipertechofficial
Copy link
Author

(async () => {
    const privateDbManager = new PrivateDatabaseManager();
    await privateDbManager.init();

    // Create or open a new database
    const db = new Database('my-database', privateDbManager);
    await db.init();

    // Create a collection
    await db.createCollection('users');

    // Get the collection instance
    const usersCollection = await db.getCollection('users');

    // Add a document
    await usersCollection.addDocument({ _id: "login", _permanent: true, data: { name: new Uint8Array(9) } }, "hello");
    await usersCollection.addDocument({ _id: "him", _compressed: true, data: { name: 'John Doe', email: '[email protected]' } });

    // Query the collection
    const queryResult = await usersCollection.getDocument("him");
    console.log(queryResult);

    // Delete a document
    await usersCollection.deleteDocument("him");

    // Query the collection
    const queryResult2 = await usersCollection.query();
    console.log(queryResult2);

    // Query the collection
    const ciphered = await usersCollection.getDocument("login");
    const deciphered = await Document.decryptDocument(ciphered, "hello");
    console.log(ciphered, deciphered);

    // Delete the collection
    //await db.deleteCollection('users');

    // Delete the entire database
    //await db.deleteDatabase();
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment