2025-11-21 23:33:48 +01:00
|
|
|
import { ISync, EntityType, SyncStatus, IEventBus } from '../types/CalendarTypes';
|
2025-11-18 16:37:33 +01:00
|
|
|
import { IEntityService } from './IEntityService';
|
|
|
|
|
import { SyncPlugin } from './SyncPlugin';
|
2025-11-20 21:45:09 +01:00
|
|
|
import { IndexedDBContext } from './IndexedDBContext';
|
2025-11-21 23:23:04 +01:00
|
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
|
|
|
import { diff } from 'json-diff-ts';
|
2025-11-21 23:33:48 +01:00
|
|
|
import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes';
|
2025-11-18 16:37:33 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
|
|
|
|
|
*
|
|
|
|
|
* HYBRID PATTERN: Inheritance + Composition
|
|
|
|
|
* - Services EXTEND this base class (inheritance for structure)
|
|
|
|
|
* - Sync logic is COMPOSED via SyncPlugin (pluggable)
|
|
|
|
|
*
|
|
|
|
|
* PROVIDES:
|
|
|
|
|
* - Generic CRUD operations (get, getAll, save, delete)
|
|
|
|
|
* - Sync status management (delegates to SyncPlugin)
|
|
|
|
|
* - Serialization hooks (override in subclass if needed)
|
2025-11-20 21:45:09 +01:00
|
|
|
* - Lazy database access via IndexedDBContext
|
2025-11-18 16:37:33 +01:00
|
|
|
*
|
|
|
|
|
* SUBCLASSES MUST IMPLEMENT:
|
|
|
|
|
* - storeName: string (IndexedDB object store name)
|
|
|
|
|
* - entityType: EntityType (for runtime routing)
|
|
|
|
|
*
|
|
|
|
|
* SUBCLASSES MAY OVERRIDE:
|
|
|
|
|
* - serialize(entity: T): any (default: no serialization)
|
|
|
|
|
* - deserialize(data: any): T (default: no deserialization)
|
|
|
|
|
*
|
|
|
|
|
* BENEFITS:
|
|
|
|
|
* - DRY: Single source of truth for CRUD logic
|
|
|
|
|
* - Type safety: Generic T ensures compile-time checking
|
|
|
|
|
* - Pluggable: SyncPlugin can be swapped for testing/different implementations
|
|
|
|
|
* - Open/Closed: New entities just extend this class
|
2025-11-20 21:45:09 +01:00
|
|
|
* - Lazy database access: db requested when needed, not at construction time
|
2025-11-18 16:37:33 +01:00
|
|
|
*/
|
|
|
|
|
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
|
|
|
|
|
// Abstract properties - must be implemented by subclasses
|
|
|
|
|
abstract readonly storeName: string;
|
|
|
|
|
abstract readonly entityType: EntityType;
|
|
|
|
|
|
|
|
|
|
// Internal composition - sync functionality
|
|
|
|
|
private syncPlugin: SyncPlugin<T>;
|
|
|
|
|
|
2025-11-20 21:45:09 +01:00
|
|
|
// IndexedDB context - provides database connection
|
|
|
|
|
private context: IndexedDBContext;
|
2025-11-18 16:37:33 +01:00
|
|
|
|
2025-11-21 23:23:04 +01:00
|
|
|
// EventBus for emitting entity events
|
|
|
|
|
protected eventBus: IEventBus;
|
|
|
|
|
|
2025-11-18 16:37:33 +01:00
|
|
|
/**
|
2025-11-20 21:45:09 +01:00
|
|
|
* @param context - IndexedDBContext instance (injected dependency)
|
2025-11-21 23:23:04 +01:00
|
|
|
* @param eventBus - EventBus for emitting entity events
|
2025-11-18 16:37:33 +01:00
|
|
|
*/
|
2025-11-21 23:23:04 +01:00
|
|
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
2025-11-20 21:45:09 +01:00
|
|
|
this.context = context;
|
2025-11-21 23:23:04 +01:00
|
|
|
this.eventBus = eventBus;
|
2025-11-18 16:37:33 +01:00
|
|
|
this.syncPlugin = new SyncPlugin<T>(this);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 21:45:09 +01:00
|
|
|
/**
|
|
|
|
|
* Get IDBDatabase instance (lazy access)
|
|
|
|
|
* Protected getter accessible to subclasses and methods in this class
|
|
|
|
|
*/
|
|
|
|
|
protected get db(): IDBDatabase {
|
|
|
|
|
return this.context.getDatabase();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 16:37:33 +01:00
|
|
|
/**
|
|
|
|
|
* Serialize entity before storing in IndexedDB
|
|
|
|
|
* Override in subclass if entity has Date fields or needs transformation
|
|
|
|
|
*
|
|
|
|
|
* @param entity - Entity to serialize
|
|
|
|
|
* @returns Serialized data (default: entity itself)
|
|
|
|
|
*/
|
|
|
|
|
protected serialize(entity: T): any {
|
|
|
|
|
return entity; // Default: no serialization
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deserialize data from IndexedDB back to entity
|
|
|
|
|
* Override in subclass if entity has Date fields or needs transformation
|
|
|
|
|
*
|
|
|
|
|
* @param data - Raw data from IndexedDB
|
|
|
|
|
* @returns Deserialized entity (default: data itself)
|
|
|
|
|
*/
|
|
|
|
|
protected deserialize(data: any): T {
|
|
|
|
|
return data as T; // Default: no deserialization
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a single entity by ID
|
|
|
|
|
*
|
|
|
|
|
* @param id - Entity ID
|
|
|
|
|
* @returns Entity or null if not found
|
|
|
|
|
*/
|
|
|
|
|
async get(id: string): Promise<T | null> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
|
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
|
const request = store.get(id);
|
|
|
|
|
|
|
|
|
|
request.onsuccess = () => {
|
|
|
|
|
const data = request.result;
|
|
|
|
|
if (data) {
|
|
|
|
|
resolve(this.deserialize(data));
|
|
|
|
|
} else {
|
|
|
|
|
resolve(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onerror = () => {
|
|
|
|
|
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all entities
|
|
|
|
|
*
|
|
|
|
|
* @returns Array of all entities
|
|
|
|
|
*/
|
|
|
|
|
async getAll(): Promise<T[]> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
|
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
|
const request = store.getAll();
|
|
|
|
|
|
|
|
|
|
request.onsuccess = () => {
|
|
|
|
|
const data = request.result as any[];
|
|
|
|
|
const entities = data.map(item => this.deserialize(item));
|
|
|
|
|
resolve(entities);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onerror = () => {
|
|
|
|
|
reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save an entity (create or update)
|
2025-11-21 23:23:04 +01:00
|
|
|
* Emits ENTITY_SAVED event with operation type and changes
|
2025-11-18 16:37:33 +01:00
|
|
|
*
|
|
|
|
|
* @param entity - Entity to save
|
|
|
|
|
*/
|
|
|
|
|
async save(entity: T): Promise<void> {
|
2025-11-21 23:23:04 +01:00
|
|
|
const entityId = (entity as any).id;
|
|
|
|
|
|
|
|
|
|
// Check if entity exists to determine create vs update
|
|
|
|
|
const existingEntity = await this.get(entityId);
|
|
|
|
|
const isCreate = existingEntity === null;
|
|
|
|
|
|
|
|
|
|
// Calculate changes: full entity for create, diff for update
|
|
|
|
|
let changes: any;
|
|
|
|
|
if (isCreate) {
|
|
|
|
|
changes = entity;
|
|
|
|
|
} else {
|
|
|
|
|
// Calculate diff between existing and new entity
|
|
|
|
|
const existingSerialized = this.serialize(existingEntity);
|
|
|
|
|
const newSerialized = this.serialize(entity);
|
|
|
|
|
changes = diff(existingSerialized, newSerialized);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 16:37:33 +01:00
|
|
|
const serialized = this.serialize(entity);
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
|
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
|
const request = store.put(serialized);
|
|
|
|
|
|
|
|
|
|
request.onsuccess = () => {
|
2025-11-21 23:23:04 +01:00
|
|
|
// Emit ENTITY_SAVED event
|
2025-11-21 23:33:48 +01:00
|
|
|
const payload: IEntitySavedPayload = {
|
2025-11-21 23:23:04 +01:00
|
|
|
entityType: this.entityType,
|
|
|
|
|
entityId,
|
|
|
|
|
operation: isCreate ? 'create' : 'update',
|
|
|
|
|
changes,
|
|
|
|
|
timestamp: Date.now()
|
2025-11-21 23:33:48 +01:00
|
|
|
};
|
|
|
|
|
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
|
2025-11-18 16:37:33 +01:00
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onerror = () => {
|
2025-11-21 23:23:04 +01:00
|
|
|
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
|
2025-11-18 16:37:33 +01:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete an entity
|
2025-11-21 23:23:04 +01:00
|
|
|
* Emits ENTITY_DELETED event
|
2025-11-18 16:37:33 +01:00
|
|
|
*
|
|
|
|
|
* @param id - Entity ID to delete
|
|
|
|
|
*/
|
|
|
|
|
async delete(id: string): Promise<void> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
|
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
|
const request = store.delete(id);
|
|
|
|
|
|
|
|
|
|
request.onsuccess = () => {
|
2025-11-21 23:23:04 +01:00
|
|
|
// Emit ENTITY_DELETED event
|
2025-11-21 23:33:48 +01:00
|
|
|
const payload: IEntityDeletedPayload = {
|
2025-11-21 23:23:04 +01:00
|
|
|
entityType: this.entityType,
|
|
|
|
|
entityId: id,
|
|
|
|
|
operation: 'delete',
|
|
|
|
|
timestamp: Date.now()
|
2025-11-21 23:33:48 +01:00
|
|
|
};
|
|
|
|
|
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
|
2025-11-18 16:37:33 +01:00
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onerror = () => {
|
|
|
|
|
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// SYNC METHODS (IEntityService implementation) - Delegates to SyncPlugin
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark entity as successfully synced (IEntityService implementation)
|
|
|
|
|
* Delegates to SyncPlugin
|
|
|
|
|
*
|
|
|
|
|
* @param id - Entity ID
|
|
|
|
|
*/
|
|
|
|
|
async markAsSynced(id: string): Promise<void> {
|
|
|
|
|
return this.syncPlugin.markAsSynced(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark entity as sync error (IEntityService implementation)
|
|
|
|
|
* Delegates to SyncPlugin
|
|
|
|
|
*
|
|
|
|
|
* @param id - Entity ID
|
|
|
|
|
*/
|
|
|
|
|
async markAsError(id: string): Promise<void> {
|
|
|
|
|
return this.syncPlugin.markAsError(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get sync status for an entity (IEntityService implementation)
|
|
|
|
|
* Delegates to SyncPlugin
|
|
|
|
|
*
|
|
|
|
|
* @param id - Entity ID
|
|
|
|
|
* @returns SyncStatus or null if entity not found
|
|
|
|
|
*/
|
|
|
|
|
async getSyncStatus(id: string): Promise<SyncStatus | null> {
|
|
|
|
|
return this.syncPlugin.getSyncStatus(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get entities by sync status
|
|
|
|
|
* Delegates to SyncPlugin - uses IndexedDB syncStatus index
|
|
|
|
|
*
|
|
|
|
|
* @param syncStatus - Sync status ('synced', 'pending', 'error')
|
|
|
|
|
* @returns Array of entities with this sync status
|
|
|
|
|
*/
|
|
|
|
|
async getBySyncStatus(syncStatus: string): Promise<T[]> {
|
|
|
|
|
return this.syncPlugin.getBySyncStatus(syncStatus);
|
|
|
|
|
}
|
|
|
|
|
}
|