temporal-db
Version:
Git-like versioning for application data
360 lines (317 loc) • 10.8 kB
JavaScript
const CryptoJS = require('crypto-js');
const _ = require('lodash');
/**
* Storage class that implements content-addressable storage using IndexedDB
* and provides utilities for working with nested object paths
*/
class Storage {
/**
* Creates a new Storage instance
* @param {string} dbName - Name of the IndexedDB database to use
*/
constructor(dbName = 'temporal-db') {
this.dbName = dbName;
this.db = null;
this.stores = {
objects: 'objects', // content-addressable objects
refs: 'refs', // branches and tags
commits: 'commits' // commit metadata
};
}
/**
* Initialize the storage and create database stores if needed
* @returns {Promise<void>}
*/
async init() {
if (this.db) return;
return new Promise((resolve, reject) => {
// For Node.js environment, use a polyfill (requires fake-indexeddb in test)
if (typeof window === 'undefined' && typeof global !== 'undefined') {
try {
// Try to require fake-indexeddb and set it up as a polyfill
const { indexedDB, IDBKeyRange } = require('fake-indexeddb');
global.indexedDB = indexedDB;
global.IDBKeyRange = IDBKeyRange;
} catch (error) {
reject(new Error('IndexedDB polyfill not available. Please install fake-indexeddb: npm install fake-indexeddb'));
return;
}
}
const request = indexedDB.open(this.dbName, 1);
request.onerror = (event) => {
reject(new Error(`Failed to open database: ${event.target.error}`));
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store for content-addressable objects
if (!db.objectStoreNames.contains(this.stores.objects)) {
db.createObjectStore(this.stores.objects);
}
// Create object store for refs (branches, tags)
if (!db.objectStoreNames.contains(this.stores.refs)) {
db.createObjectStore(this.stores.refs);
}
// Create object store for commit metadata
if (!db.objectStoreNames.contains(this.stores.commits)) {
const commitStore = db.createObjectStore(this.stores.commits, { keyPath: 'hash' });
commitStore.createIndex('branch', 'branch', { unique: false });
commitStore.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
/**
* Close the database connection
*/
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
/**
* Store an object and return its hash
* @param {Object} data - Data to store
* @param {string} [providedHash] - Optional hash to use as key
* @returns {Promise<string>} Hash of the stored data
*/
async put(data, providedHash) {
const json = JSON.stringify(data);
const hash = providedHash || CryptoJS.SHA256(json).toString();
await this._withStore(this.stores.objects, 'readwrite', (store) => {
store.put(json, hash);
});
return hash;
}
/**
* Retrieve an object by its hash
* @param {string} hash - Hash of the object to retrieve
* @returns {Promise<Object|null>} Retrieved object or null if not found
*/
async get(hash) {
const json = await this._withStore(this.stores.objects, 'readonly', (store) => {
return store.get(hash);
});
return json ? JSON.parse(json) : null;
}
/**
* Check if an object with the given hash exists
* @param {string} hash - Hash to check
* @returns {Promise<boolean>} True if object exists
*/
async exists(hash) {
const result = await this._withStore(this.stores.objects, 'readonly', (store) => {
return store.count(hash);
});
return result > 0;
}
/**
* Save a ref (branch pointer or tag)
* @param {string} name - Ref name
* @param {string} hash - Hash the ref points to
* @returns {Promise<void>}
*/
async saveRef(name, hash) {
await this._withStore(this.stores.refs, 'readwrite', (store) => {
store.put(hash, name);
});
}
/**
* Get a ref by name
* @param {string} name - Ref name to retrieve
* @returns {Promise<string|null>} Hash the ref points to or null
*/
async getRef(name) {
return this._withStore(this.stores.refs, 'readonly', (store) => {
return store.get(name);
});
}
/**
* Delete a ref by name
* @param {string} name - Ref name to delete
* @returns {Promise<void>}
*/
async deleteRef(name) {
await this._withStore(this.stores.refs, 'readwrite', (store) => {
store.delete(name);
});
}
/**
* List all refs with optional prefix
* @param {string} [prefix=''] - Optional prefix to filter refs
* @returns {Promise<Object>} Object mapping ref names to their hashes
*/
async listRefs(prefix = '') {
return this._withStore(this.stores.refs, 'readonly', (store) => {
return new Promise((resolve) => {
const refs = {};
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.key;
if (!prefix || key.startsWith(prefix)) {
refs[key] = cursor.value;
}
cursor.continue();
} else {
resolve(refs);
}
};
});
});
}
/**
* Save commit metadata
* @param {Object} commit - Commit object with hash, parent, branch and timestamp
* @returns {Promise<void>}
*/
async saveCommit(commit) {
await this._withStore(this.stores.commits, 'readwrite', (store) => {
store.put(commit);
});
}
/**
* Get commit metadata by hash
* @param {string} hash - Commit hash
* @returns {Promise<Object|null>} Commit metadata or null
*/
async getCommit(hash) {
return this._withStore(this.stores.commits, 'readonly', (store) => {
return store.get(hash);
});
}
/**
* List commits for a branch, most recent first
* @param {string} branch - Branch name
* @returns {Promise<Array<Object>>} Array of commit metadata objects
*/
async getCommitsForBranch(branch) {
return this._withStore(this.stores.commits, 'readonly', (store) => {
return new Promise((resolve) => {
const index = store.index('branch');
const commits = [];
const request = index.openCursor(IDBKeyRange.only(branch), 'prev');
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
commits.push(cursor.value);
cursor.continue();
} else {
resolve(commits);
}
};
});
});
}
/**
* Get commits after a specific date
* @param {string} branch - Branch name
* @param {Date} date - Date to filter by
* @returns {Promise<Array<Object>>} Array of commit metadata objects
*/
async getCommitsAfterDate(branch, date) {
return this._withStore(this.stores.commits, 'readonly', (store) => {
return new Promise((resolve) => {
const index = store.index('timestamp');
const commits = [];
const timestamp = date.getTime();
// Get commits after the timestamp for the specific branch
const request = index.openCursor(IDBKeyRange.lowerBound(timestamp), 'next');
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (cursor.value.branch === branch) {
commits.push(cursor.value);
}
cursor.continue();
} else {
resolve(commits);
}
};
});
});
}
/**
* Helper for working with object stores
* @private
* @param {string} storeName - Name of the store to use
* @param {string} mode - Transaction mode ('readonly' or 'readwrite')
* @param {Function} callback - Function to execute with the store
* @returns {Promise<any>} Result of the callback
*/
async _withStore(storeName, mode, callback) {
if (!this.db) {
throw new Error('Database not initialized, call init() first');
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
transaction.oncomplete = () => {
resolve(result);
};
transaction.onerror = (event) => {
reject(new Error(`Transaction error: ${event.target.error}`));
};
let result;
try {
result = callback(store);
if (result instanceof IDBRequest) {
result.onsuccess = () => {
result = result.result;
};
result.onerror = (event) => {
reject(new Error(`Request error: ${event.target.error}`));
};
}
} catch (error) {
reject(error);
}
});
}
// Path Utilities
/**
* Get a value at a specified path in an object
* @param {Object} obj - Object to get value from
* @param {string|Array<string>} path - Path to the value
* @returns {*} Value at path or undefined
*/
static getValueAtPath(obj, path) {
return _.get(obj, path);
}
/**
* Set a value at a specified path in an object
* @param {Object} obj - Object to modify
* @param {string|Array<string>} path - Path to set
* @param {*} value - Value to set
* @returns {Object} New object with the value set
*/
static setValueAtPath(obj, path, value) {
return _.set(_.cloneDeep(obj), path, value);
}
/**
* Delete a value at a specified path in an object
* @param {Object} obj - Object to modify
* @param {string|Array<string>} path - Path to delete
* @returns {Object} New object with the value removed
*/
static deleteValueAtPath(obj, path) {
const result = _.cloneDeep(obj);
_.unset(result, path);
return result;
}
/**
* Compare two values and determine if they are equal
* @param {*} a - First value
* @param {*} b - Second value
* @returns {boolean} True if values are equal
*/
static valuesEqual(a, b) {
return _.isEqual(a, b);
}
}
module.exports = Storage;