@yihuangdb/storage-object
Version:
A Node.js storage object layer library using Redis OM
1,062 lines • 102 kB
JavaScript
"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