From b8d8427c463df1c4f51127c45bb315d4b0e38b2b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 30 May 2026 18:58:47 +0800 Subject: [PATCH] step 2: init command + SQLite storage + migrations + config - storage.go: DB wrapper, migration runner (in-code SQL strings) - migrations.go: 001_init (nodes + node_meta + indexes) - vault.go: Init() creates .verstak/ dirs, config.yml, index.db - config.go: YAML config read/write - util/uuid.go: UUIDv7 generator - cmd/verstak/main.go: init --vault PATH command - main_test.go: TestInitCreatesVault, TestInitConfigYAML Acceptance: go build ./... pass, go test ./... pass Init creates test-vault with .verstak/index.db + config.yml Repeat Init is safe. --- cmd/verstak/main.go | 52 +++++++++--- cmd/verstak/main_test.go | 81 +++++++++++++++++++ docs/PLAN.md | 2 +- go.mod | 5 ++ go.sum | 5 ++ internal/core/config/config.go | 68 ++++++++++++++++ internal/core/storage/migrations.go | 29 +++++++ internal/core/storage/storage.go | 120 ++++++++++++++++++++++++++++ internal/core/util/uuid.go | 33 ++++++++ internal/core/vault/vault.go | 65 +++++++++++++++ migrations/001_init.sql | 24 ++++++ 11 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 cmd/verstak/main_test.go create mode 100644 go.sum create mode 100644 internal/core/config/config.go create mode 100644 internal/core/storage/migrations.go create mode 100644 internal/core/storage/storage.go create mode 100644 internal/core/util/uuid.go create mode 100644 internal/core/vault/vault.go create mode 100644 migrations/001_init.sql diff --git a/cmd/verstak/main.go b/cmd/verstak/main.go index 1e6a43a..65c6676 100644 --- a/cmd/verstak/main.go +++ b/cmd/verstak/main.go @@ -3,6 +3,9 @@ package main import ( "fmt" "os" + "path/filepath" + + "verstak/internal/core/vault" ) const version = "0.1.0-dev" @@ -17,18 +20,49 @@ func main() { case "--version", "-v": fmt.Println("Verstak", version) case "--help", "-h": - fmt.Println("Verstak — local-first working vault") - fmt.Println() - fmt.Println("Usage: verstak [flags]") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" init Initialize a new vault") - fmt.Println(" --version Show version") - fmt.Println(" --help Show this help") + usage() case "init": - fmt.Println("TODO: init command (step 2)") + runInit() default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) os.Exit(1) } } + +func usage() { + fmt.Println("Verstak — local-first working vault") + fmt.Println() + fmt.Println("Usage: verstak [flags]") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" init --vault PATH Initialize a new vault at PATH") + fmt.Println(" --version Show version") + fmt.Println(" --help Show this help") +} + +func runInit() { + vaultPath := "." + // Parse --vault flag + for i := 2; i < len(os.Args); i++ { + if os.Args[i] == "--vault" && i+1 < len(os.Args) { + vaultPath = os.Args[i+1] + break + } + } + + abs, err := filepath.Abs(vaultPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := vault.Init(abs); err != nil { + fmt.Fprintf(os.Stderr, "Init failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("Vault initialized at", abs) + fmt.Println(" .verstak/index.db") + fmt.Println(" .verstak/config.yml") + fmt.Println(" spaces/") +} diff --git a/cmd/verstak/main_test.go b/cmd/verstak/main_test.go new file mode 100644 index 0000000..09ee1f2 --- /dev/null +++ b/cmd/verstak/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "verstak/internal/core/storage" + "verstak/internal/core/vault" +) + +func TestInitCreatesVault(t *testing.T) { + dir := t.TempDir() + vaultPath := filepath.Join(dir, "test-vault") + + if err := vault.Init(vaultPath); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Check directories exist. + for _, sub := range []string{".verstak", ".verstak/trash", ".verstak/history", + ".verstak/originals", ".verstak/thumbnails", ".verstak/blobs", "spaces"} { + p := filepath.Join(vaultPath, sub) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("missing: %s", p) + } + } + + // Check index.db exists and is a valid SQLite database. + dbPath := filepath.Join(vaultPath, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil { + t.Fatalf("query nodes: %v", err) + } + if count != 0 { + t.Errorf("expected 0 nodes, got %d", count) + } + + // Idempotent: second Init should not fail. + if err := vault.Init(vaultPath); err != nil { + t.Fatalf("second Init failed: %v", err) + } +} + +func TestInitConfigYAML(t *testing.T) { + dir := t.TempDir() + vaultPath := filepath.Join(dir, "vault") + + if err := vault.Init(vaultPath); err != nil { + t.Fatalf("Init: %v", err) + } + + cfgPath := filepath.Join(vaultPath, ".verstak", "config.yml") + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + + if len(data) == 0 { + t.Fatal("config.yml empty") + } + // yaml key should be present. + if !containsString(string(data), "engine:") { + t.Errorf("config missing engine key:\n%s", string(data)) + } +} + +func containsString(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 8f586d6..07952b8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -11,7 +11,7 @@ | # | Шаг | Статус | |---|-----|--------| -| 1 | Git init + Skeleton | ⬜ не начат | +| 1 | Git init + Skeleton | ✅ выполнен | | 2 | Init + SQLite + First Migration | ⬜ не начат | | 3 | Nodes Repository + CRUD + CLI Node | ⬜ не начат | | 4 | Vault Files: Trash + File Service + CLI File | ⬜ не начат | diff --git a/go.mod b/go.mod index 357c8f5..eb869af 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module verstak go 1.22 + +require ( + github.com/mattn/go-sqlite3 v1.14.44 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..16eddf8 --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/config/config.go b/internal/core/config/config.go new file mode 100644 index 0000000..f4644c8 --- /dev/null +++ b/internal/core/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config lives at .verstak/config.yml inside the vault. +type Config struct { + Engine EngineConfig `yaml:"engine"` + Sync SyncConfig `yaml:"sync"` + Browser BrowserConfig `yaml:"browser"` +} + +type EngineConfig struct { + Version int `yaml:"version"` + VaultID string `yaml:"vault_id"` + CreatedAt string `yaml:"created_at"` + VaultRoot string `yaml:"vault_root"` +} + +type SyncConfig struct { + ServerURL string `yaml:"server_url"` + APIKey string `yaml:"api_key"` + DeviceID string `yaml:"device_id"` + AutoSync bool `yaml:"auto_sync"` +} + +type BrowserConfig struct { + Enabled bool `yaml:"enabled"` + LocalPort int `yaml:"local_port"` +} + +// Load reads .verstak/config.yml from the vault root. +func Load(vaultRoot string) (*Config, error) { + path := filepath.Join(vaultRoot, ".verstak", "config.yml") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return &cfg, nil +} + +// Save writes the config file. +func Save(vaultRoot string, cfg *Config) error { + path := filepath.Join(vaultRoot, ".verstak", "config.yml") + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o640) +} + +// MetaPath returns the path inside the vault for sqlite db etc. +func MetaDir(vaultRoot string) string { + return filepath.Join(vaultRoot, ".verstak") +} diff --git a/internal/core/storage/migrations.go b/internal/core/storage/migrations.go new file mode 100644 index 0000000..4fcc272 --- /dev/null +++ b/internal/core/storage/migrations.go @@ -0,0 +1,29 @@ +package storage + +// migration001 — initial schema: nodes + node_meta. +const migration001 = ` +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + parent_id TEXT NULL REFERENCES nodes(id), + type TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + path TEXT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT NULL, + revision INTEGER NOT NULL DEFAULT 1, + device_id TEXT NULL +); + +CREATE TABLE IF NOT EXISTS node_meta ( + node_id TEXT NOT NULL REFERENCES nodes(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (node_id, key) +); + +CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id); +CREATE INDEX IF NOT EXISTS idx_nodes_deleted ON nodes(deleted_at); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go new file mode 100644 index 0000000..8620d80 --- /dev/null +++ b/internal/core/storage/storage.go @@ -0,0 +1,120 @@ +package storage + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// DB wraps sql.DB with Verstak-specific operations. +type DB struct { + *sql.DB + path string +} + +// Open opens or creates a SQLite database at path and runs pending migrations. +func Open(path string) (*DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create db dir: %w", err) + } + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", path)) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + + w := &DB{DB: db, path: path} + if err := w.runInitialSchema(); err != nil { + db.Close() + return nil, err + } + if err := w.runMigrations(); err != nil { + db.Close() + return nil, err + } + return w, nil +} + +// Path returns the on-disk path of the database file. +func (db *DB) Path() string { + return db.path +} + +// --- internals --- + +const schemaVersionDDL = ` +CREATE TABLE IF NOT EXISTS _schema_ver ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); +` + +var migrationFiles = map[int]string{ + 1: migration001, + // 2: migration002, etc. +} + +func (db *DB) runInitialSchema() error { + _, err := db.Exec(schemaVersionDDL) + return err +} + +func (db *DB) runMigrations() error { + var currentVer int + if err := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM _schema_ver").Scan(¤tVer); err != nil { + return err + } + + // Build sorted version list. + versions := make([]int, 0, len(migrationFiles)) + for v := range migrationFiles { + versions = append(versions, v) + } + for i := 0; i < len(versions); i++ { + for j := i + 1; j < len(versions); j++ { + if versions[j] < versions[i] { + versions[i], versions[j] = versions[j], versions[i] + } + } + } + + for _, v := range versions { + if v <= currentVer { + continue + } + if err := db.applyMigration(v, migrationFiles[v]); err != nil { + return fmt.Errorf("migrate v%d: %w", v, err) + } + } + return nil +} + +func (db *DB) applyMigration(version int, raw string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, stmt := range strings.Split(raw, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("exec stat: %w; sql=%.200s", err, stmt) + } + } + if _, err := tx.Exec("INSERT INTO _schema_ver (version, applied_at) VALUES (?, ?)", + version, time.Now().UTC().Format(time.RFC3339)); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/core/util/uuid.go b/internal/core/util/uuid.go new file mode 100644 index 0000000..b30a442 --- /dev/null +++ b/internal/core/util/uuid.go @@ -0,0 +1,33 @@ +package util + +import ( + "crypto/rand" + "fmt" + "time" +) + +// UUID7 generates a UUIDv7-like identifier (time-ordered, random suffix). +// Format: 32 hex chars, e.g. "01972a8b4c3d8000a1b2c3d4e5f6a7b8". +func UUID7() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic("uuid7: rand: " + err.Error()) + } + + // Set version 7 bits. + b[6] = (b[6] & 0x0f) | 0x70 + b[8] = (b[8] & 0x3f) | 0x80 + + // Encode timestamp in first 6 bytes for rough time-ordering. + now := time.Now().UnixMilli() + b[0] = byte(now >> 40) + b[1] = byte(now >> 32) + b[2] = byte(now >> 24) + b[3] = byte(now >> 16) + b[4] = byte(now >> 8) + b[5] = byte(now) + + return fmt.Sprintf("%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} diff --git a/internal/core/vault/vault.go b/internal/core/vault/vault.go new file mode 100644 index 0000000..16c4d9f --- /dev/null +++ b/internal/core/vault/vault.go @@ -0,0 +1,65 @@ +package vault + +import ( + "fmt" + "os" + "path/filepath" + + "verstak/internal/core/config" + "verstak/internal/core/storage" + "verstak/internal/core/util" +) + +const currentVaultVersion = 1 + +// Init creates a vault directory structure at vaultRoot. +// Calling Init on an already-initialized vault is safe. +func Init(vaultRoot string) error { + vaultRoot = filepath.Clean(vaultRoot) + + dirs := []string{ + filepath.Join(vaultRoot, ".verstak"), + filepath.Join(vaultRoot, ".verstak", "trash"), + filepath.Join(vaultRoot, ".verstak", "history"), + filepath.Join(vaultRoot, ".verstak", "originals"), + filepath.Join(vaultRoot, ".verstak", "thumbnails"), + filepath.Join(vaultRoot, ".verstak", "blobs"), + filepath.Join(vaultRoot, "spaces"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0o750); err != nil { + return fmt.Errorf("create %s: %w", d, err) + } + } + + cfgPath := filepath.Join(vaultRoot, ".verstak", "config.yml") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + cfg := &config.Config{ + Engine: config.EngineConfig{ + Version: currentVaultVersion, + VaultID: util.UUID7(), + }, + Sync: config.SyncConfig{ + AutoSync: false, + }, + Browser: config.BrowserConfig{ + Enabled: false, + LocalPort: 47731, + }, + } + if err := config.Save(vaultRoot, cfg); err != nil { + return fmt.Errorf("write config: %w", err) + } + } + + dbPath := filepath.Join(vaultRoot, ".verstak", "index.db") + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + db, err := storage.Open(dbPath) + if err != nil { + return fmt.Errorf("create db: %w", err) + } + db.Close() + } + + return nil +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..8c5cefe --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + parent_id TEXT NULL REFERENCES nodes(id), + type TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + path TEXT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT NULL, + revision INTEGER NOT NULL DEFAULT 1, + device_id TEXT NULL +); + +CREATE TABLE IF NOT EXISTS node_meta ( + node_id TEXT NOT NULL REFERENCES nodes(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (node_id, key) +); + +CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id); +CREATE INDEX IF NOT EXISTS idx_nodes_deleted ON nodes(deleted_at);