Line data Source code
1 : import type {
2 : AppSettings,
3 : ConnectionEntry,
4 : DataFile,
5 : SessionData,
6 : SessionEntry,
7 : SessionRegistryBackend,
8 : ScriptData,
9 : StorageBackend,
10 : StorageManifest,
11 : } from './storage_backend.js';
12 : import { StorageBackendType, validateDataFileName } from './storage_backend.js';
13 : import { SESSION_FORMAT_VERSION } from './migration/versions.js';
14 : import {
15 : type SessionTreeFolder,
16 : emptySessionTree,
17 : flattenSessionTree,
18 : folderAt,
19 : folderNameError,
20 : isFolderPathWithin,
21 : moveSessionInTree,
22 : removeSessionFromTree,
23 : upsertSessionInTree,
24 : } from './session_tree.js';
25 :
26 1 : export class SessionTestBackend implements StorageBackend {
27 1 : readonly sessions = new Map<string, SessionData>();
28 1 : readonly scripts = new Map<string, Map<string, string>>();
29 1 : readonly data = new Map<string, Map<string, Uint8Array>>();
30 : /// The modification times of the data files, a counter that every save advances
31 1 : private readonly dataModified = new Map<string, number>();
32 1 : private dataClock = 0;
33 1 : readonly calls: string[] = [];
34 1 : appSettings: AppSettings | null = null;
35 : /// The connections of the manifest, undefined before the connections were migrated
36 1 : connections: ConnectionEntry[] | undefined = undefined;
37 : /// The files of the connections, keyed by `<connectionId>/<fileName>`
38 1 : readonly connectionFiles = new Map<string, string>();
39 :
40 1 : constructor(private readonly type = StorageBackendType.OPFS) {}
41 :
42 1 : getBackendType(): StorageBackendType { return this.type; }
43 1 : async listSessions(): Promise<SessionEntry[]> { return [...this.sessions.keys()].map(path => ({ path })); }
44 1 : async loadAppSettings(): Promise<AppSettings | null> { return this.appSettings; }
45 1 : async saveAppSettings(settings: AppSettings): Promise<void> { this.appSettings = settings; }
46 1 : async loadManifest(): Promise<StorageManifest> {
47 1 : return {
48 1 : ...(this.appSettings ? { appSettings: this.appSettings } : {}),
49 1 : ...(this.connections ? { connections: structuredClone(this.connections) } : {}),
50 1 : };
51 1 : }
52 1 : async updateConnections(update: (connections: ConnectionEntry[] | undefined) => ConnectionEntry[] | null): Promise<void> {
53 1 : const next = update(this.connections ? structuredClone(this.connections) : undefined);
54 1 : if (next != null) {
55 1 : this.calls.push('connections');
56 1 : this.connections = next;
57 1 : }
58 1 : }
59 1 : async loadConnectionFile(connectionId: string, fileName: string): Promise<string | null> {
60 1 : return this.connectionFiles.get(`${connectionId}/${fileName}`) ?? null;
61 1 : }
62 1 : async saveConnectionFile(connectionId: string, fileName: string, text: string): Promise<void> {
63 1 : this.calls.push(`connection-file:${connectionId}/${fileName}`);
64 1 : this.connectionFiles.set(`${connectionId}/${fileName}`, text);
65 1 : }
66 0 : async createConnectionFolder(connectionId: string): Promise<void> {
67 0 : this.calls.push(`connection-folder:${connectionId}`);
68 0 : }
69 1 : async deleteConnectionFiles(connectionId: string): Promise<void> {
70 1 : for (const key of [...this.connectionFiles.keys()]) {
71 0 : if (key.startsWith(`${connectionId}/`)) this.connectionFiles.delete(key);
72 : }
73 1 : }
74 1 : async loadSession(sessionId: string): Promise<SessionData> {
75 1 : const session = this.sessions.get(sessionId);
76 1 : if (!session) throw new Error(`No session ${sessionId}`);
77 1 : return session;
78 1 : }
79 1 : async saveSessionManifest(sessionId: string, data: SessionData): Promise<void> {
80 1 : this.calls.push(`manifest:${sessionId}`);
81 1 : this.sessions.set(sessionId, data);
82 1 : }
83 1 : async deleteSession(sessionId: string): Promise<void> {
84 1 : this.calls.push(`delete-session:${sessionId}`);
85 1 : this.sessions.delete(sessionId);
86 1 : this.scripts.delete(sessionId);
87 1 : this.data.delete(sessionId);
88 1 : }
89 1 : async loadScripts(sessionId: string): Promise<ScriptData[]> {
90 1 : return [...(this.scripts.get(sessionId) ?? new Map())]
91 1 : .map(([name, sql]) => ({ name, sql }))
92 1 : .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
93 1 : }
94 1 : async loadScript(sessionId: string, scriptName: string): Promise<ScriptData> {
95 1 : const sql = this.scripts.get(sessionId)?.get(scriptName);
96 1 : if (sql == null) throw new Error(`Script not found: ${scriptName}`);
97 1 : return { name: scriptName, sql };
98 1 : }
99 1 : async saveScript(sessionId: string, scriptName: string, sql: string): Promise<void> {
100 1 : this.calls.push(`write:${sessionId}/${scriptName}=${sql}`);
101 1 : const scripts = this.scripts.get(sessionId) ?? new Map<string, string>();
102 1 : scripts.set(scriptName, sql);
103 1 : this.scripts.set(sessionId, scripts);
104 1 : }
105 1 : async deleteScript(sessionId: string, scriptName: string): Promise<void> {
106 1 : this.calls.push(`delete:${sessionId}/${scriptName}`);
107 1 : this.scripts.get(sessionId)?.delete(scriptName);
108 1 : }
109 1 : async renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void> {
110 1 : this.calls.push(`rename:${sessionId}/${oldScriptName}->${newScriptName}`);
111 1 : const scripts = this.scripts.get(sessionId);
112 1 : if (!scripts?.has(oldScriptName)) return;
113 1 : const sql = scripts.get(oldScriptName)!;
114 1 : scripts.delete(oldScriptName);
115 1 : scripts.set(newScriptName, sql);
116 1 : }
117 1 : async listDataFiles(sessionId: string): Promise<DataFile[]> {
118 1 : return [...(this.data.get(sessionId) ?? new Map<string, Uint8Array>())]
119 1 : .map(([name, bytes]) => ({ name, sizeBytes: bytes.byteLength, mtimeMs: this.dataModified.get(`${sessionId}/${name}`) ?? 0 }))
120 1 : .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
121 1 : }
122 1 : async loadDataFile(sessionId: string, name: string): Promise<Uint8Array> {
123 1 : validateDataFileName(name);
124 1 : const bytes = this.data.get(sessionId)?.get(name);
125 1 : if (!bytes) throw new Error(`Data file not found: ${name}`);
126 1 : return bytes.slice();
127 1 : }
128 1 : async saveDataFile(sessionId: string, name: string, content: Uint8Array | Blob): Promise<void> {
129 1 : validateDataFileName(name);
130 1 : const data = this.data.get(sessionId) ?? new Map<string, Uint8Array>();
131 1 : const bytes = content instanceof Blob ? new Uint8Array(await content.arrayBuffer()) : content;
132 1 : data.set(name, bytes.slice());
133 1 : this.data.set(sessionId, data);
134 1 : this.dataModified.set(`${sessionId}/${name}`, ++this.dataClock);
135 1 : }
136 1 : async deleteDataFile(sessionId: string, name: string): Promise<void> {
137 1 : validateDataFileName(name);
138 1 : this.data.get(sessionId)?.delete(name);
139 1 : }
140 0 : async renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void> {
141 0 : validateDataFileName(oldName);
142 0 : validateDataFileName(newName);
143 0 : const data = this.data.get(sessionId);
144 0 : if (!data?.has(oldName) || oldName === newName) return;
145 0 : data.set(newName, data.get(oldName)!);
146 0 : data.delete(oldName);
147 0 : }
148 1 : }
149 :
150 1 : export const TEST_SESSION_ID = '11111111-2222-4333-8444-555555555555';
151 1 : export const TEST_CONNECTION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
152 :
153 : /// The connection fields of a test session
154 1 : export function testConnections(...connections: SessionData['connections']): Pick<SessionData, 'connections'> {
155 1 : return { connections };
156 1 : }
157 :
158 1 : export function testSession(overrides: Partial<SessionData> = {}): SessionData {
159 1 : return {
160 1 : formatVersion: SESSION_FORMAT_VERSION,
161 1 : sessionId: TEST_SESSION_ID,
162 1 : name: 'V2 Session',
163 1 : connections: [{ connectionId: TEST_CONNECTION_ID, params: { hyper: {} } as any }],
164 1 : scripts: [],
165 1 : attachments: [],
166 1 : metadata: {},
167 1 : ...overrides,
168 1 : };
169 1 : }
170 :
171 : /// A test backend that owns a session registry in memory, the backend of the composite storage
172 1 : export class SessionTestRegistry extends SessionTestBackend implements SessionRegistryBackend {
173 1 : tree: SessionTreeFolder = emptySessionTree();
174 :
175 : /// The registered sessions in tree order
176 1 : get manifest(): SessionEntry[] { return flattenSessionTree(this.tree); }
177 :
178 1 : override async listSessions(): Promise<SessionEntry[]> { return this.manifest; }
179 1 : override async saveSessionManifest(id: string, data: SessionData): Promise<void> {
180 1 : await super.saveSessionManifest(id, data);
181 1 : await this.upsertSessionEntry({ path: id, storageType: StorageBackendType.OPFS });
182 1 : }
183 1 : async loadSessionTree(): Promise<SessionTreeFolder> { return structuredClone(this.tree); }
184 1 : async upsertSessionEntry(entry: SessionEntry, folderPath?: readonly string[]): Promise<void> {
185 1 : this.tree = upsertSessionInTree(this.tree, entry, folderPath);
186 1 : }
187 1 : async removeSessionEntry(id: string): Promise<void> { this.tree = removeSessionFromTree(this.tree, id); }
188 1 : async moveSession(id: string, folderPath: string[], index: number): Promise<void> {
189 1 : this.tree = moveSessionInTree(this.tree, id, folderPath, index);
190 1 : }
191 1 : async createFolder(path: string[]): Promise<void> {
192 1 : const parent = folderAt(this.tree, path.slice(0, -1));
193 1 : const name = path[path.length - 1];
194 1 : if (parent == null || name == null) throw new Error('There is no parent folder');
195 1 : const error = folderNameError(name, parent.folders.map(folder => folder.name));
196 1 : if (error != null) throw new Error(error);
197 1 : this.tree = this.mapFolder(path.slice(0, -1), folder => ({
198 1 : ...folder,
199 1 : folders: [...folder.folders, { name, path, folders: [], sessions: [] }]
200 1 : .sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true })),
201 1 : }));
202 1 : }
203 1 : async moveFolder(path: string[], parentPath: string[], name: string = path[path.length - 1] ?? ''): Promise<void> {
204 1 : const folder = folderAt(this.tree, path);
205 1 : if (folder == null || isFolderPathWithin(parentPath, path)) throw new Error('Cannot move the folder');
206 1 : const rebase = (current: SessionTreeFolder, base: string[]): SessionTreeFolder => ({
207 1 : ...current,
208 1 : name: base[base.length - 1],
209 1 : path: base,
210 1 : folders: current.folders.map(child => rebase(child, [...base, child.name])),
211 1 : });
212 1 : this.tree = this.mapFolder(path.slice(0, -1), parent => ({ ...parent, folders: parent.folders.filter(child => child.name !== folder.name) }));
213 1 : this.tree = this.mapFolder(parentPath, parent => ({ ...parent, folders: [...parent.folders, rebase(folder, [...parentPath, name])] }));
214 1 : }
215 1 : async deleteFolder(path: string[]): Promise<void> {
216 1 : const folder = folderAt(this.tree, path);
217 1 : if (folder == null || path.length === 0) throw new Error('There is no folder');
218 1 : const moved = flattenSessionTree(folder);
219 1 : this.tree = this.mapFolder(path.slice(0, -1), parent => ({
220 1 : ...parent,
221 1 : folders: parent.folders.filter(child => child.name !== folder.name),
222 1 : sessions: [...parent.sessions, ...moved],
223 1 : }));
224 1 : }
225 1 : async deleteSessionFiles(id: string): Promise<void> { await super.deleteSession(id); }
226 : /// Delete the files and the registry entry, like the OPFS backend
227 1 : override async deleteSession(id: string): Promise<void> {
228 1 : await this.deleteSessionFiles(id);
229 1 : await this.removeSessionEntry(id);
230 1 : }
231 :
232 1 : private mapFolder(path: string[], map: (folder: SessionTreeFolder) => SessionTreeFolder): SessionTreeFolder {
233 1 : const visit = (folder: SessionTreeFolder, rest: string[]): SessionTreeFolder => rest.length === 0
234 1 : ? map(folder)
235 1 : : { ...folder, folders: folder.folders.map(child => child.name === rest[0] ? visit(child, rest.slice(1)) : child) };
236 1 : return visit(this.tree, path);
237 1 : }
238 1 : }
|