first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

38
internal/db/db.go Normal file
View File

@@ -0,0 +1,38 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
}
func Open(path string) (*DB, error) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
}
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)", path)
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
sqlDB.SetMaxOpenConns(1)
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return &DB{DB: sqlDB}, nil
}
func (d *DB) Ping(ctx context.Context) error { return d.DB.PingContext(ctx) }
func (d *DB) Close() error { return d.DB.Close() }

32
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,32 @@
package db
import (
"context"
"path/filepath"
"testing"
)
func TestOpenAndMigrate(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
d, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
if err := d.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
// Idempotent.
if err := d.Migrate(context.Background()); err != nil {
t.Fatalf("migrate twice: %v", err)
}
row := d.QueryRow(`SELECT COUNT(*) FROM schema_migrations`)
var n int
if err := row.Scan(&n); err != nil {
t.Fatalf("scan: %v", err)
}
if n < 1 {
t.Fatalf("expected migrations applied, got %d", n)
}
}

114
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,114 @@
package db
import (
"context"
"embed"
"fmt"
"io/fs"
"log/slog"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type migration struct {
version int
name string
sql string
}
func (d *DB) Migrate(ctx context.Context) error {
if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
migs, err := loadMigrations()
if err != nil {
return err
}
applied, err := d.appliedVersions(ctx)
if err != nil {
return err
}
for _, m := range migs {
if applied[m.version] {
continue
}
slog.Info("applying migration", "version", m.version, "name", m.name)
if err := d.applyOne(ctx, m); err != nil {
return fmt.Errorf("apply %d %s: %w", m.version, m.name, err)
}
}
return nil
}
func (d *DB) applyOne(ctx context.Context, m migration) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
m.version, time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
return tx.Commit()
}
func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
rows, err := d.QueryContext(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, err
}
out[v] = true
}
return out, rows.Err()
}
func loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
out := make([]migration, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
parts := strings.SplitN(e.Name(), "_", 2)
if len(parts) != 2 {
continue
}
v, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
b, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
out = append(out, migration{version: v, name: e.Name(), sql: string(b)})
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}

View File

@@ -0,0 +1,73 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
subscription_type TEXT NOT NULL,
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
manual_username INTEGER NOT NULL DEFAULT 0,
last_synced_at TEXT,
expiring_reminder_sent_at TEXT,
deactivated_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_users_expires ON users(expires_at);
CREATE INDEX idx_users_deactivated ON users(deactivated_at);
CREATE TABLE pending_invoices (
payment_hash TEXT PRIMARY KEY,
payment_request TEXT NOT NULL,
username TEXT NOT NULL,
pubkey TEXT NOT NULL,
subscription_type TEXT NOT NULL,
years INTEGER NOT NULL DEFAULT 1,
amount_sats INTEGER NOT NULL,
expires_at TEXT NOT NULL,
paid INTEGER NOT NULL DEFAULT 0,
is_renewal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_pending_unpaid ON pending_invoices(paid, expires_at);
CREATE INDEX idx_pending_username ON pending_invoices(username, paid);
CREATE TABLE webhook_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TEXT,
next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_webhook_outbox_pending ON webhook_outbox(status, next_attempt_at);
CREATE TABLE dm_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
pubkey TEXT NOT NULL,
content TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TEXT,
next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dm_outbox_pending ON dm_outbox(status, next_attempt_at);
CREATE INDEX idx_dm_outbox_pubkey ON dm_outbox(pubkey, event_type);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
actor TEXT NOT NULL,
pubkey TEXT,
details TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,9 @@
-- target_expires_at captures the expiry value computed at first confirmation
-- attempt. Subsequent attempts (e.g. after a crash mid-confirm) read this
-- value back so user mutation stays idempotent.
ALTER TABLE pending_invoices ADD COLUMN target_expires_at TEXT;
CREATE INDEX idx_audit_pubkey ON audit_log(pubkey);
CREATE INDEX idx_audit_created ON audit_log(created_at);
CREATE INDEX idx_webhook_outbox_status ON webhook_outbox(status, created_at);
CREATE INDEX idx_dm_outbox_status ON dm_outbox(status, created_at);