···6426425. Update `.env.example` with new driver's env vars
643643644644**Working with the database**:
645645-- Schema defined in `pkg/appview/db/schema.go`
646646-- Queries in `pkg/appview/db/queries.go`
647647-- Stores for OAuth, devices, sessions in separate files
648648-- Run migrations automatically on startup
649649-- Database path configurable via `ATCR_UI_DATABASE_PATH` env var
645645+- **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations
646646+- **Migrations** in `pkg/appview/db/migrations/*.yaml` - only for ALTER/UPDATE/DELETE on existing databases
647647+- **Queries** in `pkg/appview/db/queries.go`
648648+- **Stores** for OAuth, devices, sessions in separate files
649649+- **Execution order**: schema.sql first, then migrations (automatically on startup)
650650+- **Database path** configurable via `ATCR_UI_DATABASE_PATH` env var
651651+- **Adding new tables**: Add to `schema.sql` only (no migration needed)
652652+- **Altering tables**: Create migration AND update `schema.sql` to keep them in sync
650653651654**Adding web UI features**:
652655- Add handler in `pkg/appview/handlers/`
···11-description: Add hold_captain_records table for caching hold security settings
22-query: |
33- CREATE TABLE IF NOT EXISTS hold_captain_records (
44- hold_did TEXT PRIMARY KEY,
55- owner_did TEXT NOT NULL,
66- public BOOLEAN NOT NULL,
77- allow_all_crew BOOLEAN NOT NULL,
88- deployed_at TEXT,
99- region TEXT,
1010- provider TEXT,
1111- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1212- );
1313- CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
···11-description: Add crew cache tables for authorization with exponential backoff
22-query: |
33- CREATE TABLE IF NOT EXISTS hold_crew_approvals (
44- hold_did TEXT NOT NULL,
55- user_did TEXT NOT NULL,
66- approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
77- expires_at TIMESTAMP NOT NULL,
88- PRIMARY KEY(hold_did, user_did)
99- );
1010- CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
1111-1212- CREATE TABLE IF NOT EXISTS hold_crew_denials (
1313- hold_did TEXT NOT NULL,
1414- user_did TEXT NOT NULL,
1515- denial_count INTEGER NOT NULL DEFAULT 1,
1616- next_retry_at TIMESTAMP NOT NULL,
1717- last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
1818- PRIMARY KEY(hold_did, user_did)
1919- );
2020- CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
···11+description: Add readme_url column to manifests table (idempotent - handles both fresh and existing databases)
22+query: |
33+ -- Idempotent migration: adds readme_url column if it doesn't exist
44+ -- Works for both fresh installs (where schema.sql created it) and existing databases
55+66+ -- Create temp table with new schema
77+ CREATE TABLE manifests_temp (
88+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99+ did TEXT NOT NULL,
1010+ repository TEXT NOT NULL,
1111+ digest TEXT NOT NULL,
1212+ hold_endpoint TEXT NOT NULL,
1313+ schema_version INTEGER NOT NULL,
1414+ media_type TEXT NOT NULL,
1515+ config_digest TEXT,
1616+ config_size INTEGER,
1717+ created_at TIMESTAMP NOT NULL,
1818+ title TEXT,
1919+ description TEXT,
2020+ source_url TEXT,
2121+ documentation_url TEXT,
2222+ licenses TEXT,
2323+ icon_url TEXT,
2424+ readme_url TEXT,
2525+ UNIQUE(did, repository, digest),
2626+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
2727+ );
2828+2929+ -- Copy data from existing manifests table
3030+ -- Use INSERT OR IGNORE to handle case where table is already correct
3131+ INSERT OR IGNORE INTO manifests_temp
3232+ SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type,
3333+ config_digest, config_size, created_at, title, description, source_url,
3434+ documentation_url, licenses, icon_url,
3535+ NULL as readme_url -- Will be NULL for existing data
3636+ FROM manifests;
3737+3838+ -- Only proceed with table swap if we actually copied data
3939+ -- (manifests_temp will be empty if manifests table already has readme_url)
4040+ DROP TABLE IF EXISTS manifests;
4141+ ALTER TABLE manifests_temp RENAME TO manifests;
4242+4343+ -- Recreate indexes
4444+ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
4545+ CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
4646+ CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
···11-description: Add manifest_references table for multi-arch manifest support
22-query: |
33- CREATE TABLE IF NOT EXISTS manifest_references (
44- manifest_id INTEGER NOT NULL,
55- digest TEXT NOT NULL,
66- media_type TEXT NOT NULL,
77- size INTEGER NOT NULL,
88- platform_architecture TEXT,
99- platform_os TEXT,
1010- platform_variant TEXT,
1111- platform_os_version TEXT,
1212- reference_index INTEGER NOT NULL,
1313- PRIMARY KEY(manifest_id, reference_index),
1414- FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
1515- );
1616- CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
···2233This directory contains database migrations for the ATCR AppView database.
4455+## Schema vs Migrations
66+77+**`schema.sql`** (in parent directory) contains the **complete base schema** for fresh database installations. It includes all tables, indexes, and constraints.
88+99+**Migrations** (this directory) handle **changes to existing databases**. They are only for:
1010+- `ALTER TABLE` statements (add/modify/drop columns)
1111+- `UPDATE` statements (data transformations)
1212+- `DELETE` statements (data cleanup)
1313+- Creating/modifying indexes on existing tables
1414+1515+**NEW TABLES go in `schema.sql`, NOT in migrations.**
1616+517## Migration Format
618719Each migration is a YAML file with the following structure:
···33452. **Create a new YAML file** with format `000N_descriptive_name.yaml`
34463. **Add description** (optional) - Explain what the migration does
35474. **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`)
4848+5. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency
37493850## Examples
39514040-### Simple single-statement migration:
5252+### Adding a column to existing table:
41534242-Filename: `0002_add_repository_description_index.yaml`
5454+Filename: `0007_add_readme_url_to_manifests.yaml`
43554456```yaml
4545-description: Add index on manifests description field for faster searches
5757+description: Add readme_url column to manifests table for storing io.atcr.readme annotation
4658query: |
4747- CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description);
5959+ ALTER TABLE manifests ADD COLUMN readme_url TEXT;
4860```
49615050-### Complex multi-statement migration:
6262+**IMPORTANT:** After creating this migration, also add the column to `schema.sql` so fresh installations include it!
6363+6464+### Data transformation migration:
51655252-Filename: `0003_create_webhooks_table.yaml`
6666+Filename: `0005_normalize_hold_endpoint_to_did.yaml`
53675468```yaml
5555-description: Create webhooks table for repository event notifications
6969+description: Normalize hold_endpoint column to store DIDs instead of URLs
5670query: |
5757- -- Create webhooks table
5858- CREATE TABLE IF NOT EXISTS webhooks (
5959- id INTEGER PRIMARY KEY AUTOINCREMENT,
6060- url TEXT NOT NULL,
6161- events TEXT NOT NULL,
6262- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
6363- );
7171+ -- Convert HTTPS URLs to did:web: format
7272+ UPDATE manifests
7373+ SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
7474+ WHERE hold_endpoint LIKE 'https://%';
7575+7676+ -- Convert HTTP URLs to did:web: format
7777+ UPDATE manifests
7878+ SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
7979+ WHERE hold_endpoint LIKE 'http://%';
8080+```
8181+8282+### Adding an index to existing table:
64836565- -- Create index on URL for faster lookups
6666- CREATE INDEX IF NOT EXISTS idx_webhooks_url ON webhooks(url);
8484+Filename: `0008_add_repository_description_index.yaml`
67856868- -- Create index on events for filtering
6969- CREATE INDEX IF NOT EXISTS idx_webhooks_events ON webhooks(events);
8686+```yaml
8787+description: Add index on manifests description field for faster searches
8888+query: |
8989+ CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description);
7090```
71917292## How Migrations Run
···82102- **Never modify existing migrations** - Once applied, they're immutable
83103- **Test migrations** before committing - Ensure they work on existing databases
84104- **Version numbers must be unique** - The migration system will fail if duplicates exist
8585-- **Migrations are run automatically** on `InitDB()` - No manual intervention needed
105105+- **Migrations run automatically** on `InitDB()` - Schema first, then migrations
106106+- **CRITICAL: Update `schema.sql` for structural changes** - When you ALTER a table or add columns, update both the migration AND `schema.sql` so fresh installations have the same structure
107107+- **New tables go in `schema.sql` only** - Don't create migration files for new tables
+2
pkg/appview/db/models.go
···2929 DocumentationURL string
3030 Licenses string
3131 IconURL string
3232+ ReadmeURL string
3233}
33343435// Layer represents a layer in a manifest
···9495 DocumentationURL string
9596 Licenses string
9697 IconURL string
9898+ ReadmeURL string
9799}
9810099101// RepositoryStats represents statistics for a repository
···2626 }
27272828 // Test 1: No manifests - should return empty strings
2929- title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
2929+ title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
3030 if err != nil {
3131 t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
3232 }
3333- if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
3333+ if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" {
3434 t.Error("Expected all empty strings for nonexistent repository")
3535 }
3636···4747 }
48484949 // Test 3: Retrieve metadata
5050- title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
5050+ title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
5151 if err != nil {
5252 t.Fatalf("Failed to get repository metadata: %v", err)
5353 }
···8484 }
85858686 // Test 5: Should return metadata from most recent manifest
8787- title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
8787+ title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
8888 if err != nil {
8989 t.Fatalf("Failed to get repository metadata: %v", err)
9090 }
···109109 }
110110111111 // Test 7: Should handle NULL fields gracefully
112112- title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
112112+ title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
113113 if err != nil {
114114 t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
115115 }
116116117117- if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
117117+ if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" {
118118 t.Error("Expected all empty strings for manifest with NULL metadata fields")
119119 }
120120}
+4-199
pkg/appview/db/schema.go
···1717//go:embed migrations/*.yaml
1818var migrationsFS embed.FS
19192020-const schema = `
2121-CREATE TABLE IF NOT EXISTS schema_migrations (
2222- version INTEGER PRIMARY KEY,
2323- applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
2424-);
2525-2626-CREATE TABLE IF NOT EXISTS users (
2727- did TEXT PRIMARY KEY,
2828- handle TEXT NOT NULL,
2929- pds_endpoint TEXT NOT NULL,
3030- avatar TEXT,
3131- last_seen TIMESTAMP NOT NULL,
3232- UNIQUE(handle)
3333-);
3434-CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
3535-3636-CREATE TABLE IF NOT EXISTS manifests (
3737- id INTEGER PRIMARY KEY AUTOINCREMENT,
3838- did TEXT NOT NULL,
3939- repository TEXT NOT NULL,
4040- digest TEXT NOT NULL,
4141- hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com)
4242- schema_version INTEGER NOT NULL,
4343- media_type TEXT NOT NULL,
4444- config_digest TEXT,
4545- config_size INTEGER,
4646- created_at TIMESTAMP NOT NULL,
4747- title TEXT,
4848- description TEXT,
4949- source_url TEXT,
5050- documentation_url TEXT,
5151- licenses TEXT,
5252- icon_url TEXT,
5353- UNIQUE(did, repository, digest),
5454- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
5555-);
5656-CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
5757-CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
5858-CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
5959-6060-CREATE TABLE IF NOT EXISTS layers (
6161- manifest_id INTEGER NOT NULL,
6262- digest TEXT NOT NULL,
6363- size INTEGER NOT NULL,
6464- media_type TEXT NOT NULL,
6565- layer_index INTEGER NOT NULL,
6666- PRIMARY KEY(manifest_id, layer_index),
6767- FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
6868-);
6969-CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
7070-7171-CREATE TABLE IF NOT EXISTS manifest_references (
7272- manifest_id INTEGER NOT NULL,
7373- digest TEXT NOT NULL,
7474- media_type TEXT NOT NULL,
7575- size INTEGER NOT NULL,
7676- platform_architecture TEXT,
7777- platform_os TEXT,
7878- platform_variant TEXT,
7979- platform_os_version TEXT,
8080- reference_index INTEGER NOT NULL,
8181- PRIMARY KEY(manifest_id, reference_index),
8282- FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
8383-);
8484-CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
8585-8686-CREATE TABLE IF NOT EXISTS tags (
8787- id INTEGER PRIMARY KEY AUTOINCREMENT,
8888- did TEXT NOT NULL,
8989- repository TEXT NOT NULL,
9090- tag TEXT NOT NULL,
9191- digest TEXT NOT NULL,
9292- created_at TIMESTAMP NOT NULL,
9393- UNIQUE(did, repository, tag),
9494- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
9595-);
9696-CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
9797-9898-CREATE TABLE IF NOT EXISTS oauth_sessions (
9999- session_key TEXT PRIMARY KEY,
100100- account_did TEXT NOT NULL,
101101- session_id TEXT NOT NULL,
102102- session_data TEXT NOT NULL,
103103- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
104104- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
105105- UNIQUE(account_did, session_id)
106106-);
107107-CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
108108-CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
109109-110110-CREATE TABLE IF NOT EXISTS oauth_auth_requests (
111111- state TEXT PRIMARY KEY,
112112- request_data TEXT NOT NULL,
113113- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
114114-);
115115-CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
116116-117117-CREATE TABLE IF NOT EXISTS ui_sessions (
118118- id TEXT PRIMARY KEY,
119119- did TEXT NOT NULL,
120120- handle TEXT NOT NULL,
121121- pds_endpoint TEXT NOT NULL,
122122- oauth_session_id TEXT,
123123- expires_at TIMESTAMP NOT NULL,
124124- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
125125- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
126126-);
127127-CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
128128-CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
129129-130130-CREATE TABLE IF NOT EXISTS devices (
131131- id TEXT PRIMARY KEY,
132132- did TEXT NOT NULL,
133133- handle TEXT NOT NULL,
134134- name TEXT NOT NULL,
135135- secret_hash TEXT NOT NULL UNIQUE,
136136- ip_address TEXT,
137137- location TEXT,
138138- user_agent TEXT,
139139- created_at TIMESTAMP NOT NULL,
140140- last_used TIMESTAMP,
141141- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
142142-);
143143-CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
144144-CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
145145-146146-CREATE TABLE IF NOT EXISTS pending_device_auth (
147147- device_code TEXT PRIMARY KEY,
148148- user_code TEXT NOT NULL UNIQUE,
149149- device_name TEXT NOT NULL,
150150- ip_address TEXT,
151151- user_agent TEXT,
152152- expires_at TIMESTAMP NOT NULL,
153153- approved_did TEXT,
154154- approved_at TIMESTAMP,
155155- device_secret TEXT,
156156- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
157157-);
158158-CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
159159-CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
160160-161161-CREATE TABLE IF NOT EXISTS repository_stats (
162162- did TEXT NOT NULL,
163163- repository TEXT NOT NULL,
164164- pull_count INTEGER NOT NULL DEFAULT 0,
165165- last_pull TIMESTAMP,
166166- push_count INTEGER NOT NULL DEFAULT 0,
167167- last_push TIMESTAMP,
168168- PRIMARY KEY(did, repository),
169169- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
170170-);
171171-CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
172172-CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
173173-174174-CREATE TABLE IF NOT EXISTS stars (
175175- starrer_did TEXT NOT NULL,
176176- owner_did TEXT NOT NULL,
177177- repository TEXT NOT NULL,
178178- created_at TIMESTAMP NOT NULL,
179179- PRIMARY KEY(starrer_did, owner_did, repository),
180180- FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE,
181181- FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE
182182-);
183183-CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository);
184184-CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did);
185185-186186-CREATE TABLE IF NOT EXISTS hold_captain_records (
187187- hold_did TEXT PRIMARY KEY,
188188- owner_did TEXT NOT NULL,
189189- public BOOLEAN NOT NULL,
190190- allow_all_crew BOOLEAN NOT NULL,
191191- deployed_at TEXT,
192192- region TEXT,
193193- provider TEXT,
194194- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
195195-);
196196-CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
197197-198198-CREATE TABLE IF NOT EXISTS hold_crew_approvals (
199199- hold_did TEXT NOT NULL,
200200- user_did TEXT NOT NULL,
201201- approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
202202- expires_at TIMESTAMP NOT NULL,
203203- PRIMARY KEY(hold_did, user_did)
204204-);
205205-CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
206206-207207-CREATE TABLE IF NOT EXISTS hold_crew_denials (
208208- hold_did TEXT NOT NULL,
209209- user_did TEXT NOT NULL,
210210- denial_count INTEGER NOT NULL DEFAULT 1,
211211- next_retry_at TIMESTAMP NOT NULL,
212212- last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
213213- PRIMARY KEY(hold_did, user_did)
214214-);
215215-CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
216216-`
2020+//go:embed schema.sql
2121+var schemaSQL string
2172221823// InitDB initializes the SQLite database with the schema
21924func InitDB(path string) (*sql.DB, error) {
···22732 return nil, err
22833 }
22934230230- // Create schema
231231- if _, err := db.Exec(schema); err != nil {
3535+ // Create schema from embedded SQL file
3636+ if _, err := db.Exec(schemaSQL); err != nil {
23237 return nil, err
23338 }
23439
+207
pkg/appview/db/schema.sql
···11+-- ATCR AppView Database Schema
22+-- This file contains the complete base schema for fresh database installations.
33+-- Migrations (in migrations/*.yaml) handle changes to existing databases.
44+55+CREATE TABLE IF NOT EXISTS schema_migrations (
66+ version INTEGER PRIMARY KEY,
77+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
88+);
99+1010+CREATE TABLE IF NOT EXISTS users (
1111+ did TEXT PRIMARY KEY,
1212+ handle TEXT NOT NULL,
1313+ pds_endpoint TEXT NOT NULL,
1414+ avatar TEXT,
1515+ last_seen TIMESTAMP NOT NULL,
1616+ UNIQUE(handle)
1717+);
1818+CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
1919+2020+CREATE TABLE IF NOT EXISTS manifests (
2121+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2222+ did TEXT NOT NULL,
2323+ repository TEXT NOT NULL,
2424+ digest TEXT NOT NULL,
2525+ hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com)
2626+ schema_version INTEGER NOT NULL,
2727+ media_type TEXT NOT NULL,
2828+ config_digest TEXT,
2929+ config_size INTEGER,
3030+ created_at TIMESTAMP NOT NULL,
3131+ title TEXT,
3232+ description TEXT,
3333+ source_url TEXT,
3434+ documentation_url TEXT,
3535+ licenses TEXT,
3636+ icon_url TEXT,
3737+ readme_url TEXT,
3838+ UNIQUE(did, repository, digest),
3939+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
4040+);
4141+CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
4242+CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
4343+CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
4444+4545+CREATE TABLE IF NOT EXISTS layers (
4646+ manifest_id INTEGER NOT NULL,
4747+ digest TEXT NOT NULL,
4848+ size INTEGER NOT NULL,
4949+ media_type TEXT NOT NULL,
5050+ layer_index INTEGER NOT NULL,
5151+ PRIMARY KEY(manifest_id, layer_index),
5252+ FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
5353+);
5454+CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
5555+5656+CREATE TABLE IF NOT EXISTS manifest_references (
5757+ manifest_id INTEGER NOT NULL,
5858+ digest TEXT NOT NULL,
5959+ media_type TEXT NOT NULL,
6060+ size INTEGER NOT NULL,
6161+ platform_architecture TEXT,
6262+ platform_os TEXT,
6363+ platform_variant TEXT,
6464+ platform_os_version TEXT,
6565+ reference_index INTEGER NOT NULL,
6666+ PRIMARY KEY(manifest_id, reference_index),
6767+ FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
6868+);
6969+CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
7070+7171+CREATE TABLE IF NOT EXISTS tags (
7272+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7373+ did TEXT NOT NULL,
7474+ repository TEXT NOT NULL,
7575+ tag TEXT NOT NULL,
7676+ digest TEXT NOT NULL,
7777+ created_at TIMESTAMP NOT NULL,
7878+ UNIQUE(did, repository, tag),
7979+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
8080+);
8181+CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
8282+8383+CREATE TABLE IF NOT EXISTS oauth_sessions (
8484+ session_key TEXT PRIMARY KEY,
8585+ account_did TEXT NOT NULL,
8686+ session_id TEXT NOT NULL,
8787+ session_data TEXT NOT NULL,
8888+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8989+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
9090+ UNIQUE(account_did, session_id)
9191+);
9292+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
9393+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
9494+9595+CREATE TABLE IF NOT EXISTS oauth_auth_requests (
9696+ state TEXT PRIMARY KEY,
9797+ request_data TEXT NOT NULL,
9898+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
9999+);
100100+CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
101101+102102+CREATE TABLE IF NOT EXISTS ui_sessions (
103103+ id TEXT PRIMARY KEY,
104104+ did TEXT NOT NULL,
105105+ handle TEXT NOT NULL,
106106+ pds_endpoint TEXT NOT NULL,
107107+ oauth_session_id TEXT,
108108+ expires_at TIMESTAMP NOT NULL,
109109+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
110110+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
111111+);
112112+CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
113113+CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
114114+115115+CREATE TABLE IF NOT EXISTS devices (
116116+ id TEXT PRIMARY KEY,
117117+ did TEXT NOT NULL,
118118+ handle TEXT NOT NULL,
119119+ name TEXT NOT NULL,
120120+ secret_hash TEXT NOT NULL UNIQUE,
121121+ ip_address TEXT,
122122+ location TEXT,
123123+ user_agent TEXT,
124124+ created_at TIMESTAMP NOT NULL,
125125+ last_used TIMESTAMP,
126126+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
127127+);
128128+CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
129129+CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
130130+131131+CREATE TABLE IF NOT EXISTS pending_device_auth (
132132+ device_code TEXT PRIMARY KEY,
133133+ user_code TEXT NOT NULL UNIQUE,
134134+ device_name TEXT NOT NULL,
135135+ ip_address TEXT,
136136+ user_agent TEXT,
137137+ expires_at TIMESTAMP NOT NULL,
138138+ approved_did TEXT,
139139+ approved_at TIMESTAMP,
140140+ device_secret TEXT,
141141+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
142142+);
143143+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
144144+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
145145+146146+CREATE TABLE IF NOT EXISTS repository_stats (
147147+ did TEXT NOT NULL,
148148+ repository TEXT NOT NULL,
149149+ pull_count INTEGER NOT NULL DEFAULT 0,
150150+ last_pull TIMESTAMP,
151151+ push_count INTEGER NOT NULL DEFAULT 0,
152152+ last_push TIMESTAMP,
153153+ PRIMARY KEY(did, repository),
154154+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
155155+);
156156+CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
157157+CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
158158+159159+CREATE TABLE IF NOT EXISTS stars (
160160+ starrer_did TEXT NOT NULL,
161161+ owner_did TEXT NOT NULL,
162162+ repository TEXT NOT NULL,
163163+ created_at TIMESTAMP NOT NULL,
164164+ PRIMARY KEY(starrer_did, owner_did, repository),
165165+ FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE,
166166+ FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE
167167+);
168168+CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository);
169169+CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did);
170170+171171+CREATE TABLE IF NOT EXISTS hold_captain_records (
172172+ hold_did TEXT PRIMARY KEY,
173173+ owner_did TEXT NOT NULL,
174174+ public BOOLEAN NOT NULL,
175175+ allow_all_crew BOOLEAN NOT NULL,
176176+ deployed_at TEXT,
177177+ region TEXT,
178178+ provider TEXT,
179179+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
180180+);
181181+CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
182182+183183+CREATE TABLE IF NOT EXISTS hold_crew_approvals (
184184+ hold_did TEXT NOT NULL,
185185+ user_did TEXT NOT NULL,
186186+ approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
187187+ expires_at TIMESTAMP NOT NULL,
188188+ PRIMARY KEY(hold_did, user_did)
189189+);
190190+CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
191191+192192+CREATE TABLE IF NOT EXISTS hold_crew_denials (
193193+ hold_did TEXT NOT NULL,
194194+ user_did TEXT NOT NULL,
195195+ denial_count INTEGER NOT NULL DEFAULT 1,
196196+ next_retry_at TIMESTAMP NOT NULL,
197197+ last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
198198+ PRIMARY KEY(hold_did, user_did)
199199+);
200200+CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
201201+202202+CREATE TABLE IF NOT EXISTS readme_cache (
203203+ url TEXT PRIMARY KEY,
204204+ html TEXT NOT NULL,
205205+ fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
206206+);
207207+CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);