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:
2026-05-30 18:58:47 +08:00
parent 982f3064ac
commit b8d8427c46
11 changed files with 474 additions and 10 deletions
+68
View File
@@ -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")
}
+29
View File
@@ -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);
`
+120
View File
@@ -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(&currentVer); 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()
}
+33
View File
@@ -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])
}
+65
View File
@@ -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
}