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 : }
|