diff --git a/apps/storage/package-lock.json b/apps/storage/package-lock.json new file mode 100644 index 0000000..b471d8a --- /dev/null +++ b/apps/storage/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "storage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "storage", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@types/node": "^25.4.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/storage/package.json b/apps/storage/package.json new file mode 100644 index 0000000..596ec60 --- /dev/null +++ b/apps/storage/package.json @@ -0,0 +1,17 @@ +{ + "name": "storage", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@types/node": "^25.4.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/storage/src/FileSystemUtils.ts b/apps/storage/src/FileSystemUtils.ts new file mode 100644 index 0000000..6638c4f --- /dev/null +++ b/apps/storage/src/FileSystemUtils.ts @@ -0,0 +1 @@ +export{} \ No newline at end of file diff --git a/apps/storage/src/PathValidator.ts b/apps/storage/src/PathValidator.ts new file mode 100644 index 0000000..11b4b9e --- /dev/null +++ b/apps/storage/src/PathValidator.ts @@ -0,0 +1,132 @@ +/** + * @file PathValidator.ts + * @description Secure path resolution and validation for the Smart Notes vault. + * + * All filesystem paths supplied by the application layer must be validated + * before being handed to Node's `fs` APIs. Without this guard a maliciously + * or accidentally crafted relative path such as `../../etc/passwd` could + * escape the vault root and read or overwrite arbitrary files on the host. + * + * Every storage operation should call {@link resolveVaultPath} to obtain a + * safe absolute path and then {@link validateVaultPath} to assert containment + * before proceeding with any filesystem I/O. + */ + +import path from "path"; + +// --------------------------------------------------------------------------- +// Path Resolution +// --------------------------------------------------------------------------- + +/** + * Resolves a vault-relative path to a normalised absolute filesystem path. + * + * The function joins `rootPath` and `relativePath`, then calls `path.resolve` + * which both normalises redundant separators / dot segments and anchors the + * result to an absolute path regardless of the process working directory. + * + * **This function alone does not guarantee containment.** Always follow up + * with {@link validateVaultPath} to assert that the resolved path is still + * inside the vault root. + * + * @param rootPath - Absolute path to the vault's root directory. + * Must be an absolute path; passing a relative root + * produces unpredictable results. + * @param relativePath - Path supplied by the caller, relative to `rootPath`. + * May contain `.` or `..` segments — these are resolved + * by `path.resolve` and then checked by the validator. + * @returns The normalised, absolute path produced by joining the two inputs. + * + * @example + * ```ts + * resolveVaultPath("/vault", "notes/daily.md"); + * // → "/vault/notes/daily.md" + * + * resolveVaultPath("/vault", "./projects/../ideas/x.md"); + * // → "/vault/ideas/x.md" + * ``` + */ +export function resolveVaultPath( + rootPath: string, + relativePath: string +): string { + // path.resolve joins the segments left-to-right and normalises the result + // into an absolute path, collapsing any `.` and `..` components in one step. + return path.resolve(rootPath, relativePath); +} + +// --------------------------------------------------------------------------- +// Containment Validation +// --------------------------------------------------------------------------- + +/** + * Asserts that `resolvedPath` is located inside `rootPath`. + * + * ### Security rationale + * A path traversal attack uses sequences such as `../` to walk up the + * directory tree past the intended root. Even after normalisation a path + * like `/vault/../etc/passwd` collapses to `/etc/passwd`, which is outside + * the vault. This function prevents such escapes by verifying that the + * normalised absolute path still begins with the normalised root prefix. + * + * The root prefix check uses a trailing-separator sentinel (`rootWithSep`) + * to avoid false positives where the vault root is `/vault` but the resolved + * path is `/vault-backup/secret` — without the sentinel both strings would + * share the prefix `/vault`. + * + * ### Usage pattern + * ```ts + * const resolved = resolveVaultPath(rootPath, userSuppliedPath); + * validateVaultPath(rootPath, resolved); // throws if outside vault + * await fs.readFile(resolved, "utf8"); // safe to proceed + * ``` + * + * @param rootPath - Absolute path to the vault's root directory. + * Normalised internally before comparison. + * @param resolvedPath - The fully resolved absolute path to validate. + * Typically the return value of {@link resolveVaultPath}. + * + * @throws {Error} When `resolvedPath` does not begin with the vault root prefix, + * indicating a path traversal attempt or misconfiguration. + * + * @example + * ```ts + * // Safe — resolvedPath is inside the vault. + * validateVaultPath("/vault", "/vault/notes/daily.md"); // no-op + * + * // Unsafe — resolvedPath escapes the vault root. + * validateVaultPath("/vault", "/etc/passwd"); + * // → throws Error: Path traversal detected … + * ``` + */ +export function validateVaultPath( + rootPath: string, + resolvedPath: string +): void { + // Normalise both sides independently so that inconsistent trailing slashes + // or mixed separators on Windows do not produce false negatives. + const normalisedRoot = path.normalize(rootPath); + const normalisedResolved = path.normalize(resolvedPath); + + // Append the platform separator so that a vault at "/vault" cannot be + // bypassed by a resolved path of "/vault-escape/file.md". + // We also accept an exact match (resolvedPath === root) to allow operations + // directly on the vault root directory itself (e.g. listing). + const rootWithSep = normalisedRoot.endsWith(path.sep) + ? normalisedRoot + : normalisedRoot + path.sep; + + const isInsideVault = + normalisedResolved === normalisedRoot || + normalisedResolved.startsWith(rootWithSep); + + if (!isInsideVault) { + throw new Error( + `Path traversal detected: resolved path is outside the vault root.\n` + + ` Vault root : ${normalisedRoot}\n` + + ` Resolved path : ${normalisedResolved}\n` + + `Ensure all paths are relative to the vault root and contain no ` + + `traversal segments (e.g. "../").` + ); + } +} \ No newline at end of file diff --git a/apps/storage/src/SafeWrite.ts b/apps/storage/src/SafeWrite.ts new file mode 100644 index 0000000..a0c951a --- /dev/null +++ b/apps/storage/src/SafeWrite.ts @@ -0,0 +1,105 @@ +/** + * @file SafeWrite.ts + * @description Atomic file writing utilities for the Smart Notes vault. + * + * A naive `fs.writeFile` call truncates the target file before writing new + * content. If the process crashes, is killed, or loses power mid-write, the + * file is left empty or partially written, corrupting the user's note. + * + * This module solves that problem with the write-to-temp-then-rename pattern, + * which is the standard technique used by databases, editors, and package + * managers to achieve crash-safe persistence: + * + * 1. Write the full content to a sibling `.tmp` file. + * 2. Best-effort `fsync` the temp file on platforms that support it reliably. + * 3. Atomically `rename` the temp file over the target path. + * + * On POSIX systems `rename(2)` is guaranteed to be atomic by the kernel - the + * target path will always point to either the old file or the new file, never + * to a partially written state. On Windows, `fs.rename` provides a best-effort + * equivalent that is safe for single-writer scenarios like Smart Notes. + */ + +import { open, rename, writeFile } from "fs/promises"; +import type { FileHandle } from "fs/promises"; + +// --------------------------------------------------------------------------- +// Core +// --------------------------------------------------------------------------- + +/** + * Writes `content` to `filePath` atomically, preventing partial writes. + * + * ### Why atomic writes matter + * A regular `fs.writeFile` first truncates the destination file then writes + * bytes incrementally. Any interruption between those two steps (crash, SIGKILL, + * power loss) leaves the file empty or corrupted. Because Smart Notes operates + * offline-first with no network backup, a corrupted note cannot be recovered. + * + * ### How this function stays safe + * - Content is written to a temporary file (`.tmp`) so the original + * file is untouched until the new content is fully on disk. + * - A best-effort `fsync` flushes the OS write-back cache to durable storage + * before the rename on platforms where this succeeds reliably. + * - `rename` atomically replaces the target path in a single kernel operation. + * At no point is the target path missing or partially updated. + * + * ### Cleanup on failure + * If any step throws, the `.tmp` file may be left on disk. This is intentional + * - a stale `.tmp` file is harmless and will be overwritten on the next write + * attempt. The original file is always preserved. + * + * @param filePath - Absolute path to the destination file. + * The parent directory must already exist. + * @param content - UTF-8 Markdown string to write. + * @returns A promise that resolves once the file has been written and the + * target path has been atomically updated. + * + * @throws {Error} If the temp file cannot be written or renamed. A failed + * best-effort `fsync` is ignored to preserve cross-platform + * compatibility, especially on Windows. + * + * @example + * ```ts + * await writeFileAtomic("/vault/notes/daily.md", "# Today\n\nHello."); + * ``` + */ +export async function writeFileAtomic( + filePath: string, + content: string +): Promise { + // Place the temp file beside the destination so rename stays on the same + // filesystem and retains its atomic replace semantics. + const tempPath = `${filePath}.tmp`; + + // If the process dies here the original file is still untouched. + await writeFile(tempPath, content, "utf8"); + + let fileHandle: FileHandle | undefined; + + try { + // Open read-only to obtain a descriptor for sync without risking truncation. + fileHandle = await open(tempPath, "r"); + await fileHandle.sync(); + } catch (error: unknown) { + if (!isWindowsEperm(error)) { + throw error; + } + + // Some Windows environments reject fsync on newly written files with EPERM. + // The atomic rename is still the required correctness boundary for desktop use. + } finally { + await fileHandle?.close(); + } + + await rename(tempPath, filePath); +} + +function isWindowsEperm(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "EPERM" + ); +} diff --git a/apps/storage/src/VaultManager.ts b/apps/storage/src/VaultManager.ts new file mode 100644 index 0000000..0739791 --- /dev/null +++ b/apps/storage/src/VaultManager.ts @@ -0,0 +1,324 @@ +/** + * @file VaultManager.ts + * @description Concrete implementation of {@link VaultService} for the Smart + * Notes offline-first filesystem vault. + * + * `VaultManager` is the single authoritative gateway between the application + * layer and the markdown files stored on disk. Every operation resolves and + * validates its path through {@link PathValidator} before touching the + * filesystem, and every write goes through {@link writeFileAtomic} to prevent + * corruption on crash or power loss. + */ + +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; + +import { + VaultOptions, + VaultService, + VaultNoteMeta, +} from "./types"; +import { resolveVaultPath, validateVaultPath } from "./PathValidator"; +import { writeFileAtomic } from "./SafeWrite"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Recursively walks a directory tree and collects the absolute paths of every + * file that matches `predicate`. + * + * @param dir - Absolute path of the directory to walk. + * @param predicate - Optional filter; defaults to accepting every file. + * @returns A flat array of absolute file paths found beneath `dir`. + */ +async function walkDirectory( + dir: string, + predicate: (filePath: string) => boolean = () => true +): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const nested = await walkDirectory(entryPath, predicate); + results.push(...nested); + } else if (entry.isFile() && predicate(entryPath)) { + results.push(entryPath); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// VaultManager +// --------------------------------------------------------------------------- + +/** + * Manages markdown notes stored inside a single vault directory on disk. + * + * All path arguments accepted by public methods are **vault-relative** — they + * are resolved against the configured root path and validated for containment + * before any filesystem I/O occurs. This prevents path traversal attacks and + * accidental access outside the vault. + * + * @example + * ```ts + * const vault = new VaultManager({ rootPath: "/home/alice/notes" }); + * + * await vault.createNote("daily/2024-03-01.md", "# Today\n"); + * const content = await vault.readNote("daily/2024-03-01.md"); + * await vault.deleteNote("daily/2024-03-01.md"); + * ``` + */ +export class VaultManager implements VaultService { + /** + * @param options - Vault configuration. `options.rootPath` must be an + * absolute path to an existing directory. + */ + constructor(private readonly options: VaultOptions) { + if (!path.isAbsolute(options.rootPath)) { + throw new Error("Vault rootPath must be absolute"); + } + + if (!existsSync(options.rootPath)) { + throw new Error("Vault root directory does not exist"); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Resolves a vault-relative path to a safe, validated absolute path. + * + * Combines {@link resolveVaultPath} and {@link validateVaultPath} into a + * single convenience call used by every public method. + * + * @param relativePath - Vault-relative path provided by the caller. + * @returns The normalised absolute path guaranteed to be inside the vault. + * @throws {Error} If the resolved path escapes the vault root. + */ + private async resolveSafe(relativePath: string): Promise { + const resolved = resolveVaultPath(this.options.rootPath, relativePath); + validateVaultPath(this.options.rootPath, resolved); + + try { + const [realRoot, realPath] = await Promise.all([ + fs.realpath(this.options.rootPath), + fs.realpath(resolved), + ]); + + validateVaultPath(realRoot, realPath); + } catch (error: unknown) { + if (!isNotFoundError(error)) { + throw error; + } + } + + return resolved; + } + + // ------------------------------------------------------------------------- + // VaultService implementation + // ------------------------------------------------------------------------- + + /** + * Creates a new note at the given vault-relative path. + * + * Intermediate directories are created automatically so callers do not need + * to call {@link createFolder} first. The write is performed atomically via + * {@link writeFileAtomic} to guarantee the file is never partially written. + * + * @param notePath - Vault-relative path for the new note (e.g. `"daily/today.md"`). + * @param content - Initial Markdown content. + * @throws {Error} If the path escapes the vault or the write fails. + */ + async createNote(notePath: string, content: string): Promise { + const resolved = await this.resolveSafe(notePath); + + try { + await fs.stat(resolved); + throw new Error(`Note already exists: ${notePath}`); + } catch (error: unknown) { + if (!isNotFoundError(error)) { + throw error; + } + } + + // Ensure the parent directory exists before writing. + await fs.mkdir(path.dirname(resolved), { recursive: true }); + await writeFileAtomic(resolved, content); + } + + /** + * Reads and returns the full Markdown content of an existing note. + * + * @param notePath - Vault-relative path of the note to read. + * @returns The raw UTF-8 string stored on disk. + * @throws {Error} If the path escapes the vault or the file does not exist. + */ + async readNote(notePath: string): Promise { + const resolved = await this.resolveSafe(notePath); + return fs.readFile(resolved, "utf8"); + } + + /** + * Overwrites the content of an existing note. + * + * Behaviourally identical to {@link createNote} — both use + * {@link writeFileAtomic} which safely replaces any existing file. The + * method is exposed separately to make call-site intent explicit. + * + * @param notePath - Vault-relative path of the note to update. + * @param content - New Markdown content that replaces the existing body. + * @throws {Error} If the path escapes the vault or the write fails. + */ + async updateNote(notePath: string, content: string): Promise { + const resolved = await this.resolveSafe(notePath); + + try { + await fs.stat(resolved); + } catch (error: unknown) { + if (isNotFoundError(error)) { + throw new Error(`Cannot update non-existent note: ${notePath}`); + } + + throw error; + } + + // Parent directory must already exist for an update, but we create it + // defensively to keep the operation idempotent. + await fs.mkdir(path.dirname(resolved), { recursive: true }); + await writeFileAtomic(resolved, content); + } + + /** + * Permanently deletes a note from the vault. + * + * @param notePath - Vault-relative path of the note to delete. + * @throws {Error} If the path escapes the vault or the file does not exist. + */ + async deleteNote(notePath: string): Promise { + const resolved = await this.resolveSafe(notePath); + await fs.unlink(resolved); + } + + /** + * Moves or renames a note within the vault. + * + * Both the source and destination paths are independently resolved and + * validated to ensure neither escapes the vault root. The parent directory + * of `newPath` is created automatically if it does not exist. + * + * @param oldPath - Vault-relative path of the note to move. + * @param newPath - Vault-relative destination path. + * @throws {Error} If either path escapes the vault, the source does not + * exist, or the rename operation fails. + */ + async renameNote(oldPath: string, newPath: string): Promise { + const resolvedOld = await this.resolveSafe(oldPath); + const resolvedNew = await this.resolveSafe(newPath); + + try { + await fs.stat(resolvedNew); + throw new Error(`Destination note already exists: ${newPath}`); + } catch (error: unknown) { + if (!isNotFoundError(error)) { + throw error; + } + } + + // Ensure the destination directory exists before the rename. + await fs.mkdir(path.dirname(resolvedNew), { recursive: true }); + await fs.rename(resolvedOld, resolvedNew); + } + + /** + * Lists metadata for every Markdown file currently stored in the vault. + * + * Performs a recursive directory walk starting at the vault root, filters + * for `.md` files, and resolves `stat` metadata for each one. Non-markdown + * files (e.g. attachments) are ignored. + * + * @returns An array of {@link VaultNoteMeta} objects, one per `.md` file, + * sorted alphabetically by vault-relative path. + * @throws {Error} If the vault root cannot be read. + */ + async listNotes(): Promise { + const root = this.options.rootPath; + + const absolutePaths = await walkDirectory( + root, + (filePath) => filePath.endsWith(".md") + ); + + const metas = await Promise.all( + absolutePaths.map(async (absolutePath): Promise => { + const stat = await fs.stat(absolutePath); + + // Express the path relative to the vault root using forward slashes + // for consistency across platforms. + const relativePath = path + .relative(root, absolutePath) + .split(path.sep) + .join("/"); + + return { + path: relativePath, + name: path.basename(absolutePath), + createdAt: stat.birthtimeMs, + updatedAt: stat.mtimeMs, + }; + }) + ); + + // Return a stable, predictable order regardless of filesystem traversal order. + return metas.sort((a, b) => a.path.localeCompare(b.path)); + } + + /** + * Creates a new empty folder at the specified vault-relative path. + * + * Intermediate parent directories are created automatically. Calling this + * method on a path that already exists as a directory is a no-op. + * + * @param folderPath - Vault-relative path of the folder to create. + * @throws {Error} If the path escapes the vault or a non-directory file + * already exists at the target path. + */ + async createFolder(folderPath: string): Promise { + const resolved = await this.resolveSafe(folderPath); + await fs.mkdir(resolved, { recursive: true }); + } + + /** + * Recursively deletes a folder and all of its contents from the vault. + * + * **This operation is irreversible.** The path is validated against the + * vault root before deletion to prevent accidental removal of directories + * outside the vault boundary. + * + * @param folderPath - Vault-relative path of the folder to delete. + * @throws {Error} If the path escapes the vault or the directory does not exist. + */ + async deleteFolder(folderPath: string): Promise { + const resolved = await this.resolveSafe(folderPath); + await fs.rm(resolved, { recursive: true }); + } +} + +function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} diff --git a/apps/storage/src/demo/VaultDemo.ts b/apps/storage/src/demo/VaultDemo.ts new file mode 100644 index 0000000..cd7df45 --- /dev/null +++ b/apps/storage/src/demo/VaultDemo.ts @@ -0,0 +1,178 @@ +/** + * @file VaultDemo.ts + * @description End-to-end demonstration of the Smart Notes filesystem vault. + * + * Walks through every VaultManager operation in sequence so contributors and + * reviewers can verify the storage layer works correctly against a real + * filesystem without needing a running application. + * + * Run with: + * npx ts-node apps/storage/src/demo/VaultDemo.ts + * + * A temporary `demo-vault/` directory is created in the current working + * directory and fully cleaned up by the end of the demo. + */ + +import path from "path"; +import { VaultManager } from "../VaultManager"; +import { VaultOptions } from "../types"; + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/** Prints a numbered step header to make console output easy to follow. */ +function logStep(step: number, description: string): void { + console.log(`\n[Step ${step}] ${description}`); + console.log("─".repeat(50)); +} + +/** Prints a success confirmation for the current step. */ +function logSuccess(message: string): void { + console.log(` ✓ ${message}`); +} + +// --------------------------------------------------------------------------- +// Demo runner +// --------------------------------------------------------------------------- + +/** + * Runs a complete end-to-end walkthrough of all {@link VaultManager} operations. + * + * ### Operations demonstrated + * 1. Initialise `VaultManager` with a local demo-vault root. + * 2. Create a folder inside the vault. + * 3. Create a new markdown note. + * 4. Read the note back from disk and print its content. + * 5. Update the note with new content. + * 6. Read the updated content to confirm the overwrite. + * 7. List all notes in the vault and print their metadata. + * 8. Rename the note to a new path. + * 9. Delete the renamed note. + * 10. Delete the containing folder. + * + * The vault directory is fully removed at the end so the demo leaves no + * artefacts on disk. + */ +async function runDemo(): Promise { + console.log("=".repeat(50)); + console.log(" Smart Notes — Vault Storage Demo"); + console.log("=".repeat(50)); + + // ── Step 1: Initialise VaultManager ────────────────────────────────────── + logStep(1, "Initialise VaultManager"); + + const options: VaultOptions = { + rootPath: path.resolve(process.cwd(), "demo-vault"), + }; + + const vault = new VaultManager(options); + + logSuccess(`Vault root: ${options.rootPath}`); + + // ── Step 2: Create a folder ─────────────────────────────────────────────── + logStep(2, "Create folder → notes/"); + + await vault.createFolder("notes"); + + logSuccess('Created folder "notes/"'); + + // ── Step 3: Create a note ───────────────────────────────────────────────── + logStep(3, "Create note → notes/hello.md"); + + const initialContent = "# Hello Smart Notes\n\nThis is the initial content."; + await vault.createNote("notes/hello.md", initialContent); + + logSuccess('Created "notes/hello.md"'); + console.log(` Content written:\n\n ${initialContent.replace(/\n/g, "\n ")}`); + + // ── Step 4: Read the note ───────────────────────────────────────────────── + logStep(4, "Read note ← notes/hello.md"); + + const readContent = await vault.readNote("notes/hello.md"); + + logSuccess("Read note successfully"); + console.log(` Content read:\n\n ${readContent.replace(/\n/g, "\n ")}`); + + // ── Step 5: Update the note ─────────────────────────────────────────────── + logStep(5, "Update note → notes/hello.md"); + + const updatedContent = + "# Updated Note\n\nThis content was written by updateNote()."; + await vault.updateNote("notes/hello.md", updatedContent); + + logSuccess('Updated "notes/hello.md"'); + + // Confirm the update landed on disk. + const confirmedContent = await vault.readNote("notes/hello.md"); + console.log( + ` Content after update:\n\n ${confirmedContent.replace(/\n/g, "\n ")}` + ); + + // ── Step 6: Create a second note so the listing is non-trivial ─────────── + logStep(6, "Create a second note → notes/second.md"); + + await vault.createNote( + "notes/second.md", + "# Second Note\n\nAnother note for the listing demo." + ); + + logSuccess('Created "notes/second.md"'); + + // ── Step 7: List all notes ──────────────────────────────────────────────── + logStep(7, "List all notes in vault"); + + const notes = await vault.listNotes(); + + logSuccess(`Found ${notes.length} note(s):`); + notes.forEach((meta, index) => { + console.log(`\n Note #${index + 1}`); + console.log(` path : ${meta.path}`); + console.log(` name : ${meta.name}`); + console.log(` createdAt : ${new Date(meta.createdAt).toISOString()}`); + console.log(` updatedAt : ${new Date(meta.updatedAt).toISOString()}`); + }); + + // ── Step 8: Rename the first note ───────────────────────────────────────── + logStep(8, "Rename note → notes/hello.md → notes/renamed.md"); + + await vault.renameNote("notes/hello.md", "notes/renamed.md"); + + logSuccess('Renamed "notes/hello.md" → "notes/renamed.md"'); + + // ── Step 9: Delete the renamed note ────────────────────────────────────── + logStep(9, "Delete note → notes/renamed.md"); + + await vault.deleteNote("notes/renamed.md"); + + logSuccess('Deleted "notes/renamed.md"'); + + // ── Step 10: Delete the second note ────────────────────────────────────── + logStep(10, "Delete note → notes/second.md"); + + await vault.deleteNote("notes/second.md"); + + logSuccess('Deleted "notes/second.md"'); + + // ── Step 11: Delete the folder ──────────────────────────────────────────── + logStep(11, "Delete folder → notes/"); + + await vault.deleteFolder("notes"); + + logSuccess('Deleted folder "notes/"'); + + // ── Final summary ───────────────────────────────────────────────────────── + console.log("\n" + "=".repeat(50)); + console.log(" Vault demo completed successfully."); + console.log(" The demo-vault/ directory has been fully cleaned up."); + console.log("=".repeat(50) + "\n"); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +runDemo().catch((error: unknown) => { + console.error("\n[Demo] Fatal error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/apps/storage/src/index.ts b/apps/storage/src/index.ts new file mode 100644 index 0000000..5dc9c0d --- /dev/null +++ b/apps/storage/src/index.ts @@ -0,0 +1,51 @@ +/** + * @file index.ts + * @description Public API surface for the Smart Notes filesystem storage module. + * + * All consumers — the Indexer, Retriever, Electron main process, and any other + * internal package — should import exclusively from this entry point rather + * than reaching into internal modules directly. This keeps the module boundary + * stable and allows internal refactoring without breaking callers. + * + * ### What is exported + * - {@link VaultManager} — Concrete vault implementation; the primary class consumers instantiate. + * - {@link VaultOptions} — Configuration shape passed to the `VaultManager` constructor. + * - {@link VaultNoteMeta} — Lightweight note metadata returned by listing operations. + * - {@link VaultService} — Interface for typing references to the vault without coupling + * to the concrete implementation (useful for testing with mocks). + * - {@link writeFileAtomic} — Low-level atomic write utility, exposed for packages that + * need crash-safe file persistence outside the vault abstraction. + * + * ### What is intentionally omitted + * Internal helpers (`PathValidator`, `FileSystemUtils`), raw data shapes used + * only inside the module (`VaultNote`), and demo code (`VaultDemo`) are not + * exported. Consumers should never need to depend on implementation details. + * + * @example + * ```ts + * import { VaultManager, VaultService } from "@smart-notes/storage"; + * import type { VaultOptions, VaultNoteMeta } from "@smart-notes/storage"; + * + * const vault: VaultService = new VaultManager({ rootPath: "/notes" }); + * const notes: VaultNoteMeta[] = await vault.listNotes(); + * ``` + */ + +/** Concrete vault manager — the primary class consumers instantiate. */ +export { VaultManager } from "./VaultManager"; + +/** + * Public type contracts for the storage module. + * + * Exported as `export type` to ensure they are erased at compile time and + * never included in runtime bundles that do not need them. + */ +export type { VaultOptions, VaultNoteMeta, VaultService } from "./types"; + +/** + * Atomic file write utility. + * + * Exposed for packages (e.g. the Indexer) that need crash-safe file + * persistence independently of the vault abstraction layer. + */ +export { writeFileAtomic } from "./SafeWrite"; \ No newline at end of file diff --git a/apps/storage/src/types.ts b/apps/storage/src/types.ts new file mode 100644 index 0000000..d8fbe71 --- /dev/null +++ b/apps/storage/src/types.ts @@ -0,0 +1,245 @@ +/** + * @file types.ts + * @description Core type contracts for the Smart Notes filesystem vault. + * + * The vault is the canonical, offline-first store for all user notes. Every + * note is a plain Markdown file on disk; these types define the shapes that + * the rest of the storage module works with when reading, writing, and + * organising those files. + */ + +// --------------------------------------------------------------------------- +// Data Shapes +// --------------------------------------------------------------------------- + +/** + * Represents a markdown note that has been fully loaded from the vault. + * + * A `VaultNote` carries both the note's location within the vault (its + * relative path) and its complete raw Markdown content. It is the primary + * data transfer object used when reading or writing note bodies. + * + * @example + * ```ts + * const note: VaultNote = { + * path: "projects/gsoc/proposal.md", + * content: "# GSoC Proposal\n\nIntroduction...", + * }; + * ``` + */ +export interface VaultNote { + /** + * Path to the note relative to the vault root directory. + * Uses forward slashes as separators regardless of the host OS. + * + * @example "daily/2024-03-01.md" + * @example "projects/smart-notes/architecture.md" + */ + path: string; + + /** The complete raw Markdown content of the note. */ + content: string; +} + +/** + * Lightweight metadata snapshot for a note stored inside the vault. + * + * `VaultNoteMeta` is returned by listing operations so the UI can render + * file trees, sort by date, and display note titles without reading every + * file's full content from disk. + * + * @example + * ```ts + * const meta: VaultNoteMeta = { + * path: "daily/2024-03-01.md", + * name: "2024-03-01.md", + * createdAt: 1709251200000, + * updatedAt: 1709337600000, + * }; + * ``` + */ +export interface VaultNoteMeta { + /** + * Path to the note relative to the vault root directory. + * Mirrors the `path` field on {@link VaultNote} for consistency. + */ + path: string; + + /** + * The filename component of the note's path, including the `.md` extension. + * + * @example "proposal.md" + */ + name: string; + + /** + * Unix timestamp (milliseconds) when the note file was first created on disk. + * Sourced from the file system's `birthtime` stat field. + */ + createdAt: number; + + /** + * Unix timestamp (milliseconds) when the note file was last modified on disk. + * Sourced from the file system's `mtime` stat field. + */ + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** + * Configuration options supplied to the vault manager at initialisation time. + * + * `VaultOptions` is intentionally minimal — the vault manager derives all + * internal paths from the single `rootPath` anchor, keeping configuration + * straightforward and reducing the surface area for misconfiguration. + * + * @example + * ```ts + * const options: VaultOptions = { + * rootPath: "/Users/alice/Documents/SmartNotesVault", + * }; + * ``` + */ +export interface VaultOptions { + /** + * Absolute path to the vault's root directory on the local filesystem. + * All note paths stored in {@link VaultNote} and {@link VaultNoteMeta} + * are interpreted relative to this root. + * + * Must be an absolute path; relative paths are not supported. + * + * @example "/home/user/notes" + * @example "C:\\Users\\Alice\\SmartNotes" + */ + rootPath: string; +} + +// --------------------------------------------------------------------------- +// Service Interface +// --------------------------------------------------------------------------- + +/** + * Defines the complete set of operations supported by the vault manager. + * + * `VaultService` is the primary abstraction boundary between the rest of the + * Smart Notes application and the underlying filesystem. By depending on this + * interface rather than a concrete class, higher-level modules stay decoupled + * from storage implementation details and remain straightforward to test with + * mock implementations. + * + * ### Path conventions + * All `path` arguments are relative to the vault root configured via + * {@link VaultOptions}. The service implementation is responsible for + * resolving and validating these paths before touching the filesystem. + * + * @example + * ```ts + * const vault: VaultService = new VaultManager({ rootPath: "/notes" }); + * + * await vault.createNote("ideas/feature-x.md", "# Feature X\n"); + * const content = await vault.readNote("ideas/feature-x.md"); + * await vault.updateNote("ideas/feature-x.md", content + "\nMore detail."); + * await vault.renameNote("ideas/feature-x.md", "projects/feature-x.md"); + * await vault.deleteNote("projects/feature-x.md"); + * ``` + */ +export interface VaultService { + /** + * Creates a new note at the specified vault-relative path. + * + * Intermediate directories are created automatically if they do not exist. + * Implementations should reject with an error if a note already exists at + * the given path to prevent silent overwrites. + * + * @param path - Vault-relative path for the new note (e.g. `"daily/today.md"`). + * @param content - Initial Markdown content to write to the note. + * @returns A promise that resolves when the note has been written to disk. + */ + createNote(path: string, content: string): Promise; + + /** + * Reads and returns the full Markdown content of an existing note. + * + * @param path - Vault-relative path of the note to read. + * @returns A promise resolving to the raw Markdown string stored on disk. + * @throws When no file exists at the resolved path. + */ + readNote(path: string): Promise; + + /** + * Overwrites the content of an existing note. + * + * Implementations should use an atomic write strategy (e.g. write to a + * temporary file then rename) to prevent data loss if the process is + * interrupted mid-write. + * + * @param path - Vault-relative path of the note to update. + * @param content - New Markdown content that replaces the existing content. + * @returns A promise that resolves when the updated content has been flushed to disk. + * @throws When no file exists at the resolved path. + */ + updateNote(path: string, content: string): Promise; + + /** + * Permanently deletes a note from the vault. + * + * @param path - Vault-relative path of the note to delete. + * @returns A promise that resolves when the file has been removed from disk. + * @throws When no file exists at the resolved path. + */ + deleteNote(path: string): Promise; + + /** + * Moves or renames a note within the vault. + * + * Intermediate directories required by `newPath` are created automatically. + * Implementations should reject if a note already exists at `newPath`. + * + * @param oldPath - Vault-relative path of the note to move. + * @param newPath - Vault-relative destination path for the note. + * @returns A promise that resolves when the note has been moved on disk. + * @throws When no file exists at `oldPath`, or a file already exists at `newPath`. + */ + renameNote(oldPath: string, newPath: string): Promise; + + /** + * Lists metadata for every Markdown note currently stored in the vault. + * + * The returned array includes notes at all directory depths. Ordering is + * left to the implementation (typically alphabetical by path or by + * modification time) and may be sorted by the caller as needed. + * + * @returns A promise resolving to an array of {@link VaultNoteMeta} objects, + * one per `.md` file found within the vault root. + */ + listNotes(): Promise; + + /** + * Creates a new empty folder at the specified vault-relative path. + * + * Intermediate parent directories are created automatically if they do not + * exist. Implementations should reject if a file (not a directory) already + * exists at the target path. + * + * @param path - Vault-relative path of the folder to create (e.g. `"projects/gsoc"`). + * @returns A promise that resolves when the directory has been created on disk. + */ + createFolder(path: string): Promise; + + /** + * Recursively deletes a folder and all of its contents from the vault. + * + * **This operation is destructive and irreversible.** Implementations should + * validate that `path` is safely within the vault root before proceeding to + * prevent accidental deletion outside the vault boundary. + * + * @param path - Vault-relative path of the folder to delete. + * @returns A promise that resolves when the folder and its contents have + * been removed from disk. + * @throws When no directory exists at the resolved path. + */ + deleteFolder(path: string): Promise; +} \ No newline at end of file diff --git a/apps/storage/tsconfig.json b/apps/storage/tsconfig.json new file mode 100644 index 0000000..5aaa78f --- /dev/null +++ b/apps/storage/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file