tero
Version:
tero is a JSON document Manager, with ACID complaint and backup recovery mechanism.
451 lines (450 loc) • 16.6 kB
JavaScript
import { existsSync, mkdirSync } from "fs";
import { ACIDStorageEngine } from "./acid-engine.js";
import { SchemaValidator } from "./schema.js";
import { BackupManager } from "./backup.js";
import { DataRecovery } from "./recovery.js";
export class Tero {
teroDirectory = "TeroDB";
cacheSize = 100;
cache = new Map();
cacheHits = 0;
cacheRequests = 0;
acidEngine;
schemaValidator;
backupManager;
dataRecovery;
constructor(config) {
try {
const { directory, cacheSize } = config || {};
if (typeof directory === "string" && directory.trim()) {
// Sanitize directory path to prevent directory traversal
this.teroDirectory = directory.replace(/[^a-zA-Z0-9_\-\/]/g, '');
}
if (typeof cacheSize === "number" && cacheSize > 0) {
this.cacheSize = Math.min(cacheSize, 10000); // Cap at 10k entries
}
// Create directories with proper error handling
this.initializeDirectories();
// Initialize ACID storage engine (primary system)
this.acidEngine = new ACIDStorageEngine(this.teroDirectory);
// Initialize schema validator
this.schemaValidator = new SchemaValidator();
}
catch (error) {
throw new Error(`Failed to initialize Tero: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
initializeDirectories() {
try {
if (!existsSync(this.teroDirectory)) {
mkdirSync(this.teroDirectory, { recursive: true });
}
const backupDir = `${this.teroDirectory}/.backup`;
if (!existsSync(backupDir)) {
mkdirSync(backupDir, { recursive: true });
}
}
catch (error) {
throw new Error(`Failed to create directories: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
validateKey(key) {
if (!key || typeof key !== 'string') {
throw new Error('Key must be a non-empty string');
}
// Sanitize key to prevent path traversal
if (key.includes('..') || key.includes('/') || key.includes('\\')) {
throw new Error('Key contains invalid characters');
}
}
evictOldestCacheEntry() {
if (this.cache.size === 0)
return;
let oldestKey = '';
let oldestTime = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
invalidateCacheKeys(keys) {
for (const key of keys) {
this.cache.delete(key);
}
}
updateCache(key, data, transactionId) {
if (this.cache.size >= this.cacheSize) {
this.evictOldestCacheEntry();
}
this.cache.set(key, {
data: { ...data },
lastAccessed: Date.now(),
transactionId
});
}
// Core ACID Operations
beginTransaction() {
try {
return this.acidEngine.beginTransaction();
}
catch (error) {
throw new Error(`Failed to begin transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async write(transactionId, key, data, options) {
try {
this.validateKey(key);
if (data === undefined || data === null) {
throw new Error('Data cannot be null or undefined');
}
// Perform schema validation if requested
if (options?.validate || options?.schemaName) {
const schemaName = options.schemaName || key;
const validationResult = this.schemaValidator.validate(schemaName, data);
if (!validationResult.valid) {
if (options.strict) {
const errorMessages = validationResult.errors.map(e => `${e.field}: ${e.message}`).join(', ');
throw new Error(`Schema validation failed: ${errorMessages}`);
}
else {
return validationResult;
}
}
// Use sanitized data from validation
data = validationResult.data || data;
}
await this.acidEngine.write(transactionId, key, data);
// Update cache with transaction context
this.updateCache(key, data, transactionId);
if (options?.validate || options?.schemaName) {
return { valid: true, errors: [], data };
}
}
catch (error) {
throw new Error(`Write failed for key '${key}': ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async read(transactionId, key) {
try {
this.validateKey(key);
this.cacheRequests++;
// Check cache first, but only if it's from the same transaction or committed
const cachedEntry = this.cache.get(key);
if (cachedEntry && (!cachedEntry.transactionId || cachedEntry.transactionId === transactionId)) {
this.cacheHits++;
cachedEntry.lastAccessed = Date.now();
return cachedEntry.data;
}
// Read from ACID engine
const data = await this.acidEngine.read(transactionId, key);
if (data !== null) {
this.updateCache(key, data, transactionId);
}
return data;
}
catch (error) {
throw new Error(`Read failed for key '${key}': ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async delete(transactionId, key) {
try {
this.validateKey(key);
await this.acidEngine.delete(transactionId, key);
// Remove from cache
this.cache.delete(key);
}
catch (error) {
throw new Error(`Delete failed for key '${key}': ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async commit(transactionId) {
try {
// Get affected keys before commit
const transaction = this.acidEngine.getActiveTransactions().includes(transactionId);
if (!transaction) {
throw new Error(`Transaction ${transactionId} not found or not active`);
}
await this.acidEngine.commitTransaction(transactionId);
// Clean up cache entries for this transaction
for (const [key, entry] of this.cache.entries()) {
if (entry.transactionId === transactionId) {
// Remove transaction ID to mark as committed
entry.transactionId = undefined;
}
}
}
catch (error) {
throw new Error(`Failed to commit transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async rollback(transactionId) {
try {
await this.acidEngine.rollbackTransaction(transactionId);
// Remove cache entries for this transaction
const keysToRemove = [];
for (const [key, entry] of this.cache.entries()) {
if (entry.transactionId === transactionId) {
keysToRemove.push(key);
}
}
this.invalidateCacheKeys(keysToRemove);
}
catch (error) {
throw new Error(`Failed to rollback transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Convenience Methods (Auto-transaction)
async create(key, initialData, options) {
const transactionId = this.beginTransaction();
try {
// Check if file already exists
const existing = await this.read(transactionId, key);
if (existing !== null) {
await this.rollback(transactionId);
return false; // File already exists
}
// Create with initial data or empty object
const result = await this.write(transactionId, key, initialData || {}, options);
await this.commit(transactionId);
return result || true;
}
catch (error) {
await this.rollback(transactionId);
throw new Error(`Create failed for key '${key}': ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async update(key, data, options) {
const transactionId = this.beginTransaction();
try {
const result = await this.write(transactionId, key, data, options);
await this.commit(transactionId);
return result;
}
catch (error) {
await this.rollback(transactionId);
throw error;
}
}
async get(key) {
const transactionId = this.beginTransaction();
try {
const data = await this.read(transactionId, key);
await this.commit(transactionId);
return data;
}
catch (error) {
await this.rollback(transactionId);
throw error;
}
}
async remove(key) {
const transactionId = this.beginTransaction();
try {
await this.delete(transactionId, key);
await this.commit(transactionId);
}
catch (error) {
await this.rollback(transactionId);
throw error;
}
}
exists(key) {
try {
this.validateKey(key);
return existsSync(`${this.teroDirectory}/${key}.json`);
}
catch (error) {
return false;
}
}
// Batch Operations
async batchWrite(operations, options) {
const transactionId = this.beginTransaction();
try {
for (const op of operations) {
await this.write(transactionId, op.key, op.data, options);
}
await this.commit(transactionId);
}
catch (error) {
await this.rollback(transactionId);
throw new Error(`Batch write failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async batchRead(keys) {
const transactionId = this.beginTransaction();
const results = {};
try {
for (const key of keys) {
results[key] = await this.read(transactionId, key);
}
await this.commit(transactionId);
return results;
}
catch (error) {
await this.rollback(transactionId);
throw new Error(`Batch read failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Money transfer example demonstrating ACID properties
async transferMoney(fromKey, toKey, amount) {
if (amount <= 0) {
throw new Error('Transfer amount must be positive');
}
const transactionId = this.beginTransaction();
try {
// Read current balances
const fromAccount = await this.read(transactionId, fromKey);
const toAccount = await this.read(transactionId, toKey);
if (!fromAccount || !toAccount) {
throw new Error('One or both accounts do not exist');
}
if (fromAccount.balance < amount) {
throw new Error('Insufficient funds');
}
// Update balances
await this.write(transactionId, fromKey, {
...fromAccount,
balance: fromAccount.balance - amount
});
await this.write(transactionId, toKey, {
...toAccount,
balance: toAccount.balance + amount
});
// Commit the transaction
await this.commit(transactionId);
}
catch (error) {
await this.rollback(transactionId);
throw new Error(`Money transfer failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Schema Management
setSchema(collectionName, schema) {
try {
this.schemaValidator.setSchema(collectionName, schema);
}
catch (error) {
throw new Error(`Failed to set schema: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
getSchema(collectionName) {
return this.schemaValidator.getSchema(collectionName);
}
removeSchema(collectionName) {
return this.schemaValidator.removeSchema(collectionName);
}
validateData(collectionName, data) {
return this.schemaValidator.validate(collectionName, data);
}
// Backup Management
configureBackup(config) {
try {
this.backupManager = new BackupManager(this.teroDirectory, config);
}
catch (error) {
throw new Error(`Failed to configure backup: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async performBackup() {
if (!this.backupManager) {
throw new Error('Backup not configured. Call configureBackup() first.');
}
return await this.backupManager.performBackup();
}
// Data Recovery
configureDataRecovery(config) {
try {
this.dataRecovery = new DataRecovery(config);
}
catch (error) {
throw new Error(`Failed to configure data recovery: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async recoverFromCloud(key) {
if (!this.dataRecovery) {
throw new Error('Data recovery not configured. Call configureDataRecovery() first.');
}
const recovered = await this.dataRecovery.recoverSingleFile(key);
if (recovered) {
this.cache.delete(key); // Invalidate cache
}
return recovered;
}
async recoverAllFromCloud() {
if (!this.dataRecovery) {
throw new Error('Data recovery not configured. Call configureDataRecovery() first.');
}
const result = await this.dataRecovery.recoverIndividualFiles();
// Clear cache for recovered files
if (result.recovered.length > 0) {
this.invalidateCacheKeys(result.recovered);
}
return result;
}
// Utility Methods
getCacheStats() {
const hitRate = this.cacheRequests > 0 ? (this.cacheHits / this.cacheRequests) * 100 : 0;
return {
size: this.cache.size,
maxSize: this.cacheSize,
hitRate: Math.round(hitRate * 100) / 100
};
}
getActiveTransactions() {
return this.acidEngine.getActiveTransactions();
}
forceCheckpoint() {
this.acidEngine.forceCheckpoint();
}
async verifyDataIntegrity() {
const result = {
totalFiles: 0,
corruptedFiles: [],
missingFiles: [],
healthy: true
};
try {
const { readdirSync } = await import('fs');
const files = readdirSync(this.teroDirectory)
.filter((file) => file.endsWith('.json'));
result.totalFiles = files.length;
for (const file of files) {
const key = file.replace('.json', '');
try {
const data = await this.get(key);
if (data === null) {
result.missingFiles.push(key);
result.healthy = false;
}
}
catch (error) {
result.corruptedFiles.push(key);
result.healthy = false;
}
}
return result;
}
catch (error) {
throw new Error(`Data integrity verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
clearCache() {
this.cache.clear();
}
// Cleanup method
destroy() {
if (this.acidEngine) {
this.acidEngine.destroy();
}
if (this.backupManager) {
this.backupManager.destroy();
}
this.clearCache();
}
}