@workspace-fs/core
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
330 lines (280 loc) • 8.55 kB
text/typescript
import type {
ProjectConfig,
WorkspaceSettings,
ProjectMetadata,
} from "./types";
const DB_NAME = "@firesystem/workspace";
const DB_VERSION = 1;
export interface StoredProject {
id: string;
name: string;
source: any; // ProjectSource
metadata: ProjectMetadata;
lastAccessed: Date;
enabled?: boolean;
disabledAt?: Date;
enabledAt?: Date;
}
export interface WorkspaceState {
id: "current"; // Single record key
activeProjectId: string | null;
recentProjectIds: string[];
settings: WorkspaceSettings;
}
/**
* Manages workspace persistence in IndexedDB
*/
export class WorkspaceDatabase {
private db: IDBDatabase | null = null;
private dbName: string;
private dbVersion: number;
constructor(dbName: string = DB_NAME, dbVersion: number = DB_VERSION) {
this.dbName = dbName;
this.dbVersion = dbVersion;
}
/**
* Open or create the workspace database
*/
async open(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error(`Failed to open database: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = event.oldVersion;
// Create object stores for v1
if (oldVersion < 1) {
// Projects store
if (!db.objectStoreNames.contains("projects")) {
const projectStore = db.createObjectStore("projects", {
keyPath: "id",
});
projectStore.createIndex("name", "name", { unique: false });
projectStore.createIndex("lastAccessed", "lastAccessed", {
unique: false,
});
}
// Workspace state store
if (!db.objectStoreNames.contains("workspaceState")) {
db.createObjectStore("workspaceState", { keyPath: "id" });
}
}
};
});
}
/**
* Ensure database is open
*/
private ensureOpen(): void {
if (!this.db) {
throw new Error("Database not opened. Call open() first.");
}
}
/**
* Close the database
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
// Project CRUD operations
/**
* Save or update a project configuration
*/
async saveProject(project: StoredProject): Promise<void> {
this.ensureOpen();
const tx = this.db!.transaction(["projects"], "readwrite");
const store = tx.objectStore("projects");
await new Promise<void>((resolve, reject) => {
const request = store.put(project);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get a project by ID
*/
async getProject(id: string): Promise<StoredProject | null> {
this.ensureOpen();
const tx = this.db!.transaction(["projects"], "readonly");
const store = tx.objectStore("projects");
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
* List all projects
*/
async listProjects(): Promise<StoredProject[]> {
this.ensureOpen();
const tx = this.db!.transaction(["projects"], "readonly");
const store = tx.objectStore("projects");
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
/**
* List recent projects
*/
async listRecentProjects(limit = 10): Promise<StoredProject[]> {
this.ensureOpen();
const tx = this.db!.transaction(["projects"], "readonly");
const index = tx.objectStore("projects").index("lastAccessed");
const projects: StoredProject[] = [];
return new Promise((resolve, reject) => {
// Open cursor in reverse order (most recent first)
const request = index.openCursor(null, "prev");
request.onsuccess = () => {
const cursor = request.result;
if (cursor && projects.length < limit) {
projects.push(cursor.value);
cursor.continue();
} else {
resolve(projects);
}
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete a project
*/
async deleteProject(id: string): Promise<void> {
this.ensureOpen();
const tx = this.db!.transaction(["projects"], "readwrite");
const store = tx.objectStore("projects");
await new Promise<void>((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Update project last accessed time
*/
async touchProject(id: string): Promise<void> {
const project = await this.getProject(id);
if (project) {
project.lastAccessed = new Date();
await this.saveProject(project);
}
}
/**
* Update project state
*/
async updateProjectState(
id: string,
updates: Partial<
Pick<StoredProject, "enabled" | "disabledAt" | "enabledAt">
>,
): Promise<void> {
const project = await this.getProject(id);
if (project) {
// Apply updates
Object.assign(project, updates);
await this.saveProject(project);
}
}
// Workspace state operations
/**
* Save workspace state
*/
async saveWorkspaceState(state: WorkspaceState): Promise<void> {
this.ensureOpen();
const tx = this.db!.transaction(["workspaceState"], "readwrite");
const store = tx.objectStore("workspaceState");
await new Promise<void>((resolve, reject) => {
const request = store.put(state);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get workspace state
*/
async getWorkspaceState(): Promise<WorkspaceState | null> {
this.ensureOpen();
const tx = this.db!.transaction(["workspaceState"], "readonly");
const store = tx.objectStore("workspaceState");
return new Promise((resolve, reject) => {
const request = store.get("current");
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
* Clear all data
*/
async clear(): Promise<void> {
this.ensureOpen();
const tx = this.db!.transaction(
["projects", "workspaceState"],
"readwrite",
);
await Promise.all([
new Promise<void>((resolve, reject) => {
const request = tx.objectStore("projects").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
new Promise<void>((resolve, reject) => {
const request = tx.objectStore("workspaceState").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
]);
}
/**
* Check if a project exists by checking IndexedDB databases
*/
static async projectDatabaseExists(dbName: string): Promise<boolean> {
if (!("databases" in indexedDB)) {
// Fallback for browsers that don't support databases()
return new Promise((resolve) => {
const testOpen = indexedDB.open(dbName);
testOpen.onsuccess = () => {
const db = testOpen.result;
const exists = db.version > 0;
db.close();
resolve(exists);
};
testOpen.onerror = () => resolve(false);
});
}
const databases = await indexedDB.databases();
return databases.some((db) => db.name === dbName);
}
/**
* Discover existing Firesystem IndexedDB databases
*/
async discoverIndexedDBProjects(): Promise<string[]> {
if (!("databases" in indexedDB)) {
console.warn("IndexedDB.databases() not supported in this browser");
return [];
}
const databases = await indexedDB.databases();
// Filter databases that look like Firesystem projects
return databases
.filter((db) => {
const name = db.name || "";
return (
name.startsWith("firesystem-") ||
name.startsWith("@firesystem/") ||
name.includes("-filesystem")
);
})
.map((db) => db.name!);
}
}