···1-description: Normalize hold_endpoint column to store DIDs instead of URLs
2-query: |
3- -- Convert any URL-formatted hold_endpoint values to DID format
4- -- This ensures all hold identifiers are stored consistently as did:web:hostname
5-6- -- Convert HTTPS URLs to did:web: format
7- -- https://hold.example.com → did:web:hold.example.com
8- UPDATE manifests
9- SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
10- WHERE hold_endpoint LIKE 'https://%';
11-12- -- Convert HTTP URLs to did:web: format
13- -- http://172.28.0.3:8080 → did:web:172.28.0.3:8080
14- UPDATE manifests
15- SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
16- WHERE hold_endpoint LIKE 'http://%';
17-18- -- Entries already in did:web: format are left unchanged
19- -- did:web:hold.example.com → did:web:hold.example.com (no change)
···1-description: Add readme_url to manifests (obsolete - kept for migration history)
2-query: |
3- -- This migration is obsolete. The readme_url and other annotations
4- -- are now stored in the repository_annotations table (see schema.sql).
5- -- Backfill will populate annotation data from PDS records.
6- -- This migration is kept as a no-op to maintain migration history.
7- SELECT 1;
···1-description: Remove annotation columns from manifests table
2-query: |
3- -- Drop annotation columns from manifests table (if they exist)
4- -- Annotations are now stored in repository_annotations table
5- -- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table
6-7- -- Create new manifests table without annotation columns
8- CREATE TABLE IF NOT EXISTS manifests_new (
9- id INTEGER PRIMARY KEY AUTOINCREMENT,
10- did TEXT NOT NULL,
11- repository TEXT NOT NULL,
12- digest TEXT NOT NULL,
13- hold_endpoint TEXT NOT NULL,
14- schema_version INTEGER NOT NULL,
15- media_type TEXT NOT NULL,
16- config_digest TEXT,
17- config_size INTEGER,
18- created_at TIMESTAMP NOT NULL,
19- UNIQUE(did, repository, digest),
20- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
21- );
22-23- -- Copy data (only core fields, annotation columns are dropped)
24- INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at)
25- SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at
26- FROM manifests;
27-28- -- Swap tables
29- DROP TABLE manifests;
30- ALTER TABLE manifests_new RENAME TO manifests;
31-32- -- Recreate indexes
33- CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
34- CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
35- CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
···1-description: Add repo_pages table and remove readme_cache
2-query: |
3- -- Create repo_pages table for storing repository page metadata
4- -- This replaces readme_cache with PDS-synced data
5- CREATE TABLE IF NOT EXISTS repo_pages (
6- did TEXT NOT NULL,
7- repository TEXT NOT NULL,
8- description TEXT,
9- avatar_cid TEXT,
10- created_at TIMESTAMP NOT NULL,
11- updated_at TIMESTAMP NOT NULL,
12- PRIMARY KEY(did, repository),
13- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
14- );
15- CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
16-17- -- Drop readme_cache table (no longer needed)
18- DROP TABLE IF EXISTS readme_cache;
···1-description: Add artifact_type column to manifests table for Helm chart support
2-query: |
3- -- Add artifact_type column to track manifest types (container-image, helm-chart, unknown)
4- -- Default to container-image for existing manifests
5- ALTER TABLE manifests ADD COLUMN artifact_type TEXT NOT NULL DEFAULT 'container-image';
6-7- -- Add index for filtering by artifact type
8- CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
···1-description: Add hold_crew_members table for cached crew memberships from Jetstream
2-query: |
3- -- Cached hold crew memberships from Jetstream
4- -- Enables reverse lookup: "which holds is user X a member of?"
5- CREATE TABLE IF NOT EXISTS hold_crew_members (
6- hold_did TEXT NOT NULL,
7- member_did TEXT NOT NULL,
8- rkey TEXT NOT NULL,
9- role TEXT,
10- permissions TEXT, -- JSON array
11- tier TEXT,
12- added_at TEXT,
13- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
14- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
15- PRIMARY KEY (hold_did, member_did)
16- );
17- CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
18- CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
19- CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
···1+description: Drop dead annotation/platform columns from manifests table
2+query: |
3+ -- Migration 0004 was supposed to drop these columns but either failed or
4+ -- was only recorded (not executed) on production. The 11 dead columns are:
5+ -- title, description, source_url, documentation_url, licenses,
6+ -- icon_url, readme_url, platform_os, platform_architecture,
7+ -- platform_variant, platform_os_version
8+ -- Annotations now live in repository_annotations; platform info lives in
9+ -- manifest_references. Recreate the table to match schema.sql.
10+11+ -- Create the clean table (matches schema.sql exactly)
12+ CREATE TABLE IF NOT EXISTS manifests_new (
13+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14+ did TEXT NOT NULL,
15+ repository TEXT NOT NULL,
16+ digest TEXT NOT NULL,
17+ hold_endpoint TEXT NOT NULL,
18+ schema_version INTEGER NOT NULL,
19+ media_type TEXT NOT NULL,
20+ config_digest TEXT,
21+ config_size INTEGER,
22+ artifact_type TEXT NOT NULL DEFAULT 'container-image',
23+ created_at TIMESTAMP NOT NULL,
24+ UNIQUE(did, repository, digest),
25+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
26+ );
27+28+ -- Copy data (only the columns we keep)
29+ INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, artifact_type, created_at)
30+ SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, COALESCE(artifact_type, 'container-image'), created_at
31+ FROM manifests;
32+33+ -- Swap tables
34+ DROP TABLE manifests;
35+ ALTER TABLE manifests_new RENAME TO manifests;
36+37+ -- Recreate indexes (matches schema.sql)
38+ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
39+ CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
40+ CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
41+ CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
-21
pkg/appview/db/queries.go
···1551 return err
1552}
15531554-// IncrementStarCount increments the star count for a repository
1555-func IncrementStarCount(db *sql.DB, did, repository string) error {
1556- _, err := db.Exec(`
1557- INSERT INTO repository_stats (did, repository, star_count)
1558- VALUES (?, ?, 1)
1559- ON CONFLICT(did, repository) DO UPDATE SET
1560- star_count = star_count + 1
1561- `, did, repository)
1562- return err
1563-}
1564-1565-// DecrementStarCount decrements the star count for a repository
1566-func DecrementStarCount(db *sql.DB, did, repository string) error {
1567- _, err := db.Exec(`
1568- UPDATE repository_stats
1569- SET star_count = MAX(0, star_count - 1)
1570- WHERE did = ? AND repository = ?
1571- `, did, repository)
1572- return err
1573-}
1574-1575// UpsertStar inserts or updates a star record (idempotent)
1576func UpsertStar(db *sql.DB, starrerDID, ownerDID, repository string, createdAt time.Time) error {
1577 _, err := db.Exec(`
···1551 return err
1552}
15530000000000000000000001554// UpsertStar inserts or updates a star record (idempotent)
1555func UpsertStar(db *sql.DB, starrerDID, ownerDID, repository string, createdAt time.Time) error {
1556 _, err := db.Exec(`
+54-11
pkg/appview/db/schema.go
···273}
274275// splitSQLStatements splits a SQL query into individual statements.
276-// It handles semicolons as statement separators and filters out empty statements.
277func splitSQLStatements(query string) []string {
278 var statements []string
000279280- // Split on semicolons
281- for part := range strings.SplitSeq(query, ";") {
282- // Trim whitespace
283- stmt := strings.TrimSpace(part)
000000284285- // Skip empty statements (could be trailing semicolon or comment-only)
286- if stmt == "" {
000000000287 continue
288 }
289290- // Skip comment-only statements
00000000000000000000000000291 hasCode := false
292 for line := range strings.SplitSeq(stmt, "\n") {
293 trimmed := strings.TrimSpace(line)
···296 break
297 }
298 }
299-300 if hasCode {
301- statements = append(statements, stmt)
302 }
303 }
304305- return statements
306}
307308// parseMigrationFilename extracts version and name from migration filename
···273}
274275// splitSQLStatements splits a SQL query into individual statements.
276+// It splits on semicolons that are not inside -- line comments or 'string literals'.
277func splitSQLStatements(query string) []string {
278 var statements []string
279+ var current strings.Builder
280+ inLineComment := false
281+ inString := false
282283+ for i := 0; i < len(query); i++ {
284+ ch := query[i]
285+286+ if inLineComment {
287+ current.WriteByte(ch)
288+ if ch == '\n' {
289+ inLineComment = false
290+ }
291+ continue
292+ }
293294+ if inString {
295+ current.WriteByte(ch)
296+ if ch == '\'' {
297+ // Check for escaped quote ('')
298+ if i+1 < len(query) && query[i+1] == '\'' {
299+ current.WriteByte(query[i+1])
300+ i++
301+ } else {
302+ inString = false
303+ }
304+ }
305 continue
306 }
307308+ switch {
309+ case ch == '-' && i+1 < len(query) && query[i+1] == '-':
310+ inLineComment = true
311+ current.WriteByte(ch)
312+ case ch == '\'':
313+ inString = true
314+ current.WriteByte(ch)
315+ case ch == ';':
316+ // Statement boundary — flush if non-empty
317+ stmt := strings.TrimSpace(current.String())
318+ if stmt != "" {
319+ statements = append(statements, stmt)
320+ }
321+ current.Reset()
322+ default:
323+ current.WriteByte(ch)
324+ }
325+ }
326+327+ // Flush trailing statement
328+ if stmt := strings.TrimSpace(current.String()); stmt != "" {
329+ statements = append(statements, stmt)
330+ }
331+332+ // Filter out comment-only statements
333+ filtered := statements[:0]
334+ for _, stmt := range statements {
335 hasCode := false
336 for line := range strings.SplitSeq(stmt, "\n") {
337 trimmed := strings.TrimSpace(line)
···340 break
341 }
342 }
0343 if hasCode {
344+ filtered = append(filtered, stmt)
345 }
346 }
347348+ return filtered
349}
350351// parseMigrationFilename extracts version and name from migration filename