From 65b39b0de5de267684dc49e4c9d95d02232d5834 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 6 Mar 2026 13:14:32 +0000 Subject: [PATCH] feat(store): add KV store subpackage with io.Medium adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite-backed group/key/value store extracted from core/go pkg/store. Includes Medium wrapper that maps group/key paths to the io.Medium interface — first segment is group, rest is key. Both the direct KV API (Get/Set/Delete) and Medium API (Read/Write/List) work on the same underlying data. Co-Authored-By: Virgil --- store/medium.go | 349 +++++++++++++++++++++++++++++++++++++++++++ store/medium_test.go | 202 +++++++++++++++++++++++++ store/store.go | 153 +++++++++++++++++++ store/store_test.go | 103 +++++++++++++ 4 files changed, 807 insertions(+) create mode 100644 store/medium.go create mode 100644 store/medium_test.go create mode 100644 store/store.go create mode 100644 store/store_test.go diff --git a/store/medium.go b/store/medium.go new file mode 100644 index 0000000..98710f0 --- /dev/null +++ b/store/medium.go @@ -0,0 +1,349 @@ +package store + +import ( + "fmt" + goio "io" + "io/fs" + "os" + "path" + "strings" + "time" +) + +// Medium wraps a Store to satisfy the io.Medium interface. +// Paths are mapped as group/key — first segment is the group, +// the rest is the key. List("") returns groups as directories, +// List("group") returns keys as files. +type Medium struct { + s *Store +} + +// NewMedium creates an io.Medium backed by a KV store at the given SQLite path. +func NewMedium(dbPath string) (*Medium, error) { + s, err := New(dbPath) + if err != nil { + return nil, err + } + return &Medium{s: s}, nil +} + +// AsMedium returns a Medium adapter for an existing Store. +func (s *Store) AsMedium() *Medium { + return &Medium{s: s} +} + +// Store returns the underlying KV store for direct access. +func (m *Medium) Store() *Store { + return m.s +} + +// Close closes the underlying store. +func (m *Medium) Close() error { + return m.s.Close() +} + +// splitPath splits a medium-style path into group and key. +// First segment = group, remainder = key. +func splitPath(p string) (group, key string) { + clean := path.Clean(p) + clean = strings.TrimPrefix(clean, "/") + if clean == "" || clean == "." { + return "", "" + } + parts := strings.SplitN(clean, "/", 2) + if len(parts) == 1 { + return parts[0], "" + } + return parts[0], parts[1] +} + +// Read retrieves the value at group/key. +func (m *Medium) Read(p string) (string, error) { + group, key := splitPath(p) + if key == "" { + return "", fmt.Errorf("store.Read: path must include group/key: %w", os.ErrInvalid) + } + return m.s.Get(group, key) +} + +// Write stores a value at group/key. +func (m *Medium) Write(p, content string) error { + group, key := splitPath(p) + if key == "" { + return fmt.Errorf("store.Write: path must include group/key: %w", os.ErrInvalid) + } + return m.s.Set(group, key, content) +} + +// EnsureDir is a no-op — groups are created implicitly on Set. +func (m *Medium) EnsureDir(_ string) error { + return nil +} + +// IsFile returns true if a group/key pair exists. +func (m *Medium) IsFile(p string) bool { + group, key := splitPath(p) + if key == "" { + return false + } + _, err := m.s.Get(group, key) + return err == nil +} + +// FileGet is an alias for Read. +func (m *Medium) FileGet(p string) (string, error) { + return m.Read(p) +} + +// FileSet is an alias for Write. +func (m *Medium) FileSet(p, content string) error { + return m.Write(p, content) +} + +// Delete removes a key, or checks that a group is empty. +func (m *Medium) Delete(p string) error { + group, key := splitPath(p) + if group == "" { + return fmt.Errorf("store.Delete: path is required: %w", os.ErrInvalid) + } + if key == "" { + n, err := m.s.Count(group) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("store.Delete: group not empty: %s: %w", group, os.ErrExist) + } + return nil + } + return m.s.Delete(group, key) +} + +// DeleteAll removes a key, or all keys in a group. +func (m *Medium) DeleteAll(p string) error { + group, key := splitPath(p) + if group == "" { + return fmt.Errorf("store.DeleteAll: path is required: %w", os.ErrInvalid) + } + if key == "" { + return m.s.DeleteGroup(group) + } + return m.s.Delete(group, key) +} + +// Rename moves a key from one path to another. +func (m *Medium) Rename(oldPath, newPath string) error { + og, ok := splitPath(oldPath) + ng, nk := splitPath(newPath) + if ok == "" || nk == "" { + return fmt.Errorf("store.Rename: both paths must include group/key: %w", os.ErrInvalid) + } + val, err := m.s.Get(og, ok) + if err != nil { + return err + } + if err := m.s.Set(ng, nk, val); err != nil { + return err + } + return m.s.Delete(og, ok) +} + +// List returns directory entries. Empty path returns groups. +// A group path returns keys in that group. +func (m *Medium) List(p string) ([]fs.DirEntry, error) { + group, key := splitPath(p) + + if group == "" { + rows, err := m.s.db.Query("SELECT DISTINCT grp FROM kv ORDER BY grp") + if err != nil { + return nil, fmt.Errorf("store.List: %w", err) + } + defer rows.Close() + + var entries []fs.DirEntry + for rows.Next() { + var g string + if err := rows.Scan(&g); err != nil { + return nil, fmt.Errorf("store.List: scan: %w", err) + } + entries = append(entries, &kvDirEntry{name: g, isDir: true}) + } + return entries, rows.Err() + } + + if key != "" { + return nil, nil // leaf node, nothing beneath + } + + all, err := m.s.GetAll(group) + if err != nil { + return nil, err + } + var entries []fs.DirEntry + for k, v := range all { + entries = append(entries, &kvDirEntry{name: k, size: int64(len(v))}) + } + return entries, nil +} + +// Stat returns file info for a group (dir) or key (file). +func (m *Medium) Stat(p string) (fs.FileInfo, error) { + group, key := splitPath(p) + if group == "" { + return nil, fmt.Errorf("store.Stat: path is required: %w", os.ErrInvalid) + } + if key == "" { + n, err := m.s.Count(group) + if err != nil { + return nil, err + } + if n == 0 { + return nil, fmt.Errorf("store.Stat: group not found: %s: %w", group, os.ErrNotExist) + } + return &kvFileInfo{name: group, isDir: true}, nil + } + val, err := m.s.Get(group, key) + if err != nil { + return nil, err + } + return &kvFileInfo{name: key, size: int64(len(val))}, nil +} + +// Open opens a key for reading. +func (m *Medium) Open(p string) (fs.File, error) { + group, key := splitPath(p) + if key == "" { + return nil, fmt.Errorf("store.Open: path must include group/key: %w", os.ErrInvalid) + } + val, err := m.s.Get(group, key) + if err != nil { + return nil, err + } + return &kvFile{name: key, content: []byte(val)}, nil +} + +// Create creates or truncates a key. Content is stored on Close. +func (m *Medium) Create(p string) (goio.WriteCloser, error) { + group, key := splitPath(p) + if key == "" { + return nil, fmt.Errorf("store.Create: path must include group/key: %w", os.ErrInvalid) + } + return &kvWriteCloser{s: m.s, group: group, key: key}, nil +} + +// Append opens a key for appending. Content is stored on Close. +func (m *Medium) Append(p string) (goio.WriteCloser, error) { + group, key := splitPath(p) + if key == "" { + return nil, fmt.Errorf("store.Append: path must include group/key: %w", os.ErrInvalid) + } + existing, _ := m.s.Get(group, key) + return &kvWriteCloser{s: m.s, group: group, key: key, data: []byte(existing)}, nil +} + +// ReadStream returns a reader for the value. +func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) { + group, key := splitPath(p) + if key == "" { + return nil, fmt.Errorf("store.ReadStream: path must include group/key: %w", os.ErrInvalid) + } + val, err := m.s.Get(group, key) + if err != nil { + return nil, err + } + return goio.NopCloser(strings.NewReader(val)), nil +} + +// WriteStream returns a writer. Content is stored on Close. +func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) { + return m.Create(p) +} + +// Exists returns true if a group or key exists. +func (m *Medium) Exists(p string) bool { + group, key := splitPath(p) + if group == "" { + return false + } + if key == "" { + n, err := m.s.Count(group) + return err == nil && n > 0 + } + _, err := m.s.Get(group, key) + return err == nil +} + +// IsDir returns true if the path is a group with entries. +func (m *Medium) IsDir(p string) bool { + group, key := splitPath(p) + if key != "" || group == "" { + return false + } + n, err := m.s.Count(group) + return err == nil && n > 0 +} + +// --- fs helper types --- + +type kvFileInfo struct { + name string + size int64 + isDir bool +} + +func (fi *kvFileInfo) Name() string { return fi.name } +func (fi *kvFileInfo) Size() int64 { return fi.size } +func (fi *kvFileInfo) Mode() fs.FileMode { if fi.isDir { return fs.ModeDir | 0755 }; return 0644 } +func (fi *kvFileInfo) ModTime() time.Time { return time.Time{} } +func (fi *kvFileInfo) IsDir() bool { return fi.isDir } +func (fi *kvFileInfo) Sys() any { return nil } + +type kvDirEntry struct { + name string + isDir bool + size int64 +} + +func (de *kvDirEntry) Name() string { return de.name } +func (de *kvDirEntry) IsDir() bool { return de.isDir } +func (de *kvDirEntry) Type() fs.FileMode { if de.isDir { return fs.ModeDir }; return 0 } +func (de *kvDirEntry) Info() (fs.FileInfo, error) { + return &kvFileInfo{name: de.name, size: de.size, isDir: de.isDir}, nil +} + +type kvFile struct { + name string + content []byte + offset int64 +} + +func (f *kvFile) Stat() (fs.FileInfo, error) { + return &kvFileInfo{name: f.name, size: int64(len(f.content))}, nil +} + +func (f *kvFile) 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 *kvFile) Close() error { return nil } + +type kvWriteCloser struct { + s *Store + group string + key string + data []byte +} + +func (w *kvWriteCloser) Write(p []byte) (int, error) { + w.data = append(w.data, p...) + return len(p), nil +} + +func (w *kvWriteCloser) Close() error { + return w.s.Set(w.group, w.key, string(w.data)) +} diff --git a/store/medium_test.go b/store/medium_test.go new file mode 100644 index 0000000..19722e7 --- /dev/null +++ b/store/medium_test.go @@ -0,0 +1,202 @@ +package store + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestMedium(t *testing.T) *Medium { + t.Helper() + m, err := NewMedium(":memory:") + require.NoError(t, err) + t.Cleanup(func() { m.Close() }) + return m +} + +func TestMedium_ReadWrite_Good(t *testing.T) { + m := newTestMedium(t) + + err := m.Write("config/theme", "dark") + require.NoError(t, err) + + val, err := m.Read("config/theme") + require.NoError(t, err) + assert.Equal(t, "dark", val) +} + +func TestMedium_Read_Bad_NoKey(t *testing.T) { + m := newTestMedium(t) + _, err := m.Read("config") + assert.Error(t, err) +} + +func TestMedium_Read_Bad_NotFound(t *testing.T) { + m := newTestMedium(t) + _, err := m.Read("config/missing") + assert.Error(t, err) +} + +func TestMedium_IsFile_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "val") + + assert.True(t, m.IsFile("grp/key")) + assert.False(t, m.IsFile("grp/nope")) + assert.False(t, m.IsFile("grp")) +} + +func TestMedium_Delete_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "val") + + err := m.Delete("grp/key") + require.NoError(t, err) + assert.False(t, m.IsFile("grp/key")) +} + +func TestMedium_Delete_Bad_NonEmptyGroup(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "val") + + err := m.Delete("grp") + assert.Error(t, err) +} + +func TestMedium_DeleteAll_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/a", "1") + _ = m.Write("grp/b", "2") + + err := m.DeleteAll("grp") + require.NoError(t, err) + assert.False(t, m.Exists("grp")) +} + +func TestMedium_Rename_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("old/key", "val") + + err := m.Rename("old/key", "new/key") + require.NoError(t, err) + + val, err := m.Read("new/key") + require.NoError(t, err) + assert.Equal(t, "val", val) + assert.False(t, m.IsFile("old/key")) +} + +func TestMedium_List_Good_Groups(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("alpha/a", "1") + _ = m.Write("beta/b", "2") + + entries, err := m.List("") + require.NoError(t, err) + assert.Len(t, entries, 2) + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + assert.True(t, e.IsDir()) + } + assert.True(t, names["alpha"]) + assert.True(t, names["beta"]) +} + +func TestMedium_List_Good_Keys(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/a", "1") + _ = m.Write("grp/b", "22") + + entries, err := m.List("grp") + require.NoError(t, err) + assert.Len(t, entries, 2) +} + +func TestMedium_Stat_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "hello") + + // Stat group + info, err := m.Stat("grp") + require.NoError(t, err) + assert.True(t, info.IsDir()) + + // Stat key + info, err = m.Stat("grp/key") + require.NoError(t, err) + assert.Equal(t, int64(5), info.Size()) + assert.False(t, info.IsDir()) +} + +func TestMedium_Exists_IsDir_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "val") + + assert.True(t, m.Exists("grp")) + assert.True(t, m.Exists("grp/key")) + assert.True(t, m.IsDir("grp")) + assert.False(t, m.IsDir("grp/key")) + assert.False(t, m.Exists("nope")) +} + +func TestMedium_Open_Read_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "hello world") + + f, err := m.Open("grp/key") + require.NoError(t, err) + defer f.Close() + + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "hello world", string(data)) +} + +func TestMedium_CreateClose_Good(t *testing.T) { + m := newTestMedium(t) + + w, err := m.Create("grp/key") + require.NoError(t, err) + _, _ = w.Write([]byte("streamed")) + require.NoError(t, w.Close()) + + val, err := m.Read("grp/key") + require.NoError(t, err) + assert.Equal(t, "streamed", val) +} + +func TestMedium_Append_Good(t *testing.T) { + m := newTestMedium(t) + _ = m.Write("grp/key", "hello") + + w, err := m.Append("grp/key") + require.NoError(t, err) + _, _ = w.Write([]byte(" world")) + require.NoError(t, w.Close()) + + val, err := m.Read("grp/key") + require.NoError(t, err) + assert.Equal(t, "hello world", val) +} + +func TestMedium_AsMedium_Good(t *testing.T) { + s, err := New(":memory:") + require.NoError(t, err) + defer s.Close() + + m := s.AsMedium() + require.NoError(t, m.Write("grp/key", "val")) + + // Accessible through both APIs + val, err := s.Get("grp", "key") + require.NoError(t, err) + assert.Equal(t, "val", val) + + val, err = m.Read("grp/key") + require.NoError(t, err) + assert.Equal(t, "val", val) +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..a5712c7 --- /dev/null +++ b/store/store.go @@ -0,0 +1,153 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "text/template" + + _ "modernc.org/sqlite" +) + +// ErrNotFound is returned when a key does not exist in the store. +var ErrNotFound = errors.New("store: not found") + +// Store is a group-namespaced key-value store backed by SQLite. +type Store struct { + db *sql.DB +} + +// New creates a Store at the given SQLite path. Use ":memory:" for tests. +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("store.New: %w", err) + } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: WAL: %w", err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv ( + grp TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (grp, key) + )`); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// Get retrieves a value by group and key. +func (s *Store) Get(group, key string) (string, error) { + var val string + err := s.db.QueryRow("SELECT value FROM kv WHERE grp = ? AND key = ?", group, key).Scan(&val) + if err == sql.ErrNoRows { + return "", fmt.Errorf("store.Get: %s/%s: %w", group, key, ErrNotFound) + } + if err != nil { + return "", fmt.Errorf("store.Get: %w", err) + } + return val, nil +} + +// Set stores a value by group and key, overwriting if exists. +func (s *Store) Set(group, key, value string) error { + _, err := s.db.Exec( + `INSERT INTO kv (grp, key, value) VALUES (?, ?, ?) + ON CONFLICT(grp, key) DO UPDATE SET value = excluded.value`, + group, key, value, + ) + if err != nil { + return fmt.Errorf("store.Set: %w", err) + } + return nil +} + +// Delete removes a single key from a group. +func (s *Store) Delete(group, key string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key) + if err != nil { + return fmt.Errorf("store.Delete: %w", err) + } + return nil +} + +// Count returns the number of keys in a group. +func (s *Store) Count(group string) (int, error) { + var n int + err := s.db.QueryRow("SELECT COUNT(*) FROM kv WHERE grp = ?", group).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store.Count: %w", err) + } + return n, nil +} + +// DeleteGroup removes all keys in a group. +func (s *Store) DeleteGroup(group string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group) + if err != nil { + return fmt.Errorf("store.DeleteGroup: %w", err) + } + return nil +} + +// GetAll returns all key-value pairs in a group. +func (s *Store) GetAll(group string) (map[string]string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return nil, fmt.Errorf("store.GetAll: %w", err) + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("store.GetAll: scan: %w", err) + } + result[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store.GetAll: rows: %w", err) + } + return result, nil +} + +// Render loads all key-value pairs from a group and renders a Go template. +func (s *Store) Render(tmplStr, group string) (string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return "", fmt.Errorf("store.Render: query: %w", err) + } + defer rows.Close() + + vars := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return "", fmt.Errorf("store.Render: scan: %w", err) + } + vars[k] = v + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("store.Render: rows: %w", err) + } + + tmpl, err := template.New("render").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("store.Render: parse: %w", err) + } + var b strings.Builder + if err := tmpl.Execute(&b, vars); err != nil { + return "", fmt.Errorf("store.Render: exec: %w", err) + } + return b.String(), nil +} diff --git a/store/store_test.go b/store/store_test.go new file mode 100644 index 0000000..b62b88b --- /dev/null +++ b/store/store_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetGet_Good(t *testing.T) { + s, err := New(":memory:") + require.NoError(t, err) + defer s.Close() + + err = s.Set("config", "theme", "dark") + require.NoError(t, err) + + val, err := s.Get("config", "theme") + require.NoError(t, err) + assert.Equal(t, "dark", val) +} + +func TestGet_Bad_NotFound(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _, err := s.Get("config", "missing") + assert.Error(t, err) +} + +func TestDelete_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("config", "key", "val") + err := s.Delete("config", "key") + require.NoError(t, err) + + _, err = s.Get("config", "key") + assert.Error(t, err) +} + +func TestCount_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + _ = s.Set("other", "c", "3") + + n, err := s.Count("grp") + require.NoError(t, err) + assert.Equal(t, 2, n) +} + +func TestDeleteGroup_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + err := s.DeleteGroup("grp") + require.NoError(t, err) + + n, _ := s.Count("grp") + assert.Equal(t, 0, n) +} + +func TestGetAll_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + _ = s.Set("other", "c", "3") + + all, err := s.GetAll("grp") + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all) +} + +func TestGetAll_Good_Empty(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + all, err := s.GetAll("empty") + require.NoError(t, err) + assert.Empty(t, all) +} + +func TestRender_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("user", "pool", "pool.lthn.io:3333") + _ = s.Set("user", "wallet", "iz...") + + tmpl := `{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}` + out, err := s.Render(tmpl, "user") + require.NoError(t, err) + assert.Contains(t, out, "pool.lthn.io:3333") + assert.Contains(t, out, "iz...") +}