/** * File-backed storage for the registry. * * Layout under DATA_DIR: * modules/{name}/{version}/{name}-{version}.netapp — package files * index/{1,2,ab/cd}/{name} — sparse index files (one JSON line per version) * downloads/{name} — per-module download counter (single integer) */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; export interface IndexEntry { name: string; vers: string; deps: string[]; cksum: string; yanked: boolean; /** * One-line summary from the module's `manifest.yml#description`, * captured at publish time. Optional because pre-Phase-2 published * modules have no description recorded and need a republish to * backfill — see apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 * step 0). Old index files without this field still parse cleanly. */ description?: string; /** * One monochrome glyph from the module's `manifest.yml#icon`, captured at * publish time. Optional for the same reason `description` is: entries * written before the field existed have none, and a consumer reading one * without it falls back to its own table rather than failing * (openspec/changes/module-icons, D4). */ icon?: string; } export interface ModuleVersion { name: string; vers: string; cksum: string; yanked: boolean; uploadedAt: string; } export class RegistryStorage { private readonly dataDir: string; constructor(dataDir: string) { this.dataDir = dataDir; mkdirSync(join(dataDir, 'modules'), { recursive: true }); mkdirSync(join(dataDir, 'index'), { recursive: true }); } // Returns Cargo-style index path for a module name. // 1-char names → index/1/{name} // 2-char names → index/2/{name} // 3-char names → index/3/{first-two-chars}/{name} // 4+ char names → index/{chars 0-1}/{chars 2-3}/{name} indexPath(name: string): string { if (name.length === 1) return join(this.dataDir, 'index', '1', name); if (name.length === 2) return join(this.dataDir, 'index', '2', name); if (name.length === 3) return join(this.dataDir, 'index', '3', name.slice(0, 2), name); return join(this.dataDir, 'index', name.slice(0, 2), name.slice(2, 4), name); } packagePath(name: string, version: string): string { return join(this.dataDir, 'modules', name, version, `${name}-${version}.netapp`); } sha256(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } readIndex(name: string): IndexEntry[] { const path = this.indexPath(name); if (!existsSync(path)) return []; return readFileSync(path, 'utf-8') .split('\n') .filter(Boolean) .map((line) => JSON.parse(line) as IndexEntry); } appendIndex(entry: IndexEntry): void { const path = this.indexPath(entry.name); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(entry)}\n`, { flag: 'a' }); } /** * Replace a module's whole index file. * * Writes a sibling temp file and renames it over the original, which is * atomic within a directory. A plain truncating write has two failure modes * this avoids, and the sweep meets both: a crash mid-write leaves a half- * index, and an ENOSPC leaves an EMPTY one — losing every version of the * module. The sweep exists precisely because the disk filled up, so "the * write fails for want of space" is its expected environment, not a corner. * On failure the original file is untouched. */ updateIndex(name: string, entries: IndexEntry[]): void { const path = this.indexPath(name); mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp`; try { writeFileSync(tmp, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`); renameSync(tmp, path); } catch (err) { rmSync(tmp, { force: true, recursive: true }); throw err; } } storePackage(name: string, version: string, data: Buffer): string { const path = this.packagePath(name, version); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, data); return this.sha256(data); } packageExists(name: string, version: string): boolean { return existsSync(this.packagePath(name, version)); } /** Bytes on disk for one version's .netapp, or 0 when it is already gone. */ packageSize(name: string, version: string): number { const path = this.packagePath(name, version); if (!existsSync(path)) return 0; return statSync(path).size; } /** * Delete one version's payload directory (`modules/{name}/{version}/`). * Idempotent: removing a version that is already gone is a no-op, so an * interrupted sweep is safe to re-run. */ removePackage(name: string, version: string): void { rmSync(dirname(this.packagePath(name, version)), { recursive: true, force: true }); } /** * Every version with a payload directory on disk, whether or not the index * still lists it. The sweep needs the on-DISK set, not the indexed one: * removing an index line is what makes a version unreachable, and the file * it leaves behind is exactly what has to be reclaimed afterwards. */ storedVersions(name: string): string[] { const dir = join(this.dataDir, 'modules', name); if (!existsSync(dir)) return []; return readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); } /** Module names with a payload directory, including any absent from the index. */ storedNames(): string[] { const dir = join(this.dataDir, 'modules'); if (!existsSync(dir)) return []; return readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); } readPackage(name: string, version: string): Buffer | null { const path = this.packagePath(name, version); return existsSync(path) ? readFileSync(path) : null; } listModules(): Array<{ name: string; versions: IndexEntry[] }> { const modulesDir = join(this.dataDir, 'modules'); if (!existsSync(modulesDir)) return []; const names = readdirSync(modulesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); return names.map((name) => ({ name, versions: this.readIndex(name) })); } getModule(name: string): { name: string; versions: IndexEntry[] } | null { const versions = this.readIndex(name); return versions.length > 0 ? { name, versions } : null; } /** * The glyph each module holds, keyed by module name. A module holds the * icon on its latest non-yanked entry — the same "latest" the browse * endpoints read, so a module holds exactly the glyph a consumer would * render for it. Modules whose latest entry declares no icon are absent. * * This is the set the publish-time duplicate-icon refusal compares against * (openspec/changes/module-icons, D8): a new publish declaring a glyph in * this map, under a different name, is rejected. */ latestIcons(): Map { const held = new Map(); for (const { name, versions } of this.listModules()) { const latest = versions.filter((v) => !v.yanked).at(-1) ?? versions.at(-1); if (latest?.icon) held.set(name, latest.icon); } return held; } /** * Per-module download counter. Stored as a single integer in * `/downloads/`. Read-modify-write, NOT atomic * across concurrent processes — a lost increment under contention * is acceptable for a low-traffic registry. If we ever need real * concurrency safety this is the place to swap in fcntl locks or * a sqlite counter. * * Validation: `name` is checked by the caller (server.ts uses * `isValidName` before reaching this layer); we still constrain * the path lookup to the downloads/ subdirectory as defense in * depth against path traversal in `name`. */ private downloadsPath(name: string): string { return join(this.dataDir, 'downloads', name); } getDownloads(name: string): number { const path = this.downloadsPath(name); if (!existsSync(path)) return 0; const raw = readFileSync(path, 'utf-8').trim(); const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n >= 0 ? n : 0; } incrementDownloads(name: string): number { const path = this.downloadsPath(name); mkdirSync(dirname(path), { recursive: true }); const next = this.getDownloads(name) + 1; writeFileSync(path, `${next}\n`); return next; } }