// Package sqlite provides a SQLite-backed implementation of the io.Medium interface. package sqlite import ( "bytes" "database/sql" goio "io" "io/fs" "os" "path" "strings" "time" coreerr "forge.lthn.ai/core/go/pkg/framework/core" _ "modernc.org/sqlite" // Pure Go SQLite driver ) // Medium is a SQLite-backed storage backend implementing the io.Medium interface. type Medium struct { db *sql.DB table string } // Option configures a Medium. type Option func(*Medium) // WithTable sets the table name (default: "files"). func WithTable(table string) Option { return func(m *Medium) { m.table = table } } // New creates a new SQLite Medium at the given database path. // Use ":memory:" for an in-memory database. func New(dbPath string, opts ...Option) (*Medium, error) { if dbPath == "" { return nil, coreerr.E("sqlite.New", "database path is required", nil) } m := &Medium{table: "files"} for _, opt := range opts { opt(m) } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, coreerr.E("sqlite.New", "failed to open database", err) } // Enable WAL mode for better concurrency if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { db.Close() return nil, coreerr.E("sqlite.New", "failed to set WAL mode", err) } // Create the schema createSQL := `CREATE TABLE IF NOT EXISTS ` + m.table + ` ( path TEXT PRIMARY KEY, content BLOB NOT NULL, mode INTEGER DEFAULT 420, is_dir BOOLEAN DEFAULT FALSE, mtime DATETIME DEFAULT CURRENT_TIMESTAMP )` if _, err := db.Exec(createSQL); err != nil { db.Close() return nil, coreerr.E("sqlite.New", "failed to create table", err) } m.db = db return m, nil } // Close closes the underlying database connection. func (m *Medium) Close() error { if m.db != nil { return m.db.Close() } return nil } // cleanPath normalizes a path for consistent storage. // Uses a leading "/" before Clean to sandbox traversal attempts. func cleanPath(p string) string { clean := path.Clean("/" + p) if clean == "/" { return "" } return strings.TrimPrefix(clean, "/") } // Read retrieves the content of a file as a string. func (m *Medium) Read(p string) (string, error) { key := cleanPath(p) if key == "" { return "", coreerr.E("sqlite.Read", "path is required", os.ErrInvalid) } var content []byte var isDir bool err := m.db.QueryRow( `SELECT content, is_dir FROM `+m.table+` WHERE path = ?`, key, ).Scan(&content, &isDir) if err == sql.ErrNoRows { return "", coreerr.E("sqlite.Read", "file not found: "+key, os.ErrNotExist) } if err != nil { return "", coreerr.E("sqlite.Read", "query failed: "+key, err) } if isDir { return "", coreerr.E("sqlite.Read", "path is a directory: "+key, os.ErrInvalid) } return string(content), nil } // Write saves the given content to a file, overwriting it if it exists. func (m *Medium) Write(p, content string) error { key := cleanPath(p) if key == "" { return coreerr.E("sqlite.Write", "path is required", os.ErrInvalid) } _, err := m.db.Exec( `INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, 420, FALSE, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content, is_dir = FALSE, mtime = excluded.mtime`, key, []byte(content), time.Now().UTC(), ) if err != nil { return coreerr.E("sqlite.Write", "insert failed: "+key, err) } return nil } // EnsureDir makes sure a directory exists, creating it if necessary. func (m *Medium) EnsureDir(p string) error { key := cleanPath(p) if key == "" { // Root always "exists" return nil } _, err := m.db.Exec( `INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, '', 493, TRUE, ?) ON CONFLICT(path) DO NOTHING`, key, time.Now().UTC(), ) if err != nil { return coreerr.E("sqlite.EnsureDir", "insert failed: "+key, err) } return nil } // IsFile checks if a path exists and is a regular file. func (m *Medium) IsFile(p string) bool { key := cleanPath(p) if key == "" { return false } var isDir bool err := m.db.QueryRow( `SELECT is_dir FROM `+m.table+` WHERE path = ?`, key, ).Scan(&isDir) if err != nil { return false } return !isDir } // FileGet is a convenience function that reads a file from the medium. func (m *Medium) FileGet(p string) (string, error) { return m.Read(p) } // FileSet is a convenience function that writes a file to the medium. func (m *Medium) FileSet(p, content string) error { return m.Write(p, content) } // Delete removes a file or empty directory. func (m *Medium) Delete(p string) error { key := cleanPath(p) if key == "" { return coreerr.E("sqlite.Delete", "path is required", os.ErrInvalid) } // Check if it's a directory with children var isDir bool err := m.db.QueryRow( `SELECT is_dir FROM `+m.table+` WHERE path = ?`, key, ).Scan(&isDir) if err == sql.ErrNoRows { return coreerr.E("sqlite.Delete", "path not found: "+key, os.ErrNotExist) } if err != nil { return coreerr.E("sqlite.Delete", "query failed: "+key, err) } if isDir { // Check for children prefix := key + "/" var count int err := m.db.QueryRow( `SELECT COUNT(*) FROM `+m.table+` WHERE path LIKE ? AND path != ?`, prefix+"%", key, ).Scan(&count) if err != nil { return coreerr.E("sqlite.Delete", "count failed: "+key, err) } if count > 0 { return coreerr.E("sqlite.Delete", "directory not empty: "+key, os.ErrExist) } } res, err := m.db.Exec(`DELETE FROM `+m.table+` WHERE path = ?`, key) if err != nil { return coreerr.E("sqlite.Delete", "delete failed: "+key, err) } n, _ := res.RowsAffected() if n == 0 { return coreerr.E("sqlite.Delete", "path not found: "+key, os.ErrNotExist) } return nil } // DeleteAll removes a file or directory and all its contents recursively. func (m *Medium) DeleteAll(p string) error { key := cleanPath(p) if key == "" { return coreerr.E("sqlite.DeleteAll", "path is required", os.ErrInvalid) } prefix := key + "/" // Delete the exact path and all children res, err := m.db.Exec( `DELETE FROM `+m.table+` WHERE path = ? OR path LIKE ?`, key, prefix+"%", ) if err != nil { return coreerr.E("sqlite.DeleteAll", "delete failed: "+key, err) } n, _ := res.RowsAffected() if n == 0 { return coreerr.E("sqlite.DeleteAll", "path not found: "+key, os.ErrNotExist) } return nil } // Rename moves a file or directory from oldPath to newPath. func (m *Medium) Rename(oldPath, newPath string) error { oldKey := cleanPath(oldPath) newKey := cleanPath(newPath) if oldKey == "" || newKey == "" { return coreerr.E("sqlite.Rename", "both old and new paths are required", os.ErrInvalid) } tx, err := m.db.Begin() if err != nil { return coreerr.E("sqlite.Rename", "begin tx failed", err) } defer tx.Rollback() // Check if source exists var content []byte var mode int var isDir bool var mtime time.Time err = tx.QueryRow( `SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, oldKey, ).Scan(&content, &mode, &isDir, &mtime) if err == sql.ErrNoRows { return coreerr.E("sqlite.Rename", "source not found: "+oldKey, os.ErrNotExist) } if err != nil { return coreerr.E("sqlite.Rename", "query failed: "+oldKey, err) } // Insert or replace at new path _, err = tx.Exec( `INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content, mode = excluded.mode, is_dir = excluded.is_dir, mtime = excluded.mtime`, newKey, content, mode, isDir, mtime, ) if err != nil { return coreerr.E("sqlite.Rename", "insert at new path failed: "+newKey, err) } // Delete old path _, err = tx.Exec(`DELETE FROM `+m.table+` WHERE path = ?`, oldKey) if err != nil { return coreerr.E("sqlite.Rename", "delete old path failed: "+oldKey, err) } // If it's a directory, move all children if isDir { oldPrefix := oldKey + "/" newPrefix := newKey + "/" rows, err := tx.Query( `SELECT path, content, mode, is_dir, mtime FROM `+m.table+` WHERE path LIKE ?`, oldPrefix+"%", ) if err != nil { return coreerr.E("sqlite.Rename", "query children failed", err) } type child struct { path string content []byte mode int isDir bool mtime time.Time } var children []child for rows.Next() { var c child if err := rows.Scan(&c.path, &c.content, &c.mode, &c.isDir, &c.mtime); err != nil { rows.Close() return coreerr.E("sqlite.Rename", "scan child failed", err) } children = append(children, c) } rows.Close() for _, c := range children { newChildPath := newPrefix + strings.TrimPrefix(c.path, oldPrefix) _, err = tx.Exec( `INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content, mode = excluded.mode, is_dir = excluded.is_dir, mtime = excluded.mtime`, newChildPath, c.content, c.mode, c.isDir, c.mtime, ) if err != nil { return coreerr.E("sqlite.Rename", "insert child failed", err) } } // Delete old children _, err = tx.Exec(`DELETE FROM `+m.table+` WHERE path LIKE ?`, oldPrefix+"%") if err != nil { return coreerr.E("sqlite.Rename", "delete old children failed", err) } } return tx.Commit() } // List returns the directory entries for the given path. func (m *Medium) List(p string) ([]fs.DirEntry, error) { prefix := cleanPath(p) if prefix != "" { prefix += "/" } // Query all paths under the prefix rows, err := m.db.Query( `SELECT path, content, mode, is_dir, mtime FROM `+m.table+` WHERE path LIKE ? OR path LIKE ?`, prefix+"%", prefix+"%", ) if err != nil { return nil, coreerr.E("sqlite.List", "query failed", err) } defer rows.Close() seen := make(map[string]bool) var entries []fs.DirEntry for rows.Next() { var rowPath string var content []byte var mode int var isDir bool var mtime time.Time if err := rows.Scan(&rowPath, &content, &mode, &isDir, &mtime); err != nil { return nil, coreerr.E("sqlite.List", "scan failed", err) } rest := strings.TrimPrefix(rowPath, prefix) if rest == "" { continue } // Check if this is a direct child or nested if idx := strings.Index(rest, "/"); idx >= 0 { // Nested - register as a directory dirName := rest[:idx] if !seen[dirName] { seen[dirName] = true entries = append(entries, &dirEntry{ name: dirName, isDir: true, mode: fs.ModeDir | 0755, info: &fileInfo{ name: dirName, isDir: true, mode: fs.ModeDir | 0755, }, }) } } else { // Direct child if !seen[rest] { seen[rest] = true entries = append(entries, &dirEntry{ name: rest, isDir: isDir, mode: fs.FileMode(mode), info: &fileInfo{ name: rest, size: int64(len(content)), mode: fs.FileMode(mode), modTime: mtime, isDir: isDir, }, }) } } } return entries, rows.Err() } // Stat returns file information for the given path. func (m *Medium) Stat(p string) (fs.FileInfo, error) { key := cleanPath(p) if key == "" { return nil, coreerr.E("sqlite.Stat", "path is required", os.ErrInvalid) } var content []byte var mode int var isDir bool var mtime time.Time err := m.db.QueryRow( `SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, key, ).Scan(&content, &mode, &isDir, &mtime) if err == sql.ErrNoRows { return nil, coreerr.E("sqlite.Stat", "path not found: "+key, os.ErrNotExist) } if err != nil { return nil, coreerr.E("sqlite.Stat", "query failed: "+key, err) } name := path.Base(key) return &fileInfo{ name: name, size: int64(len(content)), mode: fs.FileMode(mode), modTime: mtime, isDir: isDir, }, nil } // Open opens the named file for reading. func (m *Medium) Open(p string) (fs.File, error) { key := cleanPath(p) if key == "" { return nil, coreerr.E("sqlite.Open", "path is required", os.ErrInvalid) } var content []byte var mode int var isDir bool var mtime time.Time err := m.db.QueryRow( `SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, key, ).Scan(&content, &mode, &isDir, &mtime) if err == sql.ErrNoRows { return nil, coreerr.E("sqlite.Open", "file not found: "+key, os.ErrNotExist) } if err != nil { return nil, coreerr.E("sqlite.Open", "query failed: "+key, err) } if isDir { return nil, coreerr.E("sqlite.Open", "path is a directory: "+key, os.ErrInvalid) } return &sqliteFile{ name: path.Base(key), content: content, mode: fs.FileMode(mode), modTime: mtime, }, nil } // Create creates or truncates the named file. func (m *Medium) Create(p string) (goio.WriteCloser, error) { key := cleanPath(p) if key == "" { return nil, coreerr.E("sqlite.Create", "path is required", os.ErrInvalid) } return &sqliteWriteCloser{ medium: m, path: key, }, nil } // Append opens the named file for appending, creating it if it doesn't exist. func (m *Medium) Append(p string) (goio.WriteCloser, error) { key := cleanPath(p) if key == "" { return nil, coreerr.E("sqlite.Append", "path is required", os.ErrInvalid) } var existing []byte err := m.db.QueryRow( `SELECT content FROM `+m.table+` WHERE path = ? AND is_dir = FALSE`, key, ).Scan(&existing) if err != nil && err != sql.ErrNoRows { return nil, coreerr.E("sqlite.Append", "query failed: "+key, err) } return &sqliteWriteCloser{ medium: m, path: key, data: existing, }, nil } // ReadStream returns a reader for the file content. func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) { key := cleanPath(p) if key == "" { return nil, coreerr.E("sqlite.ReadStream", "path is required", os.ErrInvalid) } var content []byte var isDir bool err := m.db.QueryRow( `SELECT content, is_dir FROM `+m.table+` WHERE path = ?`, key, ).Scan(&content, &isDir) if err == sql.ErrNoRows { return nil, coreerr.E("sqlite.ReadStream", "file not found: "+key, os.ErrNotExist) } if err != nil { return nil, coreerr.E("sqlite.ReadStream", "query failed: "+key, err) } if isDir { return nil, coreerr.E("sqlite.ReadStream", "path is a directory: "+key, os.ErrInvalid) } return goio.NopCloser(bytes.NewReader(content)), nil } // WriteStream returns a writer for the file content. Content is stored on Close. func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) { return m.Create(p) } // Exists checks if a path exists (file or directory). func (m *Medium) Exists(p string) bool { key := cleanPath(p) if key == "" { // Root always exists return true } var count int err := m.db.QueryRow( `SELECT COUNT(*) FROM `+m.table+` WHERE path = ?`, key, ).Scan(&count) if err != nil { return false } return count > 0 } // IsDir checks if a path exists and is a directory. func (m *Medium) IsDir(p string) bool { key := cleanPath(p) if key == "" { return false } var isDir bool err := m.db.QueryRow( `SELECT is_dir FROM `+m.table+` WHERE path = ?`, key, ).Scan(&isDir) if err != nil { return false } return isDir } // --- Internal types --- // fileInfo implements fs.FileInfo for SQLite entries. type fileInfo struct { name string size int64 mode fs.FileMode modTime time.Time isDir bool } func (fi *fileInfo) Name() string { return fi.name } func (fi *fileInfo) Size() int64 { return fi.size } func (fi *fileInfo) Mode() fs.FileMode { return fi.mode } func (fi *fileInfo) ModTime() time.Time { return fi.modTime } func (fi *fileInfo) IsDir() bool { return fi.isDir } func (fi *fileInfo) Sys() any { return nil } // dirEntry implements fs.DirEntry for SQLite listings. type dirEntry struct { name string isDir bool mode fs.FileMode info fs.FileInfo } func (de *dirEntry) Name() string { return de.name } func (de *dirEntry) IsDir() bool { return de.isDir } func (de *dirEntry) Type() fs.FileMode { return de.mode.Type() } func (de *dirEntry) Info() (fs.FileInfo, error) { return de.info, nil } // sqliteFile implements fs.File for SQLite entries. type sqliteFile struct { name string content []byte offset int64 mode fs.FileMode modTime time.Time } func (f *sqliteFile) Stat() (fs.FileInfo, error) { return &fileInfo{ name: f.name, size: int64(len(f.content)), mode: f.mode, modTime: f.modTime, }, nil } func (f *sqliteFile) Read(b []byte) (int, error) { if f.offset >= int64(len(f.content)) { return 0, goio.EOF } n := copy(b, f.content[f.offset:]) f.offset += int64(n) return n, nil } func (f *sqliteFile) Close() error { return nil } // sqliteWriteCloser buffers writes and stores to SQLite on Close. type sqliteWriteCloser struct { medium *Medium path string data []byte } func (w *sqliteWriteCloser) Write(p []byte) (int, error) { w.data = append(w.data, p...) return len(p), nil } func (w *sqliteWriteCloser) Close() error { _, err := w.medium.db.Exec( `INSERT INTO `+w.medium.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, 420, FALSE, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content, is_dir = FALSE, mtime = excluded.mtime`, w.path, w.data, time.Now().UTC(), ) if err != nil { return coreerr.E("sqlite.WriteCloser.Close", "store failed: "+w.path, err) } return nil }