···11-version: 1
22-name: remove_star_count_from_repository_stats
33-up: |
44- -- Drop star_count column if it exists (SQLite 3.35.0+)
55- ALTER TABLE repository_stats DROP COLUMN IF EXISTS star_count;
66-77- -- Drop the old star_count index if it exists
88- DROP INDEX IF EXISTS idx_repository_stats_star_count;
+24-16
pkg/appview/db/migrations/README.md
···77Each migration is a YAML file with the following structure:
8899```yaml
1010-version: 1
1111-name: descriptive_migration_name
1212-up: |
1010+description: Optional human-readable description of what this migration does
1111+query: |
1312 SQL commands to apply the migration
1413```
15141515+**Version and name are parsed from the filename**, so you don't need to specify them in the YAML.
1616+1617## Naming Convention
17181818-Migration files should be named: `{version:04d}_{name}.yaml`
1919+Migration files **must** be named: `{version:04d}_{migration_name}.yaml`
2020+2121+The filename determines:
2222+- **Version**: Numeric prefix (e.g., `0001` → version 1)
2323+- **Name**: Everything after first underscore (e.g., `add_repository_labels` → "add repository labels")
19242025Examples:
2121-- `0001_remove_star_count_from_repository_stats.yaml`
2222-- `0002_add_repository_labels.yaml`
2323-- `0003_create_webhooks_table.yaml`
2626+- `0001_remove_star_count_from_repository_stats.yaml` → version 1, name "remove star count from repository stats"
2727+- `0002_add_repository_labels.yaml` → version 2, name "add repository labels"
2828+- `0003_create_webhooks_table.yaml` → version 3, name "create webhooks table"
24292530## Creating a New Migration
263127321. **Choose the next version number** - Look at existing migrations and increment by 1
2828-2. **Create a new YAML file** with the naming convention above
2929-3. **Write your SQL** - Use the `|` block scalar for clean multi-line SQL
3030-4. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency
3333+2. **Create a new YAML file** with format `000N_descriptive_name.yaml`
3434+3. **Add description** (optional) - Explain what the migration does
3535+4. **Write your SQL in `query`** - Use the `|` block scalar for clean multi-line SQL
3636+5. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency (note: not supported for `DROP COLUMN`)
31373238## Examples
33393440### Simple single-statement migration:
35414242+Filename: `0002_add_repository_description_index.yaml`
4343+3644```yaml
3737-version: 2
3838-name: add_repository_description_index
3939-up: |
4545+description: Add index on manifests description field for faster searches
4646+query: |
4047 CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description);
4148```
42494350### Complex multi-statement migration:
5151+5252+Filename: `0003_create_webhooks_table.yaml`
44534554```yaml
4646-version: 3
4747-name: create_webhooks_table
4848-up: |
5555+description: Create webhooks table for repository event notifications
5656+query: |
4957 -- Create webhooks table
5058 CREATE TABLE IF NOT EXISTS webhooks (
5159 id INTEGER PRIMARY KEY AUTOINCREMENT,
+51-27
pkg/appview/db/schema.go
···2233import (
44 "database/sql"
55+ "embed"
56 "fmt"
66- "os"
77+ "io/fs"
78 "path/filepath"
89 "sort"
1010+ "strconv"
1111+ "strings"
9121013 _ "github.com/mattn/go-sqlite3"
1114 "go.yaml.in/yaml/v4"
1215)
1616+1717+//go:embed migrations/*.yaml
1818+var migrationsFS embed.FS
13191420const schema = `
1521CREATE TABLE IF NOT EXISTS schema_migrations (
···204210205211// Migration represents a database migration
206212type Migration struct {
207207- Version int `yaml:"version"`
208208- Name string `yaml:"name"`
209209- Up string `yaml:"up"`
213213+ Version int
214214+ Name string
215215+ Description string `yaml:"description"`
216216+ Query string `yaml:"query"`
210217}
211218212219// runMigrations applies any pending database migrations
···236243 }
237244238245 // Apply migration
239239- fmt.Printf("Applying migration %d: %s\n", m.Version, m.Name)
240240- if _, err := db.Exec(m.Up); err != nil {
246246+ fmt.Printf("Applying migration %d: %s\n%s\n", m.Version, m.Name, m.Description)
247247+ if _, err := db.Exec(m.Query); err != nil {
241248 return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err)
242249 }
243250···252259 return nil
253260}
254261255255-// loadMigrations loads all migration files from the migrations directory
262262+// loadMigrations loads all migration files from embedded filesystem
256263func loadMigrations() ([]Migration, error) {
257257- // Get the path to the migrations directory
258258- // Try relative to working directory first, then relative to this file
259259- migrationsDir := "pkg/appview/db/migrations"
260260- if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
261261- // Try embedded path (when running from different directory)
262262- migrationsDir = filepath.Join(".", "migrations")
263263- }
264264-265265- // Read all .yaml files in the migrations directory
266266- files, err := filepath.Glob(filepath.Join(migrationsDir, "*.yaml"))
264264+ // Read all migration files from embedded FS
265265+ entries, err := fs.Glob(migrationsFS, "migrations/[0-9][0-9][0-9][0-9]_*.yaml")
267266 if err != nil {
268267 return nil, fmt.Errorf("failed to list migration files: %w", err)
269268 }
270269271270 var migrations []Migration
272272- for _, file := range files {
273273- data, err := os.ReadFile(file)
271271+ for _, file := range entries {
272272+ // Parse version and name from filename
273273+ basename := filepath.Base(file)
274274+ version, name, err := parseMigrationFilename(basename)
275275+ if err != nil {
276276+ return nil, fmt.Errorf("invalid migration filename %s: %w", basename, err)
277277+ }
278278+279279+ // Read YAML content from embedded FS
280280+ data, err := migrationsFS.ReadFile(file)
274281 if err != nil {
275282 return nil, fmt.Errorf("failed to read migration file %s: %w", file, err)
276283 }
···280287 return nil, fmt.Errorf("failed to parse migration file %s: %w", file, err)
281288 }
282289290290+ // Set version and name from filename
291291+ m.Version = version
292292+ m.Name = name
293293+283294 // Validate migration
284284- if m.Version <= 0 {
285285- return nil, fmt.Errorf("invalid migration version in %s: %d", file, m.Version)
286286- }
287287- if m.Name == "" {
288288- return nil, fmt.Errorf("missing migration name in %s", file)
289289- }
290290- if m.Up == "" {
291291- return nil, fmt.Errorf("missing migration 'up' SQL in %s", file)
295295+ if m.Query == "" {
296296+ return nil, fmt.Errorf("missing migration 'query' in %s", file)
292297 }
293298294299 migrations = append(migrations, m)
···296301297302 return migrations, nil
298303}
304304+305305+// parseMigrationFilename extracts version and name from migration filename
306306+// Expected format: 0001_migration_name.yaml
307307+// Returns: version (int), name (string), error
308308+// Note: Glob pattern ensures format is valid, so minimal validation needed
309309+func parseMigrationFilename(filename string) (int, string, error) {
310310+ // Remove extension (.yaml or .yml)
311311+ ext := filepath.Ext(filename)
312312+ fileNameWithoutExt := filename[:len(filename)-len(ext)]
313313+314314+ // First 4 characters are the version (glob guarantees they're digits)
315315+ version, _ := strconv.Atoi(fileNameWithoutExt[:4])
316316+317317+ // Remainder after position 5 is the name (glob guarantees it exists)
318318+ name := strings.ReplaceAll(fileNameWithoutExt[5:], "_", " ")
319319+ name = strings.TrimSpace(name)
320320+321321+ return version, name, nil
322322+}