Calendar/packages/calendar/src/storage/BaseEntityService.ts
Janus C. H. Knudsen ceb44446f0 Sets up calendar package with core infrastructure
Adds core calendar package components including:
- Base services for events, resources, and settings
- Calendar app and orchestrator
- Build and bundling configuration
- IndexedDB storage setup

Prepares foundational architecture for calendar functionality
2026-01-28 15:24:03 +01:00

181 lines
5.5 KiB
TypeScript

import { ISync, EntityType, SyncStatus, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
private syncPlugin: SyncPlugin<T>;
private context: IndexedDBContext;
protected eventBus: IEventBus;
constructor(context: IndexedDBContext, eventBus: IEventBus) {
this.context = context;
this.eventBus = eventBus;
this.syncPlugin = new SyncPlugin<T>(this);
}
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Serialize entity before storing in IndexedDB
*/
protected serialize(entity: T): unknown {
return entity;
}
/**
* Deserialize data from IndexedDB back to entity
*/
protected deserialize(data: unknown): T {
return data as T;
}
/**
* Get a single entity by ID
*/
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;
resolve(data ? this.deserialize(data) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get 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 unknown[];
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)
* Emits ENTITY_SAVED event with operation type and changes (diff for updates)
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: unknown;
if (isCreate) {
changes = entity;
} else {
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
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 = () => {
// Only emit event if not silent (silent used for seeding)
if (!silent) {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
}
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
};
});
}
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
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 = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// Sync methods - delegate to SyncPlugin
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}