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) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:46:08 -07:00
parent 98990c6d76
commit bb2c7f7ef3
13 changed files with 1038 additions and 4 deletions

91
.golangci.yaml Normal file
View File

@@ -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"

View File

@@ -38,9 +38,9 @@ Core formula: **artifacts + notes + graph structure = exocortex**
Remote access: Remote access:
┌────────┐ HTTPS ┌─────────────────────┐ Tailscale ┌──────┐ ┌────────┐ HTTPS ┌─────────────────────┐ Tailscale ┌──────┐
│ Mobile │──────────►│ Reverse Proxy │────────────►│ exod │ │ Mobile │──────────►│ Reverse Proxy │────────────►│ exod │
│ Device │ │ (TLS + basic auth) │ │ │ │ Device │ │ (TLS + basic auth) │ │ │
└────────┘ └─────────────────────┘ └──────┘ └────────┘ └─────────────────────┘ └──────┘
``` ```
Three runtime components exist: Three runtime components exist:
@@ -395,7 +395,7 @@ A single Kotlin desktop application handles both artifact management and knowled
│ Outline │ • Note editor (cell-based) │ │ Outline │ • Note editor (cell-based) │
│ View │ • Artifact detail (citation, snapshots) │ │ View │ • Artifact detail (citation, snapshots) │
│ │ • Search results │ │ │ • Search results │
│ │ • Catalog (items needing attention) │ │ • Catalog (items needing attention) │
│ │ │ │ │ │
├──────────────┴──────────────────────────────────────────────┤ ├──────────────┴──────────────────────────────────────────────┤
│ [Graph View toggle] │ │ [Graph View toggle] │

101
Makefile Normal file
View File

@@ -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"

33
PROGRESS.md Normal file
View File

@@ -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

51
config/config.go Normal file
View File

@@ -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",
}
}

39
config/config_test.go Normal file
View File

@@ -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)
}
}

111
core/core.go Normal file
View File

@@ -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
}

106
core/core_test.go Normal file
View File

@@ -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))
}
}

159
db/db.go Normal file
View File

@@ -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
}

203
db/db_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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'));

8
go.mod Normal file
View File

@@ -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
)

4
go.sum Normal file
View File

@@ -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=