Files
llmgw/internal/store/migrate.go
T
Ilker ManapandClaude Opus 5 29617f8cb1 Add the store: tenancy, keys and usage on SQLite
Phase 5 begins. This is the persistence layer for the things the cluster
cannot reconstruct — who may call, what they are allowed, what they used.
Everything else llmgw knows is still derived from the fleet, which cannot be
wrong about itself the way a database can (§6.5).

SQLite behind an interface. The plan justified that by write volume, which was
the wrong argument: the write path is batched anyway, and a 500-row
transaction every 200ms is nothing for either engine. The real argument is the
failure mode. Postgres is a second service, and a gateway that stops serving
because its accounting database is unreachable is worse than one that buffers
some accounting. Embedded means the store shares the gateway's fate instead of
adding an independent way to be down. That reasoning stops holding at two
instances, where budgets have to be counted somewhere shared — so the
interface is here from the start, the SQL is portable, and migrations are per
dialect with shared version numbers.

Keys are stored as sha256 and nothing else, so a database leak hands over no
working credential. The fast hash is deliberate: the input is 256 random bits
with no dictionary to attack, so argon2 would buy nothing and charge tens of
milliseconds per request. That assumption is written down next to the code
that depends on it.

Two things the schema now says that it did not: budgets are integer
micro-units, because repeated addition of floats drifts; and an empty model
allowlist forbids everything while an absent one permits everything, which the
resolver has to keep distinct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:37:40 +02:00

107 lines
2.6 KiB
Go

package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
)
//go:embed migrations/sqlite/*.sql
var migrationFS embed.FS
// migrate applies every unapplied migration in order, each in its own
// transaction, and records it.
//
// Deliberately small. A migration tool would be a dependency and a second way
// to be wrong about which migrations have run; the whole mechanism is a sorted
// directory, a version table, and a transaction each.
//
// The directory is per dialect. SQLite and Postgres diverge enough in DDL —
// SQLite's ALTER TABLE barely exists — that a single shared set would be a
// promise this could not keep. Same version numbers, same meaning, different
// files.
func migrate(ctx context.Context, db *sql.DB, dialect string) error {
if _, err := db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`,
); err != nil {
return fmt.Errorf("creating the migration table: %w", err)
}
applied := map[string]struct{}{}
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return err
}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
rows.Close()
return err
}
applied[v] = struct{}{}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
names, err := migrationNames(dialect)
if err != nil {
return err
}
for _, name := range names {
if _, done := applied[name]; done {
continue
}
body, err := migrationFS.ReadFile("migrations/" + dialect + "/" + name)
if err != nil {
return err
}
if err := applyOne(ctx, db, name, string(body)); err != nil {
return fmt.Errorf("migration %s: %w", name, err)
}
}
return nil
}
func migrationNames(dialect string) ([]string, error) {
entries, err := fs.ReadDir(migrationFS, "migrations/"+dialect)
if err != nil {
return nil, fmt.Errorf("no migrations for dialect %q: %w", dialect, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
// Lexical order is version order, which is why the files are zero padded.
sort.Strings(names)
return names, nil
}
func applyOne(ctx context.Context, db *sql.DB, name, body string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, body); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
name, nowMillis(),
); err != nil {
return err
}
return tx.Commit()
}