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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user