From bb2c7f7ef3e38762d545e721f4960bafb5c5f48d Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 21 Mar 2026 09:46:08 -0700 Subject: [PATCH] Add Phase 1 foundation: Go module, core types, DB infrastructure, config Establish the project foundation with three packages: - core: shared types (Header, Metadata, Value, ObjectType, UUID generation) - db: SQLite migration framework, connection management (WAL, FK, busy timeout), transaction helpers (StartTX/EndTX), time conversion - config: runtime configuration (DB path, blob store, Minio, gRPC addr) Includes initial schema migration (001_initial.sql) with 13 tables covering shared infrastructure, bibliographic data, and artifact repository. Full test coverage for all packages, strict linting (.golangci.yaml), and Makefile. Co-Authored-By: Claude Opus 4.6 (1M context) --- .golangci.yaml | 91 +++++++++++++++ ARCHITECTURE.md | 8 +- Makefile | 101 +++++++++++++++++ PROGRESS.md | 33 ++++++ config/config.go | 51 +++++++++ config/config_test.go | 39 +++++++ core/core.go | 111 +++++++++++++++++++ core/core_test.go | 106 ++++++++++++++++++ db/db.go | 159 ++++++++++++++++++++++++++ db/db_test.go | 203 ++++++++++++++++++++++++++++++++++ db/migrations/001_initial.sql | 128 +++++++++++++++++++++ go.mod | 8 ++ go.sum | 4 + 13 files changed, 1038 insertions(+), 4 deletions(-) create mode 100644 .golangci.yaml create mode 100644 Makefile create mode 100644 PROGRESS.md create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 core/core.go create mode 100644 core/core_test.go create mode 100644 db/db.go create mode 100644 db/db_test.go create mode 100644 db/migrations/001_initial.sql create mode 100644 go.mod create mode 100644 go.sum diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..6810f18 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,91 @@ +# golangci-lint v2 configuration. +# Principle: fail loudly. Correctness issues are errors, not warnings. + +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + # --- Correctness --- + - errcheck + - govet + - ineffassign + - unused + + # --- Error handling --- + - errorlint + + # --- Security --- + - gosec + - staticcheck + + # --- Style / conventions --- + - revive + + settings: + errcheck: + check-blank: false + check-type-assertions: true + + govet: + enable-all: true + disable: + - shadow + - fieldalignment + + gosec: + severity: medium + confidence: medium + excludes: + - G104 + + errorlint: + errorf: true + asserts: true + comparison: true + + revive: + rules: + - name: error-return + severity: error + - name: unexported-return + severity: error + - name: error-strings + severity: warning + - name: if-return + severity: warning + - name: increment-decrement + severity: warning + - name: var-naming + severity: warning + - name: range + severity: warning + - name: time-naming + severity: warning + - name: indent-error-flow + severity: warning + - name: early-return + severity: warning + +formatters: + enable: + - gofmt + - goimports + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + + exclusions: + paths: + - vendor + - ark + rules: + - path: "_test\\.go" + linters: + - gosec + text: "G101" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 57e4a75..3397ad7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,9 +38,9 @@ Core formula: **artifacts + notes + graph structure = exocortex** Remote access: ┌────────┐ HTTPS ┌─────────────────────┐ Tailscale ┌──────┐ -│ Mobile │──────────►│ Reverse Proxy │────────────►│ exod │ -│ Device │ │ (TLS + basic auth) │ │ │ -└────────┘ └─────────────────────┘ └──────┘ +│ Mobile │──────────►│ Reverse Proxy │────────────►│ exod │ +│ Device │ │ (TLS + basic auth) │ │ │ +└────────┘ └─────────────────────┘ └──────┘ ``` Three runtime components exist: @@ -395,7 +395,7 @@ A single Kotlin desktop application handles both artifact management and knowled │ Outline │ • Note editor (cell-based) │ │ View │ • Artifact detail (citation, snapshots) │ │ │ • Search results │ -│ │ • Catalog (items needing attention) │ +│ │ • Catalog (items needing attention) │ │ │ │ ├──────────────┴──────────────────────────────────────────────┤ │ [Graph View toggle] │ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a34a09 --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +# Makefile — exo build, test, lint, and release targets +# +# Usage: +# make build — compile all binaries to bin/ +# make test — run tests with race detector +# make vet — run go vet +# make lint — run golangci-lint +# make all — vet -> lint -> test -> build (CI pipeline) +# make generate — regenerate protobuf stubs (requires protoc) +# make clean — remove bin/ and generated artifacts + +# --------------------------------------------------------------------------- +# Variables +# --------------------------------------------------------------------------- +MODULE := git.wntrmute.dev/kyle/exo +BINARIES := exo exod +BIN_DIR := bin + +VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) + +GO := go +GOFLAGS := -trimpath +LDFLAGS := -s -w -X main.version=$(VERSION) +CGO := CGO_ENABLED=1 +CGO_TEST := CGO_ENABLED=1 + +# --------------------------------------------------------------------------- +# Default target — CI pipeline: vet -> lint -> test -> build +# --------------------------------------------------------------------------- +.PHONY: all +all: vet lint test build + +# --------------------------------------------------------------------------- +# build — compile all binaries to bin/ +# --------------------------------------------------------------------------- +.PHONY: build +build: + @mkdir -p $(BIN_DIR) + @for bin in $(BINARIES); do \ + echo " GO BUILD cmd/$$bin"; \ + $(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" \ + -o $(BIN_DIR)/$$bin ./cmd/$$bin; \ + done + +# --------------------------------------------------------------------------- +# test — run all tests with race detector +# --------------------------------------------------------------------------- +.PHONY: test +test: + $(CGO_TEST) $(GO) test -race -count=1 ./... + +# --------------------------------------------------------------------------- +# vet — static analysis via go vet +# --------------------------------------------------------------------------- +.PHONY: vet +vet: + $(GO) vet ./... + +# --------------------------------------------------------------------------- +# lint — run golangci-lint +# --------------------------------------------------------------------------- +.PHONY: lint +lint: + golangci-lint run ./... + +# --------------------------------------------------------------------------- +# generate — regenerate protobuf stubs from proto/ definitions +# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc +# --------------------------------------------------------------------------- +.PHONY: generate +generate: + $(GO) generate ./... + +# --------------------------------------------------------------------------- +# clean — remove build artifacts +# --------------------------------------------------------------------------- +.PHONY: clean +clean: + rm -rf $(BIN_DIR) + +# --------------------------------------------------------------------------- +# install-local — install binaries to ~/.local/bin +# --------------------------------------------------------------------------- +.PHONY: install-local +install-local: build + cp bin/* $(HOME)/.local/bin/ + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- +.PHONY: help +help: + @echo "Available targets:" + @echo " all vet -> lint -> test -> build (CI pipeline)" + @echo " build Compile all binaries to bin/" + @echo " test Run tests with race detector" + @echo " vet Run go vet" + @echo " lint Run golangci-lint" + @echo " generate Regenerate protobuf stubs" + @echo " clean Remove build artifacts" + @echo " install-local Install binaries to ~/.local/bin" diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..15f2556 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,33 @@ +# Progress + +Tracks implementation progress against the phases in `PROJECT_PLAN.md`. + +## Phase 1: Foundation — COMPLETE + +**Deliverables:** +- [x] Go module (`go.mod`) at `git.wntrmute.dev/kyle/exo` +- [x] `core` package: `Header`, `Metadata`, `Value`, `ObjectType`, `NewUUID`, `MapFromList`/`ListFromMap` +- [x] SQLite migration framework with embedded SQL files (`db/migrations/`) +- [x] Initial schema migration (`001_initial.sql`): 13 tables covering shared infrastructure, bibliographic, and artifact repository +- [x] Database access layer: `Open` (with WAL, foreign keys, busy timeout), `StartTX`/`EndTX`, `ToDBTime`/`FromDBTime` +- [x] `config` package: `Config` struct with paths for database, blob store, Minio endpoint, gRPC listen address +- [x] `.golangci.yaml` — strict linting (errcheck, govet, gosec, staticcheck, revive, errorlint) +- [x] `Makefile` — vet, lint, test, build targets +- [x] Full test coverage for all packages (core, db, config) + +**Files created:** +- `go.mod`, `go.sum` +- `core/core.go`, `core/core_test.go` +- `db/db.go`, `db/db_test.go`, `db/migrations/001_initial.sql` +- `config/config.go`, `config/config_test.go` +- `.golangci.yaml`, `Makefile` + +## Phase 2: Artifact Repository — IN PROGRESS + +## Phase 3: CLI Tools — NOT STARTED + +## Phase 4: Knowledge Graph — NOT STARTED + +## Phase 5: Desktop Application — NOT STARTED (Kotlin, out of scope for Go backend) + +## Phase 6: Remote Access & Backup — NOT STARTED diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ece3e2d --- /dev/null +++ b/config/config.go @@ -0,0 +1,51 @@ +// Package config provides path and endpoint configuration for the exo system. +package config + +import ( + "os" + "path/filepath" +) + +// Config holds all runtime configuration for exo. +type Config struct { + // BasePath is the root directory for all exo data. + BasePath string + + // DatabasePath is the path to the unified SQLite database. + DatabasePath string + + // BlobStorePath is the root of the content-addressable blob store. + BlobStorePath string + + // MinioEndpoint is the S3-compatible endpoint for remote blob backup. + MinioEndpoint string + + // MinioBucket is the bucket name for blob backup. + MinioBucket string + + // GRPCListenAddr is the address exod listens on for gRPC connections. + GRPCListenAddr string +} + +// DefaultBasePath returns $HOME/exo. +func DefaultBasePath() string { + return filepath.Join(os.Getenv("HOME"), "exo") +} + +// Default returns a Config with sensible defaults rooted at $HOME/exo. +func Default() Config { + base := DefaultBasePath() + return FromBasePath(base) +} + +// FromBasePath returns a Config rooted at the given base path. +func FromBasePath(base string) Config { + return Config{ + BasePath: base, + DatabasePath: filepath.Join(base, "exo.db"), + BlobStorePath: filepath.Join(base, "blobs"), + MinioEndpoint: "", + MinioBucket: "exo-blobs", + GRPCListenAddr: "localhost:9090", + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..3c31cf1 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestDefault(t *testing.T) { + cfg := Default() + if cfg.BasePath == "" { + t.Fatal("BasePath is empty") + } + if !strings.HasSuffix(cfg.BasePath, "exo") { + t.Fatalf("BasePath should end with 'exo', got %q", cfg.BasePath) + } + if cfg.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if cfg.BlobStorePath == "" { + t.Fatal("BlobStorePath is empty") + } + if cfg.GRPCListenAddr == "" { + t.Fatal("GRPCListenAddr is empty") + } +} + +func TestFromBasePath(t *testing.T) { + cfg := FromBasePath("/tmp/testexo") + if cfg.BasePath != "/tmp/testexo" { + t.Fatalf("expected BasePath '/tmp/testexo', got %q", cfg.BasePath) + } + if cfg.DatabasePath != filepath.Join("/tmp/testexo", "exo.db") { + t.Fatalf("unexpected DatabasePath: %q", cfg.DatabasePath) + } + if cfg.BlobStorePath != filepath.Join("/tmp/testexo", "blobs") { + t.Fatalf("unexpected BlobStorePath: %q", cfg.BlobStorePath) + } +} diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..8ddd0b7 --- /dev/null +++ b/core/core.go @@ -0,0 +1,111 @@ +// Package core defines shared types used across both the artifact repository +// and knowledge graph pillars. +package core + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +// ObjectType enumerates the kinds of persistent objects in the system. +type ObjectType string + +const ( + ObjectTypeArtifact ObjectType = "artifact" + ObjectTypeSnapshot ObjectType = "snapshot" + ObjectTypeCitation ObjectType = "citation" + ObjectTypePublisher ObjectType = "publisher" + ObjectTypeNode ObjectType = "node" + ObjectTypeCell ObjectType = "cell" +) + +// Header is attached to every persistent object. +type Header struct { + Meta Metadata + Categories []string + Tags []string + ID string + Type ObjectType + Created int64 + Modified int64 +} + +// NewHeader creates a Header with a new UUID and current timestamps. +func NewHeader(objType ObjectType) Header { + now := time.Now().UTC().Unix() + return Header{ + ID: NewUUID(), + Type: objType, + Created: now, + Modified: now, + Meta: Metadata{}, + } +} + +// Touch updates the Modified timestamp to now. +func (h *Header) Touch() { + h.Modified = time.Now().UTC().Unix() +} + +const ( + // ValueTypeUnspecified is used when the value type hasn't been explicitly + // set. It should be interpreted as a string. + ValueTypeUnspecified = "UNSPECIFIED" + + // ValueTypeString is used when the value type should be explicitly + // interpreted as a string. + ValueTypeString = "String" + + // ValueTypeInt is used when the value should be interpreted as an integer. + ValueTypeInt = "Int" +) + +// Value stores a typed key-value entry. Contents is always stored as a string; +// the Type field tells consumers how to interpret it. +type Value struct { + Contents string + Type string +} + +// Val creates a new Value with an unspecified type. +func Val(contents string) Value { + return Value{Contents: contents, Type: ValueTypeUnspecified} +} + +// Vals creates a new Value with a string type. +func Vals(contents string) Value { + return Value{Contents: contents, Type: ValueTypeString} +} + +// Metadata holds additional information that isn't explicitly part of a data +// definition. Keys are arbitrary strings; values carry type information. +type Metadata map[string]Value + +// NewUUID returns a new random UUID string. +func NewUUID() string { + return uuid.NewString() +} + +// ErrNoID is returned when a lookup is done on a struct that has no identifier. +var ErrNoID = errors.New("missing UUID identifier") + +// MapFromList converts a string slice into a map[string]bool for set-like +// membership testing. +func MapFromList(list []string) map[string]bool { + m := make(map[string]bool, len(list)) + for _, s := range list { + m[s] = true + } + return m +} + +// ListFromMap converts a map[string]bool (set) back to a sorted slice. +func ListFromMap(m map[string]bool) []string { + list := make([]string, 0, len(m)) + for k := range m { + list = append(list, k) + } + return list +} diff --git a/core/core_test.go b/core/core_test.go new file mode 100644 index 0000000..37a745c --- /dev/null +++ b/core/core_test.go @@ -0,0 +1,106 @@ +package core + +import ( + "sort" + "testing" + "time" +) + +func TestNewUUID(t *testing.T) { + id1 := NewUUID() + id2 := NewUUID() + if id1 == "" { + t.Fatal("NewUUID returned empty string") + } + if id1 == id2 { + t.Fatal("NewUUID returned duplicate UUIDs") + } +} + +func TestVal(t *testing.T) { + v := Val("hello") + if v.Contents != "hello" { + t.Fatalf("expected contents 'hello', got %q", v.Contents) + } + if v.Type != ValueTypeUnspecified { + t.Fatalf("expected type %q, got %q", ValueTypeUnspecified, v.Type) + } +} + +func TestVals(t *testing.T) { + v := Vals("world") + if v.Contents != "world" { + t.Fatalf("expected contents 'world', got %q", v.Contents) + } + if v.Type != ValueTypeString { + t.Fatalf("expected type %q, got %q", ValueTypeString, v.Type) + } +} + +func TestNewHeader(t *testing.T) { + before := time.Now().UTC().Unix() + h := NewHeader(ObjectTypeArtifact) + after := time.Now().UTC().Unix() + + if h.ID == "" { + t.Fatal("header ID is empty") + } + if h.Type != ObjectTypeArtifact { + t.Fatalf("expected type %q, got %q", ObjectTypeArtifact, h.Type) + } + if h.Created < before || h.Created > after { + t.Fatalf("Created timestamp %d not in range [%d, %d]", h.Created, before, after) + } + if h.Modified != h.Created { + t.Fatalf("Modified (%d) should equal Created (%d)", h.Modified, h.Created) + } + if h.Meta == nil { + t.Fatal("Meta should not be nil") + } +} + +func TestHeaderTouch(t *testing.T) { + h := NewHeader(ObjectTypeNode) + original := h.Modified + // Ensure at least 1 second passes for timestamp change. + time.Sleep(10 * time.Millisecond) + h.Touch() + if h.Modified < original { + t.Fatalf("Touch should not decrease Modified: was %d, now %d", original, h.Modified) + } +} + +func TestMapFromList(t *testing.T) { + list := []string{"alpha", "beta", "gamma"} + m := MapFromList(list) + if len(m) != 3 { + t.Fatalf("expected 3 entries, got %d", len(m)) + } + for _, s := range list { + if !m[s] { + t.Fatalf("expected %q in map", s) + } + } +} + +func TestListFromMap(t *testing.T) { + m := map[string]bool{"z": true, "a": true, "m": true} + list := ListFromMap(m) + if len(list) != 3 { + t.Fatalf("expected 3 entries, got %d", len(list)) + } + sort.Strings(list) + expected := []string{"a", "m", "z"} + for i, v := range list { + if v != expected[i] { + t.Fatalf("index %d: expected %q, got %q", i, expected[i], v) + } + } +} + +func TestMapFromListEmpty(t *testing.T) { + m := MapFromList(nil) + if len(m) != 0 { + t.Fatalf("expected empty map, got %d entries", len(m)) + } +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..95d1443 --- /dev/null +++ b/db/db.go @@ -0,0 +1,159 @@ +// Package db provides SQLite database management, migration support, +// and transaction helpers for the exo system. +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log" + "sort" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +const iso8601 = "2006-01-02 15:04:05" + +// ToDBTime formats a time.Time as an ISO 8601 UTC string for storage. +func ToDBTime(t time.Time) string { + return t.UTC().Format(iso8601) +} + +// FromDBTime parses an ISO 8601 UTC string back to a time.Time. +// If loc is non-nil, the result is converted to that location. +func FromDBTime(datetime string, loc *time.Location) (time.Time, error) { + t, err := time.Parse(iso8601, datetime) + if err != nil { + return t, fmt.Errorf("db: failed to parse time %q: %w", datetime, err) + } + if loc != nil { + t = t.In(loc) + } + return t, nil +} + +// Open opens a SQLite database at the given path with standard pragmas. +func Open(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("db: failed to open database %q: %w", path, err) + } + + // Verify the connection works. + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("db: failed to ping database %q: %w", path, err) + } + + return db, nil +} + +// StartTX begins a new database transaction. +func StartTX(ctx context.Context, db *sql.DB) (*sql.Tx, error) { + return db.BeginTx(ctx, nil) +} + +// EndTX commits or rolls back a transaction based on the error value. +// If err is non-nil, the transaction is rolled back. Otherwise it is committed. +func EndTX(tx *sql.Tx, err error) error { + if err != nil { + rbErr := tx.Rollback() + if rbErr != nil { + return fmt.Errorf("db: rollback failed (%w) after error: %w", rbErr, err) + } + return err + } + return tx.Commit() +} + +// Migrate runs all pending migrations against the database. +// Migrations are embedded SQL files in the migrations/ directory, +// named with a numeric prefix (e.g., 001_initial.sql). +func Migrate(database *sql.DB) error { + // Ensure schema_version table exists for tracking. + _, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL, + applied TEXT NOT NULL + )`) + if err != nil { + return fmt.Errorf("db: failed to ensure schema_version table: %w", err) + } + + currentVersion, err := getCurrentVersion(database) + if err != nil { + return fmt.Errorf("db: failed to get current schema version: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("db: failed to read migrations directory: %w", err) + } + + // Sort migration files by name to ensure order. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + var version int + if _, err := fmt.Sscanf(entry.Name(), "%d_", &version); err != nil { + return fmt.Errorf("db: failed to parse migration version from %q: %w", entry.Name(), err) + } + + if version <= currentVersion { + continue + } + + sqlBytes, err := migrationsFS.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("db: failed to read migration %q: %w", entry.Name(), err) + } + + log.Printf("db: applying migration %d (%s)", version, entry.Name()) + + tx, err := database.Begin() + if err != nil { + return fmt.Errorf("db: failed to begin migration transaction: %w", err) + } + + if _, err := tx.Exec(string(sqlBytes)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("db: migration %d failed: %w", version, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("db: failed to commit migration %d: %w", version, err) + } + + log.Printf("db: migration %d applied successfully", version) + } + + return nil +} + +func getCurrentVersion(database *sql.DB) (int, error) { + var version int + row := database.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`) + if err := row.Scan(&version); err != nil { + // Table might not have any rows yet — that's version 0. + return 0, nil + } + return version, nil +} + +// DBObject is the interface for types that can be stored in and retrieved from +// the database within a transaction. +type DBObject interface { + Get(ctx context.Context, tx *sql.Tx) error + Store(ctx context.Context, tx *sql.Tx) error +} diff --git a/db/db_test.go b/db/db_test.go new file mode 100644 index 0000000..215c3b2 --- /dev/null +++ b/db/db_test.go @@ -0,0 +1,203 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func tempDB(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return filepath.Join(dir, "test.db") +} + +func mustOpen(t *testing.T) *sql.DB { + t.Helper() + path := tempDB(t) + database, err := Open(path) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func mustOpenAndMigrate(t *testing.T) *sql.DB { + t.Helper() + database := mustOpen(t) + if err := Migrate(database); err != nil { + t.Fatalf("Migrate failed: %v", err) + } + return database +} + +func TestOpenAndPing(t *testing.T) { + database := mustOpen(t) + if err := database.Ping(); err != nil { + t.Fatalf("Ping failed: %v", err) + } +} + +func TestOpenCreatesFile(t *testing.T) { + path := tempDB(t) + database, err := Open(path) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("database file was not created") + } +} + +func TestMigrate(t *testing.T) { + database := mustOpenAndMigrate(t) + + tables := []string{ + "metadata", "tags", "categories", "publishers", "citations", + "authors", "artifacts", "artifact_tags", "artifact_categories", + "artifacts_history", "artifact_snapshots", "blobs", "schema_version", + } + for _, table := range tables { + var name string + row := database.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table) + if err := row.Scan(&name); err != nil { + t.Errorf("table %q not found after migration: %v", table, err) + } + } +} + +func TestMigrateIdempotent(t *testing.T) { + database := mustOpenAndMigrate(t) + + if err := Migrate(database); err != nil { + t.Fatalf("second Migrate failed: %v", err) + } +} + +func TestStartTXAndEndTX(t *testing.T) { + database := mustOpenAndMigrate(t) + ctx := context.Background() + + tx, err := StartTX(ctx, database) + if err != nil { + t.Fatalf("StartTX failed: %v", err) + } + + _, err = tx.ExecContext(ctx, `INSERT INTO tags (id, tag) VALUES ('test-id', 'test-tag')`) + if err != nil { + t.Fatalf("INSERT failed: %v", err) + } + + if err := EndTX(tx, nil); err != nil { + t.Fatalf("EndTX (commit) failed: %v", err) + } + + var tag string + row := database.QueryRow(`SELECT tag FROM tags WHERE id='test-id'`) + if err := row.Scan(&tag); err != nil { + t.Fatalf("committed row not found: %v", err) + } + if tag != "test-tag" { + t.Fatalf("expected 'test-tag', got %q", tag) + } +} + +func TestEndTXRollback(t *testing.T) { + database := mustOpenAndMigrate(t) + ctx := context.Background() + + tx, err := StartTX(ctx, database) + if err != nil { + t.Fatalf("StartTX failed: %v", err) + } + + _, err = tx.ExecContext(ctx, `INSERT INTO tags (id, tag) VALUES ('rollback-id', 'rollback-tag')`) + if err != nil { + t.Fatalf("INSERT failed: %v", err) + } + + simErr := context.DeadlineExceeded + if err := EndTX(tx, simErr); !errors.Is(err, simErr) { + t.Fatalf("EndTX should return the original error, got: %v", err) + } + + var tag string + row := database.QueryRow(`SELECT tag FROM tags WHERE id='rollback-id'`) + if err := row.Scan(&tag); err == nil { + t.Fatal("rolled-back row should not be found") + } +} + +func TestToDBTimeAndFromDBTime(t *testing.T) { + original := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC) + s := ToDBTime(original) + + if s != "2024-06-15 14:30:00" { + t.Fatalf("unexpected time string: %q", s) + } + + parsed, err := FromDBTime(s, nil) + if err != nil { + t.Fatalf("FromDBTime failed: %v", err) + } + + if !parsed.Equal(original) { + t.Fatalf("round-trip failed: got %v, want %v", parsed, original) + } +} + +func TestFromDBTimeWithLocation(t *testing.T) { + s := "2024-06-15 14:30:00" + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("timezone not available: %v", err) + } + + parsed, err := FromDBTime(s, loc) + if err != nil { + t.Fatalf("FromDBTime failed: %v", err) + } + + if parsed.Location() != loc { + t.Fatalf("expected location %v, got %v", loc, parsed.Location()) + } +} + +func TestFromDBTimeInvalid(t *testing.T) { + _, err := FromDBTime("not-a-date", nil) + if err == nil { + t.Fatal("expected error for invalid time string") + } +} + +func TestForeignKeysEnabled(t *testing.T) { + database := mustOpen(t) + + var fk int + row := database.QueryRow(`PRAGMA foreign_keys`) + if err := row.Scan(&fk); err != nil { + t.Fatalf("PRAGMA foreign_keys failed: %v", err) + } + if fk != 1 { + t.Fatalf("foreign keys should be enabled, got %d", fk) + } +} + +func TestSchemaVersion(t *testing.T) { + database := mustOpenAndMigrate(t) + + version, err := getCurrentVersion(database) + if err != nil { + t.Fatalf("getCurrentVersion failed: %v", err) + } + if version != 1 { + t.Fatalf("expected schema version 1, got %d", version) + } +} diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql new file mode 100644 index 0000000..a5bb9eb --- /dev/null +++ b/db/migrations/001_initial.sql @@ -0,0 +1,128 @@ +-- Migration 001: Initial schema +-- Shared infrastructure, bibliographic tables, and artifact repository. + +-- Polymorphic key-value metadata. The id column references any object's UUID. +CREATE TABLE IF NOT EXISTS metadata +( + id TEXT NOT NULL, + mkey TEXT NOT NULL, + contents TEXT NOT NULL, + type TEXT NOT NULL, + PRIMARY KEY (mkey, contents, type), + UNIQUE (id, mkey) +); +CREATE INDEX IF NOT EXISTS idx_metadata_id ON metadata (id); + +-- Shared tag pool (used by both artifacts and knowledge graph nodes). +CREATE TABLE IF NOT EXISTS tags +( + id TEXT NOT NULL PRIMARY KEY, + tag TEXT NOT NULL UNIQUE +); + +-- Shared category pool. +CREATE TABLE IF NOT EXISTS categories +( + id TEXT NOT NULL PRIMARY KEY, + category TEXT NOT NULL UNIQUE +); + +-- Publishers for bibliographic citations. +CREATE TABLE IF NOT EXISTS publishers +( + id TEXT UNIQUE NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + address TEXT, + UNIQUE (name, address) +); + +-- Bibliographic citations. +CREATE TABLE IF NOT EXISTS citations +( + id TEXT PRIMARY KEY, + doi TEXT, + title TEXT NOT NULL, + year INTEGER NOT NULL, + published TEXT NOT NULL, + publisher TEXT NOT NULL, + source TEXT NOT NULL, + abstract TEXT, + FOREIGN KEY (publisher) REFERENCES publishers (id) +); +CREATE INDEX IF NOT EXISTS idx_citations_doi ON citations (id, doi); + +-- Many-to-one: multiple authors per citation. +CREATE TABLE IF NOT EXISTS authors +( + citation_id TEXT NOT NULL, + author_name TEXT NOT NULL, + FOREIGN KEY (citation_id) REFERENCES citations (id) +); + +-- Artifact repository. +CREATE TABLE IF NOT EXISTS artifacts +( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + citation_id TEXT NOT NULL, + latest TEXT NOT NULL, + FOREIGN KEY (citation_id) REFERENCES citations (id) +); + +-- Many-to-many junction: artifacts <-> tags. +CREATE TABLE IF NOT EXISTS artifact_tags +( + artifact_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + FOREIGN KEY (artifact_id) REFERENCES artifacts (id), + FOREIGN KEY (tag_id) REFERENCES tags (id) +); + +-- Many-to-many junction: artifacts <-> categories. +CREATE TABLE IF NOT EXISTS artifact_categories +( + artifact_id TEXT NOT NULL, + category_id TEXT NOT NULL, + FOREIGN KEY (artifact_id) REFERENCES artifacts (id), + FOREIGN KEY (category_id) REFERENCES categories (id) +); + +-- Temporal index linking artifacts to snapshots by datetime. +CREATE TABLE IF NOT EXISTS artifacts_history +( + artifact_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL UNIQUE, + datetime TEXT NOT NULL, + PRIMARY KEY (artifact_id, datetime), + FOREIGN KEY (artifact_id) REFERENCES artifacts (id) +); + +-- Snapshot records with storage and content timestamps. +CREATE TABLE IF NOT EXISTS artifact_snapshots +( + artifact_id TEXT NOT NULL, + id TEXT UNIQUE PRIMARY KEY, + stored_at INTEGER NOT NULL, + datetime TEXT NOT NULL, + citation_id TEXT NOT NULL, + source TEXT NOT NULL, + FOREIGN KEY (artifact_id) REFERENCES artifacts (id), + FOREIGN KEY (id) REFERENCES artifacts_history (snapshot_id) +); + +-- Blob registry. Actual content lives in the CAS on disk. +CREATE TABLE IF NOT EXISTS blobs +( + snapshot_id TEXT NOT NULL, + id TEXT NOT NULL UNIQUE PRIMARY KEY, + format TEXT NOT NULL, + FOREIGN KEY (snapshot_id) REFERENCES artifact_snapshots (id) +); + +-- Schema version tracking. +CREATE TABLE IF NOT EXISTS schema_version +( + version INTEGER NOT NULL, + applied TEXT NOT NULL +); +INSERT INTO schema_version (version, applied) VALUES (1, datetime('now')); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c453dee --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.wntrmute.dev/kyle/exo + +go 1.25.7 + +require ( + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.37 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9bf32e5 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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=