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:
91
.golangci.yaml
Normal file
91
.golangci.yaml
Normal 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"
|
||||||
101
Makefile
Normal file
101
Makefile
Normal 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
33
PROGRESS.md
Normal 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
51
config/config.go
Normal 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
39
config/config_test.go
Normal 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
111
core/core.go
Normal 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
106
core/core_test.go
Normal 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
159
db/db.go
Normal 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
203
db/db_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
db/migrations/001_initial.sql
Normal file
128
db/migrations/001_initial.sql
Normal 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
8
go.mod
Normal 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
4
go.sum
Normal 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=
|
||||||
Reference in New Issue
Block a user