425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { logger } from './logging.js';
|
|
import type { serviceworker } from '../dist_ts_interfaces/index.js';
|
|
import { getServiceWorkerBackend } from './init.js';
|
|
|
|
type ICumulativeMetrics = serviceworker.ICumulativeMetrics;
|
|
type IEventLogEntry = serviceworker.IEventLogEntry;
|
|
type TEventType = serviceworker.TEventType;
|
|
|
|
/**
|
|
* Generates a simple UUID
|
|
*/
|
|
function generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Default cumulative metrics
|
|
*/
|
|
function createDefaultMetrics(): ICumulativeMetrics {
|
|
return {
|
|
firstSeenTimestamp: Date.now(),
|
|
totalCacheHits: 0,
|
|
totalCacheMisses: 0,
|
|
totalCacheErrors: 0,
|
|
totalBytesServedFromCache: 0,
|
|
totalBytesFetched: 0,
|
|
totalNetworkRequests: 0,
|
|
totalNetworkSuccesses: 0,
|
|
totalNetworkFailures: 0,
|
|
totalNetworkTimeouts: 0,
|
|
totalBytesTransferred: 0,
|
|
totalUpdateChecks: 0,
|
|
totalUpdatesApplied: 0,
|
|
totalSpeedtests: 0,
|
|
swRestartCount: 0,
|
|
lastUpdatedTimestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* PersistentStore manages persistent data for the service worker:
|
|
* - Cumulative metrics: Persist across SW restarts, reset on cache invalidation
|
|
* - Event log: Persists across SW restarts AND cache invalidation
|
|
*/
|
|
export class PersistentStore {
|
|
private static instance: PersistentStore;
|
|
private store: plugins.webstore.WebStore;
|
|
private initialized = false;
|
|
|
|
// Storage keys
|
|
private readonly CUMULATIVE_KEY = 'metrics_cumulative';
|
|
private readonly EVENT_LOG_KEY = 'event_log';
|
|
|
|
// Retention settings
|
|
private readonly MAX_EVENTS = 10000;
|
|
private readonly MAX_AGE_DAYS = 30;
|
|
private readonly MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
|
|
|
|
// Save interval (60 seconds)
|
|
private readonly SAVE_INTERVAL_MS = 60000;
|
|
private saveInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// In-memory cache for cumulative metrics
|
|
private cumulativeMetrics: ICumulativeMetrics | null = null;
|
|
private isDirty = false;
|
|
|
|
private constructor() {
|
|
this.store = new plugins.webstore.WebStore({
|
|
dbName: 'losslessServiceworkerPersistent',
|
|
storeName: 'persistentStore',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the singleton instance
|
|
*/
|
|
public static getInstance(): PersistentStore {
|
|
if (!PersistentStore.instance) {
|
|
PersistentStore.instance = new PersistentStore();
|
|
}
|
|
return PersistentStore.instance;
|
|
}
|
|
|
|
/**
|
|
* Initializes the store and starts periodic saving
|
|
*/
|
|
public async init(): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Initialize the WebStore (required before using any methods)
|
|
await this.store.init();
|
|
await this.loadCumulativeMetrics();
|
|
|
|
// Increment restart count
|
|
if (this.cumulativeMetrics) {
|
|
this.cumulativeMetrics.swRestartCount++;
|
|
this.isDirty = true;
|
|
await this.saveCumulativeMetrics();
|
|
}
|
|
|
|
// Start periodic save
|
|
this.startPeriodicSave();
|
|
|
|
this.initialized = true;
|
|
logger.log('ok', '[PersistentStore] Initialized successfully');
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to initialize: ${error}`);
|
|
// Don't throw - allow SW to continue even if persistent store fails
|
|
this.initialized = true; // Mark as initialized to prevent retry loops
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts periodic saving of metrics
|
|
*/
|
|
private startPeriodicSave(): void {
|
|
if (this.saveInterval) {
|
|
clearInterval(this.saveInterval);
|
|
}
|
|
|
|
this.saveInterval = setInterval(async () => {
|
|
if (this.isDirty) {
|
|
await this.saveCumulativeMetrics();
|
|
}
|
|
}, this.SAVE_INTERVAL_MS);
|
|
}
|
|
|
|
/**
|
|
* Stops periodic saving
|
|
*/
|
|
public stopPeriodicSave(): void {
|
|
if (this.saveInterval) {
|
|
clearInterval(this.saveInterval);
|
|
this.saveInterval = null;
|
|
}
|
|
}
|
|
|
|
// ===================
|
|
// Cumulative Metrics
|
|
// ===================
|
|
|
|
/**
|
|
* Loads cumulative metrics from store
|
|
*/
|
|
public async loadCumulativeMetrics(): Promise<ICumulativeMetrics> {
|
|
try {
|
|
if (await this.store.check(this.CUMULATIVE_KEY)) {
|
|
this.cumulativeMetrics = await this.store.get(this.CUMULATIVE_KEY);
|
|
} else {
|
|
this.cumulativeMetrics = createDefaultMetrics();
|
|
this.isDirty = true;
|
|
}
|
|
} catch (error) {
|
|
logger.log('warn', `[PersistentStore] Failed to load metrics: ${error}`);
|
|
this.cumulativeMetrics = createDefaultMetrics();
|
|
this.isDirty = true;
|
|
}
|
|
|
|
return this.cumulativeMetrics!;
|
|
}
|
|
|
|
/**
|
|
* Saves cumulative metrics to store
|
|
*/
|
|
public async saveCumulativeMetrics(): Promise<void> {
|
|
if (!this.cumulativeMetrics) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.cumulativeMetrics.lastUpdatedTimestamp = Date.now();
|
|
await this.store.set(this.CUMULATIVE_KEY, this.cumulativeMetrics);
|
|
this.isDirty = false;
|
|
logger.log('note', '[PersistentStore] Cumulative metrics saved');
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to save metrics: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the current cumulative metrics
|
|
*/
|
|
public getCumulativeMetrics(): ICumulativeMetrics {
|
|
if (!this.cumulativeMetrics) {
|
|
return createDefaultMetrics();
|
|
}
|
|
return { ...this.cumulativeMetrics };
|
|
}
|
|
|
|
/**
|
|
* Updates cumulative metrics with session delta
|
|
*/
|
|
public updateCumulativeMetrics(delta: Partial<ICumulativeMetrics>): void {
|
|
if (!this.cumulativeMetrics) {
|
|
this.cumulativeMetrics = createDefaultMetrics();
|
|
}
|
|
|
|
// Add delta values to cumulative
|
|
if (delta.totalCacheHits !== undefined) {
|
|
this.cumulativeMetrics.totalCacheHits += delta.totalCacheHits;
|
|
}
|
|
if (delta.totalCacheMisses !== undefined) {
|
|
this.cumulativeMetrics.totalCacheMisses += delta.totalCacheMisses;
|
|
}
|
|
if (delta.totalCacheErrors !== undefined) {
|
|
this.cumulativeMetrics.totalCacheErrors += delta.totalCacheErrors;
|
|
}
|
|
if (delta.totalBytesServedFromCache !== undefined) {
|
|
this.cumulativeMetrics.totalBytesServedFromCache += delta.totalBytesServedFromCache;
|
|
}
|
|
if (delta.totalBytesFetched !== undefined) {
|
|
this.cumulativeMetrics.totalBytesFetched += delta.totalBytesFetched;
|
|
}
|
|
if (delta.totalNetworkRequests !== undefined) {
|
|
this.cumulativeMetrics.totalNetworkRequests += delta.totalNetworkRequests;
|
|
}
|
|
if (delta.totalNetworkSuccesses !== undefined) {
|
|
this.cumulativeMetrics.totalNetworkSuccesses += delta.totalNetworkSuccesses;
|
|
}
|
|
if (delta.totalNetworkFailures !== undefined) {
|
|
this.cumulativeMetrics.totalNetworkFailures += delta.totalNetworkFailures;
|
|
}
|
|
if (delta.totalNetworkTimeouts !== undefined) {
|
|
this.cumulativeMetrics.totalNetworkTimeouts += delta.totalNetworkTimeouts;
|
|
}
|
|
if (delta.totalBytesTransferred !== undefined) {
|
|
this.cumulativeMetrics.totalBytesTransferred += delta.totalBytesTransferred;
|
|
}
|
|
if (delta.totalUpdateChecks !== undefined) {
|
|
this.cumulativeMetrics.totalUpdateChecks += delta.totalUpdateChecks;
|
|
}
|
|
if (delta.totalUpdatesApplied !== undefined) {
|
|
this.cumulativeMetrics.totalUpdatesApplied += delta.totalUpdatesApplied;
|
|
}
|
|
if (delta.totalSpeedtests !== undefined) {
|
|
this.cumulativeMetrics.totalSpeedtests += delta.totalSpeedtests;
|
|
}
|
|
|
|
this.isDirty = true;
|
|
}
|
|
|
|
/**
|
|
* Resets cumulative metrics (called on cache invalidation)
|
|
*/
|
|
public async resetCumulativeMetrics(): Promise<void> {
|
|
this.cumulativeMetrics = createDefaultMetrics();
|
|
this.isDirty = true;
|
|
await this.saveCumulativeMetrics();
|
|
logger.log('info', '[PersistentStore] Cumulative metrics reset');
|
|
}
|
|
|
|
// ===================
|
|
// Event Log
|
|
// ===================
|
|
|
|
/**
|
|
* Logs an event to the persistent event log
|
|
*/
|
|
public async logEvent(
|
|
type: TEventType,
|
|
message: string,
|
|
details?: Record<string, any>
|
|
): Promise<void> {
|
|
const entry: IEventLogEntry = {
|
|
id: generateId(),
|
|
timestamp: Date.now(),
|
|
type,
|
|
message,
|
|
details,
|
|
};
|
|
|
|
try {
|
|
// Ensure initialized
|
|
if (!this.initialized) {
|
|
await this.init();
|
|
}
|
|
|
|
let events: IEventLogEntry[] = [];
|
|
|
|
if (await this.store.check(this.EVENT_LOG_KEY)) {
|
|
events = await this.store.get(this.EVENT_LOG_KEY);
|
|
}
|
|
|
|
// Add new entry
|
|
events.push(entry);
|
|
|
|
// Apply retention policy
|
|
events = this.applyRetentionPolicy(events);
|
|
|
|
await this.store.set(this.EVENT_LOG_KEY, events);
|
|
logger.log('note', `[PersistentStore] Logged event: ${type} - ${message}`);
|
|
|
|
// Push event to connected clients via DeesComms
|
|
try {
|
|
const backend = getServiceWorkerBackend();
|
|
if (backend) {
|
|
await backend.pushEvent(entry);
|
|
}
|
|
} catch (pushError) {
|
|
// Don't fail the log operation if push fails
|
|
logger.log('warn', `[PersistentStore] Failed to push event: ${pushError}`);
|
|
}
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to log event: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets event log entries
|
|
*/
|
|
public async getEventLog(options?: {
|
|
limit?: number;
|
|
type?: TEventType;
|
|
since?: number;
|
|
before?: number;
|
|
}): Promise<{ events: IEventLogEntry[]; totalCount: number }> {
|
|
try {
|
|
let events: IEventLogEntry[] = [];
|
|
|
|
if (await this.store.check(this.EVENT_LOG_KEY)) {
|
|
events = await this.store.get(this.EVENT_LOG_KEY);
|
|
}
|
|
|
|
const totalCount = events.length;
|
|
|
|
// Filter by type if specified
|
|
if (options?.type) {
|
|
events = events.filter(e => e.type === options.type);
|
|
}
|
|
|
|
// Filter by since timestamp if specified
|
|
if (options?.since) {
|
|
events = events.filter(e => e.timestamp >= options.since);
|
|
}
|
|
|
|
// Filter by before timestamp (for pagination)
|
|
if (options?.before) {
|
|
events = events.filter(e => e.timestamp < options.before);
|
|
}
|
|
|
|
// Sort by timestamp (newest first)
|
|
events.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
// Apply limit if specified
|
|
if (options?.limit && options.limit > 0) {
|
|
events = events.slice(0, options.limit);
|
|
}
|
|
|
|
return { events, totalCount };
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to get event log: ${error}`);
|
|
return { events: [], totalCount: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets count of events since a timestamp
|
|
*/
|
|
public async getEventCount(since: number): Promise<number> {
|
|
try {
|
|
if (!(await this.store.check(this.EVENT_LOG_KEY))) {
|
|
return 0;
|
|
}
|
|
|
|
const events: IEventLogEntry[] = await this.store.get(this.EVENT_LOG_KEY);
|
|
return events.filter(e => e.timestamp >= since).length;
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to get event count: ${error}`);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears all events from the log
|
|
*/
|
|
public async clearEventLog(): Promise<boolean> {
|
|
try {
|
|
await this.store.set(this.EVENT_LOG_KEY, []);
|
|
logger.log('info', '[PersistentStore] Event log cleared');
|
|
return true;
|
|
} catch (error) {
|
|
logger.log('error', `[PersistentStore] Failed to clear event log: ${error}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies retention policy to event log:
|
|
* - Max 10,000 events
|
|
* - Max 30 days old
|
|
*/
|
|
private applyRetentionPolicy(events: IEventLogEntry[]): IEventLogEntry[] {
|
|
const now = Date.now();
|
|
const cutoffTime = now - this.MAX_AGE_MS;
|
|
|
|
// Filter out events older than 30 days
|
|
let filtered = events.filter(e => e.timestamp >= cutoffTime);
|
|
|
|
// If still over limit, remove oldest entries
|
|
if (filtered.length > this.MAX_EVENTS) {
|
|
// Sort by timestamp (oldest first) then keep only newest MAX_EVENTS
|
|
filtered.sort((a, b) => a.timestamp - b.timestamp);
|
|
filtered = filtered.slice(filtered.length - this.MAX_EVENTS);
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
/**
|
|
* Flushes pending changes (call before SW stops)
|
|
*/
|
|
public async flush(): Promise<void> {
|
|
if (this.isDirty && this.cumulativeMetrics) {
|
|
await this.saveCumulativeMetrics();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton getter for convenience
|
|
export const getPersistentStore = (): PersistentStore => PersistentStore.getInstance();
|