/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `FlavorService` — viewer-side façade for the flavor library. * * Defaults to the IDB-backed storage adapter so flavors persist across * reloads. Tests can pass `InMemoryFlavorStorage` via options. The * service owns the active-flavor pointer, list/CRUD, switch logic, * and snapshot management. * * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md. */ import { DEFAULT_FLAVOR_ID, flavorImportedId, packFlavor, switchFlavor, unpackFlavor, validateFlavor, type Flavor, type FlavorExtensionState, type FlavorStorage, type FlavorSwitcherCallbacks, type FlavorSwitchResult, type UnpackedFlavor, } from '@ifc-lite/extensions'; import { IdbFlavorStorage } from './idb-flavor-storage.js'; export interface FlavorServiceOptions { storage?: FlavorStorage; /** * Optional callback the service fires on every lifecycle event * (activate, export, import) so the host can mirror them into the * action log for the pattern miner. Content-free: the callback * only sees the flavor id, never any flavor data. */ onLifecycle?: (event: 'activate' | 'export' | 'import', id?: string) => void; } export class FlavorService { private readonly storage: FlavorStorage; private readonly onLifecycle?: FlavorServiceOptions['onLifecycle']; private listeners = new Set<() => void>(); constructor(opts: FlavorServiceOptions = {}) { this.storage = opts.storage ?? new IdbFlavorStorage(); this.onLifecycle = opts.onLifecycle; } async list(): Promise { return this.storage.listFlavors(); } async getActive(): Promise { const id = await this.storage.getActiveId(); return id ? this.storage.getFlavor(id) : undefined; } async put(flavor: Flavor, reason?: string): Promise { await this.storage.putFlavor(flavor, reason); this.emit(); } async delete(id: string): Promise { await this.storage.deleteFlavor(id); this.emit(); } async activate(id: string | undefined): Promise { await this.storage.setActiveId(id); if (id) this.onLifecycle?.('activate', id); this.emit(); } /** * Switch to a target flavor, enabling its extension list and * disabling anything the prior flavor had that this one doesn't. * Callers wire the `callbacks` to their extension loader/runtime. */ async switchTo( target: Flavor, installed: readonly FlavorExtensionState[], callbacks: FlavorSwitcherCallbacks, ): Promise { const current = await this.getActive(); const result = await switchFlavor({ target, installed, current, callbacks, }); // Always emit — even on failure the rollback ran and the in-memory // extension state likely moved. The UI needs to refresh either way. this.emit(); return result; } /** Serialise the active (or named) flavor to a `.iflv` byte array. */ async exportFlavor(id?: string, summary?: string): Promise { const flavorId = id ?? (await this.storage.getActiveId()); if (!flavorId) throw new Error('No active flavor to export.'); const flavor = await this.storage.getFlavor(flavorId); if (!flavor) throw new Error(`Unknown flavor: ${flavorId}`); const bytes = packFlavor(flavor, { summary }); this.onLifecycle?.('export', flavorId); return bytes; } /** Parse + validate a `.iflv` byte array. Does NOT install. */ async preview(bytes: Uint8Array): Promise { const result = unpackFlavor(bytes); if (!result.ok) { throw new Error( `Flavor did not unpack: ${result.errors[0]?.message ?? 'unknown error'}`, ); } return result.value; } /** Save a previewed flavor, optionally as a new id. */ async importFlavor( unpacked: UnpackedFlavor, opts: { strategy?: 'replace' | 'save-as-new'; newId?: string } = {}, ): Promise { const validated = validateFlavor(unpacked.flavor); if (!validated.ok) { throw new Error( `Imported flavor did not validate: ${validated.errors[0]?.message ?? 'unknown'}`, ); } let flavor = validated.value; if (opts.strategy === 'save-as-new') { flavor = { ...flavor, id: opts.newId ?? flavorImportedId(flavor.id), updatedAt: new Date().toISOString(), }; } await this.storage.putFlavor(flavor, 'imported'); this.onLifecycle?.('import', flavor.id); this.emit(); return flavor; } /** Reset to a clean baseline flavor. */ async resetToDefaults(): Promise { const id = DEFAULT_FLAVOR_ID; const now = new Date().toISOString(); const flavor: Flavor = { schemaVersion: 1, id, name: 'Default', description: 'Baseline flavor — no extensions, no overrides.', createdAt: now, updatedAt: now, extensions: [], lenses: [], savedQueries: [], keybindings: [], layout: { state: {} }, settings: {}, }; await this.storage.putFlavor(flavor, 'reset to defaults'); await this.storage.setActiveId(id); this.emit(); return flavor; } /** Subscribe to flavor changes. Returns unsubscribe. */ onChange(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } private emit(): void { for (const l of this.listeners) l(); } }