feat(core): add configuration, logging and sqlite persistence
DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
This commit is contained in:
1 parent
fe0cd4bf6d
commit
88d7d6d2b9
11 files changed
+2426
No files matched your search
+118
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
// Pure-Go SQLite driver, registered as "sqlite"
|
||||
"modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// schemaSQL holds the authoritative, idempotent table definitions
|
||||
//
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
// dsnParams are applied by the driver to every pooled connection
|
||||
// foreign_keys: enforce the operations -> tokens reference
|
||||
// busy_timeout: wait instead of failing when the server and the CLI write concurrently
|
||||
// journal_mode WAL: readers never block the writer
|
||||
// _txlock=immediate: transactions take the write lock at BEGIN, so read-then-write never fails with SQLITE_BUSY
|
||||
const dsnParams = "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate"
|
||||
|
||||
// maxOpenConns bounds the SQLite connection pool
|
||||
const maxOpenConns = 4
|
||||
|
||||
// sqliteDSN builds the driver DSN for a filesystem path
|
||||
// The path is made absolute and percent-encoded so that spaces, '?', '#', '%' and other bytes are taken literally
|
||||
func sqliteDSN(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve database path: %w", err)
|
||||
}
|
||||
return "file://" + escapeURIPath(abs) + "?" + dsnParams, nil
|
||||
}
|
||||
|
||||
// escapeURIPath percent-encodes every byte except RFC 3986 unreserved characters and '/'
|
||||
func escapeURIPath(p string) string {
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
out := make([]byte, 0, len(p))
|
||||
for i := 0; i < len(p); i++ {
|
||||
c := p[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9',
|
||||
c == '-', c == '.', c == '_', c == '~', c == '/':
|
||||
out = append(out, c)
|
||||
default:
|
||||
out = append(out, '%', hexDigits[c>>4], hexDigits[c&0x0f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// OpenDatabase opens (creating if needed) the SQLite database at path and initializes the schema
|
||||
func OpenDatabase(ctx context.Context, path string, log *slog.Logger) (*sql.DB, error) {
|
||||
defer traceFunc(ctx, log, "OpenDatabase")()
|
||||
|
||||
// Build the DSN
|
||||
dsn, err := sqliteDSN(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Open the connection pool (lazy: the file is opened on first use)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxOpenConns)
|
||||
|
||||
// Create the tables if needed
|
||||
if err := InitializeSchema(ctx, db, log); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
log.DebugContext(ctx, "database opened", "path", path)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// InitializeSchema executes the embedded schema; every statement is idempotent
|
||||
func InitializeSchema(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
defer traceFunc(ctx, log, "InitializeSchema")()
|
||||
|
||||
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("initialize database schema: %w", err)
|
||||
}
|
||||
log.DebugContext(ctx, "database schema initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSQLiteCode reports whether err is a SQLite error with the given (extended) result code
|
||||
func isSQLiteCode(err error, code int) bool {
|
||||
var se *sqlite.Error
|
||||
return errors.As(err, &se) && se.Code() == code
|
||||
}
|
||||
|
||||
// formatTime renders a timestamp as stored in the database: UTC RFC3339Nano
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// parseTime parses a timestamp stored by formatTime
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
Reference in new issue
Block a user