Files
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go
T
david aa8f7af55d feat: implement offline sync endpoints with Last-Write-Wins strategy
Add comprehensive offline synchronization support for habits and entries:

## Infrastructure (Phase 1)
- Add UpdatedAt and DeletedAt timestamps to Habit and HabitEntry entities
- Implement soft delete with Delete(), Touch(), and IsDeleted() methods
- Create SQL migration with optimized composite indexes for sync queries
- Add GetChangesSince() and SoftDelete() to both repositories
- Update all Find* methods to exclude soft-deleted records
- 13 comprehensive TDD tests for sync repository methods

## HTTP Endpoints (Phase 2)
- GET /api/v1/sync/changes: retrieve all changes since timestamp
- POST /api/v1/sync/batch: apply client changes with conflict resolution
- Implement Last-Write-Wins strategy using UpdatedAt timestamps
- Add authentication and rate limiting (100 req/min)
- Validate user ownership for all sync operations
- 9 tests for sync handlers (3 queries + 6 commands)

## Technical Details
- Composite indexes: (user_id, updated_at) for optimal query performance
- No pagination: atomic sync operations for data consistency
- Upsert behavior: create resources if not found on server
- DTOs with full entity state including timestamps
- Swagger documentation updated for new endpoints

All 220+ tests passing ✓
2025-12-12 00:11:46 +01:00

259 lines
7.0 KiB
Go

package sqlite
import (
"database/sql"
"fmt"
)
func RunMigrations(db *sql.DB) error {
migrations := []string{
createUsersTable,
createHabitsTable,
createHabitEntriesTable,
createRefreshTokensTable,
createPasswordResetTokensTable,
createIndexes,
}
for _, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
return err
}
}
if err := addEmailVerificationColumns(db); err != nil {
return err
}
if err := removeTimezoneColumn(db); err != nil {
return err
}
if err := addSyncColumns(db); err != nil {
return err
}
return nil
}
func addEmailVerificationColumns(db *sql.DB) error {
columns := []struct {
name string
definition string
}{
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
}
for _, col := range columns {
exists, err := columnExists(db, "users", col.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return err
}
}
}
return nil
}
func removeTimezoneColumn(db *sql.DB) error {
exists, err := columnExists(db, "users", "timezone")
if err != nil {
return err
}
if !exists {
return nil
}
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
return fmt.Errorf("failed to drop timezone column: %w", err)
}
return nil
}
func columnExists(db *sql.DB, table, column string) (bool, error) {
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
var count int
err := db.QueryRow(query, column).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func indexExists(db *sql.DB, indexName string) (bool, error) {
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
var count int
err := db.QueryRow(query, indexName).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func addSyncColumns(db *sql.DB) error {
// Columns to add to habits table
habitColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
}
for _, col := range habitColumns {
exists, err := columnExists(db, "habits", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with created_at for existing records
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at: %w", err)
}
// Columns to add to habit_entries table
entryColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
}
for _, col := range entryColumns {
exists, err := columnExists(db, "habit_entries", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with completed_at for existing entries
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
}
// Create indexes for sync queries
indexes := []struct {
name string
definition string
}{
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
}
for _, idx := range indexes {
exists, err := indexExists(db, idx.name)
if err != nil {
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
}
if !exists {
if _, err := db.Exec(idx.definition); err != nil {
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
}
}
}
return nil
}
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
const createHabitsTable = `
CREATE TABLE IF NOT EXISTS habits (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
type TEXT CHECK(type IN ('BOOLEAN', 'COUNTER', 'VALUE')),
frequency TEXT CHECK(frequency IN ('DAILY', 'WEEKLY', 'MONTHLY')),
specific_days TEXT,
specific_dates TEXT,
carry_over BOOLEAN DEFAULT 0,
is_negative BOOLEAN DEFAULT 0,
target_value REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`
const createHabitEntriesTable = `
CREATE TABLE IF NOT EXISTS habit_entries (
id TEXT PRIMARY KEY,
habit_id TEXT NOT NULL,
scheduled_date DATE NOT NULL,
completed_at DATETIME NOT NULL,
value REAL,
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
UNIQUE(habit_id, scheduled_date)
);
`
const createRefreshTokensTable = `
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`
const createPasswordResetTokensTable = `
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`
const createIndexes = `
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
`