Refactors repository layer and IndexedDB architecture

Eliminates redundant repository abstraction layer by directly using EntityService methods

Implements key improvements:
- Removes unnecessary repository wrappers
- Introduces polymorphic DataSeeder for mock data loading
- Renames IndexedDBService to IndexedDBContext
- Fixes database injection timing with lazy access pattern
- Simplifies EventManager to use services directly

Reduces code complexity and improves separation of concerns
This commit is contained in:
Janus C. H. Knudsen 2025-11-20 21:45:09 +01:00
parent 5648c7c304
commit dcd76836bd
10 changed files with 1260 additions and 574 deletions

View file

@ -1,6 +1,7 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
@ -13,6 +14,7 @@ import { SyncPlugin } from './SyncPlugin';
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
* - Lazy database access via IndexedDBContext
*
* SUBCLASSES MUST IMPLEMENT:
* - storeName: string (IndexedDB object store name)
@ -27,6 +29,7 @@ import { SyncPlugin } from './SyncPlugin';
* - 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
* - Lazy database access: db requested when needed, not at construction time
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
// Abstract properties - must be implemented by subclasses
@ -36,17 +39,25 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
// Internal composition - sync functionality
private syncPlugin: SyncPlugin<T>;
// Protected database instance - accessible to subclasses
protected db: IDBDatabase;
// IndexedDB context - provides database connection
private context: IndexedDBContext;
/**
* @param db - IDBDatabase instance (injected dependency)
* @param context - IndexedDBContext instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
constructor(context: IndexedDBContext) {
this.context = context;
this.syncPlugin = new SyncPlugin<T>(this);
}
/**
* Get IDBDatabase instance (lazy access)
* Protected getter accessible to subclasses and methods in this class
*/
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Serialize entity before storing in IndexedDB
* Override in subclass if entity has Date fields or needs transformation

View file

@ -0,0 +1,128 @@
import { IStore } from './IStore';
/**
* IndexedDBContext - Database connection manager and provider
*
* RESPONSIBILITY:
* - Opens and manages IDBDatabase connection lifecycle
* - Creates object stores via injected IStore implementations
* - Provides shared IDBDatabase instance to all services
*
* SEPARATION OF CONCERNS:
* - This class: Connection management ONLY
* - OperationQueue: Queue and sync state operations
* - Entity Services: CRUD operations for specific entities
*
* USAGE:
* Services inject IndexedDBContext and call getDatabase() to access db.
* This lazy access pattern ensures db is ready when requested.
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 2;
static readonly QUEUE_STORE = 'operationQueue';
static readonly SYNC_STATE_STORE = 'syncState';
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
/**
* @param stores - Array of IStore implementations injected via DI
*/
constructor(stores: IStore[]) {
this.stores = stores;
}
/**
* Initialize and open the database
* Creates all entity stores, queue store, and sync state store
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
// Open/Closed Principle: Adding new entity only requires DI registration
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
// Create operation queue store (sync infrastructure)
if (!db.objectStoreNames.contains(IndexedDBContext.QUEUE_STORE)) {
const queueStore = db.createObjectStore(IndexedDBContext.QUEUE_STORE, { keyPath: 'id' });
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Create sync state store (sync metadata)
if (!db.objectStoreNames.contains(IndexedDBContext.SYNC_STATE_STORE)) {
db.createObjectStore(IndexedDBContext.SYNC_STATE_STORE, { keyPath: 'key' });
}
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Get IDBDatabase instance
* Used by services to access the database
*
* @throws Error if database not initialized
* @returns IDBDatabase instance
*/
public getDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.initialized = false;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete database: ${request.error}`));
};
});
}
}

View file

@ -1,277 +0,0 @@
import { IDataEntity } from '../types/CalendarTypes';
import { IStore } from './IStore';
/**
* Operation for the sync queue
* Generic structure supporting all entity types (Event, Booking, Customer, Resource)
*/
export interface IQueueOperation {
id: string;
type: 'create' | 'update' | 'delete';
entityId: string;
dataEntity: IDataEntity;
timestamp: number;
retryCount: number;
}
/**
* IndexedDB Service for Calendar App
* Handles database connection management and core operations
*
* Entity-specific CRUD operations are handled by specialized services:
* - EventService for calendar events
* - BookingService for bookings
* - CustomerService for customers
* - ResourceService for resources
*/
export class IndexedDBService {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 2;
private static readonly QUEUE_STORE = 'operationQueue';
private static readonly SYNC_STATE_STORE = 'syncState';
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
/**
* @param stores - Array of IStore implementations injected via DI
*/
constructor(stores: IStore[]) {
this.stores = stores;
}
/**
* Initialize and open the database
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IndexedDBService.DB_NAME, IndexedDBService.DB_VERSION);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
// Open/Closed Principle: Adding new entity only requires DI registration
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
// Create operation queue store (sync infrastructure)
if (!db.objectStoreNames.contains(IndexedDBService.QUEUE_STORE)) {
const queueStore = db.createObjectStore(IndexedDBService.QUEUE_STORE, { keyPath: 'id' });
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Create sync state store (sync metadata)
if (!db.objectStoreNames.contains(IndexedDBService.SYNC_STATE_STORE)) {
db.createObjectStore(IndexedDBService.SYNC_STATE_STORE, { keyPath: 'key' });
}
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Ensure database is initialized
*/
private ensureDB(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
// ========================================
// Event CRUD Operations - MOVED TO EventService
// ========================================
// Event operations have been moved to storage/events/EventService.ts
// for better modularity and separation of concerns.
// ========================================
// Queue Operations
// ========================================
/**
* Add operation to queue
*/
async addToQueue(operation: Omit<IQueueOperation, 'id'>): Promise<void> {
const db = this.ensureDB();
const queueItem: IQueueOperation = {
...operation,
id: `${operation.type}-${operation.entityId}-${Date.now()}`
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.put(queueItem);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to add to queue: ${request.error}`));
};
});
}
/**
* Get all queue operations (sorted by timestamp)
*/
async getQueue(): Promise<IQueueOperation[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const index = store.index('timestamp');
const request = index.getAll();
request.onsuccess = () => {
resolve(request.result as IQueueOperation[]);
};
request.onerror = () => {
reject(new Error(`Failed to get queue: ${request.error}`));
};
});
}
/**
* Remove operation from queue
*/
async removeFromQueue(id: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to remove from queue: ${request.error}`));
};
});
}
/**
* Clear entire queue
*/
async clearQueue(): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to clear queue: ${request.error}`));
};
});
}
// ========================================
// Sync State Operations
// ========================================
/**
* Save sync state value
*/
async setSyncState(key: string, value: any): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to set sync state ${key}: ${request.error}`));
};
});
}
/**
* Get sync state value
*/
async getSyncState(key: string): Promise<any | null> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => {
reject(new Error(`Failed to get sync state ${key}: ${request.error}`));
};
});
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(IndexedDBService.DB_NAME);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete database: ${request.error}`));
};
});
}
// ========================================
// Seeding - REMOVED
// ========================================
// seedIfEmpty() has been removed.
// Seeding should be implemented at application level using EventService,
// BookingService, CustomerService, and ResourceService directly.
}

View file

@ -1,21 +1,88 @@
import { IndexedDBService, IQueueOperation } from './IndexedDBService';
import { IndexedDBContext } from './IndexedDBContext';
import { IDataEntity } from '../types/CalendarTypes';
/**
* Operation for the sync queue
* Generic structure supporting all entity types (Event, Booking, Customer, Resource)
*/
export interface IQueueOperation {
id: string;
type: 'create' | 'update' | 'delete';
entityId: string;
dataEntity: IDataEntity;
timestamp: number;
retryCount: number;
}
/**
* Operation Queue Manager
* Handles FIFO queue of pending sync operations
* Handles FIFO queue of pending sync operations and sync state metadata
*
* RESPONSIBILITY:
* - Queue operations (enqueue, dequeue, peek, clear)
* - Sync state management (setSyncState, getSyncState)
* - Direct IndexedDB operations on queue and syncState stores
*
* ARCHITECTURE:
* - Moved from IndexedDBService to achieve better separation of concerns
* - IndexedDBContext provides database connection
* - OperationQueue owns queue business logic
*/
export class OperationQueue {
private indexedDB: IndexedDBService;
private context: IndexedDBContext;
constructor(indexedDB: IndexedDBService) {
this.indexedDB = indexedDB;
constructor(context: IndexedDBContext) {
this.context = context;
}
// ========================================
// Queue Operations
// ========================================
/**
* Add operation to the end of the queue
*/
async enqueue(operation: Omit<IQueueOperation, 'id'>): Promise<void> {
await this.indexedDB.addToQueue(operation);
const db = this.context.getDatabase();
const queueItem: IQueueOperation = {
...operation,
id: `${operation.type}-${operation.entityId}-${Date.now()}`
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE);
const request = store.put(queueItem);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to add to queue: ${request.error}`));
};
});
}
/**
* Get all operations in the queue (sorted by timestamp FIFO)
*/
async getAll(): Promise<IQueueOperation[]> {
const db = this.context.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE);
const index = store.index('timestamp');
const request = index.getAll();
request.onsuccess = () => {
resolve(request.result as IQueueOperation[]);
};
request.onerror = () => {
reject(new Error(`Failed to get queue: ${request.error}`));
};
});
}
/**
@ -23,22 +90,28 @@ export class OperationQueue {
* Returns null if queue is empty
*/
async peek(): Promise<IQueueOperation | null> {
const queue = await this.indexedDB.getQueue();
const queue = await this.getAll();
return queue.length > 0 ? queue[0] : null;
}
/**
* Get all operations in the queue (sorted by timestamp FIFO)
*/
async getAll(): Promise<IQueueOperation[]> {
return await this.indexedDB.getQueue();
}
/**
* Remove a specific operation from the queue
*/
async remove(operationId: string): Promise<void> {
await this.indexedDB.removeFromQueue(operationId);
const db = this.context.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE);
const request = store.delete(operationId);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to remove from queue: ${request.error}`));
};
});
}
/**
@ -57,7 +130,20 @@ export class OperationQueue {
* Clear all operations from the queue
*/
async clear(): Promise<void> {
await this.indexedDB.clearQueue();
const db = this.context.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE);
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to clear queue: ${request.error}`));
};
});
}
/**
@ -122,4 +208,56 @@ export class OperationQueue {
await this.enqueue(operation);
}
}
// ========================================
// Sync State Operations
// ========================================
/**
* Save sync state value
* Used to store sync metadata like lastSyncTime, etc.
*
* @param key - State key
* @param value - State value (any serializable data)
*/
async setSyncState(key: string, value: any): Promise<void> {
const db = this.context.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.SYNC_STATE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBContext.SYNC_STATE_STORE);
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to set sync state ${key}: ${request.error}`));
};
});
}
/**
* Get sync state value
*
* @param key - State key
* @returns State value or null if not found
*/
async getSyncState(key: string): Promise<any | null> {
const db = this.context.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBContext.SYNC_STATE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBContext.SYNC_STATE_STORE);
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => {
reject(new Error(`Failed to get sync state ${key}: ${request.error}`));
};
});
}
}