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:
parent
5648c7c304
commit
dcd76836bd
10 changed files with 1260 additions and 574 deletions
13
src/index.ts
13
src/index.ts
|
|
@ -23,18 +23,16 @@ import { HeaderManager } from './managers/HeaderManager';
|
|||
import { WorkweekPresets } from './components/WorkweekPresets';
|
||||
|
||||
// Import repositories and storage
|
||||
import { IEventRepository } from './repositories/IEventRepository';
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
|
||||
import { IApiRepository } from './repositories/IApiRepository';
|
||||
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
||||
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
|
||||
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
|
||||
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
|
||||
import { IndexedDBService } from './storage/IndexedDBService';
|
||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||
import { OperationQueue } from './storage/OperationQueue';
|
||||
import { IStore } from './storage/IStore';
|
||||
import { BookingStore } from './storage/bookings/BookingStore';
|
||||
|
|
@ -126,7 +124,7 @@ async function initializeCalendar(): Promise<void> {
|
|||
|
||||
|
||||
// Register storage and repository services
|
||||
builder.registerType(IndexedDBService).as<IndexedDBService>();
|
||||
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
||||
builder.registerType(OperationQueue).as<OperationQueue>();
|
||||
|
||||
// Register Mock repositories (development/testing - load from JSON files)
|
||||
|
|
@ -144,9 +142,6 @@ async function initializeCalendar(): Promise<void> {
|
|||
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||
|
||||
// Register IndexedDB repositories (offline-first)
|
||||
builder.registerType(IndexedDBEventRepository).as<IEventRepository>();
|
||||
|
||||
// Register workers
|
||||
builder.registerType(SyncManager).as<SyncManager>();
|
||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
|
@ -190,8 +185,8 @@ async function initializeCalendar(): Promise<void> {
|
|||
const app = builder.build();
|
||||
|
||||
// Initialize database and seed data BEFORE initializing managers
|
||||
const indexedDBService = app.resolveType<IndexedDBService>();
|
||||
await indexedDBService.initialize();
|
||||
const indexedDBContext = app.resolveType<IndexedDBContext>();
|
||||
await indexedDBContext.initialize();
|
||||
|
||||
const dataSeeder = app.resolveType<DataSeeder>();
|
||||
await dataSeeder.seedIfEmpty();
|
||||
|
|
|
|||
|
|
@ -2,38 +2,39 @@ import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
|
|||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { IEventRepository } from '../repositories/IEventRepository';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
|
||||
/**
|
||||
* EventManager - Event lifecycle and CRUD operations
|
||||
* Delegates all data operations to IEventRepository
|
||||
* No longer maintains in-memory cache - repository is single source of truth
|
||||
* Delegates all data operations to EventService
|
||||
* EventService provides CRUD operations via BaseEntityService (save, delete, getAll)
|
||||
*/
|
||||
export class EventManager {
|
||||
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private repository: IEventRepository;
|
||||
private eventService: EventService;
|
||||
|
||||
constructor(
|
||||
private eventBus: IEventBus,
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
repository: IEventRepository
|
||||
eventService: IEntityService<ICalendarEvent>
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.repository = repository;
|
||||
this.eventService = eventService as EventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load event data from repository
|
||||
* No longer caches - delegates to repository
|
||||
* Load event data from service
|
||||
* Ensures data is loaded (called during initialization)
|
||||
*/
|
||||
public async loadData(): Promise<void> {
|
||||
try {
|
||||
// Just ensure repository is ready - no caching
|
||||
await this.repository.loadEvents();
|
||||
// Just ensure service is ready - getAll() will return data
|
||||
await this.eventService.getAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
throw error;
|
||||
|
|
@ -41,19 +42,19 @@ export class EventManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all events from repository
|
||||
* Get all events from service
|
||||
*/
|
||||
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
|
||||
const events = await this.repository.loadEvents();
|
||||
const events = await this.eventService.getAll();
|
||||
return copy ? [...events] : events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID from repository
|
||||
* Get event by ID from service
|
||||
*/
|
||||
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
|
||||
const events = await this.repository.loadEvents();
|
||||
return events.find(event => event.id === id);
|
||||
const event = await this.eventService.get(id);
|
||||
return event || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,7 +117,7 @@ export class EventManager {
|
|||
* Get events that overlap with a given time period
|
||||
*/
|
||||
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
|
||||
const events = await this.repository.loadEvents();
|
||||
const events = await this.eventService.getAll();
|
||||
// Event overlaps period if it starts before period ends AND ends after period starts
|
||||
return events.filter(event => {
|
||||
return event.start <= endDate && event.end >= startDate;
|
||||
|
|
@ -125,10 +126,19 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Create a new event and add it to the calendar
|
||||
* Delegates to repository with source='local'
|
||||
* Generates ID and saves via EventService
|
||||
*/
|
||||
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
|
||||
const newEvent = await this.repository.createEvent(event, 'local');
|
||||
// Generate unique ID
|
||||
const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const newEvent: ICalendarEvent = {
|
||||
...event,
|
||||
id,
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(newEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
||||
event: newEvent
|
||||
|
|
@ -139,11 +149,23 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Update an existing event
|
||||
* Delegates to repository with source='local'
|
||||
* Merges updates with existing event and saves
|
||||
*/
|
||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
|
||||
try {
|
||||
const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...existingEvent,
|
||||
...updates,
|
||||
id, // Ensure ID doesn't change
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
|
|
@ -158,11 +180,11 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Delete an event
|
||||
* Delegates to repository with source='local'
|
||||
* Calls EventService.delete()
|
||||
*/
|
||||
public async deleteEvent(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.repository.deleteEvent(id, 'local');
|
||||
await this.eventService.delete(id);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
|
||||
eventId: id
|
||||
|
|
@ -177,18 +199,24 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Handle remote update from SignalR
|
||||
* Delegates to repository with source='remote'
|
||||
* Saves remote event directly (no queue logic yet)
|
||||
*/
|
||||
public async handleRemoteUpdate(event: ICalendarEvent): Promise<void> {
|
||||
try {
|
||||
await this.repository.updateEvent(event.id, event, 'remote');
|
||||
// Mark as synced since it comes from remote
|
||||
const remoteEvent: ICalendarEvent = {
|
||||
...event,
|
||||
syncStatus: 'synced'
|
||||
};
|
||||
|
||||
await this.eventService.save(remoteEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
|
||||
event
|
||||
event: remoteEvent
|
||||
});
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event
|
||||
event: remoteEvent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle remote update for event ${event.id}:`, error);
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* Update source type
|
||||
* - 'local': Changes made by the user locally (needs sync)
|
||||
* - 'remote': Changes from API/SignalR (already synced)
|
||||
*/
|
||||
export type UpdateSource = 'local' | 'remote';
|
||||
|
||||
/**
|
||||
* IEventRepository - Interface for event data access
|
||||
*
|
||||
* Abstracts the data source for calendar events, allowing easy switching
|
||||
* between IndexedDB, REST API, GraphQL, or other data sources.
|
||||
*
|
||||
* Implementations:
|
||||
* - IndexedDBEventRepository: Local storage with offline support
|
||||
* - MockEventRepository: (Legacy) Loads from local JSON file
|
||||
* - ApiEventRepository: (Future) Loads from backend API
|
||||
*/
|
||||
export interface IEventRepository {
|
||||
/**
|
||||
* Load all calendar events from the data source
|
||||
* @returns Promise resolving to array of ICalendarEvent objects
|
||||
* @throws Error if loading fails
|
||||
*/
|
||||
loadEvents(): Promise<ICalendarEvent[]>;
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* @param event - Event to create (without ID, will be generated)
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving to the created event with generated ID
|
||||
* @throws Error if creation fails
|
||||
*/
|
||||
createEvent(event: Omit<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent>;
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* @param id - ID of the event to update
|
||||
* @param updates - Partial event data to update
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving to the updated event
|
||||
* @throws Error if update fails or event not found
|
||||
*/
|
||||
updateEvent(id: string, updates: Partial<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent>;
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* @param id - ID of the event to delete
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving when deletion is complete
|
||||
* @throws Error if deletion fails or event not found
|
||||
*/
|
||||
deleteEvent(id: string, source?: UpdateSource): Promise<void>;
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { IEventRepository, UpdateSource } from './IEventRepository';
|
||||
import { IndexedDBService } from '../storage/IndexedDBService';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { OperationQueue } from '../storage/OperationQueue';
|
||||
|
||||
/**
|
||||
* IndexedDBEventRepository
|
||||
* Offline-first repository using IndexedDB as single source of truth
|
||||
*
|
||||
* All CRUD operations:
|
||||
* - Save to IndexedDB immediately via EventService (always succeeds)
|
||||
* - Add to sync queue if source is 'local'
|
||||
* - Background SyncManager processes queue to sync with API
|
||||
*/
|
||||
export class IndexedDBEventRepository implements IEventRepository {
|
||||
private indexedDB: IndexedDBService;
|
||||
private eventService: EventService;
|
||||
private queue: OperationQueue;
|
||||
|
||||
constructor(indexedDB: IndexedDBService, queue: OperationQueue) {
|
||||
this.indexedDB = indexedDB;
|
||||
this.queue = queue;
|
||||
// EventService will be initialized after IndexedDB is ready
|
||||
this.eventService = null as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure EventService is initialized with database connection
|
||||
*/
|
||||
private ensureEventService(): void {
|
||||
if (!this.eventService && this.indexedDB.isInitialized()) {
|
||||
const db = (this.indexedDB as any).db; // Access private db property
|
||||
this.eventService = new EventService(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all events from IndexedDB
|
||||
* Ensures IndexedDB is initialized on first call
|
||||
*/
|
||||
async loadEvents(): Promise<ICalendarEvent[]> {
|
||||
// Lazy initialization on first data load
|
||||
if (!this.indexedDB.isInitialized()) {
|
||||
await this.indexedDB.initialize();
|
||||
// TODO: Seeding should be done at application level, not here
|
||||
}
|
||||
|
||||
this.ensureEventService();
|
||||
return await this.eventService.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* - Generates ID
|
||||
* - Saves to IndexedDB
|
||||
* - Adds to queue if local (needs sync)
|
||||
*/
|
||||
async createEvent(event: Omit<ICalendarEvent, 'id'>, source: UpdateSource = 'local'): Promise<ICalendarEvent> {
|
||||
// Generate unique ID
|
||||
const id = this.generateEventId();
|
||||
|
||||
// Determine sync status based on source
|
||||
const syncStatus = source === 'local' ? 'pending' : 'synced';
|
||||
|
||||
// Create full event object
|
||||
const newEvent: ICalendarEvent = {
|
||||
...event,
|
||||
id,
|
||||
syncStatus
|
||||
} as ICalendarEvent;
|
||||
|
||||
// Save to IndexedDB via EventService
|
||||
this.ensureEventService();
|
||||
await this.eventService.save(newEvent);
|
||||
|
||||
// If local change, add to sync queue
|
||||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'create',
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: newEvent
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* - Updates in IndexedDB
|
||||
* - Adds to queue if local (needs sync)
|
||||
*/
|
||||
async updateEvent(id: string, updates: Partial<ICalendarEvent>, source: UpdateSource = 'local'): Promise<ICalendarEvent> {
|
||||
// Get existing event via EventService
|
||||
this.ensureEventService();
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Determine sync status based on source
|
||||
const syncStatus = source === 'local' ? 'pending' : 'synced';
|
||||
|
||||
// Merge updates
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...existingEvent,
|
||||
...updates,
|
||||
id, // Ensure ID doesn't change
|
||||
syncStatus
|
||||
};
|
||||
|
||||
// Save to IndexedDB via EventService
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// If local change, add to sync queue
|
||||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'update',
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: updates
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* - Removes from IndexedDB
|
||||
* - Adds to queue if local (needs sync)
|
||||
*/
|
||||
async deleteEvent(id: string, source: UpdateSource = 'local'): Promise<void> {
|
||||
// Check if event exists via EventService
|
||||
this.ensureEventService();
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// If local change, add to sync queue BEFORE deleting
|
||||
// (so we can send the delete operation to API later)
|
||||
if (source === 'local') {
|
||||
await this.queue.enqueue({
|
||||
type: 'delete',
|
||||
entityId: id,
|
||||
dataEntity: {
|
||||
typename: 'Event',
|
||||
data: { id } // Minimal data for delete - just ID
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from IndexedDB via EventService
|
||||
await this.eventService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique event ID
|
||||
* Format: {timestamp}-{random}
|
||||
*/
|
||||
private generateEventId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 9);
|
||||
return `${timestamp}-${random}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
128
src/storage/IndexedDBContext.ts
Normal file
128
src/storage/IndexedDBContext.ts
Normal 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}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { IEventBus, EntityType, ISync } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { OperationQueue } from '../storage/OperationQueue';
|
||||
import { IQueueOperation } from '../storage/IndexedDBService';
|
||||
import { IndexedDBService } from '../storage/IndexedDBService';
|
||||
import { OperationQueue, IQueueOperation } from '../storage/OperationQueue';
|
||||
import { IApiRepository } from '../repositories/IApiRepository';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
|
||||
|
|
@ -33,7 +31,6 @@ import { IEntityService } from '../storage/IEntityService';
|
|||
export class SyncManager {
|
||||
private eventBus: IEventBus;
|
||||
private queue: OperationQueue;
|
||||
private indexedDB: IndexedDBService;
|
||||
private repositories: Map<EntityType, IApiRepository<any>>;
|
||||
private entityServices: IEntityService<any>[];
|
||||
|
||||
|
|
@ -46,13 +43,11 @@ export class SyncManager {
|
|||
constructor(
|
||||
eventBus: IEventBus,
|
||||
queue: OperationQueue,
|
||||
indexedDB: IndexedDBService,
|
||||
apiRepositories: IApiRepository<any>[],
|
||||
entityServices: IEntityService<any>[]
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.queue = queue;
|
||||
this.indexedDB = indexedDB;
|
||||
this.entityServices = entityServices;
|
||||
|
||||
// Build map: EntityType → IApiRepository
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue