import { promises as fs } from 'node:fs' import path from 'node:path' import { downloadFile, uploadFiles, repoExists, createRepo, listFiles, } from '@huggingface/hub' /** * Durable storage for decks. Each deck persists two artifacts: * decks/.ydoc — binary Yjs snapshot (lossless reload) * decks/.md — human-readable markdown (= version history in git) * * Two backends: local filesystem (dev) and a git-backed HF Dataset repo (prod, since * HF Space disks are ephemeral). Chosen by env at startup. */ export interface DeckStorage { readonly kind: string load(deckId: string): Promise save(deckId: string, state: Uint8Array, markdown: string): Promise listDecks(): Promise /** Generic small text files (e.g. per-user deck index at users/.json). */ readText(path: string): Promise writeText(path: string, content: string): Promise } const safeId = (id: string): string => id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default' class LocalDeckStorage implements DeckStorage { readonly kind = 'local' constructor(private readonly dir: string) {} private file(id: string, ext: string) { return path.join(this.dir, safeId(id) + ext) } async load(deckId: string): Promise { try { return new Uint8Array(await fs.readFile(this.file(deckId, '.ydoc'))) } catch { return null } } async save(deckId: string, state: Uint8Array, markdown: string): Promise { await fs.mkdir(this.dir, { recursive: true }) await fs.writeFile(this.file(deckId, '.ydoc'), Buffer.from(state)) await fs.writeFile(this.file(deckId, '.md'), markdown, 'utf8') } async listDecks(): Promise { try { const names = await fs.readdir(this.dir) return names.filter((n) => n.endsWith('.md')).map((n) => n.replace(/\.md$/, '')) } catch { return [] } } // Generic files live next to the decks dir (base = parent of decks dir). private base() { return path.dirname(this.dir) } async readText(p: string): Promise { try { return await fs.readFile(path.resolve(this.base(), p), 'utf8') } catch { return null } } async writeText(p: string, content: string): Promise { const full = path.resolve(this.base(), p) await fs.mkdir(path.dirname(full), { recursive: true }) await fs.writeFile(full, content, 'utf8') } } class HfDeckStorage implements DeckStorage { readonly kind = 'hf-dataset' private readonly repo: { type: 'dataset'; name: string } constructor(repoName: string, private readonly token: string) { this.repo = { type: 'dataset', name: repoName } } async ensureRepo(): Promise { try { if (!(await repoExists({ repo: this.repo, accessToken: this.token }))) { await createRepo({ repo: this.repo, accessToken: this.token, visibility: 'private' }) console.log(`[storage] created dataset repo ${this.repo.name}`) } } catch (e) { console.error('[storage] ensureRepo failed:', (e as Error)?.message) } } async load(deckId: string): Promise { try { const blob = await downloadFile({ repo: this.repo, path: `decks/${safeId(deckId)}.ydoc`, accessToken: this.token, }) if (!blob) return null return new Uint8Array(await blob.arrayBuffer()) } catch (e) { console.error('[storage] load failed:', (e as Error)?.message) return null } } async save(deckId: string, state: Uint8Array, markdown: string): Promise { const id = safeId(deckId) await uploadFiles({ repo: this.repo, accessToken: this.token, commitTitle: `Update deck ${id}`, files: [ { path: `decks/${id}.ydoc`, content: new Blob([new Uint8Array(state)]) }, { path: `decks/${id}.md`, content: new Blob([new TextEncoder().encode(markdown)]) }, ], }) } async listDecks(): Promise { try { const out: string[] = [] for await (const f of listFiles({ repo: this.repo, path: 'decks', accessToken: this.token })) { if (f.path.endsWith('.md')) { out.push(f.path.replace(/^decks\//, '').replace(/\.md$/, '')) } } return out } catch { return [] } } async readText(p: string): Promise { try { const blob = await downloadFile({ repo: this.repo, path: p, accessToken: this.token }) return blob ? await blob.text() : null } catch { return null } } async writeText(p: string, content: string): Promise { await uploadFiles({ repo: this.repo, accessToken: this.token, commitTitle: `Update ${p}`, files: [{ path: p, content: new Blob([new TextEncoder().encode(content)]) }], }) } } export function createStorage(): DeckStorage { const repo = process.env.HF_DATASET_REPO const token = process.env.HF_TOKEN if (repo && token) { const storage = new HfDeckStorage(repo, token) void storage.ensureRepo() console.log(`[storage] using HF dataset: ${repo}`) return storage } const dir = process.env.DATA_DIR || './data/decks' console.log(`[storage] using local filesystem: ${dir}`) return new LocalDeckStorage(dir) }