From 615f0e49f1c077a53ed8ef2665aac547e9b4526e Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Sun, 30 Aug 2026 16:11:32 +0530 Subject: [PATCH] feat(db): #71: add versioned SQL migration module and clean up repository DDL --- internal/clients/db/client.go | 29 ++- internal/features/auth/repository.go | 59 +----- internal/features/auth/service.go | 29 ++- internal/features/home/repository.go | 25 +-- internal/features/upload/repository.go | 115 +--------- .../postgresql/001_initial_schema.sql | 61 ++++++ internal/migrations/runner.go | 199 ++++++++++++++++++ .../migrations/sqlite/001_initial_schema.sql | 61 ++++++ internal/platform/queue/repository.go | 66 +----- main.go | 8 +- 10 files changed, 398 insertions(+), 254 deletions(-) create mode 100644 internal/migrations/postgresql/001_initial_schema.sql create mode 100644 internal/migrations/runner.go create mode 100644 internal/migrations/sqlite/001_initial_schema.sql diff --git a/internal/clients/db/client.go b/internal/clients/db/client.go index 9478ed2..5893068 100644 --- a/internal/clients/db/client.go +++ b/internal/clients/db/client.go @@ -11,6 +11,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -53,10 +54,18 @@ type Client struct { Dialect Dialect } +// MigrationRunner is the interface Connection.SetAndMigrate uses to apply +// pending database migrations. The concrete implementation lives in +// internal/migrations; this interface breaks the potential import cycle because +// migrations imports db and db therefore cannot import migrations. +type MigrationRunner interface { + Run(ctx context.Context, db *Client) error +} + // NewClient opens a connection to the database described by config, verifies // the connection is live and returns a dialect-aware Client. It dispatches to -// the driver-specific open functions. Table creation is intentionally NOT done -// here; each feature repository owns its schema via initializeTable. +// the driver-specific open functions. Schema management is handled separately +// by the migrations runner, not here. func NewClient(config Config) (*Client, error) { switch config.Type { case SQLite: @@ -138,7 +147,8 @@ func NewConnection() *Connection { } // Set replaces the active client with a new one, closing the previous if any. -// It is called by the auth service after opening a user's database. +// Prefer SetAndMigrate when opening a user's database so that pending +// migrations are applied before any repository code runs. func (c *Connection) Set(client *Client) { c.mu.Lock() defer c.mu.Unlock() @@ -148,6 +158,19 @@ func (c *Connection) Set(client *Client) { c.client = client } +// SetAndMigrate runs pending migrations on client via runner and, if they all +// succeed, atomically replaces the active connection by calling Set. If +// migration fails the new client is closed and the error is returned — the +// caller never receives a partially-initialised database. +func (c *Connection) SetAndMigrate(ctx context.Context, client *Client, runner MigrationRunner) error { + if err := runner.Run(ctx, client); err != nil { + _ = client.Close() + return fmt.Errorf("database migration failed: %w", err) + } + c.Set(client) + return nil +} + // Current returns the active client, or ErrNoConnection when none is set (no // user signed in). func (c *Connection) Current() (*Client, error) { diff --git a/internal/features/auth/repository.go b/internal/features/auth/repository.go index b220342..3843112 100644 --- a/internal/features/auth/repository.go +++ b/internal/features/auth/repository.go @@ -9,7 +9,6 @@ import ( stderrors "errors" "fmt" "strings" - "sync" dbclient "ayo/internal/clients/db" "ayo/internal/platform/keyring" @@ -53,9 +52,7 @@ type Repository interface { } type repository struct { - conn *dbclient.Connection - initMu sync.Mutex - initClient *dbclient.Client + conn *dbclient.Connection } // NewRepository returns a repository bound to the shared connection holder. The @@ -65,56 +62,12 @@ func NewRepository(conn *dbclient.Connection) Repository { return &repository{conn: conn} } -// resolve returns the active client for the current session, creating the -// feature's tables on it the first time it is seen. Each user's database is -// initialized once, on first access after login (or registration). +// resolve returns the active database client for the current session. The +// schema is guaranteed to be up-to-date before any repository method is +// called, because migrations run inside Connection.SetAndMigrate at login and +// registration time. func (r *repository) resolve() (*dbclient.Client, error) { - c, err := r.conn.Current() - if err != nil { - return nil, err - } - r.initMu.Lock() - defer r.initMu.Unlock() - if r.initClient != c { - if err := initializeTable(c); err != nil { - return nil, err - } - r.initClient = c - } - return c, nil -} - -// initializeTable idempotently ensures the users table exists. It stores only -// hashes and encrypted material - never plaintext credentials. Column types use -// BYTEA notation but SQLite is untyped, so []byte values are stored as blobs; -// PostgreSQL stores them in native BYTEA columns. The id column and DDL differ -// per dialect. -func initializeTable(db *dbclient.Client) error { - idColumn := "id INTEGER PRIMARY KEY AUTOINCREMENT" - if db.IsPostgres() { - idColumn = "id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY" - } - - query := `CREATE TABLE IF NOT EXISTS users ( - ` + idColumn + `, - username VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - recovery_key VARCHAR(255) NOT NULL, - - password_salt BYTEA NOT NULL, - password_nonce BYTEA NOT NULL, - password_master_key BYTEA NOT NULL, - - recovery_salt BYTEA NOT NULL, - recovery_nonce BYTEA NOT NULL, - recovery_master_key BYTEA NOT NULL - )` - - _, err := db.Exec(query) - if err != nil { - return err - } - return nil + return r.conn.Current() } // CreateUser inserts a new account row and returns the created User populated diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 3a5da07..3caf407 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -68,6 +68,7 @@ type Service struct { validate *validator.Validate mu sync.Mutex inactivityTimeoutMinutes int + migrationRunner dbclient.MigrationRunner } // Startup stores the Wails application context, which native dialogs (e.g. @@ -103,10 +104,11 @@ func validatePasswordStrength(fl validator.FieldLevel) bool { return hasUpper && hasLower && hasDigit && hasSymbol } -// NewService wires a shared connection holder, the database-credentials -// keyring repository, the master-key keyring repository and a validator with -// the custom validation rules into a ready-to-use auth Service. -func NewService(conn *dbclient.Connection) *Service { +// NewService wires a shared connection holder, a migration runner, the +// database-credentials keyring repository, the master-key keyring repository +// and a validator with the custom validation rules into a ready-to-use auth +// Service. +func NewService(conn *dbclient.Connection, migrationRunner dbclient.MigrationRunner) *Service { validate := validator.New() // Register custom validators @@ -118,6 +120,7 @@ func NewService(conn *dbclient.Connection) *Service { repo: NewRepository(conn), validate: validate, inactivityTimeoutMinutes: 15, + migrationRunner: migrationRunner, } } @@ -171,7 +174,12 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { if err != nil { return nil, errors.ErrDatabaseUnavailable } - s.conn.Set(client) + // Run migrations so the full schema exists before CreateUser is called. + // Registration does not sign the user in, so the connection is always + // closed before returning (see defer below). + if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil { + return nil, errors.ErrDatabaseUnavailable + } // Registration does not sign the user in, so the temporary connection is // always closed before returning. defer s.conn.Close() @@ -300,12 +308,15 @@ func (s *Service) Login(input LoginInput) (bool, error) { } config := creds.ToConfig() - // Connect to the user's database before touching its tables. + // Connect to the user's database and run any pending migrations before + // touching its tables. client, err := dbclient.NewClient(config) if err != nil { return false, errors.ErrDatabaseUnavailable } - s.conn.Set(client) + if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil { + return false, errors.ErrDatabaseUnavailable + } user, err := s.repo.GetUserByUsername(context.Background(), input.Username) if err != nil { @@ -395,7 +406,9 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro if err != nil { return nil, errors.ErrDatabaseUnavailable } - s.conn.Set(client) + if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil { + return nil, errors.ErrDatabaseUnavailable + } user, err := s.repo.GetUserByUsername(context.Background(), input.Username) if err != nil { diff --git a/internal/features/home/repository.go b/internal/features/home/repository.go index 3443476..d7ec721 100644 --- a/internal/features/home/repository.go +++ b/internal/features/home/repository.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "sync" dbclient "ayo/internal/clients/db" "ayo/internal/features/upload" @@ -20,8 +19,6 @@ import ( type repository struct { conn *dbclient.Connection uploadRepo upload.Repository - initMu sync.Mutex - initClient *dbclient.Client } // NewRepository returns a repository bound to the shared connection holder and @@ -32,24 +29,12 @@ func NewRepository(conn *dbclient.Connection, uploadRepo upload.Repository) *rep return &repository{conn: conn, uploadRepo: uploadRepo} } -// resolve returns the active client for the current session, ensuring the -// shared uploads/chunks schema exists on it the first time it is seen. The DDL -// lives only in the upload feature; this repository reuses it rather than -// duplicating it. +// resolve returns the active database client for the current session. The +// schema is guaranteed to be up-to-date before any repository method is +// called, because migrations run inside Connection.SetAndMigrate at login and +// registration time. func (r *repository) resolve() (*dbclient.Client, error) { - c, err := r.conn.Current() - if err != nil { - return nil, err - } - r.initMu.Lock() - defer r.initMu.Unlock() - if r.initClient != c { - if err := upload.InitializeSchema(c); err != nil { - return nil, err - } - r.initClient = c - } - return c, nil + return r.conn.Current() } // GetRecentFiles returns the most recently uploaded stored files, newest diff --git a/internal/features/upload/repository.go b/internal/features/upload/repository.go index edc7d11..8ecc8ce 100644 --- a/internal/features/upload/repository.go +++ b/internal/features/upload/repository.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "sync" dbclient "ayo/internal/clients/db" ) @@ -44,9 +43,7 @@ type Repository interface { } type repository struct { - conn *dbclient.Connection - initMu sync.Mutex - initClient *dbclient.Client + conn *dbclient.Connection } // NewRepository returns a repository bound to the shared connection holder. The @@ -59,112 +56,12 @@ func NewRepository(conn *dbclient.Connection) *repository { return &repository{conn: conn} } -// resolve returns the active client for the current session, creating the -// feature's tables on it the first time it is seen. +// resolve returns the active database client for the current session. The +// schema is guaranteed to be up-to-date before any repository method is +// called, because migrations run inside Connection.SetAndMigrate at login and +// registration time. func (r *repository) resolve() (*dbclient.Client, error) { - c, err := r.conn.Current() - if err != nil { - return nil, err - } - r.initMu.Lock() - defer r.initMu.Unlock() - if r.initClient != c { - if err := InitializeSchema(c); err != nil { - return nil, err - } - r.initClient = c - } - return c, nil -} - -// InitializeSchema idempotently ensures the uploads and chunks tables exist. -// The upload feature owns the shared schema (it is the writer); the home -// feature bootstraps the same tables through this function so both read and -// write the same storage without duplicating the DDL. chunks.file_id references -// uploads.id (via the foreign_keys pragma on SQLite / a native FK on -// PostgreSQL), and chunks.chunk_id is globally unique so shard names can never -// collide even across users or uploads. The uploads table also carries the -// reconstruction metadata (encrypted size, shard layout, block count) that a -// local manifest used to hold, so a stored file can always be rebuilt from its -// row. It also carries the envelope-encryption metadata (the per-file DEK -// wrapped by the master key and its two nonces) so a stored file can be -// unwrapped and decrypted from its row alone. The DDL branches on the client's -// dialect (AUTOINCREMENT vs IDENTITY, DATETIME vs TIMESTAMP, BIGINT for size -// columns). -func InitializeSchema(db *dbclient.Client) error { - var queries []string - - if db.IsPostgres() { - queries = []string{ - `CREATE TABLE IF NOT EXISTS uploads ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - job_id BIGINT NOT NULL UNIQUE, - file TEXT NOT NULL, - custom_name TEXT NOT NULL DEFAULT '', - size BIGINT NOT NULL, - tags TEXT NOT NULL DEFAULT '[]', - encrypted_size BIGINT NOT NULL, - data_shards INTEGER NOT NULL, - parity_shards INTEGER NOT NULL, - shard_size BIGINT NOT NULL, - block_count INTEGER NOT NULL, - file_nonce BYTEA NOT NULL, - encrypted_file_key BYTEA NOT NULL, - key_nonce BYTEA NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - )`, - `CREATE TABLE IF NOT EXISTS chunks ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - file_id BIGINT NOT NULL, - shard_index INTEGER NOT NULL, - chunk_id TEXT NOT NULL UNIQUE, - storage_id TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)`, - } - } else { - queries = []string{ - `CREATE TABLE IF NOT EXISTS uploads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - job_id INTEGER NOT NULL UNIQUE, - file TEXT NOT NULL, - custom_name TEXT NOT NULL DEFAULT '', - size INTEGER NOT NULL, - tags TEXT NOT NULL DEFAULT '[]', - encrypted_size INTEGER NOT NULL, - data_shards INTEGER NOT NULL, - parity_shards INTEGER NOT NULL, - shard_size INTEGER NOT NULL, - block_count INTEGER NOT NULL, - file_nonce BLOB NOT NULL, - encrypted_file_key BLOB NOT NULL, - key_nonce BLOB NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`, - `CREATE TABLE IF NOT EXISTS chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL, - shard_index INTEGER NOT NULL, - chunk_id TEXT NOT NULL UNIQUE, - storage_id TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)`, - } - } - - for _, query := range queries { - if _, err := db.Exec(query); err != nil { - return err - } - } - - return nil + return r.conn.Current() } // CreateUpload inserts a stored-file record and returns it populated with its diff --git a/internal/migrations/postgresql/001_initial_schema.sql b/internal/migrations/postgresql/001_initial_schema.sql new file mode 100644 index 0000000..7bda02b --- /dev/null +++ b/internal/migrations/postgresql/001_initial_schema.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + recovery_key VARCHAR(255) NOT NULL, + password_salt BYTEA NOT NULL, + password_nonce BYTEA NOT NULL, + password_master_key BYTEA NOT NULL, + recovery_salt BYTEA NOT NULL, + recovery_nonce BYTEA NOT NULL, + recovery_master_key BYTEA NOT NULL +); + +CREATE TABLE IF NOT EXISTS queue ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'upload' + CHECK (type IN ('upload', 'download', 'delete')), + file_id BIGINT NOT NULL DEFAULT 0, + file TEXT NOT NULL, + custom_name TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, + size BIGINT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_queue_status ON queue (status); + +CREATE TABLE IF NOT EXISTS uploads ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + job_id BIGINT NOT NULL UNIQUE, + file TEXT NOT NULL, + custom_name TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + encrypted_size BIGINT NOT NULL, + data_shards INTEGER NOT NULL, + parity_shards INTEGER NOT NULL, + shard_size BIGINT NOT NULL, + block_count INTEGER NOT NULL, + file_nonce BYTEA NOT NULL, + encrypted_file_key BYTEA NOT NULL, + key_nonce BYTEA NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS chunks ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + file_id BIGINT NOT NULL, + shard_index INTEGER NOT NULL, + chunk_id TEXT NOT NULL UNIQUE, + storage_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks (file_id) diff --git a/internal/migrations/runner.go b/internal/migrations/runner.go new file mode 100644 index 0000000..710ac37 --- /dev/null +++ b/internal/migrations/runner.go @@ -0,0 +1,199 @@ +// Package migrations owns all database schema definitions and their evolution. +// SQL migration files live in the sqlite/ and postgresql/ subdirectories, named +// NNN_description.sql where NNN is a monotonically increasing version number. +// The Runner reads these embedded files, compares their versions against the +// schema_migrations bookkeeping table, and applies any pending migrations in +// order inside individual transactions. +// +// Adding a schema change in the future requires only a new numbered SQL file +// in each dialect directory — no Go code changes. +package migrations + +import ( + "context" + "embed" + "fmt" + "io/fs" + "log/slog" + "regexp" + "sort" + "strconv" + "strings" + + dbclient "ayo/internal/clients/db" +) + +//go:embed sqlite postgresql +var sqlFiles embed.FS + +// versionRegex extracts the leading numeric version from a migration filename +// such as "001_initial_schema.sql". +var versionRegex = regexp.MustCompile(`^(\d+)_`) + +// migration holds a parsed SQL migration file ready to apply. +type migration struct { + Version int + Name string // filename without directory prefix + SQL string // full file contents +} + +// Runner applies pending SQL migrations to a database in version order. +// It has no mutable state; the zero value is ready to use. +type Runner struct{} + +// New returns a Runner. It satisfies dbclient.MigrationRunner. +func New() *Runner { return &Runner{} } + +// Run bootstraps the schema_migrations bookkeeping table if it does not yet +// exist, finds the highest version already applied, and then runs every +// migration file whose version is greater — in ascending order, each in its +// own transaction. If any migration fails its transaction is rolled back, the +// version is not recorded, and Run returns an error immediately so the caller +// can surface a meaningful message rather than letting the app start against +// an incomplete schema. +func (r *Runner) Run(ctx context.Context, db *dbclient.Client) error { + if err := createMigrationsTable(ctx, db); err != nil { + return fmt.Errorf("migrations: bootstrap schema_migrations: %w", err) + } + + var last int + if err := db.QueryRowContext(ctx, + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&last); err != nil { + return fmt.Errorf("migrations: query last applied version: %w", err) + } + + subdir := "sqlite" + if db.IsPostgres() { + subdir = "postgresql" + } + + pending, err := loadMigrations(subdir, last) + if err != nil { + return fmt.Errorf("migrations: load migration files: %w", err) + } + + for _, m := range pending { + slog.Info("migrations: applying migration", "version", m.Version, "name", m.Name) + if err := applyMigration(ctx, db, m); err != nil { + return fmt.Errorf("migrations: apply %s (version %d): %w", m.Name, m.Version, err) + } + slog.Info("migrations: migration applied", "version", m.Version, "name", m.Name) + } + + return nil +} + +// createMigrationsTable idempotently creates the schema_migrations bookkeeping +// table. The timestamp type differs by dialect but the table structure is the +// same: a single integer primary key (the migration version) and a server-side +// timestamp recording when it was applied. +func createMigrationsTable(ctx context.Context, db *dbclient.Client) error { + tsType := "DATETIME" + if db.IsPostgres() { + tsType = "TIMESTAMP" + } + _, err := db.ExecContext(ctx, fmt.Sprintf( + `CREATE TABLE IF NOT EXISTS schema_migrations (`+ + `version INTEGER PRIMARY KEY, `+ + `applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP`+ + `)`, tsType)) + return err +} + +// loadMigrations reads all .sql files from the given subdirectory of the +// embedded FS, parses the leading version number from each filename, and +// returns only the migrations whose version is strictly greater than last, +// sorted ascending by version. +func loadMigrations(subdir string, last int) ([]migration, error) { + entries, err := fs.ReadDir(sqlFiles, subdir) + if err != nil { + return nil, fmt.Errorf("read %s directory: %w", subdir, err) + } + + var migrations []migration + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + matches := versionRegex.FindStringSubmatch(e.Name()) + if matches == nil { + // File doesn't start with a version number — skip it silently so + // README.md or other non-migration files in the dir are ignored. + continue + } + version, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + if version <= last { + continue // already applied + } + data, err := fs.ReadFile(sqlFiles, subdir+"/"+e.Name()) + if err != nil { + return nil, fmt.Errorf("read migration file %s: %w", e.Name(), err) + } + migrations = append(migrations, migration{ + Version: version, + Name: e.Name(), + SQL: string(data), + }) + } + + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].Version < migrations[j].Version + }) + return migrations, nil +} + +// applyMigration runs a single migration file inside an explicit transaction. +// The SQL file content is split into individual statements on semicolons so +// that multi-statement files execute correctly across both drivers. The version +// row is inserted inside the same transaction, so a failure rolls back both the +// DDL and the bookkeeping row atomically. +func applyMigration(ctx context.Context, db *dbclient.Client, m migration) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + for _, stmt := range splitSQL(m.SQL) { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("execute %q: %w", truncate(stmt, 80), err) + } + } + + // Record the version inside the same transaction so it is only committed + // if all DDL statements above succeeded. + if _, err := tx.ExecContext(ctx, + db.Rebind("INSERT INTO schema_migrations (version) VALUES (?)"), + m.Version, + ); err != nil { + return fmt.Errorf("record version %d: %w", m.Version, err) + } + + return tx.Commit() +} + +// splitSQL splits a SQL script on semicolons, trims whitespace from each +// piece, and discards empty results. This lets migration files be written as +// plain SQL without manual statement delimiting in Go. +func splitSQL(sql string) []string { + parts := strings.Split(sql, ";") + stmts := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + stmts = append(stmts, s) + } + } + return stmts +} + +// truncate returns s truncated to n bytes with "…" appended when it is longer. +// Used to keep error messages readable when a long SQL statement fails. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/migrations/sqlite/001_initial_schema.sql b/internal/migrations/sqlite/001_initial_schema.sql new file mode 100644 index 0000000..5e22fa9 --- /dev/null +++ b/internal/migrations/sqlite/001_initial_schema.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + recovery_key VARCHAR(255) NOT NULL, + password_salt BLOB NOT NULL, + password_nonce BLOB NOT NULL, + password_master_key BLOB NOT NULL, + recovery_salt BLOB NOT NULL, + recovery_nonce BLOB NOT NULL, + recovery_master_key BLOB NOT NULL +); + +CREATE TABLE IF NOT EXISTS queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'upload' + CHECK (type IN ('upload', 'download', 'delete')), + file_id BIGINT NOT NULL DEFAULT 0, + file TEXT NOT NULL, + custom_name TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, + size BIGINT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_queue_status ON queue (status); + +CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL UNIQUE, + file TEXT NOT NULL, + custom_name TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + encrypted_size INTEGER NOT NULL, + data_shards INTEGER NOT NULL, + parity_shards INTEGER NOT NULL, + shard_size INTEGER NOT NULL, + block_count INTEGER NOT NULL, + file_nonce BLOB NOT NULL, + encrypted_file_key BLOB NOT NULL, + key_nonce BLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + shard_index INTEGER NOT NULL, + chunk_id TEXT NOT NULL UNIQUE, + storage_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks (file_id) diff --git a/internal/platform/queue/repository.go b/internal/platform/queue/repository.go index e257f52..df64e1c 100644 --- a/internal/platform/queue/repository.go +++ b/internal/platform/queue/repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" stderrors "errors" "fmt" - "sync" dbclient "ayo/internal/clients/db" "ayo/internal/shared/errors" @@ -36,9 +35,7 @@ type Repository interface { } type repository struct { - conn *dbclient.Connection - initMu sync.Mutex - initClient *dbclient.Client + conn *dbclient.Connection } // NewRepository returns a repository bound to the shared connection holder. The @@ -48,63 +45,12 @@ func NewRepository(conn *dbclient.Connection) Repository { return &repository{conn: conn} } -// resolve returns the active client for the current session, creating the -// feature's tables on it the first time it is seen. +// resolve returns the active database client for the current session. The +// schema is guaranteed to be up-to-date before any repository method is +// called, because migrations run inside Connection.SetAndMigrate at login and +// registration time. func (r *repository) resolve() (*dbclient.Client, error) { - c, err := r.conn.Current() - if err != nil { - return nil, err - } - r.initMu.Lock() - defer r.initMu.Unlock() - if r.initClient != c { - if err := initializeTable(c); err != nil { - return nil, err - } - r.initClient = c - } - return c, nil -} - -// initializeTable idempotently ensures the queue table exists and has all -// expected columns. Type is the operation kind (upload/download/delete), stored -// as TEXT and constrained to the enum values; status and timestamps are stored -// as TEXT/DATETIME (TIMESTAMP on PostgreSQL), progress as an INTEGER (0-100), -// and tags as a JSON-encoded TEXT array. The id column and timestamp type -// branch on the client's dialect. -func initializeTable(db *dbclient.Client) error { - idColumn := "id INTEGER PRIMARY KEY AUTOINCREMENT" - timestampType := "DATETIME" - if db.IsPostgres() { - idColumn = "id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY" - timestampType = "TIMESTAMP" - } - - query := `CREATE TABLE IF NOT EXISTS queue ( - ` + idColumn + `, - type TEXT NOT NULL DEFAULT 'upload' CHECK (type IN ('upload', 'download', 'delete')), - file_id BIGINT NOT NULL DEFAULT 0, - file TEXT NOT NULL, - custom_name TEXT NOT NULL DEFAULT '', - path TEXT NOT NULL, - size BIGINT NOT NULL, - status TEXT NOT NULL, - progress INTEGER NOT NULL DEFAULT 0, - tags TEXT NOT NULL DEFAULT '[]', - created_at ` + timestampType + ` NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at ` + timestampType + ` NOT NULL DEFAULT CURRENT_TIMESTAMP - )` - - if _, err := db.Exec(query); err != nil { - return err - } - - // Keep the active-job lookup (status-based) fast as the audit table grows - // without bound. - if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_queue_status ON queue (status)`); err != nil { - return err - } - return nil + return r.conn.Current() } // Add inserts a new job row and returns the job populated with its assigned ID diff --git a/main.go b/main.go index 016d92a..0a0a710 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "ayo/internal/features/home" "ayo/internal/features/settings" "ayo/internal/features/upload" + "ayo/internal/migrations" "ayo/internal/platform/queue" "github.com/wailsapp/wails/v2" @@ -59,6 +60,11 @@ func main() { // queue/upload repositories serve whichever user is currently signed in. conn := dbclient.NewConnection() + // The migration runner applies pending SQL migrations to the user's database + // at login/registration time, before any repository code runs. The runner + // reads embedded SQL files from internal/migrations/{sqlite,postgresql}/. + migrationRunner := migrations.New() + // Wire up the internal services. The auth service is the keystone: it owns // the in-memory session, the master key and the active database connection, // and is injected into the settings service (which needs the session to @@ -67,7 +73,7 @@ func main() { // feature's keyring helpers. The encrypted master-key material can likewise // live in the OS keyring (account-scoped "mkey_{username}") or in the users // table; the auth service migrates between the two via Get/SetMasterKeyStorage. - authService := auth.NewService(conn) + authService := auth.NewService(conn, migrationRunner) // Settings service: stores per-user settings in the OS keyring, encrypted // with the session master key. Provider configs are validated through the