Compare commits

...

5 Commits

Author SHA1 Message Date
3d8af35a89 REORDER ME into database 2025-05-08 23:41:36 +02:00
5145191c2b Add version package and update the makefile
The makefile will grab the version info from git and pass it to the
linker so that version information based on tags, commits and commit
times is available in the code.
2025-05-08 16:08:55 +02:00
cd0fb4edd2 Add basic database interaction 2025-05-08 15:11:54 +02:00
8a896b27d3 Add database schema 2025-05-08 15:11:54 +02:00
e4923b500a Move formatting to gofumpt (from gofmt) 2025-05-04 00:50:44 +02:00
8 changed files with 219 additions and 5 deletions

View File

@ -3,6 +3,15 @@ BINARIES = $(patsubst cmd/%/,%,$(wildcard cmd/*/))
.PHONY: all build test validate clean run $(BINARIES)
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
COMMIT_DATETIME := $(shell git log -1 --format=%cd --date=iso8601)
LDFLAGS := -X git.omicron.one/omicron/linkshare/internal/version.Version=$(VERSION) \
-X git.omicron.one/omicron/linkshare/internal/version.GitCommit=$(COMMIT) \
-X "git.omicron.one/omicron/linkshare/internal/version.CommitDateTime=$(COMMIT_DATETIME)"
all: build
@ -13,13 +22,13 @@ $(BINARY_DIR):
mkdir -p $(BINARY_DIR)
$(BINARIES): %: $(BINARY_DIR)
go build -o $(BINARY_DIR)/$@ ./cmd/$@/
go build -ldflags '$(LDFLAGS)' -o $(BINARY_DIR)/$@ ./cmd/$@/
test:
go test ./...
validate:
@test -z "$(shell gofmt -l .)" || (echo "Incorrect formatting in:"; gofmt -l .; exit 1)
@test -z "$(shell gofumpt -l .)" && echo "No files need formatting" || (echo "Incorrect formatting in:"; gofumpt -l .; exit 1)
go vet ./...
clean:

View File

@ -1,7 +1,10 @@
package main
import "fmt"
import "git.omicron.one/omicron/linkshare/internal/util"
import (
"fmt"
"git.omicron.one/omicron/linkshare/internal/util"
)
func main() {
paths, err := util.FindDirectories("")

2
go.mod
View File

@ -1,3 +1,5 @@
module git.omicron.one/omicron/linkshare
go 1.24
require github.com/mattn/go-sqlite3 v1.14.28

2
go.sum
View File

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

View File

@ -0,0 +1,162 @@
// Package database provides all database interactions for linkshare.
// This includes functions to read and write structured link data, setting and
// getting configurations, updating and initializing the schema and backing up
// data
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
_ "github.com/mattn/go-sqlite3"
"git.omicron.one/omicron/linkshare/internal/version"
)
// DB represents a database connection
type DB struct {
conn *sql.DB
}
var (
ErrDatabaseNotInitialized = errors.New("database not initialized")
ErrDatabaseSchemaOutdated = errors.New("database schema needs updating")
ErrDatabaseSchemaUnsupported = errors.New("database schema is too new for the server")
ErrMigrationFailed = errors.New("migration failed")
)
// Open opens a connection to the sqlite database at the given path
func Open(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
conn.SetMaxOpenConns(1) // SQLite only supports one writer at a time
if err := conn.Ping(); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
_, err = conn.Exec("PRAGMA foreign_keys = ON")
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to enable foreign key constraints: %w", err)
}
return &DB{conn: conn}, nil
}
// Close closes the database connection if it's open
func (db *DB) Close() error {
if db.conn != nil {
return db.conn.Close()
}
return nil
}
// Initialize the database schema
func (db *DB) Initialize(schemaPath string) error {
err := db.CheckInitialized()
if err == nil {
return nil
}
currentSchema := filepath.Join(schemaPath, "current.sql")
schema, err := os.ReadFile(currentSchema)
if err != nil {
return fmt.Errorf("failed to read schema file: %w", err)
}
_, err = db.conn.Exec(string(schema))
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
return nil
}
// CheckInitialized returns nil if the database is initialized and an error otherwise
func (db *DB) CheckInitialized() error {
var count int
err := db.conn.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='settings'").Scan(&count)
if err != nil {
return fmt.Errorf("failed to check if database is initialized: %w", err)
}
if count == 0 {
return ErrDatabaseNotInitialized
}
return nil
}
// GetSchemaVersion returns the schema version or an error
func (db *DB) GetSchemaVersion() (int, error) {
var version string
err := db.conn.QueryRow("SELECT value FROM settings WHERE key='schema-version'").Scan(&version)
if err != nil {
return 0, fmt.Errorf("failed to get schema version: %w", err)
}
versionInt, err := strconv.Atoi(version)
if err != nil {
return 0, fmt.Errorf("invalid schema version: %w", err)
}
if versionInt < 1 {
return 0, fmt.Errorf("invalid schema version %d", versionInt)
}
return versionInt, nil
}
// CheckSchemaVersion verifies that the schema is initialized and has the correct version
func (db *DB) CheckSchemaVersion() error {
err := db.CheckInitialized()
if err != nil {
return err
}
version_, err := db.GetSchemaVersion()
if err != nil {
return err
}
if version_ < version.SchemaVersion {
return ErrDatabaseSchemaOutdated
} else if version_ > version.SchemaVersion {
return ErrDatabaseSchemaUnsupported
}
return nil
}
func (db *DB) transaction(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("error rolling back transaction: %v (original error: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}

View File

@ -110,7 +110,7 @@ func FindDirectories(dbPath string) (*AppPaths, error) {
// CreateDirectories ensures all application managed directories are created
func CreateDirectories(paths *AppPaths) error {
err := os.MkdirAll(filepath.Dir(paths.DatabaseFile), 0750)
err := os.MkdirAll(filepath.Dir(paths.DatabaseFile), 0o750)
if err != nil {
return err
}

View File

@ -0,0 +1,17 @@
package version
import "fmt"
var (
Version = "dev"
GitCommit = "unknown"
CommitDateTime = "unknown"
SchemaVersion = 1
)
// PrintVersionInfo prints formatted version information to stdout
func Print() {
fmt.Printf("Version: %s\n", Version)
fmt.Printf("Git commit: %s %s\n", GitCommit, CommitDateTime)
fmt.Printf("Schema: v%d\n", SchemaVersion)
}

19
schema/current.sql Normal file
View File

@ -0,0 +1,19 @@
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
kind TEXT CHECK(kind IN ('int', 'string', 'bool', 'json', 'glob')) NOT NULL
);
INSERT INTO settings (key, value, kind) VALUES ('schema-version', '1', 'int');
CREATE TABLE links (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
is_private BOOLEAN NOT NULL DEFAULT 0
);
CREATE INDEX idx_links_created_at ON links(created_at);
CREATE INDEX idx_links_url ON links(url);