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:
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
// --- Mock idb for reading old IndexedDB projects ---
|
||||
const mockProjects: Array<{ name: string; data: Record<string, unknown>; lastModified: number }> = [];
|
||||
|
||||
vi.mock('idb', () => {
|
||||
const stores: Record<string, Map<string, unknown>> = {};
|
||||
const getStore = (name: string) => {
|
||||
if (!stores[name]) stores[name] = new Map();
|
||||
return stores[name];
|
||||
};
|
||||
|
||||
return {
|
||||
openDB: vi.fn(() => {
|
||||
const db = {
|
||||
getAll: vi.fn((storeName: string) => {
|
||||
if (storeName === 'projects') return Promise.resolve([...mockProjects]);
|
||||
return Promise.resolve([...getStore(storeName).values()]);
|
||||
}),
|
||||
get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)),
|
||||
put: vi.fn((storeName: string, value: { name: string }) => {
|
||||
getStore(storeName).set(value.name, value);
|
||||
}),
|
||||
close: vi.fn(),
|
||||
objectStoreNames: { contains: () => false },
|
||||
};
|
||||
return Promise.resolve(db);
|
||||
}),
|
||||
__stores: stores,
|
||||
__reset: () => Object.keys(stores).forEach((k) => delete stores[k]),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Mock OPFS for KGProjectStorage ---
|
||||
class MockWritable {
|
||||
data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileHandle {
|
||||
kind = 'file' as const;
|
||||
private _content = '';
|
||||
constructor(public name: string) {}
|
||||
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
||||
async createWritable() {
|
||||
const w = new MockWritable();
|
||||
const self = this;
|
||||
const origClose = w.close.bind(w);
|
||||
w.close = async () => { self._content = w.data; await origClose(); };
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
class MockDirHandle {
|
||||
kind = 'directory' as const;
|
||||
entries = new Map<string, MockDirHandle | MockFileHandle>();
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, opts?: { create?: boolean }) {
|
||||
let e = this.entries.get(name);
|
||||
if (!e || e.kind !== 'directory') {
|
||||
if (opts?.create) {
|
||||
e = new MockDirHandle(name);
|
||||
this.entries.set(name, e);
|
||||
} else {
|
||||
throw new DOMException('Not found', 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return e as MockDirHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, opts?: { create?: boolean }) {
|
||||
let e = this.entries.get(name);
|
||||
if (!e || e.kind !== 'file') {
|
||||
if (opts?.create) {
|
||||
e = new MockFileHandle(name);
|
||||
this.entries.set(name, e);
|
||||
} else {
|
||||
throw new DOMException('Not found', 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return e as MockFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string) { this.entries.delete(name); }
|
||||
|
||||
async *values() {
|
||||
for (const e of this.entries.values()) yield e;
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = new MockDirHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
|
||||
persist: vi.fn(() => Promise.resolve(true)),
|
||||
},
|
||||
});
|
||||
|
||||
// Now import the module under test
|
||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||
import { KGProjectStorage } from '../io/KGProjectStorage';
|
||||
|
||||
describe('upgradeConfigToV1', () => {
|
||||
beforeEach(async () => {
|
||||
mockProjects.length = 0;
|
||||
mockRoot.entries.clear();
|
||||
|
||||
// Reset KGProjectStorage singleton
|
||||
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
await storage.initialize();
|
||||
});
|
||||
|
||||
it('migrates projects from IndexedDB to OPFS', async () => {
|
||||
// Set up a mock IndexedDB project
|
||||
const project = new KGProject('My Old Song', 32, 0, 130);
|
||||
mockProjects.push({
|
||||
name: 'My Old Song',
|
||||
data: instanceToPlain(project) as Record<string, unknown>,
|
||||
lastModified: 1700000000000,
|
||||
});
|
||||
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const names = await storage.list();
|
||||
expect(names).toContain('My Old Song');
|
||||
|
||||
const loaded = await storage.load('My Old Song');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.getBpm()).toBe(130);
|
||||
});
|
||||
|
||||
it('sanitizes project names with invalid characters', async () => {
|
||||
const project = new KGProject('My:Song/Here', 32, 0, 120);
|
||||
mockProjects.push({
|
||||
name: 'My:Song/Here',
|
||||
data: instanceToPlain(project) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const names = await storage.list();
|
||||
// Should be sanitized — no colons or slashes
|
||||
expect(names).toContain('My_Song_Here');
|
||||
});
|
||||
|
||||
it('skips projects that already exist in OPFS', async () => {
|
||||
// Pre-create a project in OPFS
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const existing = new KGProject('Existing', 32, 0, 100);
|
||||
await storage.save('Existing', existing);
|
||||
|
||||
// Add same project to IndexedDB mock
|
||||
mockProjects.push({
|
||||
name: 'Existing',
|
||||
data: instanceToPlain(existing) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
// Should not throw or overwrite
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const loaded = await storage.load('Existing');
|
||||
expect(loaded!.getBpm()).toBe(100); // Original BPM preserved
|
||||
});
|
||||
|
||||
it('handles empty IndexedDB gracefully', async () => {
|
||||
// No projects in IndexedDB
|
||||
await expect(upgradeConfigToV1()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user