A person was in exactly one project, chosen at approval, and could not use the gateway before that. People belong to several teams and to none, so: - memberships: a person is a member of any number of organizations, teams and projects, or of none; a member of a team or organization may use every project in it. users join/leave, and the people page adds and takes out from either side. Approval no longer needs a project. - personal keys: a key may have an owner and no project. /profile asks what a new key spends against; keys create --for does the same. auth.personal sets default limits for personal use or turns it off. - limits for people: --scope user holds a person across every key, project and the chat page; --per-member on an org, team or project gives each member their own share there. Resolution folds the tree, each per-member limit for the key's owner, and the owner's own, each in its own pool; the quotas page shows and sets both. - the chat page asks what to spend against and checks it per message. - usage records who spent it, so a person's budget survives a restart, personal use and chat included; restore no longer drops rows with no project. Leaving a project revokes the keys made there unless another membership still reaches it. A personal key keeps no threads or notes, which are per project, and is refused rather than dropped into the anonymous pool. Migration 0010 turns each person's project into a membership, adds usage.user_id and backfills it, and makes api_keys.project_id nullable. SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign keys off, which only works outside a transaction, so migrations marked for it run on one connection with foreign_key_check before commit. Tested on a populated SQLite store with claims, and on a copy of the cluster's PostgreSQL store: 5 people became members, 17 keys and 3 claims intact, 145 of 577 usage rows and all 69 chat rows attributed. The PostgreSQL store tests, including new membership cases, pass on 16. users move is gone; join and leave replace it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed migrations/sqlite/*.sql migrations/postgres/*.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 {
|
|
// BIGINT, not INTEGER. SQLite's INTEGER holds 64 bits; PostgreSQL's is
|
|
// int4, and a millisecond timestamp does not fit in it — which the first
|
|
// migration discovers by failing to record that it ran. This table is
|
|
// created here rather than in a migration file, which is exactly how it
|
|
// escaped the port of the others.
|
|
stamp := "BIGINT"
|
|
if dialect == string(SQLiteDialect) {
|
|
stamp = "INTEGER"
|
|
}
|
|
if _, err := db.ExecContext(ctx,
|
|
`CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at `+stamp+` 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, dialect, 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
|
|
}
|
|
|
|
// fkOffMarker in a SQLite migration runs it with foreign keys off.
|
|
//
|
|
// Rebuilding a table — the only way SQLite changes a column's constraints —
|
|
// drops the old one, and with foreign keys on that deletes every row other
|
|
// tables point at. SQLite's procedure for this turns them off, which only
|
|
// works outside a transaction, so this takes one connection, turns them off
|
|
// on it, runs the migration in a transaction there, and refuses to commit
|
|
// unless foreign_key_check finds nothing.
|
|
const fkOffMarker = "-- migrate: foreign_keys=off"
|
|
|
|
func applyOne(ctx context.Context, db *sql.DB, dialect, name, body string) error {
|
|
type execer interface {
|
|
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
|
|
}
|
|
var on execer = db
|
|
checkFKs := false
|
|
if dialect == string(SQLiteDialect) && strings.Contains(body, fkOffMarker) {
|
|
c, err := db.Conn(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
if _, err := c.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _, _ = c.ExecContext(context.WithoutCancel(ctx), `PRAGMA foreign_keys = ON`) }()
|
|
on, checkFKs = c, true
|
|
}
|
|
|
|
tx, err := on.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, body); err != nil {
|
|
return err
|
|
}
|
|
if checkFKs {
|
|
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
broken := rows.Next()
|
|
rows.Close()
|
|
if broken {
|
|
return fmt.Errorf("leaves foreign keys pointing at nothing; not applied")
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
Dialect(dialect).rebind(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`),
|
|
name, nowMillis(),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|