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>
107 lines
2.6 KiB
Go
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()
|
|
}
|