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.
This commit is contained in:
parent
982f3064ac
commit
b8d8427c46
|
|
@ -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 <command> [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 <command> [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/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 | ⬜ не начат |
|
||||
|
|
|
|||
5
go.mod
5
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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);
|
||||
`
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue