LCOV - code coverage report
Current view: top level - dashql-app/src/storage - composite_storage_backend.ts Coverage Total Hit
Test: DashQL 915828b Lines: 86.7 % 330 286
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import {
       2              :     type StorageBackend,
       3              :     type SessionRegistryBackend,
       4              :     type SessionData,
       5              :     type ScriptData,
       6              :     type DataFile,
       7              :     type DataFileContent,
       8              :     type SessionEntry,
       9              :     type AppSettings,
      10              :     type ConnectionEntry,
      11              :     type StorageManifest,
      12              :     StorageBackendType,
      13              :     validateDataFileName,
      14              : } from './storage_backend.js';
      15              : import {
      16              :     type SessionTreeFolder,
      17              :     emptySessionTree,
      18              :     flattenSessionTree,
      19              :     removeSessionFromTree,
      20              :     upsertSessionInTree,
      21              : } from './session_tree.js';
      22              : import { type FileStamp } from '../platform/electron_fs.js';
      23              : import { NativeStorageBackend } from './native_storage_backend.js';
      24              : import { type SessionLocation, locationFromEntry } from './session_locator.js';
      25              : import { copySession, verifySession } from './session_transfer.js';
      26              : import { validateSessionData, describeSessionValidationError } from './session_validation.js';
      27              : import {
      28              :     assembleSessionBundle,
      29              :     readSessionBundle,
      30              :     readSessionBundleHead,
      31              :     reconcileStoredSessionFiles,
      32              :     seedConnectionCatalogs,
      33              :     writeSessionBundle,
      34              :     type SessionBundle,
      35              : } from './session_bundle.js';
      36              : import type { Logger } from '../platform/logger/logger.js';
      37              : import { NativeFolder, OverlayFolder } from './migration/migration_folder.js';
      38              : import { migrateSessionFolder } from './migration/session_migration.js';
      39              : 
      40            1 : const LOG_CTX = 'composite_storage_backend';
      41              : 
      42              : export interface SessionImportConflict {
      43              :     /// The registry key, preserving its registered spelling/casing.
      44              :     sessionId: string;
      45              :     location: SessionLocation;
      46              : }
      47              : 
      48              : export interface PreparedNativeSession {
      49              :     bundle: SessionBundle;
      50              :     dir: string;
      51              : }
      52              : 
      53              : /// A storage backend that routes by per-session location.
      54              : ///
      55              : /// The OPFS session tree is the single registry of *every* session; registry-level operations
      56              : /// (listing sessions, app settings, clear-all) therefore always go to OPFS. Per-session operations
      57              : /// are routed by the session UUID to whichever backend physically holds that session's files:
      58              : ///   - OPFS sessions -> the shared OPFS backend (`sessions/<uuid>/…`).
      59              : ///   - Native sessions -> a per-session `NativeStorageBackend` bound to the recorded directory.
      60              : ///
      61              : /// Native filesystem scope is re-granted lazily (and idempotently) the first time a native session
      62              : /// is touched. The compatibility scope hook is currently a no-op under Electron.
      63            1 : export class CompositeStorageBackend implements SessionRegistryBackend {
      64              :     /// The OPFS backend, which owns the session registry
      65            1 :     private readonly opfs: SessionRegistryBackend;
      66              :     /// uuid -> physical location, built from the session tree at init and kept in sync on writes
      67            1 :     private readonly locations: Map<string, SessionLocation>;
      68              :     /// The session tree, loaded at init and kept in sync on writes
      69            1 :     private tree: SessionTreeFolder = emptySessionTree();
      70              :     /// Cached per-session native backends, keyed by uuid
      71            1 :     private readonly nativeCache: Map<string, NativeStorageBackend>;
      72              :     /// The stamps of the native attachments that are mirrored into OPFS, keyed by session uuid
      73            1 :     private readonly mirroredAttachments: Map<string, Map<string, FileStamp>>;
      74              :     /// The logger
      75            1 :     private readonly logger: Logger;
      76              : 
      77            1 :     constructor(opfs: SessionRegistryBackend, logger: Logger) {
      78            1 :         this.opfs = opfs;
      79            1 :         this.locations = new Map();
      80            1 :         this.nativeCache = new Map();
      81            1 :         this.mirroredAttachments = new Map();
      82            1 :         this.logger = logger;
      83            1 :     }
      84              : 
      85            1 :     getBackendType(): StorageBackendType {
      86            1 :         return StorageBackendType.OPFS;
      87            1 :     }
      88              : 
      89            1 :     async initialize(): Promise<void> {
      90            1 :         await this.opfs.initialize?.();
      91            1 :         await this.refreshLocations();
      92            1 :     }
      93              : 
      94              :     /// Rebuild the session tree and the uuid -> location map from the OPFS session tree.
      95              :     ///
      96              :     /// Called at init (and after relocate, before reload re-runs init).
      97            1 :     async refreshLocations(): Promise<void> {
      98            1 :         this.tree = await this.opfs.loadSessionTree();
      99            1 :         this.locations.clear();
     100            1 :         for (const entry of flattenSessionTree(this.tree)) {
     101            1 :             this.locations.set(entry.path, locationFromEntry(entry));
     102              :         }
     103            1 :     }
     104              : 
     105              :     /// Reload the session tree after a change of its folders
     106            1 :     private async refreshTree(): Promise<void> {
     107            1 :         this.tree = await this.opfs.loadSessionTree();
     108            1 :     }
     109              : 
     110              :     /// The current known location for a session (defaulting to OPFS for unknown UUIDs).
     111            1 :     private locationOf(sessionId: string): SessionLocation {
     112            1 :         return this.locations.get(sessionId) ?? { type: StorageBackendType.OPFS };
     113            1 :     }
     114              : 
     115              :     /// Resolve the backend that physically holds a session's files.
     116            1 :     private async backendFor(sessionId: string): Promise<StorageBackend> {
     117            1 :         return this.nativeSessionFolder(sessionId) ?? this.opfs;
     118            1 :     }
     119              : 
     120              :     /// The backend of a session that lives in a native folder, null for other sessions
     121            1 :     nativeSessionFolder(sessionId: string): NativeStorageBackend | null {
     122            1 :         const loc = this.locationOf(sessionId);
     123            1 :         if (loc.type !== StorageBackendType.Native || !loc.nativePath) {
     124            1 :             return null;
     125              :         }
     126            1 :         let native = this.nativeCache.get(sessionId);
     127            1 :         if (!native) {
     128            1 :             native = new NativeStorageBackend(loc.nativePath);
     129            1 :             this.nativeCache.set(sessionId, native);
     130            1 :         }
     131            1 :         return native;
     132            1 :     }
     133              : 
     134              :     // ---- Registry-level operations (always OPFS) ----------------------------------------------
     135              : 
     136            1 :     listSessions(): Promise<SessionEntry[]> {
     137            1 :         return this.opfs.listSessions();
     138            1 :     }
     139            0 :     loadSessionTree(): Promise<SessionTreeFolder> {
     140            0 :         return this.opfs.loadSessionTree();
     141            0 :     }
     142            1 :     loadAppSettings(): Promise<AppSettings | null> {
     143            1 :         return this.opfs.loadAppSettings();
     144            1 :     }
     145            1 :     saveAppSettings(settings: AppSettings): Promise<void> {
     146            1 :         return this.opfs.saveAppSettings(settings);
     147            1 :     }
     148            1 :     loadManifest(): Promise<StorageManifest> {
     149            1 :         return this.opfs.loadManifest();
     150            1 :     }
     151            1 :     updateConnections(update: (connections: ConnectionEntry[] | undefined) => ConnectionEntry[] | null): Promise<void> {
     152            1 :         return this.opfs.updateConnections(update);
     153            1 :     }
     154            1 :     loadConnectionFile(connectionId: string, fileName: string): Promise<string | null> {
     155            1 :         return this.opfs.loadConnectionFile(connectionId, fileName);
     156            1 :     }
     157            1 :     saveConnectionFile(connectionId: string, fileName: string, text: string): Promise<void> {
     158            1 :         return this.opfs.saveConnectionFile(connectionId, fileName, text);
     159            1 :     }
     160            1 :     createConnectionFolder(connectionId: string): Promise<void> {
     161            1 :         return this.opfs.createConnectionFolder(connectionId);
     162            1 :     }
     163            0 :     deleteConnectionFiles(connectionId: string): Promise<void> {
     164            0 :         return this.opfs.deleteConnectionFiles(connectionId);
     165            0 :     }
     166              : 
     167            1 :     async upsertSessionEntry(entry: SessionEntry, folderPath: readonly string[] = []): Promise<void> {
     168            1 :         await this.opfs.upsertSessionEntry(entry, folderPath);
     169            1 :         this.locations.set(entry.path, locationFromEntry(entry));
     170            1 :         this.tree = upsertSessionInTree(this.tree, entry, folderPath);
     171            1 :     }
     172            0 :     async removeSessionEntry(sessionId: string): Promise<void> {
     173            0 :         await this.opfs.removeSessionEntry(sessionId);
     174            0 :         this.forgetSession(sessionId);
     175            0 :     }
     176            1 :     async moveSession(sessionId: string, folderPath: string[], index: number): Promise<void> {
     177            1 :         await this.opfs.moveSession(sessionId, folderPath, index);
     178            1 :         await this.refreshTree();
     179            1 :     }
     180            1 :     async createFolder(path: string[]): Promise<void> {
     181            1 :         await this.opfs.createFolder(path);
     182            1 :         await this.refreshTree();
     183            1 :     }
     184            1 :     async moveFolder(path: string[], parentPath: string[], name?: string): Promise<void> {
     185            1 :         await this.opfs.moveFolder(path, parentPath, name);
     186            1 :         await this.refreshTree();
     187            1 :     }
     188            1 :     async deleteFolder(path: string[]): Promise<void> {
     189            1 :         await this.opfs.deleteFolder(path);
     190            1 :         await this.refreshTree();
     191            1 :     }
     192              : 
     193              :     /// Drop the in-memory state of a session that is no longer registered
     194            1 :     private forgetSession(sessionId: string): void {
     195            1 :         this.locations.delete(sessionId);
     196            1 :         this.nativeCache.delete(sessionId);
     197            1 :         this.mirroredAttachments.delete(sessionId);
     198            1 :         this.tree = removeSessionFromTree(this.tree, sessionId);
     199            1 :     }
     200              : 
     201              :     /// The session tree, the folders and sessions of the sidebar
     202            1 :     getSessionTree(): SessionTreeFolder {
     203            1 :         return this.tree;
     204            1 :     }
     205              : 
     206              :     /// The user-facing session order (the order of the session tree), as session UUIDs.
     207              :     /// Sessions that are routed but not yet in the tree come last.
     208            1 :     getSessionOrder(): string[] {
     209            1 :         const order = flattenSessionTree(this.tree).map(entry => entry.path);
     210            1 :         const listed = new Set(order);
     211            1 :         for (const id of this.locations.keys()) {
     212            1 :             if (!listed.has(id)) order.push(id);
     213              :         }
     214            1 :         return order;
     215            1 :     }
     216            0 :     deleteSessionFiles(sessionId: string): Promise<void> {
     217            0 :         return this.backendFor(sessionId).then(b => {
     218            0 :             if (b === this.opfs) {
     219            0 :                 return this.opfs.deleteSessionFiles(sessionId);
     220              :             }
     221              :             // For native sessions, the directory *is* the session.
     222            0 :             return b.deleteSession(sessionId);
     223            0 :         });
     224            0 :     }
     225              : 
     226              :     // ---- Per-session operations (routed by uuid) ----------------------------------------------
     227              : 
     228            1 :     async loadSession(sessionId: string): Promise<SessionData> {
     229            1 :         const backend = await this.backendFor(sessionId);
     230            1 :         const session = await backend.loadSession(sessionId);
     231            1 :         await this.syncNativeAttachments(sessionId);
     232            1 :         return session;
     233            1 :     }
     234              : 
     235              :     /// Mirror the attachments of a native session into OPFS, HyperDB-WASM reads them from there.
     236              :     /// A file is copied when its stamp differs from the one it was mirrored with, so the first sync of
     237              :     /// a session in an app run copies every attachment and later syncs copy only the changed ones.
     238            1 :     private async syncNativeAttachments(sessionId: string): Promise<void> {
     239            1 :         const native = this.nativeSessionFolder(sessionId);
     240            1 :         if (native == null) {
     241            1 :             return;
     242              :         }
     243            1 :         const files = (await native.stampFolder()).attachments;
     244            1 :         let mirrored = this.mirroredAttachments.get(sessionId);
     245            1 :         if (mirrored === undefined) {
     246            1 :             mirrored = new Map();
     247            1 :             this.mirroredAttachments.set(sessionId, mirrored);
     248            1 :         }
     249            1 :         const names = new Set<string>();
     250            1 :         for (const file of files) {
     251            1 :             validateDataFileName(file.name);
     252            1 :             names.add(file.name);
     253            1 :             const stamp = mirrored.get(file.name);
     254            1 :             if (stamp !== undefined && stamp.size === file.size && stamp.mtimeMs === file.mtimeMs) {
     255            1 :                 continue;
     256              :             }
     257              :             // Forget the stamp first, a failed copy is retried by the next sync
     258            1 :             mirrored.delete(file.name);
     259            1 :             await this.opfs.saveDataFile(sessionId, file.name, await native.loadDataFile(sessionId, file.name));
     260            1 :             mirrored.set(file.name, { size: file.size, mtimeMs: file.mtimeMs });
     261            1 :         }
     262            1 :         for (const file of await this.opfs.listDataFiles(sessionId)) {
     263            1 :             if (!names.has(file.name)) {
     264            1 :                 await this.opfs.deleteDataFile(sessionId, file.name);
     265            1 :                 mirrored.delete(file.name);
     266            1 :             }
     267              :         }
     268            1 :     }
     269              : 
     270            1 :     async saveSessionManifest(sessionId: string, data: SessionData): Promise<void> {
     271            1 :         const backend = await this.backendFor(sessionId);
     272            1 :         await backend.saveSessionManifest(sessionId, data);
     273            1 :         const loc = this.locationOf(sessionId);
     274            1 :         if (loc.type === StorageBackendType.Native && loc.nativePath) {
     275              :             // The native backend does not touch the registry, so keep its OPFS entry in sync here.
     276            1 :             await this.opfs.upsertSessionEntry({
     277            1 :                 path: sessionId,
     278            1 :                 storageType: StorageBackendType.Native,
     279            1 :                 nativePath: loc.nativePath,
     280            1 :             });
     281            1 :         } else {
     282              :             // The OPFS backend registered the session, a new one at the end of the root. Mirror that in
     283              :             // memory so getSessionOrder() includes it immediately, before the next app initialization.
     284            1 :             this.locations.set(sessionId, loc);
     285            1 :             this.tree = upsertSessionInTree(this.tree, { path: sessionId, storageType: StorageBackendType.OPFS });
     286            1 :         }
     287            1 :     }
     288              : 
     289            1 :     async deleteSession(sessionId: string): Promise<void> {
     290            1 :         const loc = this.locationOf(sessionId);
     291            1 :         if (loc.type === StorageBackendType.Native) {
     292              :             // Native: the files live in a user-owned folder on disk, so we never delete them —
     293              :             // deleting just unregisters the session by dropping the registry entry kept in OPFS.
     294              :             //
     295              :             // We deliberately do NOT route through `backendFor` here. The native backend's
     296              :             // `deleteSession` is a no-op, but resolving it would `initialize()` the directory, which
     297              :             // re-creates it via `mkdir`. For a session whose folder was moved/deleted on disk (the
     298              :             // exact case that makes it deletable-because-invalid) that would either resurrect an empty
     299              :             // folder or throw before we ever dropped the stale registry entry — leaving it to error
     300              :             // again on the next launch. Dropping the entry is all that's needed and can't fail on a
     301              :             // missing folder.
     302            1 :             await this.opfs.removeSessionEntry(sessionId);
     303              :             // Native files remain user-owned, but their OPFS attachment mirror belongs to DashQL.
     304            1 :             await this.opfs.deleteSessionFiles(sessionId);
     305            1 :         } else {
     306              :             // OPFS deletes files and removes the registry entry in one step.
     307            0 :             await this.opfs.deleteSession(sessionId);
     308              :         }
     309            1 :         this.forgetSession(sessionId);
     310            1 :     }
     311              : 
     312            1 :     async loadScripts(sessionId: string): Promise<ScriptData[]> {
     313            1 :         return (await this.backendFor(sessionId)).loadScripts(sessionId);
     314            1 :     }
     315            0 :     async loadScript(sessionId: string, scriptName: string): Promise<ScriptData> {
     316            0 :         return (await this.backendFor(sessionId)).loadScript(sessionId, scriptName);
     317            0 :     }
     318            1 :     async saveScript(sessionId: string, scriptName: string, sql: string): Promise<void> {
     319            1 :         return (await this.backendFor(sessionId)).saveScript(sessionId, scriptName, sql);
     320            1 :     }
     321            0 :     async deleteScript(sessionId: string, scriptName: string): Promise<void> {
     322            0 :         return (await this.backendFor(sessionId)).deleteScript(sessionId, scriptName);
     323            0 :     }
     324            1 :     async renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void> {
     325            1 :         return (await this.backendFor(sessionId)).renameScript(sessionId, oldScriptName, newScriptName);
     326            1 :     }
     327            1 :     async listDataFiles(sessionId: string): Promise<DataFile[]> {
     328            1 :         return (await this.backendFor(sessionId)).listDataFiles(sessionId);
     329            1 :     }
     330            1 :     async loadDataFile(sessionId: string, name: string): Promise<Uint8Array> {
     331            1 :         return (await this.backendFor(sessionId)).loadDataFile(sessionId, name);
     332            1 :     }
     333            1 :     async saveDataFile(sessionId: string, name: string, content: DataFileContent): Promise<void> {
     334            1 :         const backend = await this.backendFor(sessionId);
     335            1 :         await backend.saveDataFile(sessionId, name, content);
     336            1 :         await this.syncNativeAttachments(sessionId);
     337            1 :     }
     338            1 :     async deleteDataFile(sessionId: string, name: string): Promise<void> {
     339            1 :         const backend = await this.backendFor(sessionId);
     340            1 :         await backend.deleteDataFile(sessionId, name);
     341            1 :         await this.syncNativeAttachments(sessionId);
     342            1 :     }
     343            1 :     async renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void> {
     344            1 :         const backend = await this.backendFor(sessionId);
     345            1 :         await backend.renameDataFile(sessionId, oldName, newName);
     346            1 :         await this.syncNativeAttachments(sessionId);
     347            1 :     }
     348            0 :     async clearAllStorage(): Promise<void> {
     349              :         // Only the OPFS root is wiped (registry + OPFS-backed sessions). Native sessions live in
     350              :         // user-owned folders on disk that we never delete; they're simply unregistered when the
     351              :         // OPFS session tree is removed. We drop the in-memory location/backend caches to match.
     352            0 :         this.nativeCache.clear();
     353            0 :         this.mirroredAttachments.clear();
     354            0 :         this.locations.clear();
     355            0 :         this.tree = emptySessionTree();
     356            0 :         await this.opfs.clearAllStorage?.();
     357            0 :     }
     358              : 
     359              :     /// The recorded location for a session (used by the UI to render a display path).
     360            1 :     getSessionLocation(sessionId: string): SessionLocation {
     361            1 :         return this.locationOf(sessionId);
     362            1 :     }
     363              : 
     364              :     /// Find a registered UUID collision without attempting to read the session itself.
     365              :     ///
     366              :     /// The session tree is authoritative, including entries whose files or metadata are invalid.
     367              :     /// UUID comparison is case-insensitive, but the returned id preserves the actual registry key.
     368            1 :     async findSessionImportConflict(sessionId: string): Promise<SessionImportConflict | null> {
     369            1 :         const normalizedId = sessionId.toLowerCase();
     370            1 :         const entries = await this.opfs.listSessions();
     371            1 :         const entry = entries.find(candidate => candidate.path.toLowerCase() === normalizedId);
     372            1 :         return entry == null
     373            1 :             ? null
     374            1 :             : { sessionId: entry.path, location: locationFromEntry(entry) };
     375            1 :     }
     376              : 
     377              :     /// Read and validate a selected native folder without creating or modifying anything in it.
     378              :     ///
     379              :     /// A folder of an older version is migrated in memory, the folder itself is migrated once it is
     380              :     /// registered.
     381            1 :     async prepareNativeSession(dir: string): Promise<PreparedNativeSession> {
     382            1 :         const head = await readSessionBundleHead(new OverlayFolder(new NativeFolder(dir)), `Invalid session folder ${dir}`);
     383              : 
     384            1 :         let bundle: SessionBundle;
     385            1 :         try {
     386              :             // Native reads do not require initialize(); avoiding it keeps a missing selected folder
     387              :             // from being created as a side effect of validation.
     388            1 :             const native = new NativeStorageBackend(dir);
     389            1 :             const scripts = await native.loadScripts(dir);
     390            1 :             const data = await Promise.all((await native.listDataFiles(dir))
     391            1 :                 .map(async file => ({ name: file.name, bytes: await native.loadDataFile(dir, file.name) })));
     392            1 :             bundle = assembleSessionBundle(head, scripts, data);
     393            1 :         } catch (error) {
     394            0 :             throw new Error(`Failed to read session in ${dir}: ${(error as Error)?.message ?? String(error)}`);
     395            0 :         }
     396            1 :         return { bundle, dir };
     397            1 :     }
     398              : 
     399              :     /// Read durable data from the physical location currently registered for a session.
     400            1 :     readSessionBundle(sessionId: string): Promise<SessionBundle> {
     401            1 :         return readSessionBundle(sessionId, this);
     402            1 :     }
     403              : 
     404              :     /// Read durable data explicitly from OPFS, bypassing composite location routing.
     405            1 :     readPortableSessionBundle(sessionId: string): Promise<SessionBundle> {
     406            1 :         return readSessionBundle(sessionId, this.opfs);
     407            1 :     }
     408              : 
     409              :     /// Write a complete bundle explicitly to OPFS, bypassing a current native route.
     410              :     ///
     411              :     /// `targetIsFresh` enables writeSessionBundle's whole-session rollback. The in-memory route is
     412              :     /// changed only after every durable write succeeds.
     413            1 :     async writePortableSessionBundle(
     414            1 :         bundle: SessionBundle,
     415            1 :         targetSessionId: string,
     416            1 :         targetIsFresh: boolean,
     417            1 :     ): Promise<void> {
     418            1 :         await writeSessionBundle(bundle, this.opfs, { targetSessionId, targetIsFresh });
     419            1 :         this.setLocationAfterPortableWrite(targetSessionId);
     420            1 :         this.nativeCache.delete(targetSessionId);
     421            1 :     }
     422              : 
     423            1 :     private setLocationAfterPortableWrite(sessionId: string): void {
     424              :         // Preserve the registry key when the source UUID differs only by case, the tree keeps its place
     425            1 :         const registeredId = [...this.locations.keys()]
     426            1 :             .find(candidate => candidate.toLowerCase() === sessionId.toLowerCase()) ?? sessionId;
     427            1 :         this.locations.set(registeredId, { type: StorageBackendType.OPFS });
     428            1 :         this.tree = upsertSessionInTree(this.tree, { path: registeredId, storageType: StorageBackendType.OPFS });
     429            1 :     }
     430              : 
     431              :     /// Delete the complete OPFS session directory (durable files and derived cache), preserving its
     432              :     /// registry entry and any native folder registered under the same UUID.
     433            1 :     deletePortableSessionFiles(sessionId: string): Promise<void> {
     434            1 :         return this.opfs.deleteSessionFiles(sessionId);
     435            1 :     }
     436              : 
     437              :     /// Delete an OPFS session directory and registry entry. Used for fresh staging UUID cleanup.
     438            1 :     async deletePortableSession(sessionId: string): Promise<void> {
     439            1 :         await this.opfs.deleteSession(sessionId);
     440            1 :         this.forgetSession(sessionId);
     441            1 :     }
     442              : 
     443              :     /// Register an already-prepared native folder and reconcile its file lists with its files.
     444            1 :     async registerPreparedNativeSession(prepared: PreparedNativeSession, sessionId: string): Promise<void> {
     445            1 :         const validation = validateSessionData(prepared.bundle.session);
     446            1 :         if (!validation.ok) {
     447            0 :             throw new Error(`Session in ${prepared.dir} is invalid: ${describeSessionValidationError(validation.error)}`);
     448              :         }
     449              :         // The catalogs of the folder seed the connections that are new to the app. The app ignores
     450              :         // them afterwards, and the migration removes the catalog copy of older folders.
     451            1 :         await seedConnectionCatalogs(this.opfs, prepared.bundle, sessionId);
     452            1 :         await migrateSessionFolder(new NativeFolder(prepared.dir));
     453            1 :         const native = new NativeStorageBackend(prepared.dir);
     454            1 :         await reconcileStoredSessionFiles(native, sessionId);
     455            1 :         await this.upsertSessionEntry({
     456            1 :             path: sessionId,
     457            1 :             storageType: StorageBackendType.Native,
     458            1 :             nativePath: prepared.dir,
     459            1 :         });
     460            1 :         this.nativeCache.set(sessionId, native);
     461            1 :     }
     462              : 
     463              :     /// Relocate a single OPFS session's files into a native directory.
     464              :     ///
     465              :     /// The registry entry stays in OPFS; only the files move. Steps:
     466              :     ///   1. Grant scope for, and initialize, the target directory.
     467              :     ///   2. Copy the session's files OPFS -> native (UUID preserved).
     468              :     ///   3. Verify the copy; on failure throw without touching OPFS.
     469              :     ///   4. Flip the OPFS registry entry to `location=native, nativePath=dir` and persist the
     470              :     ///      session manifest's `storageType`/`nativePath`.
     471              :     ///   5. Delete the OPFS copy of the session's files (NOT the registry entry).
     472              :     ///
     473              :     /// On any failure before step 4 the OPFS copy is left fully intact, so the caller can recover.
     474            1 :     async relocateSessionToNative(uuid: string, dir: string): Promise<void> {
     475            1 :         if (this.locationOf(uuid).type !== StorageBackendType.OPFS) {
     476            1 :             throw new Error(`Session ${uuid} is not an OPFS session`);
     477              :         }
     478              : 
     479            1 :         const native = new NativeStorageBackend(dir);
     480            1 :         await native.initialize();
     481              : 
     482              :         // 2. Copy. Stamp the session manifest with its new physical location as we write it.
     483            1 :         const sessionData = await this.opfs.loadSession(uuid);
     484            1 :         await copySession(uuid, this.opfs, native, this.logger);
     485            1 :         await native.saveSessionManifest(uuid, {
     486            1 :             ...sessionData,
     487            1 :             storageType: StorageBackendType.Native,
     488            1 :             nativePath: dir,
     489            1 :         });
     490              : 
     491              :         // 3. Verify before we touch OPFS.
     492            1 :         const ok = await verifySession(uuid, this.opfs, native);
     493            1 :         if (!ok) {
     494            0 :             throw new Error(`Relocation verification failed for session ${uuid} - keeping OPFS copy`);
     495              :         }
     496              : 
     497              :         // 4. Flip the registry entry + cache the new location/backend.
     498            1 :         await this.opfs.upsertSessionEntry({
     499            1 :             path: uuid,
     500            1 :             storageType: StorageBackendType.Native,
     501            1 :             nativePath: dir,
     502            1 :         });
     503            1 :         this.locations.set(uuid, { type: StorageBackendType.Native, nativePath: dir });
     504            1 :         this.tree = upsertSessionInTree(this.tree, { path: uuid, storageType: StorageBackendType.Native, nativePath: dir });
     505            1 :         this.nativeCache.set(uuid, native);
     506              : 
     507              :         // 5. Now it's safe to drop the OPFS copy of the files (registry entry stays).
     508            1 :         await this.opfs.deleteSessionFiles(uuid);
     509            1 :         this.logger.info('relocated session to native storage', { sessionId: uuid, dir }, LOG_CTX);
     510            1 :     }
     511              : 
     512              :     /// Load a pre-existing native session directory into the registry.
     513              :     ///
     514              :     /// Unlike `relocateSessionToNative`, this copies nothing: the directory already holds a complete
     515              :     /// session written by a previous run (or another machine). We simply read its session manifest,
     516              :     /// validate the metadata up front (the same fail-fast gate the loader uses), and record a
     517              :     /// `location=native` entry in the OPFS session tree so the next restore picks it up.
     518              :     ///
     519              :     /// Returns the loaded session's UUID. Throws if the directory holds no readable session, the
     520              :     /// metadata is invalid, or a session with the same UUID is already registered (we never silently
     521              :     /// overwrite an existing entry — the caller surfaces the error).
     522            0 :     async loadNativeSession(dir: string): Promise<string> {
     523            0 :         const prepared = await this.prepareNativeSession(dir);
     524            0 :         const uuid = prepared.bundle.session.sessionId;
     525              : 
     526              :         // Never clobber a session that's already registered (same folder added twice, or a UUID
     527              :         // collision with an existing OPFS/native session).
     528            0 :         if (await this.findSessionImportConflict(uuid)) {
     529            0 :             throw new Error(`Session ${uuid} is already registered`);
     530              :         }
     531              : 
     532            0 :         await this.registerPreparedNativeSession(prepared, uuid);
     533              : 
     534            0 :         this.logger.info('loaded native session', { sessionId: uuid, dir }, LOG_CTX);
     535            0 :         return uuid;
     536            0 :     }
     537            1 : }
        

Generated by: LCOV version 2.0-1