UNPKG

@yihuangdb/storage-object

Version:

A Node.js storage object layer library using Redis OM

1,062 lines 102 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.StorageObject = void 0; const redis_om_1 = require("redis-om"); const uuid_1 = require("uuid"); const connection_1 = require("./connection"); const schema_1 = require("./schema"); const storage_schema_1 = require("./storage-schema"); const performance_profiler_1 = require("./performance-profiler"); const optimistic_lock_1 = require("./optimistic-lock"); const batch_operations_1 = require("./batch-operations"); const redis_key_manager_1 = require("./redis-key-manager"); class StorageObject { connection; repository; schema; newSchema = null; options; isInitialized = false; entityName; versionField = '__version'; createdAtField = '__createdAt'; updatedAtField = '__updatedAt'; keyManager = (0, redis_key_manager_1.getRedisKeyManager)(); storageVersionManager; schemaVersionManager; changeTrackingEnabled = false; constructor(entityName, schemaConfig, options = {}) { // Validate required parameters if (!entityName || typeof entityName !== 'string') { throw new Error('StorageObject requires a valid entityName as the first parameter'); } // Validate schema configuration if (!schemaConfig || typeof schemaConfig !== 'object') { throw new Error('StorageObject requires a valid schema configuration as the second parameter. ' + 'Schema must be an object defining field types, e.g., { name: "text", age: "number" }'); } // Check if schema is empty (only allowed if explicitly passed as empty object) const schemaKeys = Object.keys(schemaConfig); if (schemaKeys.length === 0) { console.warn(`[StorageObject] Warning: Creating storage "${entityName}" with empty schema. ` + 'Only system fields (__version, __createdAt, __updatedAt) will be available for queries.'); } this.entityName = entityName; this.options = { prefix: options.prefix || 'storage', idGenerator: options.idGenerator || uuid_1.v4, ...options, }; this.connection = connection_1.RedisConnection.getInstance(options); // Add system fields for optimistic locking if enabled const enhancedSchemaConfig = { ...schemaConfig }; if (options.enableOptimisticLocking !== false) { enhancedSchemaConfig[this.versionField] = { type: 'number', indexed: false }; enhancedSchemaConfig[this.createdAtField] = { type: 'date', indexed: true }; enhancedSchemaConfig[this.updatedAtField] = { type: 'date', indexed: true }; } // Generate the prefixed entity name using the key manager const prefixedEntityName = this.keyManager.generateKey('{prefix}:{entityName}', { prefix: this.options.prefix || redis_key_manager_1.RedisKeyManager.getDefaultStoragePrefix(), entityName }); this.schema = new schema_1.StorageSchema(prefixedEntityName, enhancedSchemaConfig, options.useJSON !== false); // Also create the new schema for enhanced functionality this.newSchema = storage_schema_1.StorageSchema.define(enhancedSchemaConfig, { entityName: prefixedEntityName, useJSON: options.useJSON !== false }); // Note: Schema registration is handled by StorageSystem.create() // We don't register here to avoid duplicate registration with enhanced fields } async initialize() { if (this.isInitialized) { return; } return performance_profiler_1.profiler.measure('storage.initialize', async () => { // Skip warm up during initialization - connections will be created on demand // This avoids the 300ms+ delay during initialization // Connections are still pooled and reused, just created lazily if (this.options.usePool !== false && this.options.poolSize) { const { ConnectionPool } = await Promise.resolve().then(() => __importStar(require('./connection-pool'))); // Just get the instance, don't warm up ConnectionPool.getInstance(this.options); } performance_profiler_1.profiler.startTimer('connection.connect'); const client = await this.connection.connect(); performance_profiler_1.profiler.endTimer('connection.connect'); performance_profiler_1.profiler.startTimer('repository.create'); this.repository = new redis_om_1.Repository(this.schema.getSchema(), client); performance_profiler_1.profiler.endTimer('repository.create'); performance_profiler_1.profiler.startTimer('index.create'); await this.repository.createIndex(); performance_profiler_1.profiler.endTimer('index.create'); // Note: Version tracking initialization is handled by StorageSystem.create() // to avoid duplicate initialization and ensure proper integration // with the SchemaVersionManager in the registry this.isInitialized = true; }); } ensureInitialized() { if (!this.isInitialized) { throw new Error('StorageObject not initialized. Call initialize() first.'); } } prepareDataForJSON(data) { if (!data || !this.schema.isJSON()) { return data; } const prepared = { ...data }; // In JSON mode, complex objects in string fields need to be stringified for (const [key, value] of Object.entries(prepared)) { if (value !== null && value !== undefined) { const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; // If it's a string field but the value is an object, stringify it if (fieldType === 'string' && typeof value === 'object') { prepared[key] = JSON.stringify(value); } } } } return prepared; } prepareDataForRedisJSON(data) { // Prepare data for direct Redis JSON storage (bypassing Redis OM) const prepared = { ...data }; for (const [key, value] of Object.entries(prepared)) { if (value instanceof Date) { // Convert dates to ISO strings for Redis JSON prepared[key] = value.toISOString(); } else if (value !== null && value !== undefined) { const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; if (fieldType === 'string' && typeof value === 'object') { prepared[key] = JSON.stringify(value); } } } } return prepared; } parseDataFromJSON(data) { if (!data || !this.schema.isJSON()) { return data; } // Preserve entityId from the original data const entityIdSymbol = Symbol.for('entityId'); const originalEntityId = data[entityIdSymbol] || data.entityId; const parsed = { ...data }; // Try to parse string fields that might contain JSON for (const [key, value] of Object.entries(parsed)) { if (typeof value === 'string') { const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; // Only try to parse string fields that look like JSON if (fieldType === 'string' && (value.startsWith('{') || value.startsWith('['))) { try { parsed[key] = JSON.parse(value); } catch (e) { // Keep as string if parsing fails } } } } } // Ensure entityId is always preserved after parsing if (originalEntityId) { parsed[entityIdSymbol] = originalEntityId; parsed.entityId = originalEntityId; } return parsed; } async create(data) { this.ensureInitialized(); return performance_profiler_1.profiler.measure('storage.create', async () => { // Preserve entityId if it exists in the data, otherwise generate new one const dataWithId = data; console.log('[DEBUG] StorageObject.create - Input data keys:', Object.keys(dataWithId)); const providedId = dataWithId.entityId; const id = providedId || this.options.idGenerator(); const now = new Date(); // Debug logging if (providedId) { console.log(`[DEBUG] StorageObject.create - Using provided entityId: ${providedId}`); } else { console.log(`[DEBUG] StorageObject.create - New entity created with ID: ${id}`); } // Create enhanced data without entityId to avoid duplication const { entityId, ...dataWithoutId } = dataWithId; // Add optimistic locking fields const enhancedData = { ...dataWithoutId }; if (this.options.enableOptimisticLocking !== false) { enhancedData[this.versionField] = 1; enhancedData[this.createdAtField] = now; enhancedData[this.updatedAtField] = now; } // Prepare data for JSON storage performance_profiler_1.profiler.startTimer('prepareDataForJSON'); const preparedData = this.prepareDataForJSON(enhancedData); performance_profiler_1.profiler.endTimer('prepareDataForJSON'); performance_profiler_1.profiler.startTimer('repository.save', { id }); const entity = await this.repository.save(id, preparedData); performance_profiler_1.profiler.endTimer('repository.save'); // Key is created in Redis, no need for auxiliary tracking const client = await this.connection.getClient(); // Set both Symbol and regular entityId const entityIdSymbol = Symbol.for('entityId'); entity[entityIdSymbol] = id; entity.entityId = id; // Parse JSON fields back to objects performance_profiler_1.profiler.startTimer('parseDataFromJSON'); const result = this.parseDataFromJSON(entity); performance_profiler_1.profiler.endTimer('parseDataFromJSON'); // Ensure entityId is set on the result result.entityId = id; // Track change if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { await this.storageVersionManager.trackStorageChange(id, 'c'); } // Also track entity in SchemaVersionManager for accurate counting if (this.schemaVersionManager) { await this.schemaVersionManager.trackEntity(id); } return result; }); } async createMany(items, options = {}) { this.ensureInitialized(); if (options.atomic) { return this.createManyAtomic(items, options); } else { // Non-atomic batch creation with optional rollback if (options.rollbackOnError) { return this.createManyWithRollback(items); } // Simple non-atomic creation const entities = await Promise.all(items.map(item => this.create(item))); return entities; } } /** * Truly atomic batch creation using Redis transactions */ async createManyAtomic(items, options = {}) { // For very large batches, split into chunks to avoid queue overflow const MAX_BATCH_SIZE = 100; // Redis can handle this in a single transaction if (items.length > MAX_BATCH_SIZE) { const results = []; for (let i = 0; i < items.length; i += MAX_BATCH_SIZE) { const chunk = items.slice(i, i + MAX_BATCH_SIZE); const chunkResults = await this.createManyAtomic(chunk, options); results.push(...chunkResults); } return results; } const client = await this.connection.connect(); const multi = client.multi(); const preparedEntities = []; const ids = []; const now = new Date(); try { for (const item of items) { const id = this.options.idGenerator(); const enhancedData = { ...item }; if (this.options.enableOptimisticLocking !== false) { enhancedData[this.versionField] = 1; enhancedData[this.createdAtField] = now; enhancedData[this.updatedAtField] = now; } // Add entityId const entityIdSymbol = Symbol.for('entityId'); enhancedData[entityIdSymbol] = id; enhancedData.entityId = id; const preparedData = this.prepareDataForJSON(enhancedData); const key = this.keyManager.getStorageEntityKey(this.options.prefix, this.entityName, id); // Add to transaction if (this.options.useJSON !== false) { // Use special preparation for direct Redis JSON storage const jsonData = this.prepareDataForRedisJSON(preparedData); multi.json.set(key, '$', jsonData); } else { multi.hSet(key, (0, batch_operations_1.entityToHash)(preparedData)); } ids.push(id); preparedEntities.push(preparedData); } // Execute transaction atomically const results = await multi.exec(); if (!results || results.includes(null)) { throw new batch_operations_1.BatchOperationError('Atomic batch creation failed - transaction aborted', 'create', items.length, 0, [new Error('Transaction failed')]); } // Now save through repository to update indices const savedEntities = []; const entityIdSymbol = Symbol.for('entityId'); for (let i = 0; i < preparedEntities.length; i++) { const entity = preparedEntities[i]; const id = ids[i]; // Ensure entityId is set entity[entityIdSymbol] = id; entity.entityId = id; // Save to repository for indexing await this.repository.save(id, entity); // Parse and add to results const parsed = this.parseDataFromJSON(entity); parsed[entityIdSymbol] = id; parsed.entityId = id; savedEntities.push(parsed); } // Track batch changes if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { const changes = savedEntities.map(entity => ({ entityId: entity.entityId, operation: 'c' })); await this.storageVersionManager.trackBatchStorageChanges(changes); } // Also track entities in SchemaVersionManager for accurate counting if (this.schemaVersionManager) { for (const entity of savedEntities) { await this.schemaVersionManager.trackEntity(entity.entityId); } } return savedEntities; } catch (error) { // Transaction failed - nothing was created const errorMessage = error instanceof Error ? error.message : String(error); throw new batch_operations_1.BatchOperationError(`Atomic batch creation failed: ${errorMessage}`, 'create', items.length, 0, [error instanceof Error ? error : new Error(String(error))]); } } /** * Batch creation with rollback on failure */ async createManyWithRollback(items) { const created = []; const rollback = new batch_operations_1.RollbackManager(); try { for (const item of items) { const entity = await this.create(item); created.push(entity); // Add rollback action rollback.addRollback(async () => { await this.delete(entity.entityId); }); } rollback.clear(); // Success - clear rollback actions return created; } catch (error) { // Rollback created entities console.error('Batch creation failed, rolling back...'); const rolledBack = await rollback.executeRollback(); throw new batch_operations_1.BatchOperationError(`Batch creation failed and rolled back ${rolledBack} entities`, 'create', items.length - created.length, created.length, [error instanceof Error ? error : new Error(String(error))]); } } async findById(id) { this.ensureInitialized(); return performance_profiler_1.profiler.measure('storage.findById', async () => { try { performance_profiler_1.profiler.startTimer('repository.fetch', { id }); const entity = await this.repository.fetch(id); performance_profiler_1.profiler.endTimer('repository.fetch'); // Check if entity has any actual data (besides just the entityId symbol) const hasData = entity && Object.getOwnPropertyNames(entity).some(key => key !== 'entityId' && entity[key] !== undefined); if (!hasData) { return null; } // Redis OM uses symbols for entityId - ensure it's set const entityIdSymbol = Symbol.for('entityId'); const storedId = entity[entityIdSymbol] || id; entity[entityIdSymbol] = storedId; entity.entityId = storedId; // Parse JSON fields back to objects performance_profiler_1.profiler.startTimer('parseDataFromJSON'); const result = this.parseDataFromJSON(entity); performance_profiler_1.profiler.endTimer('parseDataFromJSON'); // Ensure entityId is set on the result result.entityId = storedId; return result; } catch (error) { console.error('Error in findById:', error); return null; } }, { id }); } async findAll(options = {}) { this.ensureInitialized(); const search = this.repository.search(); return this.executeSearch(search, options); } async findOne(query) { const results = await this.find(query, { limit: 1 }); return results[0] || null; } async find(query, options = {}) { this.ensureInitialized(); return performance_profiler_1.profiler.measure('storage.find', async () => { performance_profiler_1.profiler.startTimer('query.build', { query, options }); let search = this.repository.search(); // Track invalid fields for warning const invalidFields = []; for (const [field, value] of Object.entries(query)) { if (value === null || value === undefined) { continue; } const fieldConfig = this.schema.getFields()[field]; if (!fieldConfig) { invalidFields.push(field); continue; } const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; switch (fieldType) { case 'string': if (typeof value === 'string') { search = search.where(field).equals(value); } break; case 'text': if (typeof value === 'string') { search = search.where(field).matches(value); } break; case 'number': if (typeof value === 'number') { search = search.where(field).equals(value); } else if (typeof value === 'object' && value !== null) { // Handle MongoDB-style operators if ('$gte' in value) { search = search.where(field).gte(value.$gte); } else if ('$lte' in value) { search = search.where(field).lte(value.$lte); } else if ('$gt' in value) { search = search.where(field).gt(value.$gt); } else if ('$lt' in value) { search = search.where(field).lt(value.$lt); } else if ('min' in value && 'max' in value) { search = search.where(field).between(value.min, value.max); } else if ('min' in value) { search = search.where(field).gte(value.min); } else if ('max' in value) { search = search.where(field).lte(value.max); } } break; case 'boolean': search = search.where(field).equals(Boolean(value)); break; case 'date': if (value instanceof Date) { search = search.where(field).on(value); } else if (typeof value === 'object' && value !== null) { if ('before' in value && 'after' in value) { search = search.where(field).between(value.after, value.before); } else if ('before' in value) { search = search.where(field).before(value.before); } else if ('after' in value) { search = search.where(field).after(value.after); } } break; case 'string[]': if (Array.isArray(value)) { search = search.where(field).containsOneOf(...value); } else if (typeof value === 'string') { // If searching for a single value in an array field search = search.where(field).contains(value); } break; } } performance_profiler_1.profiler.endTimer('query.build'); // Warn about invalid fields if (invalidFields.length > 0) { console.warn(`[StorageObject] Warning: Query contains fields not in schema for "${this.entityName}": ${invalidFields.join(', ')}. ` + 'These fields will be ignored. Available queryable fields: ' + Object.keys(this.schema.getFields()).join(', ')); } const results = await this.executeSearch(search, options); return results; }, { queryKeys: Object.keys(query), resultCount: 0 }); } async update(id, data, options) { this.ensureInitialized(); // If optimistic locking is enabled, use atomic updateWithLock if (this.options.enableOptimisticLocking !== false) { // Handle retry logic if requested if (options?.retryOnConflict) { const maxRetries = options.maxRetries || 3; let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { // If we're retrying, get the latest version if (attempt > 0) { const current = await this.findById(id); if (!current) { return null; } // Use the current version for retry return await this.updateWithLock(id, data, current.__version); } // First attempt with provided expectedVersion return await this.updateWithLock(id, data, options.expectedVersion); } catch (error) { lastError = error; if (error instanceof optimistic_lock_1.OptimisticLockError) { // Retry on version mismatch if (attempt < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, 10 * (attempt + 1))); continue; } } throw error; } } throw lastError; } // No retry requested, just call updateWithLock return this.updateWithLock(id, data, options?.expectedVersion); } // Otherwise, use non-atomic update for backward compatibility const entity = await this.findById(id); if (!entity) { return null; } // Merge updates into entity // Process all keys in the update data, including undefined values const undefinedBehavior = this.options.undefinedBehavior || 'delete'; for (const key in data) { if (data.hasOwnProperty(key)) { const value = data[key]; // Handle different value types if (value === undefined) { if (undefinedBehavior === 'delete') { // undefined means clear the field (but not entityId) - v0.0.3 behavior if (key !== 'entityId' && key !== Symbol.for('entityId').toString()) { delete entity[key]; } } // If 'ignore', skip undefined values (v0.0.2 behavior) } else if (value !== null && typeof value === 'object') { // Handle objects that need to be stringified for string fields const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; if (fieldType === 'string') { entity[key] = JSON.stringify(value); } else { entity[key] = value; } } else { entity[key] = value; } } else { // Set the value directly (including null) entity[key] = value; } } } // Update timestamp (no version for non-atomic) entity[this.updatedAtField] = new Date(); try { // Ensure entityId is properly set for Redis OM const entityIdSymbol = Symbol.for('entityId'); entity[entityIdSymbol] = id; // Prepare the entity for saving (stringify objects in string fields) const entityToSave = this.prepareDataForJSON(entity); // Ensure entityId is preserved after preparation (entityToSave)[entityIdSymbol] = id; (entityToSave).entityId = id; // Save the entity with explicit ID const saved = await this.repository.save(id, entityToSave); // Ensure entityId is set on the result saved.entityId = id; saved[entityIdSymbol] = id; // Parse JSON fields back to objects const result = this.parseDataFromJSON(saved); // Track change if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { await this.storageVersionManager.trackStorageChange(id, 'u'); } return result; } catch (error) { console.error(`Update error for entity ${id}:`, error); return null; } } async updateMany(query, data, options = {}) { this.ensureInitialized(); const entities = await this.find(query); if (entities.length === 0) { return 0; } if (options.atomic) { return this.updateManyAtomic(entities, data, options); } else if (options.rollbackOnError) { return this.updateManyWithRollback(entities, data); } else { // Non-atomic update (existing implementation) return this.updateManyNonAtomic(entities, data); } } /** * Truly atomic batch update using Redis transactions */ async updateManyAtomic(entities, data, options = {}) { // For very large batches, split into chunks to avoid queue overflow const MAX_BATCH_SIZE = 100; if (entities.length > MAX_BATCH_SIZE) { let totalUpdated = 0; for (let i = 0; i < entities.length; i += MAX_BATCH_SIZE) { const chunk = entities.slice(i, i + MAX_BATCH_SIZE); const chunkCount = await this.updateManyAtomic(chunk, data, options); totalUpdated += chunkCount; } return totalUpdated; } const client = await this.connection.connect(); const multi = client.multi(); const entityIdSymbol = Symbol.for('entityId'); const now = new Date(); try { const updatedEntities = []; for (const entity of entities) { const id = entity[entityIdSymbol] || entity.entityId; if (!id) { throw new Error('Entity missing entityId'); } // Apply updates const updated = { ...entity }; const undefinedBehavior = this.options.undefinedBehavior || 'delete'; for (const key in data) { if (data.hasOwnProperty(key)) { const value = data[key]; if (value === undefined) { if (undefinedBehavior === 'delete' && key !== 'entityId') { delete updated[key]; } } else { updated[key] = value; } } } // Update version and timestamp if (this.options.enableOptimisticLocking !== false) { const currentVersion = updated[this.versionField] || 0; updated[this.versionField] = currentVersion + 1; updated[this.updatedAtField] = now; } // Prepare for storage const preparedData = this.prepareDataForJSON(updated); const key = this.keyManager.getStorageEntityKey(this.options.prefix, this.entityName, id); // Add to transaction if (this.options.useJSON !== false) { // Use special preparation for direct Redis JSON storage const jsonData = this.prepareDataForRedisJSON(preparedData); multi.json.set(key, '$', jsonData); } else { multi.hSet(key, (0, batch_operations_1.entityToHash)(preparedData)); } updatedEntities.push(updated); } // Execute transaction atomically const results = await multi.exec(); if (!results || results.includes(null)) { throw new batch_operations_1.BatchOperationError('Atomic batch update failed - transaction aborted', 'update', entities.length, 0, [new Error('Transaction failed')]); } // Update repository indices for (const entity of updatedEntities) { const id = (entity)[entityIdSymbol] || (entity).entityId; if (id) { await this.repository.save(id, entity); } } // Track batch changes if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { const changes = updatedEntities.map(entity => { const id = (entity)[entityIdSymbol] || (entity).entityId; return { entityId: id, operation: 'u' }; }); await this.storageVersionManager.trackBatchStorageChanges(changes); } return updatedEntities.length; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new batch_operations_1.BatchOperationError(`Atomic batch update failed: ${errorMessage}`, 'update', entities.length, 0, [error instanceof Error ? error : new Error(String(error))]); } } /** * Batch update with rollback on failure */ async updateManyWithRollback(entities, data) { const rollback = new batch_operations_1.RollbackManager(); const backups = new Map(); const updated = []; try { // Backup current state for (const entity of entities) { const id = entity.entityId; if (id) { backups.set(id, { ...entity }); } } // Apply updates for (const entity of entities) { const id = entity.entityId; if (!id) { continue; } const result = await this.update(id, data); if (result) { updated.push(id); // Add rollback action const backup = backups.get(id); if (backup) { rollback.addRollback(async () => { await this.update(id, backup); }); } } } rollback.clear(); // Success - clear rollback actions return updated.length; } catch (error) { // Rollback updated entities console.error('Batch update failed, rolling back...'); const rolledBack = await rollback.executeRollback(); throw new batch_operations_1.BatchOperationError(`Batch update failed and rolled back ${rolledBack} entities`, 'update', entities.length - updated.length, updated.length, [error instanceof Error ? error : new Error(String(error))]); } } /** * Non-atomic batch update (original implementation) */ async updateManyNonAtomic(entities, data) { if (entities.length === 0) { return 0; } if (false && entities.length > 0) { // Disabled old broken atomic attempt // Use Redis transaction for atomic batch update const client = await this.connection.getClient(); const multi = client.multi(); const now = new Date(); const updates = entities.map(entity => { // Merge updates into entity const undefinedBehavior = this.options.undefinedBehavior || 'delete'; for (const key in data) { if (data.hasOwnProperty(key)) { const value = data[key]; if (value === undefined) { if (undefinedBehavior === 'delete') { if (key !== 'entityId' && key !== Symbol.for('entityId').toString()) { delete entity[key]; } } } else if (value !== null && typeof value === 'object') { const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; if (fieldType === 'string') { entity[key] = JSON.stringify(value); } else { entity[key] = value; } } else { entity[key] = value; } } else { entity[key] = value; } } } if (this.options.enableOptimisticLocking !== false) { const currentVersion = entity[this.versionField] || 0; entity[this.versionField] = currentVersion + 1; entity[this.updatedAtField] = now; } return entity; }); try { await multi.exec(); // Save all entities after transaction succeeds const entityIdSymbol = Symbol.for('entityId'); await Promise.all(updates.map(entity => { const id = entity.entityId || entity[entityIdSymbol]; if (!id) { console.error('Entity missing entityId:', entity); throw new Error('Entity missing entityId during update'); } // Prepare entity for JSON storage const entityToSave = this.prepareDataForJSON(entity); (entityToSave)[entityIdSymbol] = id; (entityToSave).entityId = id; return this.repository.save(id, entityToSave); })); return updates.length; } catch (error) { throw new Error(`Atomic batch update failed: ${error}`); } } else { // Non-atomic batch update const entityIdSymbol = Symbol.for('entityId'); await Promise.all(entities.map(entity => { // Merge updates into entity const undefinedBehavior = this.options.undefinedBehavior || 'delete'; for (const key in data) { if (data.hasOwnProperty(key)) { const value = data[key]; if (value === undefined) { if (undefinedBehavior === 'delete') { if (key !== 'entityId' && key !== Symbol.for('entityId').toString()) { delete entity[key]; } } } else if (value !== null && typeof value === 'object') { const fieldConfig = this.schema.getFields()[key]; if (fieldConfig) { const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type; if (fieldType === 'string') { entity[key] = JSON.stringify(value); } else { entity[key] = value; } } else { entity[key] = value; } } else { entity[key] = value; } } } if (this.options.enableOptimisticLocking !== false) { const currentVersion = entity[this.versionField] || 0; entity[this.versionField] = currentVersion + 1; entity[this.updatedAtField] = new Date(); } // Multiple fallback mechanisms to extract entityId let id = entity.entityId || entity[entityIdSymbol] || entity['entityId']; // If still no ID, check symbols directly if (!id) { const symbols = Object.getOwnPropertySymbols(entity); const entityIdSym = symbols.find(sym => sym === entityIdSymbol || sym.toString() === 'Symbol(entityId)'); if (entityIdSym) { id = entity[entityIdSym]; } } if (!id) { // Log more detailed information for debugging console.error('Entity missing entityId. Entity keys:', Object.keys(entity)); console.error('Entity symbols:', Object.getOwnPropertySymbols(entity).map(s => s.toString())); console.error('Full entity:', JSON.stringify(entity, null, 2)); throw new Error('Entity missing entityId during update'); } // Ensure entityId is set before saving entity[entityIdSymbol] = id; entity.entityId = id; // Prepare entity for JSON storage (stringify objects in string fields) const entityToSave = this.prepareDataForJSON(entity); // Preserve entityId after preparation (entityToSave)[entityIdSymbol] = id; (entityToSave).entityId = id; return this.repository.save(id, entityToSave); })); return entities.length; } } async delete(id) { this.ensureInitialized(); try { // Check if entity exists first const entity = await this.repository.fetch(id); if (!entity || Object.keys(entity).length === 0) { return false; } await this.repository.remove(id); // Key is deleted from Redis, no need for auxiliary tracking // Track change if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { await this.storageVersionManager.trackStorageChange(id, 'd'); } return true; } catch (error) { return false; } } async deleteMany(query, options = {}) { this.ensureInitialized(); const entities = await this.find(query); if (entities.length === 0) { return 0; } if (options.atomic) { return this.deleteManyAtomic(entities, options); } else if (options.rollbackOnError) { return this.deleteManyWithRollback(entities); } else { // Non-atomic delete return this.deleteManyNonAtomic(entities); } } /** * Truly atomic batch deletion using Redis transactions */ async deleteManyAtomic(entities, options = {}) { // For very large batches, split into chunks to avoid queue overflow const MAX_BATCH_SIZE = 100; if (entities.length > MAX_BATCH_SIZE) { let totalDeleted = 0; for (let i = 0; i < entities.length; i += MAX_BATCH_SIZE) { const chunk = entities.slice(i, i + MAX_BATCH_SIZE); const chunkCount = await this.deleteManyAtomic(chunk, options); totalDeleted += chunkCount; } return totalDeleted; } const client = await this.connection.connect(); const multi = client.multi(); const entityIdSymbol = Symbol.for('entityId'); try { const ids = []; for (const entity of entities) { const id = entity[entityIdSymbol] || entity.entityId; if (!id) { throw new Error('Entity missing entityId'); } ids.push(id); const key = this.keyManager.getStorageEntityKey(this.options.prefix, this.entityName, id); // Add delete operation to transaction if (this.options.useJSON !== false) { multi.json.del(key); } else { multi.del(key); } // Also delete from index if needed // Using custom pattern for set-based index (not a standard RedisOM pattern) const indexKey = this.keyManager.getStorageIndexKey(this.options.prefix, this.entityName); multi.sRem(indexKey, id); } // Execute transaction atomically const results = await multi.exec(); if (!results || results.includes(null)) { throw new batch_operations_1.BatchOperationError('Atomic batch deletion failed - transaction aborted', 'delete', entities.length, 0, [new Error('Transaction failed')]); } // Update repository indices by removing for (const id of ids) { await this.repository.remove(id); } // Track batch changes if enabled if (this.changeTrackingEnabled && this.storageVersionManager) { const changes = ids.map(id => ({ entityId: id, operation: 'd' })); await this.storageVersionManager.trackBatchStorageChanges(changes); } return ids.length; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new batch_operations_1.BatchOperationError(`Atomic batch deletion failed: ${errorMessage}`, 'delete', entities.length, 0, [error instanceof Error ? error : new Error(String(error))]); } } /** * Batch deletion with rollback capability */ async deleteManyWithRollback(entities) { const rollback = new batch_operations_1.RollbackManager(); const deleted = []; try { for (const entity of entities) { const id = entity.entityId || entity[Symbol.for('entityId')]; if (!id) { continue; } // Store data for rollback before deletion deleted.push({ id, data: { ...entity } }); await this.delete(id); // Add rollback action to recreate rollback.addRollback(async () => { // Recreate the entity with its original data const { entityId, ...data } = entity; await this.create(data); }); } rollback.clear(); // Success - clear rollback actions return deleted.leng