A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix db migration logic

+80 -53
+2 -2
docker-compose.yml
··· 55 55 container_name: atcr-hold 56 56 ports: 57 57 - "8080:8080" 58 - volumes: 59 - - atcr-hold:/var/lib/atcr/hold 58 + # volumes: 59 + # - atcr-hold:/var/lib/atcr/hold 60 60 restart: unless-stopped 61 61 dns: 62 62 - 8.8.8.8
+3
pkg/appview/db/migrations/0001_example.yaml
··· 1 + description: Example migrarion query 2 + query: | 3 + SELECT COUNT(*) FROM schema_migrations;
-8
pkg/appview/db/migrations/0001_remove_star_count_from_repository_stats.yaml
··· 1 - version: 1 2 - name: remove_star_count_from_repository_stats 3 - up: | 4 - -- Drop star_count column if it exists (SQLite 3.35.0+) 5 - ALTER TABLE repository_stats DROP COLUMN IF EXISTS star_count; 6 - 7 - -- Drop the old star_count index if it exists 8 - DROP INDEX IF EXISTS idx_repository_stats_star_count;
+24 -16
pkg/appview/db/migrations/README.md
··· 7 7 Each migration is a YAML file with the following structure: 8 8 9 9 ```yaml 10 - version: 1 11 - name: descriptive_migration_name 12 - up: | 10 + description: Optional human-readable description of what this migration does 11 + query: | 13 12 SQL commands to apply the migration 14 13 ``` 15 14 15 + **Version and name are parsed from the filename**, so you don't need to specify them in the YAML. 16 + 16 17 ## Naming Convention 17 18 18 - Migration files should be named: `{version:04d}_{name}.yaml` 19 + Migration files **must** be named: `{version:04d}_{migration_name}.yaml` 20 + 21 + The filename determines: 22 + - **Version**: Numeric prefix (e.g., `0001` → version 1) 23 + - **Name**: Everything after first underscore (e.g., `add_repository_labels` → "add repository labels") 19 24 20 25 Examples: 21 - - `0001_remove_star_count_from_repository_stats.yaml` 22 - - `0002_add_repository_labels.yaml` 23 - - `0003_create_webhooks_table.yaml` 26 + - `0001_remove_star_count_from_repository_stats.yaml` → version 1, name "remove star count from repository stats" 27 + - `0002_add_repository_labels.yaml` → version 2, name "add repository labels" 28 + - `0003_create_webhooks_table.yaml` → version 3, name "create webhooks table" 24 29 25 30 ## Creating a New Migration 26 31 27 32 1. **Choose the next version number** - Look at existing migrations and increment by 1 28 - 2. **Create a new YAML file** with the naming convention above 29 - 3. **Write your SQL** - Use the `|` block scalar for clean multi-line SQL 30 - 4. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency 33 + 2. **Create a new YAML file** with format `000N_descriptive_name.yaml` 34 + 3. **Add description** (optional) - Explain what the migration does 35 + 4. **Write your SQL in `query`** - Use the `|` block scalar for clean multi-line SQL 36 + 5. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency (note: not supported for `DROP COLUMN`) 31 37 32 38 ## Examples 33 39 34 40 ### Simple single-statement migration: 35 41 42 + Filename: `0002_add_repository_description_index.yaml` 43 + 36 44 ```yaml 37 - version: 2 38 - name: add_repository_description_index 39 - up: | 45 + description: Add index on manifests description field for faster searches 46 + query: | 40 47 CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description); 41 48 ``` 42 49 43 50 ### Complex multi-statement migration: 51 + 52 + Filename: `0003_create_webhooks_table.yaml` 44 53 45 54 ```yaml 46 - version: 3 47 - name: create_webhooks_table 48 - up: | 55 + description: Create webhooks table for repository event notifications 56 + query: | 49 57 -- Create webhooks table 50 58 CREATE TABLE IF NOT EXISTS webhooks ( 51 59 id INTEGER PRIMARY KEY AUTOINCREMENT,
+51 -27
pkg/appview/db/schema.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "embed" 5 6 "fmt" 6 - "os" 7 + "io/fs" 7 8 "path/filepath" 8 9 "sort" 10 + "strconv" 11 + "strings" 9 12 10 13 _ "github.com/mattn/go-sqlite3" 11 14 "go.yaml.in/yaml/v4" 12 15 ) 16 + 17 + //go:embed migrations/*.yaml 18 + var migrationsFS embed.FS 13 19 14 20 const schema = ` 15 21 CREATE TABLE IF NOT EXISTS schema_migrations ( ··· 204 210 205 211 // Migration represents a database migration 206 212 type Migration struct { 207 - Version int `yaml:"version"` 208 - Name string `yaml:"name"` 209 - Up string `yaml:"up"` 213 + Version int 214 + Name string 215 + Description string `yaml:"description"` 216 + Query string `yaml:"query"` 210 217 } 211 218 212 219 // runMigrations applies any pending database migrations ··· 236 243 } 237 244 238 245 // Apply migration 239 - fmt.Printf("Applying migration %d: %s\n", m.Version, m.Name) 240 - if _, err := db.Exec(m.Up); err != nil { 246 + fmt.Printf("Applying migration %d: %s\n%s\n", m.Version, m.Name, m.Description) 247 + if _, err := db.Exec(m.Query); err != nil { 241 248 return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err) 242 249 } 243 250 ··· 252 259 return nil 253 260 } 254 261 255 - // loadMigrations loads all migration files from the migrations directory 262 + // loadMigrations loads all migration files from embedded filesystem 256 263 func loadMigrations() ([]Migration, error) { 257 - // Get the path to the migrations directory 258 - // Try relative to working directory first, then relative to this file 259 - migrationsDir := "pkg/appview/db/migrations" 260 - if _, err := os.Stat(migrationsDir); os.IsNotExist(err) { 261 - // Try embedded path (when running from different directory) 262 - migrationsDir = filepath.Join(".", "migrations") 263 - } 264 - 265 - // Read all .yaml files in the migrations directory 266 - files, err := filepath.Glob(filepath.Join(migrationsDir, "*.yaml")) 264 + // Read all migration files from embedded FS 265 + entries, err := fs.Glob(migrationsFS, "migrations/[0-9][0-9][0-9][0-9]_*.yaml") 267 266 if err != nil { 268 267 return nil, fmt.Errorf("failed to list migration files: %w", err) 269 268 } 270 269 271 270 var migrations []Migration 272 - for _, file := range files { 273 - data, err := os.ReadFile(file) 271 + for _, file := range entries { 272 + // Parse version and name from filename 273 + basename := filepath.Base(file) 274 + version, name, err := parseMigrationFilename(basename) 275 + if err != nil { 276 + return nil, fmt.Errorf("invalid migration filename %s: %w", basename, err) 277 + } 278 + 279 + // Read YAML content from embedded FS 280 + data, err := migrationsFS.ReadFile(file) 274 281 if err != nil { 275 282 return nil, fmt.Errorf("failed to read migration file %s: %w", file, err) 276 283 } ··· 280 287 return nil, fmt.Errorf("failed to parse migration file %s: %w", file, err) 281 288 } 282 289 290 + // Set version and name from filename 291 + m.Version = version 292 + m.Name = name 293 + 283 294 // Validate migration 284 - if m.Version <= 0 { 285 - return nil, fmt.Errorf("invalid migration version in %s: %d", file, m.Version) 286 - } 287 - if m.Name == "" { 288 - return nil, fmt.Errorf("missing migration name in %s", file) 289 - } 290 - if m.Up == "" { 291 - return nil, fmt.Errorf("missing migration 'up' SQL in %s", file) 295 + if m.Query == "" { 296 + return nil, fmt.Errorf("missing migration 'query' in %s", file) 292 297 } 293 298 294 299 migrations = append(migrations, m) ··· 296 301 297 302 return migrations, nil 298 303 } 304 + 305 + // parseMigrationFilename extracts version and name from migration filename 306 + // Expected format: 0001_migration_name.yaml 307 + // Returns: version (int), name (string), error 308 + // Note: Glob pattern ensures format is valid, so minimal validation needed 309 + func parseMigrationFilename(filename string) (int, string, error) { 310 + // Remove extension (.yaml or .yml) 311 + ext := filepath.Ext(filename) 312 + fileNameWithoutExt := filename[:len(filename)-len(ext)] 313 + 314 + // First 4 characters are the version (glob guarantees they're digits) 315 + version, _ := strconv.Atoi(fileNameWithoutExt[:4]) 316 + 317 + // Remainder after position 5 is the name (glob guarantees it exists) 318 + name := strings.ReplaceAll(fileNameWithoutExt[5:], "_", " ") 319 + name = strings.TrimSpace(name) 320 + 321 + return version, name, nil 322 + }