diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d9886dbc..ea792b1c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -58,6 +58,14 @@ Tipos algebraicos: `product` (struct, todos los campos presentes) o `sum` (union - **DiseƱo del schema:** carpeta docs/ - **registry.db:** solo indice, regenerable con `fn index` +## Build + +```bash +# Requiere CGO y tag fts5 para SQLite FTS5 +CGO_ENABLED=1 go build -tags fts5 ./... +CGO_ENABLED=1 go test -tags fts5 ./... +``` + ## CLI (cmd/fn) ```bash diff --git a/go.mod b/go.mod index b2d5d014..e2be7856 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module fn-registry go 1.22.2 + +require github.com/mattn/go-sqlite3 v1.14.37 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9c79a75d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/registry/db.go b/registry/db.go new file mode 100644 index 00000000..f184864f --- /dev/null +++ b/registry/db.go @@ -0,0 +1,153 @@ +package registry + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS functions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')), + lang TEXT NOT NULL, + domain TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + purity TEXT NOT NULL CHECK(purity IN ('pure','impure')), + signature TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_functions TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + returns TEXT NOT NULL DEFAULT '[]', + returns_optional INTEGER NOT NULL DEFAULT 0, + error_type TEXT NOT NULL DEFAULT '', + imports TEXT NOT NULL DEFAULT '[]', + example TEXT NOT NULL DEFAULT '', + tested INTEGER NOT NULL DEFAULT 0, + tests TEXT NOT NULL DEFAULT '[]', + test_file_path TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + -- Component fields + props TEXT NOT NULL DEFAULT '[]', + emits TEXT NOT NULL DEFAULT '[]', + has_state INTEGER, + framework TEXT NOT NULL DEFAULT '', + variant TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lang TEXT NOT NULL, + domain TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')), + definition TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + file_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5( + id, + name, + description, + tags, + signature, + domain, + content='functions', + content_rowid='rowid' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5( + id, + name, + description, + tags, + domain, + content='types', + content_rowid='rowid' +); + +-- Triggers to keep FTS in sync +CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN + INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); +END; + +CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN + INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); +END; + +CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN + INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); + INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); +END; + +CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN + INSERT INTO types_fts(rowid, id, name, description, tags, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain); +END; + +CREATE TRIGGER IF NOT EXISTS types_ad AFTER DELETE ON types BEGIN + INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); +END; + +CREATE TRIGGER IF NOT EXISTS types_au AFTER UPDATE ON types BEGIN + INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); + INSERT INTO types_fts(rowid, id, name, description, tags, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain); +END; +` + +// DB wraps a SQLite connection for the registry. +type DB struct { + conn *sql.DB + path string +} + +// Open opens or creates the registry database at the given path. +func Open(path string) (*DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating db directory: %w", err) + } + + conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + if _, err := conn.Exec(schemaSQL); err != nil { + conn.Close() + return nil, fmt.Errorf("applying schema: %w", err) + } + + return &DB{conn: conn, path: path}, nil +} + +// Close closes the database connection. +func (db *DB) Close() error { + return db.conn.Close() +} + +// Drop removes the database file. Used by `fn index` to regenerate. +func (db *DB) Drop() error { + db.Close() + return os.Remove(db.path) +} diff --git a/registry/models.go b/registry/models.go new file mode 100644 index 00000000..06346058 --- /dev/null +++ b/registry/models.go @@ -0,0 +1,93 @@ +package registry + +import "time" + +// Kind classifies a registry entry. +type Kind string + +const ( + KindFunction Kind = "function" + KindPipeline Kind = "pipeline" + KindComponent Kind = "component" +) + +// Purity indicates whether a function has side effects. +type Purity string + +const ( + PurityPure Purity = "pure" + PurityImpure Purity = "impure" +) + +// Algebraic classifies a type. +type Algebraic string + +const ( + AlgebraicProduct Algebraic = "product" + AlgebraicSum Algebraic = "sum" +) + +// Function represents an entry in the functions table. +// Covers kind: function, pipeline, and component. +type Function struct { + ID string `json:"id"` + Name string `json:"name"` + Kind Kind `json:"kind"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Version string `json:"version"` + Purity Purity `json:"purity"` + Signature string `json:"signature"` + Description string `json:"description"` + Tags []string `json:"tags"` + UsesFunctions []string `json:"uses_functions"` + UsesTypes []string `json:"uses_types"` + Returns []string `json:"returns"` + ReturnsOptional bool `json:"returns_optional"` + ErrorType string `json:"error_type"` + Imports []string `json:"imports"` + Example string `json:"example"` + Tested bool `json:"tested"` + Tests []string `json:"tests"` + TestFilePath string `json:"test_file_path"` + FilePath string `json:"file_path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Component-only fields (kind: component) + Props []PropDef `json:"props,omitempty"` + Emits []string `json:"emits,omitempty"` + HasState *bool `json:"has_state,omitempty"` + Framework string `json:"framework,omitempty"` + Variant []string `json:"variant,omitempty"` +} + +// PropDef describes a component prop. +type PropDef struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` +} + +// Type represents an entry in the types table. +type Type struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Version string `json:"version"` + Algebraic Algebraic `json:"algebraic"` + Definition string `json:"definition"` + Description string `json:"description"` + Tags []string `json:"tags"` + UsesTypes []string `json:"uses_types"` + FilePath string `json:"file_path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GenerateID builds the canonical ID: {name}_{lang}_{domain} +func GenerateID(name, lang, domain string) string { + return name + "_" + lang + "_" + domain +} diff --git a/registry/store.go b/registry/store.go new file mode 100644 index 00000000..c7cac284 --- /dev/null +++ b/registry/store.go @@ -0,0 +1,307 @@ +package registry + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +func marshalStrings(ss []string) string { + if ss == nil { + ss = []string{} + } + b, _ := json.Marshal(ss) + return string(b) +} + +func unmarshalStrings(s string) []string { + var out []string + json.Unmarshal([]byte(s), &out) + if out == nil { + out = []string{} + } + return out +} + +func marshalProps(ps []PropDef) string { + if ps == nil { + ps = []PropDef{} + } + b, _ := json.Marshal(ps) + return string(b) +} + +func unmarshalProps(s string) []PropDef { + var out []PropDef + json.Unmarshal([]byte(s), &out) + return out +} + +// InsertFunction inserts or replaces a function entry. +func (db *DB) InsertFunction(f *Function) error { + now := time.Now().UTC().Format(time.RFC3339) + if f.CreatedAt.IsZero() { + f.CreatedAt = time.Now().UTC() + } + f.UpdatedAt = time.Now().UTC() + + if f.ID == "" { + f.ID = GenerateID(f.Name, f.Lang, f.Domain) + } + + var hasState *int + if f.HasState != nil { + v := 0 + if *f.HasState { + v = 1 + } + hasState = &v + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO functions ( + id, name, kind, lang, domain, version, purity, signature, + description, tags, uses_functions, uses_types, returns, + returns_optional, error_type, imports, example, tested, + tests, test_file_path, file_path, created_at, updated_at, + props, emits, has_state, framework, variant + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + )`, + f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature, + f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns), + f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested, + marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.CreatedAt.Format(time.RFC3339), now, + marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant), + ) + return err +} + +// InsertType inserts or replaces a type entry. +func (db *DB) InsertType(t *Type) error { + now := time.Now().UTC().Format(time.RFC3339) + if t.CreatedAt.IsZero() { + t.CreatedAt = time.Now().UTC() + } + t.UpdatedAt = time.Now().UTC() + + if t.ID == "" { + t.ID = GenerateID(t.Name, t.Lang, t.Domain) + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO types ( + id, name, lang, domain, version, algebraic, + definition, description, tags, uses_types, + file_path, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic), + t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes), + t.FilePath, t.CreatedAt.Format(time.RFC3339), now, + ) + return err +} + +// SearchFunctions performs FTS search on functions with optional filters. +func (db *DB) SearchFunctions(query string, kind Kind, purity Purity, lang, domain string) ([]Function, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "f.id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?)") + args = append(args, query) + } + if kind != "" { + where = append(where, "f.kind = ?") + args = append(args, string(kind)) + } + if purity != "" { + where = append(where, "f.purity = ?") + args = append(args, string(purity)) + } + if lang != "" { + where = append(where, "f.lang = ?") + args = append(args, lang) + } + if domain != "" { + where = append(where, "f.domain = ?") + args = append(args, domain) + } + + sql := "SELECT * FROM functions f" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY f.name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search functions: %w", err) + } + defer rows.Close() + + return scanFunctions(rows) +} + +// SearchTypes performs FTS search on types with optional filters. +func (db *DB) SearchTypes(query string, lang, domain string) ([]Type, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "t.id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?)") + args = append(args, query) + } + if lang != "" { + where = append(where, "t.lang = ?") + args = append(args, lang) + } + if domain != "" { + where = append(where, "t.domain = ?") + args = append(args, domain) + } + + sql := "SELECT * FROM types t" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY t.name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search types: %w", err) + } + defer rows.Close() + + return scanTypes(rows) +} + +// GetFunction returns a single function by ID. +func (db *DB) GetFunction(id string) (*Function, error) { + rows, err := db.conn.Query("SELECT * FROM functions WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + fns, err := scanFunctions(rows) + if err != nil { + return nil, err + } + if len(fns) == 0 { + return nil, fmt.Errorf("function %q not found", id) + } + return &fns[0], nil +} + +// GetType returns a single type by ID. +func (db *DB) GetType(id string) (*Type, error) { + rows, err := db.conn.Query("SELECT * FROM types WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + ts, err := scanTypes(rows) + if err != nil { + return nil, err + } + if len(ts) == 0 { + return nil, fmt.Errorf("type %q not found", id) + } + return &ts[0], nil +} + +// DeleteFunction removes a function by ID. +func (db *DB) DeleteFunction(id string) error { + _, err := db.conn.Exec("DELETE FROM functions WHERE id = ?", id) + return err +} + +// DeleteType removes a type by ID. +func (db *DB) DeleteType(id string) error { + _, err := db.conn.Exec("DELETE FROM types WHERE id = ?", id) + return err +} + +// Purge deletes all data from both tables. Used before re-indexing. +func (db *DB) Purge() error { + if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { + return err + } + _, err := db.conn.Exec("DELETE FROM types") + return err +} + +func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) { + var result []Function + for rows.Next() { + var f Function + var tagsJSON, usesFnJSON, usesTypJSON, returnsJSON, importsJSON, testsJSON string + var propsJSON, emitsJSON, variantJSON string + var hasState *int + var createdAt, updatedAt string + + err := rows.Scan( + &f.ID, &f.Name, &f.Kind, &f.Lang, &f.Domain, &f.Version, &f.Purity, &f.Signature, + &f.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &returnsJSON, + &f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested, + &testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt, + &propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON, + ) + if err != nil { + return nil, fmt.Errorf("scanning function: %w", err) + } + + f.Tags = unmarshalStrings(tagsJSON) + f.UsesFunctions = unmarshalStrings(usesFnJSON) + f.UsesTypes = unmarshalStrings(usesTypJSON) + f.Returns = unmarshalStrings(returnsJSON) + f.Imports = unmarshalStrings(importsJSON) + f.Tests = unmarshalStrings(testsJSON) + f.Props = unmarshalProps(propsJSON) + f.Emits = unmarshalStrings(emitsJSON) + f.Variant = unmarshalStrings(variantJSON) + f.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + f.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + if hasState != nil { + v := *hasState == 1 + f.HasState = &v + } + + result = append(result, f) + } + return result, nil +} + +func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error) { + var result []Type + for rows.Next() { + var t Type + var tagsJSON, usesTypJSON string + var createdAt, updatedAt string + + err := rows.Scan( + &t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic, + &t.Definition, &t.Description, &tagsJSON, &usesTypJSON, + &t.FilePath, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning type: %w", err) + } + + t.Tags = unmarshalStrings(tagsJSON) + t.UsesTypes = unmarshalStrings(usesTypJSON) + t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + result = append(result, t) + } + return result, nil +} diff --git a/registry/store_test.go b/registry/store_test.go new file mode 100644 index 00000000..c3a4204c --- /dev/null +++ b/registry/store_test.go @@ -0,0 +1,168 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" +) + +func tempDB(t *testing.T) *DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestInsertAndGetFunction(t *testing.T) { + db := tempDB(t) + + f := &Function{ + Name: "filter_slice", + Kind: KindFunction, + Lang: "go", + Domain: "core", + Version: "1.0.0", + Purity: PurityPure, + Signature: "func FilterSlice[T any](xs []T, pred func(T) bool) []T", + Description: "Filtra un slice con un predicado sin mutar el original", + Tags: []string{"slice", "functional", "generic"}, + FilePath: "functions/core/filter_slice.go", + } + + if err := db.InsertFunction(f); err != nil { + t.Fatal(err) + } + + if f.ID != "filter_slice_go_core" { + t.Fatalf("expected ID filter_slice_go_core, got %s", f.ID) + } + + got, err := db.GetFunction("filter_slice_go_core") + if err != nil { + t.Fatal(err) + } + + if got.Name != "filter_slice" { + t.Errorf("name: got %q, want %q", got.Name, "filter_slice") + } + if got.Purity != PurityPure { + t.Errorf("purity: got %q, want %q", got.Purity, PurityPure) + } + if len(got.Tags) != 3 { + t.Errorf("tags: got %d, want 3", len(got.Tags)) + } +} + +func TestInsertAndGetType(t *testing.T) { + db := tempDB(t) + + typ := &Type{ + Name: "ohlcv", + Lang: "go", + Domain: "finance", + Version: "1.0.0", + Algebraic: AlgebraicProduct, + Definition: `type OHLCV struct { + Open, High, Low, Close, Volume float64 +}`, + Description: "Vela de mercado con precios OHLCV", + Tags: []string{"finance", "market", "candle"}, + FilePath: "types/finance/ohlcv.go", + } + + if err := db.InsertType(typ); err != nil { + t.Fatal(err) + } + + got, err := db.GetType("ohlcv_go_finance") + if err != nil { + t.Fatal(err) + } + + if got.Algebraic != AlgebraicProduct { + t.Errorf("algebraic: got %q, want %q", got.Algebraic, AlgebraicProduct) + } +} + +func TestSearchFunctionsFTS(t *testing.T) { + db := tempDB(t) + + fns := []*Function{ + {Name: "filter_slice", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "Filtra un slice con un predicado", Version: "1.0.0"}, + {Name: "map_slice", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "Transforma cada elemento de un slice", Version: "1.0.0"}, + {Name: "fetch_ticks", Kind: KindFunction, Lang: "go", Domain: "io", Purity: PurityImpure, Description: "Obtiene ticks de un exchange", ErrorType: "error_go_core", Version: "1.0.0"}, + } + for _, f := range fns { + if err := db.InsertFunction(f); err != nil { + t.Fatal(err) + } + } + + // FTS search + results, err := db.SearchFunctions("slice", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Errorf("FTS 'slice': got %d results, want 2", len(results)) + } + + // Filter by purity + results, err = db.SearchFunctions("", "", PurityImpure, "", "") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Name != "fetch_ticks" { + t.Errorf("filter impure: unexpected results %v", results) + } + + // Filter by domain + results, err = db.SearchFunctions("", "", "", "", "core") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Errorf("filter domain=core: got %d, want 2", len(results)) + } +} + +func TestPurge(t *testing.T) { + db := tempDB(t) + + db.InsertFunction(&Function{Name: "test_fn", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "test", Version: "1.0.0"}) + db.InsertType(&Type{Name: "test_type", Lang: "go", Domain: "core", Algebraic: AlgebraicProduct, Description: "test", Version: "1.0.0"}) + + if err := db.Purge(); err != nil { + t.Fatal(err) + } + + fns, _ := db.SearchFunctions("", "", "", "", "") + if len(fns) != 0 { + t.Errorf("after purge: got %d functions, want 0", len(fns)) + } + + ts, _ := db.SearchTypes("", "", "") + if len(ts) != 0 { + t.Errorf("after purge: got %d types, want 0", len(ts)) + } +} + +func TestDrop(t *testing.T) { + path := filepath.Join(t.TempDir(), "drop.db") + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + + if err := db.Drop(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("db file should not exist after Drop") + } +}