Refactors calendar data management and sync infrastructure
Introduces comprehensive data management system for calendar V2 - Adds IndexedDB storage with pluggable entity services - Implements EventBus for decoupled event communication - Creates data seeding mechanism for initial application setup - Establishes sync and repository abstractions for flexible data handling
This commit is contained in:
parent
dee977d4df
commit
e581039b62
17 changed files with 1076 additions and 4 deletions
158
src/v2/storage/BaseEntityService.ts
Normal file
158
src/v2/storage/BaseEntityService.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
async save(entity: T): Promise<void> {
|
||||
const entityId = (entity as unknown as { id: string }).id;
|
||||
const existingEntity = await this.get(entityId);
|
||||
const isNew = existingEntity === null;
|
||||
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 = () => {
|
||||
const payload: IEntitySavedPayload = {
|
||||
entityType: this.entityType,
|
||||
entity,
|
||||
isNew
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entity
|
||||
*/
|
||||
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,
|
||||
id
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
38
src/v2/storage/IEntityService.ts
Normal file
38
src/v2/storage/IEntityService.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* IEntityService<T> - Generic interface for entity services with sync capabilities
|
||||
*
|
||||
* All entity services implement this interface to enable polymorphic operations.
|
||||
*/
|
||||
export interface IEntityService<T extends ISync> {
|
||||
/**
|
||||
* Entity type discriminator for runtime routing
|
||||
*/
|
||||
readonly entityType: EntityType;
|
||||
|
||||
/**
|
||||
* Get all entities from IndexedDB
|
||||
*/
|
||||
getAll(): Promise<T[]>;
|
||||
|
||||
/**
|
||||
* Save an entity (create or update) to IndexedDB
|
||||
*/
|
||||
save(entity: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark entity as successfully synced
|
||||
*/
|
||||
markAsSynced(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark entity as sync error
|
||||
*/
|
||||
markAsError(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get current sync status for an entity
|
||||
*/
|
||||
getSyncStatus(id: string): Promise<SyncStatus | null>;
|
||||
}
|
||||
18
src/v2/storage/IStore.ts
Normal file
18
src/v2/storage/IStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* IStore - Interface for IndexedDB ObjectStore definitions
|
||||
*
|
||||
* Each entity store implements this interface to define its schema.
|
||||
* Enables Open/Closed Principle: IndexedDBContext works with any IStore.
|
||||
*/
|
||||
export interface IStore {
|
||||
/**
|
||||
* The name of the ObjectStore in IndexedDB
|
||||
*/
|
||||
readonly storeName: string;
|
||||
|
||||
/**
|
||||
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
|
||||
* Called during database upgrade (onupgradeneeded event)
|
||||
*/
|
||||
create(db: IDBDatabase): void;
|
||||
}
|
||||
92
src/v2/storage/IndexedDBContext.ts
Normal file
92
src/v2/storage/IndexedDBContext.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { IStore } from './IStore';
|
||||
|
||||
/**
|
||||
* IndexedDBContext - Database connection manager
|
||||
*
|
||||
* RESPONSIBILITY:
|
||||
* - Opens and manages IDBDatabase connection lifecycle
|
||||
* - Creates object stores via injected IStore implementations
|
||||
* - Provides shared IDBDatabase instance to all services
|
||||
*/
|
||||
export class IndexedDBContext {
|
||||
private static readonly DB_NAME = 'CalendarV2DB';
|
||||
private static readonly DB_VERSION = 1;
|
||||
|
||||
private db: IDBDatabase | null = null;
|
||||
private initialized: boolean = false;
|
||||
private stores: IStore[];
|
||||
|
||||
constructor(stores: IStore[]) {
|
||||
this.stores = stores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and open the database
|
||||
*/
|
||||
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
|
||||
this.stores.forEach(store => {
|
||||
if (!db.objectStoreNames.contains(store.storeName)) {
|
||||
store.create(db);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is initialized
|
||||
*/
|
||||
public isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 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}`));
|
||||
});
|
||||
}
|
||||
}
|
||||
64
src/v2/storage/SyncPlugin.ts
Normal file
64
src/v2/storage/SyncPlugin.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { ISync, SyncStatus } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* SyncPlugin<T extends ISync> - Pluggable sync functionality for entity services
|
||||
*
|
||||
* COMPOSITION PATTERN:
|
||||
* - Encapsulates all sync-related logic in separate class
|
||||
* - Composed into BaseEntityService (not inheritance)
|
||||
*/
|
||||
export class SyncPlugin<T extends ISync> {
|
||||
constructor(private service: any) {}
|
||||
|
||||
/**
|
||||
* Mark entity as successfully synced
|
||||
*/
|
||||
async markAsSynced(id: string): Promise<void> {
|
||||
const entity = await this.service.get(id);
|
||||
if (entity) {
|
||||
entity.syncStatus = 'synced';
|
||||
await this.service.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entity as sync error
|
||||
*/
|
||||
async markAsError(id: string): Promise<void> {
|
||||
const entity = await this.service.get(id);
|
||||
if (entity) {
|
||||
entity.syncStatus = 'error';
|
||||
await this.service.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status for an entity
|
||||
*/
|
||||
async getSyncStatus(id: string): Promise<SyncStatus | null> {
|
||||
const entity = await this.service.get(id);
|
||||
return entity ? entity.syncStatus : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities by sync status using IndexedDB index
|
||||
*/
|
||||
async getBySyncStatus(syncStatus: string): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.service.db.transaction([this.service.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.service.storeName);
|
||||
const index = store.index('syncStatus');
|
||||
const request = index.getAll(syncStatus);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const data = request.result as unknown[];
|
||||
const entities = data.map(item => this.service.deserialize(item));
|
||||
resolve(entities);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get by sync status ${syncStatus}: ${request.error}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/v2/storage/events/EventSerialization.ts
Normal file
32
src/v2/storage/events/EventSerialization.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* EventSerialization - Handles Date field serialization for IndexedDB
|
||||
*
|
||||
* IndexedDB doesn't store Date objects directly, so we convert:
|
||||
* - Date → ISO string (serialize) when writing
|
||||
* - ISO string → Date (deserialize) when reading
|
||||
*/
|
||||
export class EventSerialization {
|
||||
/**
|
||||
* Serialize event for IndexedDB storage
|
||||
*/
|
||||
static serialize(event: ICalendarEvent): unknown {
|
||||
return {
|
||||
...event,
|
||||
start: event.start instanceof Date ? event.start.toISOString() : event.start,
|
||||
end: event.end instanceof Date ? event.end.toISOString() : event.end
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize event from IndexedDB storage
|
||||
*/
|
||||
static deserialize(data: Record<string, unknown>): ICalendarEvent {
|
||||
return {
|
||||
...data,
|
||||
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
|
||||
end: typeof data.end === 'string' ? new Date(data.end) : data.end
|
||||
} as ICalendarEvent;
|
||||
}
|
||||
}
|
||||
84
src/v2/storage/events/EventService.ts
Normal file
84
src/v2/storage/events/EventService.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||
import { EventStore } from './EventStore';
|
||||
import { EventSerialization } from './EventSerialization';
|
||||
import { BaseEntityService } from '../BaseEntityService';
|
||||
import { IndexedDBContext } from '../IndexedDBContext';
|
||||
|
||||
/**
|
||||
* EventService - CRUD operations for calendar events in IndexedDB
|
||||
*
|
||||
* Extends BaseEntityService for shared CRUD and sync logic.
|
||||
* Provides event-specific query methods.
|
||||
*/
|
||||
export class EventService extends BaseEntityService<ICalendarEvent> {
|
||||
readonly storeName = EventStore.STORE_NAME;
|
||||
readonly entityType: EntityType = 'Event';
|
||||
|
||||
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||
super(context, eventBus);
|
||||
}
|
||||
|
||||
protected serialize(event: ICalendarEvent): unknown {
|
||||
return EventSerialization.serialize(event);
|
||||
}
|
||||
|
||||
protected deserialize(data: unknown): ICalendarEvent {
|
||||
return EventSerialization.deserialize(data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events within a date range
|
||||
*/
|
||||
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index('start');
|
||||
|
||||
const range = IDBKeyRange.lowerBound(start.toISOString());
|
||||
const request = index.getAll(range);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const data = request.result as unknown[];
|
||||
const events = data
|
||||
.map(item => this.deserialize(item))
|
||||
.filter(event => event.start <= end);
|
||||
resolve(events);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get events by date range: ${request.error}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific resource
|
||||
*/
|
||||
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index('resourceId');
|
||||
const request = index.getAll(resourceId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const data = request.result as unknown[];
|
||||
const events = data.map(item => this.deserialize(item));
|
||||
resolve(events);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a resource within a date range
|
||||
*/
|
||||
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
|
||||
const resourceEvents = await this.getByResource(resourceId);
|
||||
return resourceEvents.filter(event => event.start >= start && event.start <= end);
|
||||
}
|
||||
}
|
||||
37
src/v2/storage/events/EventStore.ts
Normal file
37
src/v2/storage/events/EventStore.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { IStore } from '../IStore';
|
||||
|
||||
/**
|
||||
* EventStore - IndexedDB ObjectStore definition for calendar events
|
||||
*/
|
||||
export class EventStore implements IStore {
|
||||
static readonly STORE_NAME = 'events';
|
||||
readonly storeName = EventStore.STORE_NAME;
|
||||
|
||||
/**
|
||||
* Create the events ObjectStore with indexes
|
||||
*/
|
||||
create(db: IDBDatabase): void {
|
||||
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
|
||||
|
||||
// Index: start (for date range queries)
|
||||
store.createIndex('start', 'start', { unique: false });
|
||||
|
||||
// Index: end (for date range queries)
|
||||
store.createIndex('end', 'end', { unique: false });
|
||||
|
||||
// Index: syncStatus (for filtering by sync state)
|
||||
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||
|
||||
// Index: resourceId (for resource-mode filtering)
|
||||
store.createIndex('resourceId', 'resourceId', { unique: false });
|
||||
|
||||
// Index: customerId (for customer-centric queries)
|
||||
store.createIndex('customerId', 'customerId', { unique: false });
|
||||
|
||||
// Index: bookingId (for event-to-booking lookups)
|
||||
store.createIndex('bookingId', 'bookingId', { unique: false });
|
||||
|
||||
// Compound index: startEnd (for optimized range queries)
|
||||
store.createIndex('startEnd', ['start', 'end'], { unique: false });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue