Files
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
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>
2026-09-28 19:03:46 +02:00

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()
}