refactor: migrate project storage from IndexedDB to OPFS with folder-based structure

- Replace monolithic KGStorage with KGProjectStorage (OPFS) and KGConfigStorage (IndexedDB)
- Each project stored as folder: project.json + meta.json + media/
- Add KGConfigUpgrader with V1 upgrader to auto-migrate existing IndexedDB projects to OPFS
- Request persistent storage via navigator.storage.persist() to prevent browser eviction
- Show loading spinner during one-time migration
- Enforce safe project name characters (letters, numbers, space, hyphen, underscore, period, parens)
- Export projects as .kgstudio zip bundles instead of raw JSON
- Import supports .kgstudio bundles (with meta.json validation) and legacy JSON files
- Auto-deduplicate project names on import with (1), (2), etc. suffix
- Add OPFS shell debugger (pwd, ls, cd, cat, dl) accessible via KGDebugger.opfs()
- Add ESLint semi rule for consistent semicolons
- Delete KGStorage.ts — all logic absorbed by new storage classes
- Add jszip dependency for zip export/import
This commit is contained in:
Xiaohan-Tian
2026-04-09 19:25:08 -07:00
parent 17610f12f3
commit 2d3ea33ce6
21 changed files with 1781 additions and 235 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock idb before importing KGConfigStorage
vi.mock('idb', () => {
const stores: Record<string, Map<string, unknown>> = {};
const getStore = (name: string): Map<string, unknown> => {
if (!stores[name]) stores[name] = new Map();
return stores[name];
};
const mockDB = {
get: vi.fn((storeName: string, key: string) => {
return getStore(storeName).get(key) ?? undefined;
}),
put: vi.fn((storeName: string, value: { name: string }) => {
getStore(storeName).set(value.name, value);
}),
delete: vi.fn((storeName: string, key: string) => {
getStore(storeName).delete(key);
}),
objectStoreNames: { contains: () => false },
};
return {
openDB: vi.fn(() => Promise.resolve(mockDB)),
__stores: stores,
__reset: () => {
Object.keys(stores).forEach((k) => delete stores[k]);
},
};
});
import { KGConfigStorage } from './KGConfigStorage';
// Access mock internals
const idbMock = await import('idb') as unknown as {
__stores: Record<string, Map<string, unknown>>;
__reset: () => void;
};
describe('KGConfigStorage', () => {
let storage: KGConfigStorage;
beforeEach(() => {
idbMock.__reset();
// Reset singleton for test isolation
;(KGConfigStorage as unknown as { _instance: undefined })._instance = undefined;
storage = KGConfigStorage.getInstance();
});
it('saves and loads a config entry', async () => {
await storage.save('testKey', { foo: 'bar' }, true);
const result = await storage.load('testKey', Object);
expect(result).toBeDefined();
expect((result as Record<string, unknown>).foo).toBe('bar');
});
it('deletes a config entry', async () => {
await storage.save('toDelete', { x: 1 }, true);
await storage.delete('toDelete');
const result = await storage.load('toDelete', Object);
expect(result).toBeNull();
});
it('saveRaw and getRaw work for version markers', async () => {
await storage.saveRaw('__config_version', { version: 1, upgradedAt: 123 });
const raw = await storage.getRaw('__config_version');
expect(raw).toBeDefined();
expect(raw!.version).toBe(1);
});
it('getRaw returns null for non-existent key', async () => {
const result = await storage.getRaw('nonexistent');
expect(result).toBeNull();
});
it('returns singleton instance', () => {
const a = KGConfigStorage.getInstance();
const b = KGConfigStorage.getInstance();
expect(a).toBe(b);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { openDB } from 'idb';
import type { IDBPDatabase } from 'idb';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { DB_CONSTANTS } from '../../constants/coreConstants';
interface ConfigStorageEntry {
name: string;
data: Record<string, unknown>;
lastModified: number;
}
/**
* KGConfigStorage — IndexedDB-backed storage for application configuration.
* Extracted from the former KGStorage class; only manages the config object store.
*/
export class KGConfigStorage {
private static _instance: KGConfigStorage;
private dbPromise: Promise<IDBPDatabase> | null = null;
private constructor() {}
public static getInstance(): KGConfigStorage {
if (!KGConfigStorage._instance) {
KGConfigStorage._instance = new KGConfigStorage();
}
return KGConfigStorage._instance;
}
private getDB(): Promise<IDBPDatabase> {
if (!this.dbPromise) {
this.dbPromise = openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
upgrade(db) {
// Create required object stores if they don't exist
const requiredStores = [
DB_CONSTANTS.PROJECTS_STORE_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME,
];
for (const store of requiredStores) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'name' });
console.log(`Created object store: ${store}`);
}
}
},
});
}
return this.dbPromise;
}
public async save(name: string, data: unknown, overwrite: boolean = true): Promise<void> {
const db = await this.getDB();
const storeName = DB_CONSTANTS.CONFIG_STORE_NAME;
if (!overwrite) {
const existing = await db.get(storeName, name);
if (existing) {
throw new Error(`Config entry "${name}" already exists`);
}
}
const entry: ConfigStorageEntry = {
name,
data: instanceToPlain(data) as Record<string, unknown>,
lastModified: Date.now(),
};
await db.put(storeName, entry);
}
public async load<T>(name: string, classType: new () => T): Promise<T | null> {
try {
const db = await this.getDB();
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
if (!entry?.data) {
return null;
}
const instance = plainToInstance(classType, entry.data);
return Array.isArray(instance) ? instance[0] || null : instance;
} catch (error) {
console.error(`Error loading config entry "${name}":`, error);
return null;
}
}
public async delete(name: string): Promise<void> {
const db = await this.getDB();
await db.delete(DB_CONSTANTS.CONFIG_STORE_NAME, name);
}
/**
* Get a raw value from the config store (no class-transformer deserialization).
* Used by KGConfigUpgrader to read the config version marker.
*/
public async getRaw(name: string): Promise<Record<string, unknown> | null> {
try {
const db = await this.getDB();
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
return entry?.data ?? null;
} catch (error) {
console.error(`Error loading raw config entry "${name}":`, error);
return null;
}
}
/**
* Save a raw value to the config store (no class-transformer serialization).
* Used by KGConfigUpgrader to write the config version marker.
*/
public async saveRaw(name: string, data: Record<string, unknown>): Promise<void> {
const db = await this.getDB();
const entry: ConfigStorageEntry = {
name,
data,
lastModified: Date.now(),
};
await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry);
}
}
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGProject } from '../KGProject';
// --- OPFS mock infrastructure ---
class MockFileSystemWritableFileStream {
public data = '';
async write(content: string) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
constructor(public name: string, private _content: string = '') {}
async getFile() {
return { text: () => Promise.resolve(this._content) };
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
// When stream closes, update our content
const self = this;
const origClose = stream.close.bind(stream);
stream.close = async () => {
self._content = stream.data;
await origClose();
};
return stream;
}
}
class MockFileSystemDirectoryHandle {
kind = 'directory' as const;
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'directory') {
if (options?.create) {
entry = new MockFileSystemDirectoryHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'file') {
if (options?.create) {
entry = new MockFileSystemFileHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemFileHandle;
}
async removeEntry(name: string, _options?: { recursive?: boolean }): Promise<void> {
if (!this.entries.has(name)) {
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
}
this.entries.delete(name);
}
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
for (const entry of this.entries.values()) {
yield entry;
}
}
}
// Install the mock
const mockRoot = new MockFileSystemDirectoryHandle('root');
vi.stubGlobal('navigator', {
...navigator,
storage: {
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
persist: vi.fn(() => Promise.resolve(true)),
estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })),
},
});
describe('KGProjectStorage', () => {
let storage: KGProjectStorage;
beforeEach(async () => {
// Reset singleton and mock filesystem
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
// Clear the mock root directory entries
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
entries.clear();
storage = KGProjectStorage.getInstance();
await storage.initialize();
});
function createTestProject(name = 'Test Project'): KGProject {
return new KGProject(name, 16, 0, 120);
}
it('initializes and creates the projects directory', async () => {
// The projects directory should exist after init
const projects = await mockRoot.getDirectoryHandle('projects');
expect(projects).toBeDefined();
expect(projects.kind).toBe('directory');
});
it('saves and loads a project', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
const loaded = await storage.load('My Song');
expect(loaded).not.toBeNull();
expect(loaded!.getName()).toBe('My Song');
expect(loaded!.getBpm()).toBe(120);
});
it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
const projectsDir = await mockRoot.getDirectoryHandle('projects');
const projectDir = await projectsDir.getDirectoryHandle('My Song');
// meta.json should exist
const metaHandle = await projectDir.getFileHandle('meta.json');
const metaFile = await metaHandle.getFile();
const meta = JSON.parse(await metaFile.text());
expect(meta.name).toBe('My Song');
expect(meta.createdAt).toBeGreaterThan(0);
expect(meta.updatedAt).toBeGreaterThan(0);
// media/ directory should exist
const mediaDir = await projectDir.getDirectoryHandle('media');
expect(mediaDir.kind).toBe('directory');
});
it('throws DuplicateEntryError when overwrite is false', async () => {
const project = createTestProject('Duplicate');
await storage.save('Duplicate', project);
await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError);
});
it('allows overwrite when overwrite is true', async () => {
const project = createTestProject('Overwrite Test');
await storage.save('Overwrite Test', project);
project.setBpm(140);
await storage.save('Overwrite Test', project, true);
const loaded = await storage.load('Overwrite Test');
expect(loaded!.getBpm()).toBe(140);
});
it('preserves createdAt on overwrite', async () => {
const project = createTestProject('Preserve');
await storage.save('Preserve', project);
// Read the original createdAt
const projectsDir = await mockRoot.getDirectoryHandle('projects');
const projectDir = await projectsDir.getDirectoryHandle('Preserve');
const metaHandle1 = await projectDir.getFileHandle('meta.json');
const meta1 = JSON.parse(await (await metaHandle1.getFile()).text());
// Save again (overwrite)
await storage.save('Preserve', project, true);
const metaHandle2 = await projectDir.getFileHandle('meta.json');
const meta2 = JSON.parse(await (await metaHandle2.getFile()).text());
expect(meta2.createdAt).toBe(meta1.createdAt);
expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt);
});
it('lists project names', async () => {
await storage.save('Alpha', createTestProject('Alpha'));
await storage.save('Beta', createTestProject('Beta'));
await storage.save('Charlie', createTestProject('Charlie'));
const names = await storage.list();
expect(names).toEqual(['Alpha', 'Beta', 'Charlie']);
});
it('checks if project exists', async () => {
expect(await storage.exists('Nonexistent')).toBe(false);
await storage.save('Exists', createTestProject('Exists'));
expect(await storage.exists('Exists')).toBe(true);
});
it('deletes a project', async () => {
await storage.save('ToDelete', createTestProject('ToDelete'));
expect(await storage.exists('ToDelete')).toBe(true);
await storage.delete('ToDelete');
expect(await storage.exists('ToDelete')).toBe(false);
});
it('returns null for non-existent project on load', async () => {
const result = await storage.load('Ghost');
expect(result).toBeNull();
});
it('rejects invalid project names on save', async () => {
const project = createTestProject();
await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name');
await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name');
await expect(storage.save('', project)).rejects.toThrow('Invalid project name');
});
it('renames a project', async () => {
await storage.save('Old Name', createTestProject('Old Name'));
await storage.rename('Old Name', 'New Name');
expect(await storage.exists('Old Name')).toBe(false);
expect(await storage.exists('New Name')).toBe(true);
const loaded = await storage.load('New Name');
expect(loaded!.getName()).toBe('New Name');
});
});
+398
View File
@@ -0,0 +1,398 @@
import { instanceToPlain, plainToInstance } from 'class-transformer';
import JSZip from 'jszip';
import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { isValidProjectName } from '../../util/projectNameUtil';
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
export class DuplicateEntryError extends Error {
constructor(name: string) {
super(`Entry "${name}" already exists`);
this.name = 'DuplicateEntryError';
}
}
interface ProjectMeta {
name: string;
createdAt: number;
updatedAt: number;
}
/**
* KGProjectStorage — OPFS-backed storage for project files.
* Each project lives in its own directory under the OPFS `projects/` root.
*
* Folder structure:
* projects/<ProjectName>/meta.json
* projects/<ProjectName>/project.json
* projects/<ProjectName>/media/
*/
export class KGProjectStorage {
private static _instance: KGProjectStorage;
private rootDirHandle: FileSystemDirectoryHandle | null = null;
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
private _initialized = false;
private constructor() {}
public static getInstance(): KGProjectStorage {
if (!KGProjectStorage._instance) {
KGProjectStorage._instance = new KGProjectStorage();
}
return KGProjectStorage._instance;
}
/**
* Initialize OPFS root and request persistent storage.
* Must be called before any other method.
*/
public async initialize(): Promise<void> {
if (this._initialized) return;
this.rootDirHandle = await navigator.storage.getDirectory();
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
OPFS_CONSTANTS.ROOT_DIR,
{ create: true },
);
// Request persistent storage so the browser won't evict our data
try {
const persisted = await navigator.storage.persist();
console.log(`Persistent storage ${persisted ? 'granted' : 'denied'}`);
} catch (error) {
console.warn('navigator.storage.persist() not available:', error);
}
this._initialized = true;
console.log('KGProjectStorage initialized (OPFS)');
}
private ensureInitialized(): void {
if (!this._initialized || !this.projectsDirHandle) {
throw new Error('KGProjectStorage not initialized. Call initialize() first.');
}
}
/**
* Save a project. Creates the folder structure and writes meta.json + project.json.
*/
public async save(name: string, data: KGProject, overwrite: boolean = false): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(name)) {
throw new Error(
`Invalid project name "${name}". Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.`,
);
}
const exists = await this.exists(name);
if (exists && !overwrite) {
throw new DuplicateEntryError(name);
}
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name, { create: true });
// Ensure media/ directory exists
await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
// Write project.json
const projectData = instanceToPlain(data) as Record<string, unknown>;
const projectJson = JSON.stringify(projectData, null, 2);
await this.writeFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE, projectJson);
// Write/update meta.json
const now = Date.now();
let meta: ProjectMeta;
try {
const existingMeta = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
const parsed = JSON.parse(existingMeta) as ProjectMeta;
meta = { name, createdAt: parsed.createdAt, updatedAt: now };
} catch {
meta = { name, createdAt: now, updatedAt: now };
}
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Load a project by name. Runs the project upgrader on the loaded data.
*/
public async load(name: string): Promise<KGProject | null> {
this.ensureInitialized();
try {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const projectJson = await this.readFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE);
const plainData = JSON.parse(projectJson);
const instance = plainToInstance(KGProject, plainData);
const project = Array.isArray(instance) ? instance[0] || null : instance;
if (!project) return null;
project.setName(name);
return upgradeProjectToLatest(project);
} catch (error) {
console.error(`Error loading project "${name}":`, error);
return null;
}
}
/**
* List all project names (folder names under projects/).
*/
public async list(): Promise<string[]> {
this.ensureInitialized();
const names: string[] = [];
// FileSystemDirectoryHandle.entries() returns AsyncIterableIterator
// TypeScript's lib.dom.d.ts may lack full typing for this, so we iterate via values()
for await (const entry of this.projectsDirHandle!.values()) {
if (entry.kind === 'directory') {
names.push(entry.name);
}
}
return names.sort();
}
/**
* Delete a project and all its files.
*/
public async delete(name: string): Promise<void> {
this.ensureInitialized();
try {
await this.projectsDirHandle!.removeEntry(name, { recursive: true });
} catch (error) {
console.error(`Error deleting project "${name}":`, error);
throw error;
}
}
/**
* Check if a project exists.
*/
public async exists(name: string): Promise<boolean> {
this.ensureInitialized();
try {
await this.projectsDirHandle!.getDirectoryHandle(name);
return true;
} catch {
return false;
}
}
/**
* Rename a project by copying its directory contents to a new name and deleting the old one.
*/
public async rename(oldName: string, newName: string): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(newName)) {
throw new Error(`Invalid project name "${newName}".`);
}
if (await this.exists(newName)) {
throw new DuplicateEntryError(newName);
}
// Load the project from the old location
const project = await this.load(oldName);
if (!project) {
throw new Error(`Project "${oldName}" not found.`);
}
// Save to new location
project.setName(newName);
await this.save(newName, project, false);
// Delete old location
await this.delete(oldName);
}
/**
* Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/.
*/
public async exportAsZip(name: string): Promise<Blob> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const zip = new JSZip();
await this.addDirectoryToZip(zip, projectDir);
return zip.generateAsync({ type: 'blob' });
}
/**
* Recursively add all files and subdirectories from an OPFS directory to a JSZip instance.
*/
private async addDirectoryToZip(
zip: JSZip,
dirHandle: FileSystemDirectoryHandle,
path: string = '',
): Promise<void> {
for await (const entry of dirHandle.values()) {
const entryPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
zip.file(entryPath, file.arrayBuffer());
} else {
const subDir = entry as FileSystemDirectoryHandle;
await this.addDirectoryToZip(zip, subDir, entryPath);
}
}
}
/**
* Import a .kgstudio zip bundle into OPFS.
* Validates that meta.json exists and is valid.
* Returns the project name on success.
* On failure, cleans up any partially written folder and throws.
*/
public async importFromZip(blob: Blob): Promise<string> {
this.ensureInitialized();
const zip = await JSZip.loadAsync(blob);
// Validate meta.json
const metaFile = zip.file(OPFS_CONSTANTS.METADATA_FILE);
if (!metaFile) {
throw new Error('Invalid .kgstudio file: missing meta.json');
}
let meta: { name?: string };
try {
const metaText = await metaFile.async('text');
meta = JSON.parse(metaText);
} catch {
throw new Error('Invalid .kgstudio file: meta.json is corrupted');
}
if (!meta.name || typeof meta.name !== 'string') {
throw new Error('Invalid .kgstudio file: meta.json missing project name');
}
const projectName = await this.resolveUniqueName(meta.name);
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create: true });
try {
// Write all files from the zip into the OPFS project directory
for (const [relativePath, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.dir) {
// Create subdirectory
await this.getOrCreateSubDir(projectDir, relativePath);
} else {
// Write file
const data = await zipEntry.async('arraybuffer');
const parts = relativePath.split('/');
const fileName = parts.pop()!;
let targetDir = projectDir;
if (parts.length > 0) {
targetDir = await this.getOrCreateSubDir(projectDir, parts.join('/'));
}
const fileHandle = await targetDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
}
// If the name was deduplicated, update meta.json and project.json to reflect it
if (projectName !== meta.name) {
// Patch meta.json
try {
const metaHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.METADATA_FILE);
const metaFileObj = await metaHandle.getFile();
const metaData = JSON.parse(await metaFileObj.text());
metaData.name = projectName;
const w1 = await metaHandle.createWritable();
await w1.write(JSON.stringify(metaData, null, 2));
await w1.close();
} catch { /* best effort */ }
// Patch project.json name field
try {
const projHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.PROJECT_FILE);
const projFileObj = await projHandle.getFile();
const projData = JSON.parse(await projFileObj.text());
projData.name = projectName;
const w2 = await projHandle.createWritable();
await w2.write(JSON.stringify(projData, null, 2));
await w2.close();
} catch { /* best effort */ }
}
return projectName;
} catch (error) {
// Clean up the partially written folder
try {
await this.projectsDirHandle!.removeEntry(projectName, { recursive: true });
} catch {
// Best effort cleanup
}
throw error;
}
}
/**
* Get or create a nested subdirectory from a path like "media/subfolder".
*/
private async getOrCreateSubDir(
root: FileSystemDirectoryHandle,
path: string,
): Promise<FileSystemDirectoryHandle> {
const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
let current = root;
for (const seg of segments) {
current = await current.getDirectoryHandle(seg, { create: true });
}
return current;
}
/**
* Return a unique project name by appending (1), (2), etc. if the name already exists.
*/
public async resolveUniqueName(name: string): Promise<string> {
this.ensureInitialized();
if (!(await this.exists(name))) return name;
let counter = 1;
let candidate: string;
do {
candidate = `${name} (${counter})`;
counter++;
} while (await this.exists(candidate));
return candidate;
}
// --- File I/O helpers ---
private async writeFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
content: string,
): Promise<void> {
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}
private async readFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
): Promise<string> {
const fileHandle = await dirHandle.getFileHandle(fileName);
const file = await fileHandle.getFile();
return file.text();
}
}
-132
View File
@@ -1,132 +0,0 @@
// src/core/io/KGStorage.ts
import { openDB } from 'idb'
import type { IDBPDatabase } from 'idb'
import { plainToInstance, instanceToPlain } from 'class-transformer'
import { DB_CONSTANTS } from '../../constants/coreConstants'
export interface StorageEntry {
name: string
data: Record<string, unknown>
lastModified: number
}
export class DuplicateEntryError extends Error {
constructor(name: string) {
super(`Entry "${name}" already exists`)
this.name = 'DuplicateEntryError'
}
}
export class KGStorage {
private static instance: KGStorage
private dbPromises: Map<string, Promise<IDBPDatabase>>
private constructor() {
this.dbPromises = new Map()
}
public static getInstance(): KGStorage {
if (!KGStorage.instance) {
KGStorage.instance = new KGStorage()
}
return KGStorage.instance
}
private getDB(dbName: string, _storeName: string, version: number = 1): Promise<IDBPDatabase> {
const key = `${dbName}_${version}`
if (!this.dbPromises.has(key)) {
const dbPromise = openDB(dbName, version, {
upgrade(db) {
// Create all required object stores for this database
const requiredStores = [
DB_CONSTANTS.PROJECTS_STORE_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME
];
for (const store of requiredStores) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'name' })
console.log(`Created object store: ${store}`)
}
}
},
})
this.dbPromises.set(key, dbPromise)
}
return this.dbPromises.get(key)!
}
public async save<T>(
dbName: string,
storeName: string,
name: string,
data: T,
overwrite: boolean = false,
version: number = 1
): Promise<void> {
const db = await this.getDB(dbName, storeName, version)
const existing = await db.get(storeName, name)
if (existing && !overwrite) {
throw new DuplicateEntryError(name)
}
const entry: StorageEntry = {
name: name,
data: instanceToPlain(data) as Record<string, unknown>,
lastModified: Date.now(),
}
await db.put(storeName, entry)
}
public async load<T>(
dbName: string,
storeName: string,
name: string,
classType: new() => T,
version: number = 1
): Promise<T | null> {
try {
const db = await this.getDB(dbName, storeName, version)
const entry = await db.get(storeName, name)
if (!entry?.data) {
console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`)
return null
}
const instance = plainToInstance(classType, entry.data)
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
(loadedInstance as { setName: (projectName: string) => void }).setName(name)
}
return loadedInstance
} catch (error) {
console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error)
return null
}
}
public async list(
dbName: string,
storeName: string,
version: number = 1
): Promise<string[]> {
const db = await this.getDB(dbName, storeName, version)
const all = await db.getAllKeys(storeName)
return all as string[]
}
public async delete(
dbName: string,
storeName: string,
name: string,
version: number = 1
): Promise<void> {
const db = await this.getDB(dbName, storeName, version)
await db.delete(storeName, name)
}
}