diff --git a/docs/js-client-sdk.iclientconfigsync.configfetchedat.md b/docs/js-client-sdk.iclientconfigsync.configfetchedat.md new file mode 100644 index 0000000..780d523 --- /dev/null +++ b/docs/js-client-sdk.iclientconfigsync.configfetchedat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) > [configFetchedAt](./js-client-sdk.iclientconfigsync.configfetchedat.md) + +## IClientConfigSync.configFetchedAt property + +**Signature:** + +```typescript +configFetchedAt?: string; +``` diff --git a/docs/js-client-sdk.iclientconfigsync.configpublishedat.md b/docs/js-client-sdk.iclientconfigsync.configpublishedat.md new file mode 100644 index 0000000..b100f91 --- /dev/null +++ b/docs/js-client-sdk.iclientconfigsync.configpublishedat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) > [configPublishedAt](./js-client-sdk.iclientconfigsync.configpublishedat.md) + +## IClientConfigSync.configPublishedAt property + +**Signature:** + +```typescript +configPublishedAt?: string; +``` diff --git a/docs/js-client-sdk.iclientconfigsync.md b/docs/js-client-sdk.iclientconfigsync.md index 9b2eac1..92b81c2 100644 --- a/docs/js-client-sdk.iclientconfigsync.md +++ b/docs/js-client-sdk.iclientconfigsync.md @@ -72,6 +72,44 @@ IBanditLogger _(Optional)_ + + + +[configFetchedAt?](./js-client-sdk.iclientconfigsync.configfetchedat.md) + + + + + + + +string + + + + +_(Optional)_ + + + + + +[configPublishedAt?](./js-client-sdk.iclientconfigsync.configpublishedat.md) + + + + + + + +string + + + + +_(Optional)_ + + diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 3b9af0e..05762c4 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -172,6 +172,10 @@ export interface IClientConfigSync { // (undocumented) banditLogger?: IBanditLogger; // (undocumented) + configFetchedAt?: string; + // (undocumented) + configPublishedAt?: string; + // (undocumented) enableOverrides?: boolean; // (undocumented) flagsConfiguration: Record; diff --git a/package.json b/package.json index 8990128..e259647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk", - "version": "3.16.1", + "version": "3.17.0-alpha.1", "description": "Eppo SDK for client-side JavaScript applications", "main": "dist/index.js", "files": [ @@ -62,5 +62,9 @@ "@types/chrome": "^0.0.313", "@eppo/js-client-sdk-common": "4.15.1" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "volta": { + "node": "22.18.0", + "yarn": "1.22.22" + } } diff --git a/src/configuration-factory.spec.ts b/src/configuration-factory.spec.ts index fa50157..c5b3b61 100644 --- a/src/configuration-factory.spec.ts +++ b/src/configuration-factory.spec.ts @@ -55,6 +55,45 @@ describe('configurationStorageFactory', () => { expect(result).toBeInstanceOf(HybridConfigurationStore); }); + it('is a HybridConfigurationStore with Web Cache API when hasWebCacheAPI is true', () => { + // Mock caches API + const mockCache = { + match: jest.fn(), + put: jest.fn(), + add: jest.fn(), + addAll: jest.fn(), + delete: jest.fn(), + keys: jest.fn(), + }; + const mockCaches = { + open: jest.fn().mockResolvedValue(mockCache), + delete: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + match: jest.fn(), + }; + const originalCaches = (global as any).caches; + (global as any).caches = mockCaches; + + const mockLocalStorage = { + clear: jest.fn(), + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + key: jest.fn(), + length: 0, + }; + + const result = configurationStorageFactory( + { hasWebCacheAPI: true }, + { windowLocalStorage: mockLocalStorage }, + ); + expect(result).toBeInstanceOf(HybridConfigurationStore); + + // Clean up + (global as any).caches = originalCaches; + }); + it('is a HybridConfigurationStore with a LocalStorageBackedAsyncStore persistentStore when window local storage is available', () => { const mockLocalStorage = { clear: jest.fn(), @@ -72,6 +111,45 @@ describe('configurationStorageFactory', () => { expect(result).toBeInstanceOf(HybridConfigurationStore); }); + it('prefers Web Cache API over localStorage when both are available', () => { + // Mock caches API + const mockCache = { + match: jest.fn(), + put: jest.fn(), + add: jest.fn(), + addAll: jest.fn(), + delete: jest.fn(), + keys: jest.fn(), + }; + const mockCaches = { + open: jest.fn().mockResolvedValue(mockCache), + delete: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + match: jest.fn(), + }; + const originalCaches = (global as any).caches; + (global as any).caches = mockCaches; + + const mockLocalStorage = { + clear: jest.fn(), + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + key: jest.fn(), + length: 0, + }; + + const result = configurationStorageFactory( + { hasWebCacheAPI: true, hasWindowLocalStorage: true }, + { windowLocalStorage: mockLocalStorage }, + ); + expect(result).toBeInstanceOf(HybridConfigurationStore); + + // Clean up + (global as any).caches = originalCaches; + }); + it('falls back to MemoryOnlyConfigurationStore when no persistence options are available', () => { const result = configurationStorageFactory({}); expect(result).toBeInstanceOf(MemoryOnlyConfigurationStore); diff --git a/src/configuration-factory.ts b/src/configuration-factory.ts index bc41b7c..a543d04 100644 --- a/src/configuration-factory.ts +++ b/src/configuration-factory.ts @@ -12,13 +12,16 @@ import { import ChromeStorageAsyncMap from './cache/chrome-storage-async-map'; import { ChromeStorageEngine } from './chrome-storage-engine'; +import { ConfigurationAsyncStore } from './configuration-store'; import { IsolatableHybridConfigurationStore, ServingStoreUpdateStrategy, } from './isolatable-hybrid.store'; import { LocalStorageEngine } from './local-storage-engine'; +import { MigrationManager } from './migrations'; import { OVERRIDES_KEY } from './storage-key-constants'; import { StringValuedAsyncStore } from './string-valued.store'; +import { WebCacheStorageEngine } from './web-cache-storage-engine'; export function precomputedFlagsStorageFactory(): IConfigurationStore { return new MemoryOnlyConfigurationStore(); @@ -33,6 +36,7 @@ export function configurationStorageFactory( maxAgeSeconds = 0, servingStoreUpdateStrategy = 'always', hasChromeStorage = false, + hasWebCacheAPI = false, hasWindowLocalStorage = false, persistentStore = undefined, forceMemoryOnly = false, @@ -40,6 +44,7 @@ export function configurationStorageFactory( maxAgeSeconds?: number; servingStoreUpdateStrategy?: ServingStoreUpdateStrategy; hasChromeStorage?: boolean; + hasWebCacheAPI?: boolean; hasWindowLocalStorage?: boolean; persistentStore?: IAsyncStore; forceMemoryOnly?: boolean; @@ -73,6 +78,22 @@ export function configurationStorageFactory( new StringValuedAsyncStore(chromeStorageEngine, maxAgeSeconds), servingStoreUpdateStrategy, ); + } else if (hasWebCacheAPI) { + // Web Cache API is available and preferred for better storage limits + // Run migration from localStorage to Cache API only if localStorage exists + if (windowLocalStorage) { + const migrationManager = new MigrationManager(windowLocalStorage); + migrationManager + .runPendingMigrations() + .catch((error) => console.warn('Storage migration failed:', error)); + } + + const webCacheEngine = new WebCacheStorageEngine(storageKeySuffix ?? ''); + return new IsolatableHybridConfigurationStore( + new MemoryStore(), + new ConfigurationAsyncStore(webCacheEngine, maxAgeSeconds), + servingStoreUpdateStrategy, + ); } else if (hasWindowLocalStorage && windowLocalStorage) { // window.localStorage is available, use it as a fallback const localStorageEngine = new LocalStorageEngine(windowLocalStorage, storageKeySuffix ?? ''); @@ -135,3 +156,13 @@ export function hasWindowLocalStorage(): boolean { export function localStorageIfAvailable(): Storage | undefined { return hasWindowLocalStorage() ? window.localStorage : undefined; } + +/** Returns whether Web Cache API is available */ +export function hasWebCacheAPI(): boolean { + try { + return typeof caches !== 'undefined' && typeof caches.open === 'function'; + } catch { + // Some environments may throw when accessing caches + return false; + } +} diff --git a/src/configuration-store.ts b/src/configuration-store.ts new file mode 100644 index 0000000..8788c2e --- /dev/null +++ b/src/configuration-store.ts @@ -0,0 +1,67 @@ +import { IAsyncStore } from '@eppo/js-client-sdk-common'; + +/** + * Simplified storage engine interface for configuration data with built-in timestamps. + * Unlike IStringStorageEngine, this doesn't require separate metadata storage since + * configuration responses include createdAt timestamps. + */ +export interface IConfigurationStorageEngine { + getContentsJsonString: () => Promise; + setContentsJsonString: (configurationJsonString: string) => Promise; +} + +/** + * Configuration store that works with storage engines optimized for configuration data. + * Uses the configuration response's built-in createdAt timestamp for expiration logic + * instead of maintaining separate metadata. + */ +export class ConfigurationAsyncStore implements IAsyncStore { + private initialized = false; + + public constructor( + private storageEngine: IConfigurationStorageEngine, + private cooldownSeconds = 0, + ) {} + + public isInitialized(): boolean { + return this.initialized; + } + + public async isExpired(): Promise { + if (!this.cooldownSeconds) { + return true; + } + + try { + const contentsJsonString = await this.storageEngine.getContentsJsonString(); + if (!contentsJsonString) { + return true; + } + + const contents = JSON.parse(contentsJsonString); + + // Check if this is a configuration response with createdAt + if (contents.createdAt) { + const createdAtMs = new Date(contents.createdAt).getTime(); + return Date.now() - createdAtMs > this.cooldownSeconds * 1000; + } + + // Fallback: if no createdAt, consider expired + return true; + } catch (error) { + console.warn('Failed to check expiration:', error); + return true; + } + } + + public async entries(): Promise> { + const contentsJsonString = await this.storageEngine.getContentsJsonString(); + return contentsJsonString ? JSON.parse(contentsJsonString) : {}; + } + + public async setEntries(entries: Record): Promise { + // Store the entire configuration response as-is + await this.storageEngine.setContentsJsonString(JSON.stringify(entries)); + this.initialized = true; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index daac6db..b84048a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { chromeStorageIfAvailable, configurationStorageFactory, hasChromeStorage, + hasWebCacheAPI, hasWindowLocalStorage, localStorageIfAvailable, overrideStorageFactory, @@ -330,6 +331,7 @@ export class EppoJSClient extends EppoClient { servingStoreUpdateStrategy: updateOnFetch, persistentStore, hasChromeStorage: hasChromeStorage(), + hasWebCacheAPI: hasWebCacheAPI(), hasWindowLocalStorage: hasWindowLocalStorage(), }, { diff --git a/src/migrations/README.md b/src/migrations/README.md new file mode 100644 index 0000000..7de7806 --- /dev/null +++ b/src/migrations/README.md @@ -0,0 +1,64 @@ +# Storage Migrations + +This directory contains versioned storage migrations for the Eppo SDK. + +## Migration Versioning + +Each migration is versioned using the SDK version when it was introduced, following this pattern: +- `v{major}.{minor}.{patch}-{description}` +- Example: `v3.17.0-localStorage-to-cache` + +## Current Migrations + +### v3.17.0-localStorage-to-cache +- **Purpose**: Migrate from localStorage to Web Cache API for better storage limits +- **What it does**: Clears all `eppo-configuration*` keys from localStorage when upgrading to Cache API storage +- **Completion tracking**: Uses `eppo-migration-v3.17.0-localStorage-to-cache-completed` localStorage flag + +## Adding New Migrations + +1. Create a new file: `v{version}-{description}.ts` +2. Implement the `Migration` interface +3. Add the migration to `MigrationManager.constructor()` +4. Export from `index.ts` + +Example: +```typescript +// v3.18.0-example-migration.ts +export class ExampleMigration implements Migration { + public readonly version = 'v3.18.0-example-migration'; + + public isMigrationCompleted(): boolean { + // Check if migration was already run + } + + public async migrate(): Promise { + // Perform migration logic + } +} +``` + +## Migration Manager + +The `MigrationManager` handles: +- Running all pending migrations in order +- Tracking completion status +- Error handling (continues other migrations if one fails) +- Logging migration progress + +## Usage + +```typescript +import { MigrationManager } from './migrations'; + +const migrationManager = new MigrationManager(localStorage); +await migrationManager.runPendingMigrations(); +``` + +## Best Practices + +1. **Idempotent**: Migrations should be safe to run multiple times +2. **Backward Compatible**: Don't break existing functionality during migration +3. **Error Tolerant**: Handle errors gracefully and continue other migrations +4. **Logged**: Provide clear logging for debugging +5. **Versioned**: Always version migrations with the SDK release they're introduced in \ No newline at end of file diff --git a/src/migrations/index.ts b/src/migrations/index.ts new file mode 100644 index 0000000..d187f5c --- /dev/null +++ b/src/migrations/index.ts @@ -0,0 +1,12 @@ +/** + * Storage Migrations + * + * This module handles versioned storage migrations for the Eppo SDK. + * Each migration is versioned and tracked to ensure it only runs once. + */ + +export { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache'; +export { MigrationManager, type Migration } from './migration-manager'; + +// Convenience function for the current migration +export { MigrationManager as StorageMigration } from './migration-manager'; diff --git a/src/migrations/migration-manager.spec.ts b/src/migrations/migration-manager.spec.ts new file mode 100644 index 0000000..e43363a --- /dev/null +++ b/src/migrations/migration-manager.spec.ts @@ -0,0 +1,237 @@ +import { type Migration, MigrationManager } from './migration-manager'; + +// Mock migration for testing +class MockMigrationV1 implements Migration { + public readonly version = 'v1.0.0-test-migration'; + private completed = false; + private shouldFail = false; + public migrateCalled = false; + + constructor(completed = false, shouldFail = false) { + this.completed = completed; + this.shouldFail = shouldFail; + } + + public isMigrationCompleted(): boolean { + return this.completed; + } + + public async migrate(): Promise<{ + migrationNeeded: boolean; + clearedKeys: string[]; + errors: string[]; + version: string; + }> { + this.migrateCalled = true; + + if (this.shouldFail) { + throw new Error('Mock migration failed'); + } + + this.completed = true; + return { + migrationNeeded: true, + clearedKeys: ['test-key-1', 'test-key-2'], + errors: [], + version: this.version, + }; + } +} + +class MockMigrationV2 implements Migration { + public readonly version = 'v2.0.0-another-migration'; + private completed = false; + public migrateCalled = false; + + constructor(completed = false) { + this.completed = completed; + } + + public isMigrationCompleted(): boolean { + return this.completed; + } + + public async migrate(): Promise<{ + migrationNeeded: boolean; + clearedKeys: string[]; + errors: string[]; + version: string; + }> { + this.migrateCalled = true; + this.completed = true; + return { + migrationNeeded: true, + clearedKeys: ['test-key-3'], + errors: [], + version: this.version, + }; + } +} + +describe('MigrationManager', () => { + let mockLocalStorage: Storage; + let migrationManager: MigrationManager; + let mockMigrationV1: MockMigrationV1; + let mockMigrationV2: MockMigrationV2; + + beforeEach(() => { + mockLocalStorage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + }; + + // Create a test migration manager with mock migrations + mockMigrationV1 = new MockMigrationV1(); + mockMigrationV2 = new MockMigrationV2(); + + migrationManager = new MigrationManager(mockLocalStorage); + // Override migrations with our mocks for testing + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + // Mock console methods to avoid noise in tests + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with localStorage', () => { + const manager = new MigrationManager(mockLocalStorage); + expect(manager).toBeInstanceOf(MigrationManager); + }); + + it('should handle missing window.localStorage gracefully', () => { + const manager = new MigrationManager(); + expect(manager).toBeInstanceOf(MigrationManager); + }); + }); + + describe('getAllMigrationVersions', () => { + it('should return all registered migration versions', () => { + const versions = migrationManager.getAllMigrationVersions(); + expect(versions).toEqual(['v1.0.0-test-migration', 'v2.0.0-another-migration']); + }); + }); + + describe('getPendingMigrationVersions', () => { + it('should return pending migration versions', () => { + // Both migrations are pending initially + const pending = migrationManager.getPendingMigrationVersions(); + expect(pending).toEqual(['v1.0.0-test-migration', 'v2.0.0-another-migration']); + }); + + it('should return only uncompleted migrations', () => { + // Mark first migration as completed + mockMigrationV1 = new MockMigrationV1(true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + const pending = migrationManager.getPendingMigrationVersions(); + expect(pending).toEqual(['v2.0.0-another-migration']); + }); + + it('should return empty array when all migrations are completed', () => { + // Mark both migrations as completed + mockMigrationV1 = new MockMigrationV1(true); + mockMigrationV2 = new MockMigrationV2(true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + const pending = migrationManager.getPendingMigrationVersions(); + expect(pending).toEqual([]); + }); + }); + + describe('isMigrationCompleted', () => { + it('should return true for completed migration', () => { + mockMigrationV1 = new MockMigrationV1(true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + expect(migrationManager.isMigrationCompleted('v1.0.0-test-migration')).toBe(true); + }); + + it('should return false for uncompleted migration', () => { + expect(migrationManager.isMigrationCompleted('v1.0.0-test-migration')).toBe(false); + }); + + it('should return false for non-existent migration', () => { + expect(migrationManager.isMigrationCompleted('v999.0.0-does-not-exist')).toBe(false); + }); + }); + + describe('runPendingMigrations', () => { + it('should run all pending migrations', async () => { + await migrationManager.runPendingMigrations(); + + expect(mockMigrationV1.migrateCalled).toBe(true); + expect(mockMigrationV2.migrateCalled).toBe(true); + expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...'); + expect(console.log).toHaveBeenCalledWith('Running pending migration: v1.0.0-test-migration'); + expect(console.log).toHaveBeenCalledWith( + 'Running pending migration: v2.0.0-another-migration', + ); + }); + + it('should skip completed migrations', async () => { + // Mark first migration as completed + mockMigrationV1 = new MockMigrationV1(true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + await migrationManager.runPendingMigrations(); + + expect(mockMigrationV1.migrateCalled).toBe(false); + expect(mockMigrationV2.migrateCalled).toBe(true); + expect(console.log).not.toHaveBeenCalledWith( + 'Running pending migration: v1.0.0-test-migration', + ); + expect(console.log).toHaveBeenCalledWith( + 'Running pending migration: v2.0.0-another-migration', + ); + }); + + it('should continue other migrations if one fails', async () => { + // Make first migration fail + mockMigrationV1 = new MockMigrationV1(false, true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + await migrationManager.runPendingMigrations(); + + expect(mockMigrationV1.migrateCalled).toBe(true); + expect(mockMigrationV2.migrateCalled).toBe(true); + expect(console.error).toHaveBeenCalledWith( + 'Migration v1.0.0-test-migration failed:', + expect.any(Error), + ); + }); + + it('should handle empty migrations array', async () => { + (migrationManager as any).migrations = []; + + await migrationManager.runPendingMigrations(); + + expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...'); + // Should not throw or cause issues + }); + + it('should not run migrations if all are completed', async () => { + // Mark both migrations as completed + mockMigrationV1 = new MockMigrationV1(true); + mockMigrationV2 = new MockMigrationV2(true); + (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2]; + + await migrationManager.runPendingMigrations(); + + expect(mockMigrationV1.migrateCalled).toBe(false); + expect(mockMigrationV2.migrateCalled).toBe(false); + expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...'); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Running pending migration:'), + ); + }); + }); +}); diff --git a/src/migrations/migration-manager.ts b/src/migrations/migration-manager.ts new file mode 100644 index 0000000..f263b5a --- /dev/null +++ b/src/migrations/migration-manager.ts @@ -0,0 +1,77 @@ +/** + * Migration Manager for handling versioned storage migrations + */ + +import { localStorageIfAvailable } from '../configuration-factory'; + +import { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache'; + +export interface Migration { + version: string; + isMigrationCompleted(): boolean; + + migrate(): Promise<{ + migrationNeeded: boolean; + clearedKeys: string[]; + errors: string[]; + version: string; + }>; +} + +/** + * Manages all storage migrations in order + */ +export class MigrationManager { + private readonly migrations: Migration[]; + + constructor(localStorage?: Storage) { + const storageToUse = localStorage || localStorageIfAvailable(); + + // Register all migrations in order + this.migrations = [ + new LocalStorageToCacheMigration(storageToUse), + // Future migrations will be added here + ]; + } + + /** + * Run all pending migrations + */ + public async runPendingMigrations(): Promise { + console.log('Checking for pending storage migrations...'); + + for (const migration of this.migrations) { + if (!migration.isMigrationCompleted()) { + console.log(`Running pending migration: ${migration.version}`); + try { + await migration.migrate(); + } catch (error) { + console.error(`Migration ${migration.version} failed:`, error); + // Continue with other migrations even if one fails + } + } + } + } + + /** + * Check if a specific migration has been completed + */ + public isMigrationCompleted(version: string): boolean { + const migration = this.migrations.find((m) => m.version === version); + return migration ? migration.isMigrationCompleted() : false; + } + + /** + * Get all registered migration versions + */ + public getAllMigrationVersions(): string[] { + return this.migrations.map((m) => m.version); + } + + /** + * Get pending migration versions + */ + public getPendingMigrationVersions(): string[] { + return this.migrations.filter((m) => !m.isMigrationCompleted()).map((m) => m.version); + } +} diff --git a/src/migrations/v3.17.0-localStorage-to-cache.spec.ts b/src/migrations/v3.17.0-localStorage-to-cache.spec.ts new file mode 100644 index 0000000..2e8edc1 --- /dev/null +++ b/src/migrations/v3.17.0-localStorage-to-cache.spec.ts @@ -0,0 +1,337 @@ +import { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache'; + +// Mock the configuration factory functions +jest.mock('../configuration-factory', () => ({ + hasWindowLocalStorage: jest.fn(() => true), + localStorageIfAvailable: jest.fn(), +})); + +describe('LocalStorageToCacheMigration', () => { + let mockLocalStorage: Storage; + let migration: LocalStorageToCacheMigration; + const migrationFlagKey = 'eppo-migration-v3.17.0-localStorage-to-cache-completed'; + + beforeEach(() => { + mockLocalStorage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + get length() { + return this._length || 0; + }, + set length(value) { + this._length = value; + }, + _length: 0, + } as any; + + migration = new LocalStorageToCacheMigration(mockLocalStorage); + + // Mock console methods to avoid noise in tests + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with provided localStorage', () => { + expect(migration).toBeInstanceOf(LocalStorageToCacheMigration); + expect(migration.version).toBe('v3.17.0-localStorage-to-cache'); + }); + + it('should handle missing window.localStorage gracefully', () => { + const { hasWindowLocalStorage, localStorageIfAvailable } = jest.requireMock( + '../configuration-factory', + ); + hasWindowLocalStorage.mockReturnValue(false); + localStorageIfAvailable.mockReturnValue(undefined); + + const defaultMigration = new LocalStorageToCacheMigration(); + expect(defaultMigration).toBeInstanceOf(LocalStorageToCacheMigration); + + // Should assume migration is completed when no localStorage + expect(defaultMigration.isMigrationCompleted()).toBe(true); + + // Restore mocks + hasWindowLocalStorage.mockReturnValue(true); + localStorageIfAvailable.mockReturnValue(mockLocalStorage); + }); + }); + + describe('isMigrationCompleted', () => { + it('should return true when migration flag is set', () => { + (mockLocalStorage.getItem as jest.Mock).mockReturnValue('true'); + + expect(migration.isMigrationCompleted()).toBe(true); + expect(mockLocalStorage.getItem).toHaveBeenCalledWith(migrationFlagKey); + }); + + it('should return false when migration flag is not set', () => { + (mockLocalStorage.getItem as jest.Mock).mockReturnValue(null); + + expect(migration.isMigrationCompleted()).toBe(false); + expect(mockLocalStorage.getItem).toHaveBeenCalledWith(migrationFlagKey); + }); + + it('should return false when migration flag has wrong value', () => { + (mockLocalStorage.getItem as jest.Mock).mockReturnValue('false'); + + expect(migration.isMigrationCompleted()).toBe(false); + }); + + it('should handle localStorage errors gracefully', () => { + (mockLocalStorage.getItem as jest.Mock).mockImplementation(() => { + throw new Error('localStorage error'); + }); + + expect(migration.isMigrationCompleted()).toBe(true); + expect(console.warn).toHaveBeenCalledWith( + 'Failed to check migration status for v3.17.0-localStorage-to-cache:', + expect.any(Error), + ); + }); + }); + + describe('markMigrationCompleted', () => { + it('should set migration flag to true', () => { + migration.markMigrationCompleted(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(migrationFlagKey, 'true'); + }); + + it('should handle localStorage errors gracefully', () => { + (mockLocalStorage.setItem as jest.Mock).mockImplementation(() => { + throw new Error('localStorage error'); + }); + + migration.markMigrationCompleted(); + + expect(console.warn).toHaveBeenCalledWith( + 'Failed to mark migration as completed for v3.17.0-localStorage-to-cache:', + expect.any(Error), + ); + }); + }); + + describe('getConfigurationKeys', () => { + it('should return keys that start with eppo-configuration', () => { + (mockLocalStorage as any)._length = 5; + (mockLocalStorage.key as jest.Mock) + .mockReturnValueOnce('eppo-configuration-key1') + .mockReturnValueOnce('other-key') + .mockReturnValueOnce('eppo-configuration-key2') + .mockReturnValueOnce('eppo-configuration-meta') + .mockReturnValueOnce('random-key'); + + const keys = migration.getConfigurationKeys(); + + expect(keys).toEqual([ + 'eppo-configuration-key1', + 'eppo-configuration-key2', + 'eppo-configuration-meta', + ]); + expect(mockLocalStorage.key).toHaveBeenCalledTimes(5); + }); + + it('should return empty array when no configuration keys exist', () => { + (mockLocalStorage as any)._length = 2; + (mockLocalStorage.key as jest.Mock) + .mockReturnValueOnce('other-key') + .mockReturnValueOnce('random-key'); + + const keys = migration.getConfigurationKeys(); + + expect(keys).toEqual([]); + }); + + it('should handle null keys from localStorage', () => { + (mockLocalStorage as any)._length = 3; + (mockLocalStorage.key as jest.Mock) + .mockReturnValueOnce('eppo-configuration-key1') + .mockReturnValueOnce(null) + .mockReturnValueOnce('eppo-configuration-key2'); + + const keys = migration.getConfigurationKeys(); + + expect(keys).toEqual(['eppo-configuration-key1', 'eppo-configuration-key2']); + }); + + it('should handle localStorage errors gracefully', () => { + (mockLocalStorage as any)._length = 1; + (mockLocalStorage.key as jest.Mock).mockImplementation(() => { + throw new Error('localStorage error'); + }); + + const keys = migration.getConfigurationKeys(); + + expect(keys).toEqual([]); + expect(console.warn).toHaveBeenCalledWith( + 'Failed to enumerate localStorage keys for v3.17.0-localStorage-to-cache:', + expect.any(Error), + ); + }); + }); + + describe('clearConfigurationKeys', () => { + beforeEach(() => { + // Mock isMigrationCompleted to return false initially + jest.spyOn(migration, 'isMigrationCompleted').mockReturnValue(false); + jest.spyOn(migration, 'markMigrationCompleted').mockImplementation(); + }); + + it('should return early if migration already completed', () => { + jest.spyOn(migration, 'isMigrationCompleted').mockReturnValue(true); + + const result = migration.clearConfigurationKeys(); + + expect(result).toEqual({ + migrationNeeded: false, + clearedKeys: [], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + expect(migration.markMigrationCompleted).not.toHaveBeenCalled(); + }); + + it('should clear configuration keys successfully', () => { + jest + .spyOn(migration, 'getConfigurationKeys') + .mockReturnValue(['eppo-configuration-key1', 'eppo-configuration-key2']); + + const result = migration.clearConfigurationKeys(); + + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-key1'); + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-key2'); + expect(migration.markMigrationCompleted).toHaveBeenCalled(); + expect(result).toEqual({ + migrationNeeded: true, + clearedKeys: ['eppo-configuration-key1', 'eppo-configuration-key2'], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + }); + + it('should handle removal errors but continue with other keys', () => { + jest + .spyOn(migration, 'getConfigurationKeys') + .mockReturnValue([ + 'eppo-configuration-key1', + 'eppo-configuration-key2', + 'eppo-configuration-key3', + ]); + + (mockLocalStorage.removeItem as jest.Mock).mockImplementation((key) => { + if (key === 'eppo-configuration-key2') { + throw new Error('Remove failed'); + } + }); + + const result = migration.clearConfigurationKeys(); + + expect(result.migrationNeeded).toBe(true); + expect(result.clearedKeys).toEqual(['eppo-configuration-key1', 'eppo-configuration-key3']); + expect(result.errors).toEqual([ + 'Failed to remove key eppo-configuration-key2: Error: Remove failed', + ]); + expect(migration.markMigrationCompleted).toHaveBeenCalled(); + }); + + it('should return no migration needed when no configuration keys exist', () => { + jest.spyOn(migration, 'getConfigurationKeys').mockReturnValue([]); + + const result = migration.clearConfigurationKeys(); + + expect(result).toEqual({ + migrationNeeded: false, + clearedKeys: [], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + expect(migration.markMigrationCompleted).not.toHaveBeenCalled(); + }); + }); + + describe('migrate', () => { + it('should run migration successfully', async () => { + jest.spyOn(migration, 'clearConfigurationKeys').mockReturnValue({ + migrationNeeded: true, + clearedKeys: ['eppo-configuration-key1'], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + + const result = await migration.migrate(); + + expect(console.log).toHaveBeenCalledWith( + 'Starting migration v3.17.0-localStorage-to-cache...', + ); + expect(console.log).toHaveBeenCalledWith( + 'Migration v3.17.0-localStorage-to-cache completed: cleared 1 keys', + expect.objectContaining({ + clearedKeys: ['eppo-configuration-key1'], + errors: [], + }), + ); + expect(result.migrationNeeded).toBe(true); + }); + + it('should handle case when no migration is needed', async () => { + jest.spyOn(migration, 'clearConfigurationKeys').mockReturnValue({ + migrationNeeded: false, + clearedKeys: [], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + + const result = await migration.migrate(); + + expect(console.log).toHaveBeenCalledWith( + 'Migration v3.17.0-localStorage-to-cache: No migration needed - already completed or no keys found', + ); + expect(result.migrationNeeded).toBe(false); + }); + }); + + describe('forceMigrate', () => { + it('should clear migration flag and run migration', async () => { + jest.spyOn(migration, 'migrate').mockResolvedValue({ + migrationNeeded: true, + clearedKeys: ['test-key'], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + + const result = await migration.forceMigrate(); + + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(migrationFlagKey); + expect(migration.migrate).toHaveBeenCalled(); + expect(result.migrationNeeded).toBe(true); + }); + + it('should handle errors when clearing migration flag', async () => { + (mockLocalStorage.removeItem as jest.Mock).mockImplementation(() => { + throw new Error('Remove failed'); + }); + + jest.spyOn(migration, 'migrate').mockResolvedValue({ + migrationNeeded: false, + clearedKeys: [], + errors: [], + version: 'v3.17.0-localStorage-to-cache', + }); + + await migration.forceMigrate(); + + expect(console.warn).toHaveBeenCalledWith( + 'Failed to clear migration flag for v3.17.0-localStorage-to-cache:', + expect.any(Error), + ); + expect(migration.migrate).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/migrations/v3.17.0-localStorage-to-cache.ts b/src/migrations/v3.17.0-localStorage-to-cache.ts new file mode 100644 index 0000000..0e4f9df --- /dev/null +++ b/src/migrations/v3.17.0-localStorage-to-cache.ts @@ -0,0 +1,166 @@ +import { localStorageIfAvailable } from '../configuration-factory'; + +/** + * Migration v3.17.0: localStorage to Web Cache API + * + * This migration clears localStorage keys with 'eppo-configuration' prefix + * when upgrading to Web Cache API storage. + */ + +const MIGRATION_VERSION = 'v3.17.0-localStorage-to-cache'; +const MIGRATION_FLAG_KEY = `eppo-migration-${MIGRATION_VERSION}-completed`; +const CONFIGURATION_PREFIX = 'eppo-configuration'; + +export interface MigrationResult { + migrationNeeded: boolean; + clearedKeys: string[]; + errors: string[]; + version: string; +} + +/** + * Migration v3.17.0: Move from localStorage to Web Cache API + */ +export class LocalStorageToCacheMigration { + private readonly localStorage?: Storage; + public readonly version = MIGRATION_VERSION; + + constructor(localStorage?: Storage) { + this.localStorage = localStorage || localStorageIfAvailable(); + } + + /** + * Check if this specific migration has been completed + */ + public isMigrationCompleted(): boolean { + // If no localStorage available, assume migration is completed + if (!this.localStorage) { + return true; + } + + try { + return this.localStorage.getItem(MIGRATION_FLAG_KEY) === 'true'; + } catch (error) { + console.warn(`Failed to check migration status for ${this.version}:`, error); + return true; // Assume completed on error + } + } + + /** + * Mark this migration as completed + */ + public markMigrationCompleted(): void { + if (!this.localStorage) { + return; // Can't persist without localStorage + } + + try { + this.localStorage.setItem(MIGRATION_FLAG_KEY, 'true'); + } catch (error) { + console.warn(`Failed to mark migration as completed for ${this.version}:`, error); + } + } + + /** + * Get all localStorage keys that match the eppo-configuration prefix + */ + public getConfigurationKeys(): string[] { + const keys: string[] = []; + + if (!this.localStorage) { + return keys; + } + + try { + for (let i = 0; i < this.localStorage.length; i++) { + const key = this.localStorage.key(i); + if (key && key.startsWith(CONFIGURATION_PREFIX)) { + keys.push(key); + } + } + } catch (error) { + console.warn(`Failed to enumerate localStorage keys for ${this.version}:`, error); + } + + return keys; + } + + /** + * Clear all localStorage keys with eppo-configuration prefix + */ + public clearConfigurationKeys(): MigrationResult { + const result: MigrationResult = { + migrationNeeded: false, + clearedKeys: [], + errors: [], + version: this.version, + }; + + // Check if migration already completed + if (this.isMigrationCompleted()) { + return result; + } + + const keysToRemove = this.getConfigurationKeys(); + result.migrationNeeded = keysToRemove.length > 0; + + // Remove each configuration key + for (const key of keysToRemove) { + try { + this.localStorage?.removeItem(key); + result.clearedKeys.push(key); + } catch (error) { + const errorMsg = `Failed to remove key ${key}: ${error}`; + console.warn(errorMsg); + result.errors.push(errorMsg); + } + } + + // Mark migration as completed if we processed any keys (even with errors) + if (result.migrationNeeded) { + this.markMigrationCompleted(); + } + + return result; + } + + /** + * Run the complete migration process + */ + public async migrate(): Promise { + console.log(`Starting migration ${this.version}...`); + + const result = this.clearConfigurationKeys(); + + if (result.migrationNeeded) { + console.log( + `Migration ${this.version} completed: cleared ${result.clearedKeys.length} keys`, + { + clearedKeys: result.clearedKeys, + errors: result.errors, + }, + ); + } else { + console.log( + `Migration ${this.version}: No migration needed - already completed or no keys found`, + ); + } + + return result; + } + + /** + * Force re-run migration (clears migration flag first) + */ + public async forceMigrate(): Promise { + if (this.localStorage) { + try { + this.localStorage.removeItem(MIGRATION_FLAG_KEY); + } catch (error) { + console.warn(`Failed to clear migration flag for ${this.version}:`, error); + } + } + + return this.migrate(); + } +} diff --git a/src/web-cache-storage-engine.spec.ts b/src/web-cache-storage-engine.spec.ts new file mode 100644 index 0000000..dc95147 --- /dev/null +++ b/src/web-cache-storage-engine.spec.ts @@ -0,0 +1,251 @@ +import { WebCacheStorageEngine } from './web-cache-storage-engine'; + +describe('WebCacheStorageEngine', () => { + let engine: WebCacheStorageEngine; + let mockCache: jest.Mocked; + let mockCaches: jest.Mocked; + let originalCaches: CacheStorage | undefined; + + beforeEach(() => { + // Create mock Cache object + mockCache = { + match: jest.fn(), + matchAll: jest.fn(), + put: jest.fn(), + add: jest.fn(), + addAll: jest.fn(), + delete: jest.fn(), + keys: jest.fn(), + } as jest.Mocked; + + // Create mock CacheStorage object + mockCaches = { + open: jest.fn().mockResolvedValue(mockCache), + delete: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + match: jest.fn(), + } as jest.Mocked; + + // Store original caches and replace with mock + originalCaches = (global as any).caches; + (global as any).caches = mockCaches; + + engine = new WebCacheStorageEngine('test-suffix'); + + // Mock console methods to avoid noise in tests + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + // Restore original caches + (global as any).caches = originalCaches; + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with cache name and key', () => { + expect(engine).toBeInstanceOf(WebCacheStorageEngine); + }); + + it('should handle empty storage key suffix', () => { + const defaultEngine = new WebCacheStorageEngine(); + expect(defaultEngine).toBeInstanceOf(WebCacheStorageEngine); + }); + }); + + describe('getContentsJsonString', () => { + it('should return cached content successfully', async () => { + const testData = '{"test": "data"}'; + const mockResponse = new Response(testData); + mockCache.match.mockResolvedValue(mockResponse); + + const result = await engine.getContentsJsonString(); + + expect(mockCaches.open).toHaveBeenCalledWith('eppo-sdk-test-suffix'); + expect(mockCache.match).toHaveBeenCalledWith('eppo-configuration-test-suffix'); + expect(result).toBe(testData); + }); + + it('should return null when no cached data exists', async () => { + mockCache.match.mockResolvedValue(undefined); + + const result = await engine.getContentsJsonString(); + + expect(result).toBeNull(); + }); + + it('should return null when Cache API is not supported', async () => { + (global as any).caches = undefined; + + const result = await engine.getContentsJsonString(); + + expect(result).toBeNull(); + }); + + it('should handle cache errors gracefully', async () => { + mockCache.match.mockRejectedValue(new Error('Cache error')); + + const result = await engine.getContentsJsonString(); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith('Failed to read from Web Cache API:', expect.any(Error)); + }); + }); + + describe('setContentsJsonString', () => { + it('should store content successfully', async () => { + const testData = '{"test": "data"}'; + + await engine.setContentsJsonString(testData); + + expect(mockCaches.open).toHaveBeenCalledWith('eppo-sdk-test-suffix'); + expect(mockCache.put).toHaveBeenCalledWith( + 'eppo-configuration-test-suffix', + expect.any(Response) + ); + + // Verify the Response object has correct properties + const putCall = mockCache.put.mock.calls[0]; + const response = putCall[1] as Response; + expect(await response.text()).toBe(testData); + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(response.headers.get('Cache-Stored-At')).toBeTruthy(); + }); + + it('should throw error when Cache API is not supported', async () => { + (global as any).caches = undefined; + + await expect(engine.setContentsJsonString('test')).rejects.toThrow('Cache API not supported'); + }); + + it('should handle cache errors and log them', async () => { + mockCache.put.mockRejectedValue(new Error('Cache write error')); + + await expect(engine.setContentsJsonString('test')).rejects.toThrow('Cache write error'); + expect(console.error).toHaveBeenCalledWith('Failed to write to Web Cache API:', expect.any(Error)); + }); + }); + + describe('isExpired', () => { + it('should return true when cooldown is 0', async () => { + const result = await engine.isExpired(0); + expect(result).toBe(true); + }); + + it('should return false for non-expired cache', async () => { + const recentTime = new Date(Date.now() - 30000).toISOString(); // 30 seconds ago + const testData = JSON.stringify({ createdAt: recentTime }); + const mockResponse = new Response(testData); + mockCache.match.mockResolvedValue(mockResponse); + + const result = await engine.isExpired(60); // 60 second cooldown + + expect(result).toBe(false); + }); + + it('should return true for expired cache', async () => { + const oldTime = new Date(Date.now() - 120000).toISOString(); // 2 minutes ago + const testData = JSON.stringify({ createdAt: oldTime }); + const mockResponse = new Response(testData); + mockCache.match.mockResolvedValue(mockResponse); + + const result = await engine.isExpired(60); // 60 second cooldown + + expect(result).toBe(true); + }); + + it('should return true when no cached data exists', async () => { + mockCache.match.mockResolvedValue(undefined); + + const result = await engine.isExpired(60); + + expect(result).toBe(true); + }); + + it('should return true when cached data has no createdAt', async () => { + const testData = JSON.stringify({ someOtherField: 'value' }); + const mockResponse = new Response(testData); + mockCache.match.mockResolvedValue(mockResponse); + + const result = await engine.isExpired(60); + + expect(result).toBe(true); + }); + + it('should return true when cached data is invalid JSON', async () => { + const mockResponse = new Response('invalid json'); + mockCache.match.mockResolvedValue(mockResponse); + + const result = await engine.isExpired(60); + + expect(result).toBe(true); + expect(console.warn).toHaveBeenCalledWith('Failed to check cache expiration:', expect.any(Error)); + }); + + it('should handle cache read errors gracefully', async () => { + jest.spyOn(engine, 'getContentsJsonString').mockRejectedValue(new Error('Read error')); + + const result = await engine.isExpired(60); + + expect(result).toBe(true); + expect(console.warn).toHaveBeenCalledWith('Failed to check cache expiration:', expect.any(Error)); + }); + }); + + describe('clear', () => { + it('should clear cache successfully', async () => { + await engine.clear(); + + expect(mockCaches.delete).toHaveBeenCalledWith('eppo-sdk-test-suffix'); + }); + + it('should handle missing Cache API gracefully', async () => { + (global as any).caches = undefined; + + await engine.clear(); + + // Should not throw error + }); + + it('should handle cache deletion errors gracefully', async () => { + mockCaches.delete.mockRejectedValue(new Error('Delete error')); + + await engine.clear(); + + expect(console.warn).toHaveBeenCalledWith('Failed to clear Web Cache API:', expect.any(Error)); + }); + }); + + describe('integration scenarios', () => { + it('should handle full read-write-expire cycle', async () => { + const testData = JSON.stringify({ + createdAt: new Date().toISOString(), + flags: { testFlag: true } + }); + + // Write data + await engine.setContentsJsonString(testData); + + // Read data back + const mockResponse = new Response(testData); + mockCache.match.mockResolvedValue(mockResponse); + const readData = await engine.getContentsJsonString(); + expect(readData).toBe(testData); + + // Check expiration (should not be expired with short cooldown) + const isExpired = await engine.isExpired(3600); // 1 hour + expect(isExpired).toBe(false); + }); + + it('should work with different storage key suffixes', () => { + const engine1 = new WebCacheStorageEngine('suffix1'); + const engine2 = new WebCacheStorageEngine('suffix2'); + + // Engines should be independent (different cache names) + expect(engine1).toBeInstanceOf(WebCacheStorageEngine); + expect(engine2).toBeInstanceOf(WebCacheStorageEngine); + }); + }); +}); \ No newline at end of file diff --git a/src/web-cache-storage-engine.ts b/src/web-cache-storage-engine.ts new file mode 100644 index 0000000..adf85ea --- /dev/null +++ b/src/web-cache-storage-engine.ts @@ -0,0 +1,108 @@ +import { IConfigurationStorageEngine } from './configuration-store'; +import { CONFIGURATION_KEY } from './storage-key-constants'; + +/** + * Web Cache API implementation for storing configuration data. + * + * Uses the Cache API for better storage limits and performance compared to localStorage. + * Since the configuration response includes a createdAt timestamp, no separate metadata storage is needed. + */ +export class WebCacheStorageEngine implements IConfigurationStorageEngine { + private readonly cacheKey: string; + private readonly cacheName: string; + + public constructor(storageKeySuffix = '') { + const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : ''; + this.cacheName = `eppo-sdk${keySuffix}`; + this.cacheKey = CONFIGURATION_KEY + keySuffix; + } + + public getContentsJsonString = async (): Promise => { + try { + // Check if Cache API is supported + if (typeof caches === 'undefined') { + return null; + } + + const cache = await caches.open(this.cacheName); + const response = await cache.match(this.cacheKey); + + if (!response) { + return null; + } + + return await response.text(); + } catch (error) { + console.warn('Failed to read from Web Cache API:', error); + return null; + } + }; + + public setContentsJsonString = async (configurationJsonString: string): Promise => { + try { + // Check if Cache API is supported + if (typeof caches === 'undefined') { + throw new Error('Cache API not supported'); + } + + const cache = await caches.open(this.cacheName); + + // Create a Response object to store in cache + const response = new Response(configurationJsonString, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Stored-At': new Date().toISOString(), + }, + }); + + await cache.put(this.cacheKey, response); + } catch (error) { + console.error('Failed to write to Web Cache API:', error); + throw error; + } + }; + + /** + * Check if the cached configuration has expired based on its createdAt timestamp + */ + public async isExpired(cooldownSeconds: number): Promise { + if (!cooldownSeconds) { + return true; + } + + try { + const configString = await this.getContentsJsonString(); + if (!configString) { + return true; + } + + const config = JSON.parse(configString); + const createdAt = config.createdAt; + + if (!createdAt) { + return true; + } + + const createdAtMs = new Date(createdAt).getTime(); + return Date.now() - createdAtMs > cooldownSeconds * 1000; + } catch (error) { + console.warn('Failed to check cache expiration:', error); + return true; + } + } + + /** + * Clear all cached data for this storage engine + */ + public async clear(): Promise { + try { + if (typeof caches === 'undefined') { + return; + } + + await caches.delete(this.cacheName); + } catch (error) { + console.warn('Failed to clear Web Cache API:', error); + } + } +}