Line data Source code
1 : import { StorageBackendType, type SessionEntry } from './storage_backend.js';
2 : import {
3 : assembleSessionBundle,
4 : seedConnectionCatalogs,
5 : type SessionBundle,
6 : sessionBundlesEqual,
7 : } from './session_bundle.js';
8 : import {
9 : type CompositeStorageBackend,
10 : type SessionImportConflict,
11 : type PreparedNativeSession,
12 : } from './composite_storage_backend.js';
13 :
14 : export type { SessionImportConflict, PreparedNativeSession } from './composite_storage_backend.js';
15 :
16 : export interface SessionImportTransactionOptions {
17 : randomUUID?: () => string;
18 : }
19 :
20 1 : export async function findSessionImportConflict(
21 1 : backend: CompositeStorageBackend,
22 1 : sessionId: string,
23 1 : ): Promise<SessionImportConflict | null> {
24 1 : return await backend.findSessionImportConflict(sessionId);
25 1 : }
26 :
27 1 : export async function prepareNativeSessionImport(
28 1 : backend: CompositeStorageBackend,
29 1 : dir: string,
30 1 : ): Promise<PreparedNativeSession> {
31 1 : return await backend.prepareNativeSession(dir);
32 1 : }
33 :
34 : /// Write a new portable session under an explicit UUID and verify every durable field.
35 1 : export async function writePortableSessionFresh(
36 1 : backend: CompositeStorageBackend,
37 1 : bundle: SessionBundle,
38 1 : targetSessionId: string = bundle.session.sessionId,
39 1 : ): Promise<string> {
40 1 : const conflict = await backend.findSessionImportConflict(targetSessionId);
41 1 : if (conflict) {
42 1 : throw new Error(`Session ${conflict.sessionId} is already registered`);
43 : }
44 :
45 1 : try {
46 1 : await backend.writePortableSessionBundle(bundle, targetSessionId, true);
47 1 : await verifyPortableBundle(backend, bundle, targetSessionId);
48 1 : await seedConnectionCatalogs(backend, bundle, targetSessionId);
49 1 : } catch (error) {
50 1 : try {
51 1 : await backend.deletePortableSession(targetSessionId);
52 1 : } catch {
53 : // Preserve the import failure when best-effort cleanup also fails.
54 1 : }
55 1 : throw error;
56 1 : }
57 1 : return targetSessionId;
58 1 : }
59 :
60 : /// Replace a registered session with a portable OPFS copy using a verified staging UUID.
61 1 : export async function replaceSessionWithPortableBundle(
62 1 : backend: CompositeStorageBackend,
63 1 : bundle: SessionBundle,
64 1 : conflict: SessionImportConflict,
65 1 : options: SessionImportTransactionOptions = {},
66 1 : ): Promise<string> {
67 1 : const targetSessionId = conflict.sessionId;
68 1 : requireMatchingSource(bundle, targetSessionId);
69 1 : const oldEntry = await findManifestEntry(backend, targetSessionId);
70 : // A replacement is also the recovery path for registered sessions that the current format can
71 : // no longer read (for example V1 sessions containing dashql-draft.sql). Keep rollback data when
72 : // possible, but do not prevent the explicitly confirmed replacement when it is unavailable.
73 1 : const oldBundle = await tryReadBundle(backend, targetSessionId);
74 1 : const stagingId = (options.randomUUID ?? (() => crypto.randomUUID()))();
75 1 : if (await backend.findSessionImportConflict(stagingId)) {
76 1 : throw new Error(`Staging session ${stagingId} is already registered`);
77 : }
78 :
79 1 : let operationError: unknown = null;
80 1 : let targetMutationStarted = false;
81 1 : try {
82 1 : await backend.writePortableSessionBundle(bundle, stagingId, true);
83 1 : await verifyPortableBundle(backend, bundle, stagingId);
84 :
85 : // Upsert first to retain the existing manifest position and route subsequent live accesses
86 : // to OPFS. Deleting the OPFS directory then removes every stale durable/cache file at once.
87 1 : await backend.upsertSessionEntry({ path: targetSessionId, storageType: StorageBackendType.OPFS });
88 1 : targetMutationStarted = true;
89 1 : await backend.deletePortableSessionFiles(targetSessionId);
90 1 : await backend.writePortableSessionBundle(bundle, targetSessionId, false);
91 1 : await verifyPortableBundle(backend, bundle, targetSessionId);
92 1 : await seedConnectionCatalogs(backend, bundle, targetSessionId);
93 1 : return targetSessionId;
94 1 : } catch (error) {
95 1 : operationError = error;
96 1 : if (targetMutationStarted) {
97 1 : await rollbackPortableReplacement(backend, targetSessionId, oldEntry, oldBundle);
98 : }
99 1 : throw error;
100 1 : } finally {
101 1 : try {
102 1 : await backend.deletePortableSession(stagingId);
103 1 : } catch (cleanupError) {
104 1 : if (operationError == null) {
105 1 : throw cleanupError;
106 : }
107 1 : }
108 1 : }
109 1 : }
110 :
111 : /// Register a validated selected folder directly, preserving its UUID and all folder/cache contents.
112 1 : export async function registerNativeSession(
113 1 : backend: CompositeStorageBackend,
114 1 : prepared: PreparedNativeSession,
115 1 : ): Promise<string> {
116 1 : const sessionId = prepared.bundle.session.sessionId;
117 1 : const conflict = await backend.findSessionImportConflict(sessionId);
118 1 : if (conflict) {
119 1 : throw new Error(`Session ${conflict.sessionId} is already registered`);
120 : }
121 1 : await backend.registerPreparedNativeSession(prepared, sessionId);
122 1 : return sessionId;
123 1 : }
124 :
125 : /// Replace a registration with a validated selected native folder. User-owned native folders are
126 : /// never changed; an old OPFS directory is removed only after the manifest flip succeeds.
127 1 : export async function replaceSessionWithNativeFolder(
128 1 : backend: CompositeStorageBackend,
129 1 : prepared: PreparedNativeSession,
130 1 : conflict: SessionImportConflict,
131 1 : ): Promise<string> {
132 1 : const targetSessionId = conflict.sessionId;
133 1 : requireMatchingSource(prepared.bundle, targetSessionId);
134 1 : if (conflict.location.type === StorageBackendType.Native
135 1 : && conflict.location.nativePath === prepared.dir) {
136 1 : return targetSessionId;
137 : }
138 :
139 1 : const oldEntry = await findManifestEntry(backend, targetSessionId);
140 1 : let manifestFlipped = false;
141 1 : try {
142 1 : await backend.registerPreparedNativeSession(prepared, targetSessionId);
143 1 : manifestFlipped = true;
144 1 : if (conflict.location.type === StorageBackendType.OPFS) {
145 1 : await backend.deletePortableSessionFiles(targetSessionId);
146 : }
147 1 : return targetSessionId;
148 1 : } catch (error) {
149 1 : if (manifestFlipped) {
150 1 : try {
151 1 : await backend.upsertSessionEntry(oldEntry);
152 1 : } catch {
153 : // Preserve the replacement error if manifest rollback also fails.
154 0 : }
155 : }
156 1 : throw error;
157 1 : }
158 1 : }
159 :
160 1 : async function rollbackPortableReplacement(
161 1 : backend: CompositeStorageBackend,
162 1 : targetSessionId: string,
163 1 : oldEntry: SessionEntry,
164 1 : oldBundle: SessionBundle | null,
165 1 : ): Promise<void> {
166 1 : await backend.deletePortableSessionFiles(targetSessionId);
167 1 : if (oldEntry.storageType !== StorageBackendType.Native && oldBundle) {
168 1 : await backend.writePortableSessionBundle(oldBundle, targetSessionId, false);
169 : }
170 1 : await backend.upsertSessionEntry(oldEntry);
171 1 : }
172 :
173 1 : async function findManifestEntry(
174 1 : backend: CompositeStorageBackend,
175 1 : sessionId: string,
176 1 : ): Promise<SessionEntry> {
177 1 : const entries = await backend.listSessions();
178 1 : const entry = entries.find(candidate => candidate.path.toLowerCase() === sessionId.toLowerCase());
179 1 : if (!entry) {
180 1 : throw new Error(`Session ${sessionId} is no longer registered`);
181 : }
182 1 : return { ...entry };
183 1 : }
184 :
185 1 : async function tryReadBundle(
186 1 : backend: CompositeStorageBackend,
187 1 : sessionId: string,
188 1 : ): Promise<SessionBundle | null> {
189 1 : try {
190 1 : return await backend.readSessionBundle(sessionId);
191 1 : } catch {
192 1 : return null;
193 1 : }
194 1 : }
195 :
196 1 : async function verifyPortableBundle(
197 1 : backend: CompositeStorageBackend,
198 1 : source: SessionBundle,
199 1 : targetSessionId: string,
200 1 : ): Promise<void> {
201 1 : const expected = normalizePortableBundle(source, targetSessionId);
202 1 : const actual = await backend.readPortableSessionBundle(targetSessionId);
203 1 : if (!sessionBundlesEqual(expected, actual)) {
204 1 : throw new Error(`Session import verification failed for ${targetSessionId}`);
205 : }
206 1 : }
207 :
208 1 : function normalizePortableBundle(bundle: SessionBundle, sessionId: string): SessionBundle {
209 1 : const session = { ...bundle.session, sessionId };
210 1 : delete session.sessionPath;
211 1 : delete session.storageType;
212 1 : delete session.nativePath;
213 : // The written session lists exactly the written scripts and attachments, the catalog belongs to
214 : // the connection
215 1 : return assembleSessionBundle({ session, catalogs: [] }, bundle.scripts, bundle.data ?? []);
216 1 : }
217 :
218 1 : function requireMatchingSource(bundle: SessionBundle, targetSessionId: string): void {
219 1 : if (bundle.session.sessionId.toLowerCase() !== targetSessionId.toLowerCase()) {
220 1 : throw new Error(
221 1 : `Source session ${bundle.session.sessionId} does not match replacement ${targetSessionId}`,
222 1 : );
223 : }
224 1 : }
|