// Package database handles SQLite database operations package database import ( "context" "database/sql" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) // DB wraps the database connection with business logic type DB struct { *sql.DB } // Developer represents a developer account type Developer struct { ID string Email string Name string PasswordHash string OAuthProvider string OAuthID string AvatarURL string Verified bool CreatedAt time.Time UpdatedAt time.Time } // NewDB creates a new DB wrapper func NewDB(db *sql.DB) *DB { return &DB{db} } // Open opens the SQLite database with WAL mode enabled func Open(path string) (*sql.DB, error) { // Ensure directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("create database directory: %w", err) } // Open database with WAL mode and busy timeout dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // Test connection if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("ping database: %w", err) } // Set connection pool settings for SQLite db.SetMaxOpenConns(1) // SQLite single writer db.SetMaxIdleConns(1) return db, nil } // Migrate runs all database migrations func Migrate(db *sql.DB) error { migrations := []string{ migrationDevelopers, migrationAPIKeys, migrationApps, migrationAppVersions, migrationSigningKeys, migrationTelemetry, migrationAuditLogs, migrationIndexes, } for i, migration := range migrations { if _, err := db.Exec(migration); err != nil { return fmt.Errorf("migration %d: %w", i+1, err) } } return nil } const migrationDevelopers = ` CREATE TABLE IF NOT EXISTS developers ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, password_hash TEXT, oauth_provider TEXT, oauth_id TEXT, verified INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); ` const migrationAPIKeys = ` CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, name TEXT NOT NULL, key_hash TEXT NOT NULL, key_prefix TEXT NOT NULL, permissions TEXT DEFAULT '[]', last_used_at TEXT, expires_at TEXT, created_at TEXT DEFAULT (datetime('now')) ); ` const migrationApps = ` CREATE TABLE IF NOT EXISTS apps ( id TEXT PRIMARY KEY, developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, package_id TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, category TEXT, tags TEXT DEFAULT '[]', status TEXT DEFAULT 'draft', created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); ` const migrationAppVersions = ` CREATE TABLE IF NOT EXISTS app_versions ( id TEXT PRIMARY KEY, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version_code INTEGER NOT NULL, version_name TEXT NOT NULL, package_url TEXT NOT NULL, package_size INTEGER NOT NULL, signature TEXT NOT NULL, permissions TEXT DEFAULT '[]', min_mosis_version TEXT, release_notes TEXT, status TEXT DEFAULT 'draft', review_notes TEXT, published_at TEXT, created_at TEXT DEFAULT (datetime('now')), UNIQUE(app_id, version_code) ); ` const migrationSigningKeys = ` CREATE TABLE IF NOT EXISTS signing_keys ( id TEXT PRIMARY KEY, developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, name TEXT NOT NULL, public_key TEXT NOT NULL, fingerprint TEXT NOT NULL, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); ` const migrationTelemetry = ` CREATE TABLE IF NOT EXISTS telemetry_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, device_id TEXT NOT NULL, event_type TEXT NOT NULL, event_data TEXT, mosis_version TEXT, timestamp TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS crash_reports ( id TEXT PRIMARY KEY, app_id TEXT NOT NULL, app_version TEXT NOT NULL, device_id TEXT NOT NULL, crash_type TEXT NOT NULL, message TEXT, stack_trace TEXT, context TEXT, mosis_version TEXT, timestamp TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS telemetry_daily ( app_id TEXT NOT NULL, date TEXT NOT NULL, event_type TEXT NOT NULL, count INTEGER NOT NULL, unique_devices INTEGER NOT NULL, PRIMARY KEY (app_id, date, event_type) ); ` const migrationAuditLogs = ` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, developer_id TEXT, action TEXT NOT NULL, resource_type TEXT, resource_id TEXT, details TEXT, ip_address TEXT, user_agent TEXT, created_at TEXT DEFAULT (datetime('now')) ); ` const migrationIndexes = ` CREATE INDEX IF NOT EXISTS idx_developers_email ON developers(email); CREATE INDEX IF NOT EXISTS idx_developers_oauth ON developers(oauth_provider, oauth_id); CREATE INDEX IF NOT EXISTS idx_api_keys_developer ON api_keys(developer_id); CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix); CREATE INDEX IF NOT EXISTS idx_apps_developer ON apps(developer_id); CREATE INDEX IF NOT EXISTS idx_apps_package ON apps(package_id); CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status); CREATE INDEX IF NOT EXISTS idx_versions_app ON app_versions(app_id); CREATE INDEX IF NOT EXISTS idx_versions_status ON app_versions(status); CREATE INDEX IF NOT EXISTS idx_signing_keys_developer ON signing_keys(developer_id); CREATE INDEX IF NOT EXISTS idx_signing_keys_fingerprint ON signing_keys(fingerprint); CREATE INDEX IF NOT EXISTS idx_telemetry_app ON telemetry_events(app_id, timestamp); CREATE INDEX IF NOT EXISTS idx_crashes_app ON crash_reports(app_id, timestamp); CREATE INDEX IF NOT EXISTS idx_audit_developer ON audit_logs(developer_id); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); ` // FindOrCreateDeveloper finds an existing developer by email or creates a new one func (db *DB) FindOrCreateDeveloper(ctx context.Context, dev *Developer) (*Developer, error) { // First try to find by email existing, err := db.GetDeveloperByEmail(ctx, dev.Email) if err == nil { // Update OAuth info if changed if dev.OAuthProvider != "" && (existing.OAuthProvider != dev.OAuthProvider || existing.OAuthID != dev.OAuthID) { _, err := db.ExecContext(ctx, ` UPDATE developers SET oauth_provider = ?, oauth_id = ?, updated_at = datetime('now') WHERE id = ? `, dev.OAuthProvider, dev.OAuthID, existing.ID) if err != nil { return nil, fmt.Errorf("update oauth: %w", err) } existing.OAuthProvider = dev.OAuthProvider existing.OAuthID = dev.OAuthID } return existing, nil } // Create new developer dev.ID = uuid.New().String() _, err = db.ExecContext(ctx, ` INSERT INTO developers (id, email, name, oauth_provider, oauth_id, verified) VALUES (?, ?, ?, ?, ?, 1) `, dev.ID, dev.Email, dev.Name, dev.OAuthProvider, dev.OAuthID) if err != nil { return nil, fmt.Errorf("create developer: %w", err) } dev.Verified = true dev.CreatedAt = time.Now() dev.UpdatedAt = dev.CreatedAt return dev, nil } // GetDeveloper retrieves a developer by ID func (db *DB) GetDeveloper(ctx context.Context, id string) (*Developer, error) { row := db.QueryRowContext(ctx, ` SELECT id, email, name, password_hash, oauth_provider, oauth_id, verified, created_at, updated_at FROM developers WHERE id = ? `, id) return scanDeveloper(row) } // GetDeveloperByEmail retrieves a developer by email func (db *DB) GetDeveloperByEmail(ctx context.Context, email string) (*Developer, error) { row := db.QueryRowContext(ctx, ` SELECT id, email, name, password_hash, oauth_provider, oauth_id, verified, created_at, updated_at FROM developers WHERE email = ? `, email) return scanDeveloper(row) } func scanDeveloper(row *sql.Row) (*Developer, error) { var dev Developer var passwordHash, oauthProvider, oauthID sql.NullString var createdAt, updatedAt string err := row.Scan(&dev.ID, &dev.Email, &dev.Name, &passwordHash, &oauthProvider, &oauthID, &dev.Verified, &createdAt, &updatedAt) if err != nil { return nil, err } dev.PasswordHash = passwordHash.String dev.OAuthProvider = oauthProvider.String dev.OAuthID = oauthID.String dev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) dev.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) return &dev, nil } // ValidateAPIKey validates an API key and returns the associated developer func (db *DB) ValidateAPIKey(ctx context.Context, key string) (*Developer, error) { // Extract prefix (first 15 chars: mk_live_xxxxxxx) if len(key) < 15 { return nil, fmt.Errorf("invalid key format") } prefix := key[:15] // Find key by prefix row := db.QueryRowContext(ctx, ` SELECT k.key_hash, k.developer_id, k.expires_at FROM api_keys k WHERE k.key_prefix = ? `, prefix) var keyHash, developerID string var expiresAt sql.NullString if err := row.Scan(&keyHash, &developerID, &expiresAt); err != nil { return nil, fmt.Errorf("key not found") } // Check expiration if expiresAt.Valid { expiry, err := time.Parse("2006-01-02 15:04:05", expiresAt.String) if err == nil && time.Now().After(expiry) { return nil, fmt.Errorf("key expired") } } // Verify key hash if err := bcrypt.CompareHashAndPassword([]byte(keyHash), []byte(key)); err != nil { return nil, fmt.Errorf("invalid key") } // Update last used db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = datetime('now') WHERE key_prefix = ?`, prefix) // Get developer return db.GetDeveloper(ctx, developerID) } // LogAudit logs an audit event func (db *DB) LogAudit(ctx context.Context, developerID, action, ipAddress, userAgent string, success bool, failureReason string) { details := "" if !success { details = fmt.Sprintf(`{"success":false,"reason":"%s"}`, failureReason) } else { details = `{"success":true}` } db.ExecContext(ctx, ` INSERT INTO audit_logs (developer_id, action, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?) `, developerID, action, details, ipAddress, userAgent) } // App represents an app in the database type App struct { ID string `json:"id"` DeveloperID string `json:"developer_id"` PackageID string `json:"package_id"` Name string `json:"name"` Description string `json:"description,omitempty"` Category string `json:"category,omitempty"` Tags []string `json:"tags"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // AppVersion represents an app version in the database type AppVersion struct { ID string `json:"id"` AppID string `json:"app_id"` VersionCode int `json:"version_code"` VersionName string `json:"version_name"` PackageURL string `json:"package_url,omitempty"` PackageSize int64 `json:"package_size,omitempty"` Signature string `json:"signature,omitempty"` Permissions []string `json:"permissions"` MinMosisVersion string `json:"min_mosis_version,omitempty"` ReleaseNotes string `json:"release_notes,omitempty"` Status string `json:"status"` ReviewNotes string `json:"review_notes,omitempty"` PublishedAt *time.Time `json:"published_at,omitempty"` CreatedAt time.Time `json:"created_at"` } // CreateApp creates a new app func (db *DB) CreateApp(ctx context.Context, app *App) (*App, error) { app.ID = uuid.New().String() app.Tags = []string{} app.CreatedAt = time.Now() app.UpdatedAt = app.CreatedAt _, err := db.ExecContext(ctx, ` INSERT INTO apps (id, developer_id, package_id, name, description, category, tags, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, '[]', ?, datetime('now'), datetime('now')) `, app.ID, app.DeveloperID, app.PackageID, app.Name, app.Description, app.Category, app.Status) if err != nil { return nil, err } return app, nil } // GetApp retrieves an app by ID func (db *DB) GetApp(ctx context.Context, id string) (*App, error) { row := db.QueryRowContext(ctx, ` SELECT id, developer_id, package_id, name, description, category, tags, status, created_at, updated_at FROM apps WHERE id = ? `, id) return scanApp(row) } // GetAppByPackageID retrieves an app by package ID func (db *DB) GetAppByPackageID(ctx context.Context, packageID string) (*App, error) { row := db.QueryRowContext(ctx, ` SELECT id, developer_id, package_id, name, description, category, tags, status, created_at, updated_at FROM apps WHERE package_id = ? `, packageID) return scanApp(row) } func scanApp(row *sql.Row) (*App, error) { var app App var desc, cat, tagsJSON sql.NullString var createdAt, updatedAt string err := row.Scan(&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &desc, &cat, &tagsJSON, &app.Status, &createdAt, &updatedAt) if err != nil { return nil, err } app.Description = desc.String app.Category = cat.String app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) // Parse tags JSON app.Tags = []string{} if tagsJSON.Valid && tagsJSON.String != "" { json.Unmarshal([]byte(tagsJSON.String), &app.Tags) } return &app, nil } // ListApps lists apps for a developer func (db *DB) ListApps(ctx context.Context, developerID, status string, page, limit int) ([]*App, int, error) { offset := (page - 1) * limit // Build query query := "SELECT id, developer_id, package_id, name, description, category, tags, status, created_at, updated_at FROM apps WHERE developer_id = ?" countQuery := "SELECT COUNT(*) FROM apps WHERE developer_id = ?" args := []interface{}{developerID} countArgs := []interface{}{developerID} if status != "" { query += " AND status = ?" countQuery += " AND status = ?" args = append(args, status) countArgs = append(countArgs, status) } query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) // Get total count var total int db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total) // Get apps rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var apps []*App for rows.Next() { var app App var desc, cat, tagsJSON sql.NullString var createdAt, updatedAt string err := rows.Scan(&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &desc, &cat, &tagsJSON, &app.Status, &createdAt, &updatedAt) if err != nil { continue } app.Description = desc.String app.Category = cat.String app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) app.Tags = []string{} if tagsJSON.Valid && tagsJSON.String != "" { json.Unmarshal([]byte(tagsJSON.String), &app.Tags) } apps = append(apps, &app) } return apps, total, nil } // UpdateApp updates an app func (db *DB) UpdateApp(ctx context.Context, app *App) error { tagsJSON, _ := json.Marshal(app.Tags) _, err := db.ExecContext(ctx, ` UPDATE apps SET name = ?, description = ?, category = ?, tags = ?, updated_at = datetime('now') WHERE id = ? `, app.Name, app.Description, app.Category, string(tagsJSON), app.ID) return err } // UpdateAppStatus updates an app's status func (db *DB) UpdateAppStatus(ctx context.Context, id, status string) error { _, err := db.ExecContext(ctx, ` UPDATE apps SET status = ?, updated_at = datetime('now') WHERE id = ? `, status, id) return err } // DeleteApp deletes an app and its versions func (db *DB) DeleteApp(ctx context.Context, id string) error { _, err := db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, id) return err } // AppHasPublishedVersions checks if an app has any published versions func (db *DB) AppHasPublishedVersions(ctx context.Context, appID string) (bool, error) { var count int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM app_versions WHERE app_id = ? AND status = 'published' `, appID).Scan(&count) return count > 0, err } // CreateVersion creates a new app version func (db *DB) CreateVersion(ctx context.Context, version *AppVersion) (*AppVersion, error) { version.ID = uuid.New().String() version.Permissions = []string{} version.CreatedAt = time.Now() _, err := db.ExecContext(ctx, ` INSERT INTO app_versions (id, app_id, version_code, version_name, package_url, package_size, signature, permissions, min_mosis_version, release_notes, status, created_at) VALUES (?, ?, ?, ?, '', 0, '', '[]', '', ?, 'draft', datetime('now')) `, version.ID, version.AppID, version.VersionCode, version.VersionName, version.ReleaseNotes) if err != nil { return nil, err } return version, nil } // GetVersion retrieves a version by ID func (db *DB) GetVersion(ctx context.Context, id string) (*AppVersion, error) { row := db.QueryRowContext(ctx, ` SELECT id, app_id, version_code, version_name, package_url, package_size, signature, permissions, min_mosis_version, release_notes, status, review_notes, published_at, created_at FROM app_versions WHERE id = ? `, id) return scanVersion(row) } func scanVersion(row *sql.Row) (*AppVersion, error) { var v AppVersion var packageURL, signature, permsJSON, minVersion, releaseNotes, reviewNotes sql.NullString var publishedAt, createdAt sql.NullString var packageSize sql.NullInt64 err := row.Scan(&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &packageURL, &packageSize, &signature, &permsJSON, &minVersion, &releaseNotes, &v.Status, &reviewNotes, &publishedAt, &createdAt) if err != nil { return nil, err } v.PackageURL = packageURL.String v.PackageSize = packageSize.Int64 v.Signature = signature.String v.MinMosisVersion = minVersion.String v.ReleaseNotes = releaseNotes.String v.ReviewNotes = reviewNotes.String v.Permissions = []string{} if permsJSON.Valid && permsJSON.String != "" { json.Unmarshal([]byte(permsJSON.String), &v.Permissions) } if createdAt.Valid { v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt.String) } if publishedAt.Valid { t, _ := time.Parse("2006-01-02 15:04:05", publishedAt.String) v.PublishedAt = &t } return &v, nil } // ListVersions lists versions for an app func (db *DB) ListVersions(ctx context.Context, appID, status string, page, limit int) ([]*AppVersion, int, error) { offset := (page - 1) * limit query := "SELECT id, app_id, version_code, version_name, package_url, package_size, signature, permissions, min_mosis_version, release_notes, status, review_notes, published_at, created_at FROM app_versions WHERE app_id = ?" countQuery := "SELECT COUNT(*) FROM app_versions WHERE app_id = ?" args := []interface{}{appID} countArgs := []interface{}{appID} if status != "" { query += " AND status = ?" countQuery += " AND status = ?" args = append(args, status) countArgs = append(countArgs, status) } query += " ORDER BY version_code DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) var total int db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total) rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var versions []*AppVersion for rows.Next() { var v AppVersion var packageURL, signature, permsJSON, minVersion, releaseNotes, reviewNotes sql.NullString var publishedAt, createdAt sql.NullString var packageSize sql.NullInt64 err := rows.Scan(&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &packageURL, &packageSize, &signature, &permsJSON, &minVersion, &releaseNotes, &v.Status, &reviewNotes, &publishedAt, &createdAt) if err != nil { continue } v.PackageURL = packageURL.String v.PackageSize = packageSize.Int64 v.Signature = signature.String v.MinMosisVersion = minVersion.String v.ReleaseNotes = releaseNotes.String v.ReviewNotes = reviewNotes.String v.Permissions = []string{} if permsJSON.Valid && permsJSON.String != "" { json.Unmarshal([]byte(permsJSON.String), &v.Permissions) } if createdAt.Valid { v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt.String) } if publishedAt.Valid { t, _ := time.Parse("2006-01-02 15:04:05", publishedAt.String) v.PublishedAt = &t } versions = append(versions, &v) } return versions, total, nil } // VersionCodeExists checks if a version code exists for an app func (db *DB) VersionCodeExists(ctx context.Context, appID string, code int) (bool, error) { var count int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM app_versions WHERE app_id = ? AND version_code = ? `, appID, code).Scan(&count) return count > 0, err } // UpdateVersionStatus updates a version's status func (db *DB) UpdateVersionStatus(ctx context.Context, id, status string) error { _, err := db.ExecContext(ctx, ` UPDATE app_versions SET status = ? WHERE id = ? `, status, id) return err } // PublishVersion publishes a version func (db *DB) PublishVersion(ctx context.Context, id string) error { _, err := db.ExecContext(ctx, ` UPDATE app_versions SET status = 'published', published_at = datetime('now') WHERE id = ? `, id) return err } // PublicApp represents a published app for the store type PublicApp struct { PackageID string `json:"package_id"` Name string `json:"name"` Description string `json:"description,omitempty"` Category string `json:"category,omitempty"` Tags []string `json:"tags"` AuthorName string `json:"author_name"` LatestVersion string `json:"latest_version"` DownloadCount int64 `json:"download_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // ListPublishedApps lists published apps for the store func (db *DB) ListPublishedApps(ctx context.Context, query, category, sort string, page, limit int) ([]*PublicApp, int, error) { offset := (page - 1) * limit // Base query baseQuery := ` SELECT a.package_id, a.name, a.description, a.category, a.tags, d.name as author_name, COALESCE(v.version_name, '') as latest_version, 0 as download_count, a.created_at, a.updated_at FROM apps a JOIN developers d ON d.id = a.developer_id LEFT JOIN app_versions v ON v.app_id = a.id AND v.status = 'published' WHERE a.status = 'published' ` countQuery := `SELECT COUNT(*) FROM apps WHERE status = 'published'` var args []interface{} var countArgs []interface{} // Search filter if query != "" { baseQuery += " AND (a.name LIKE ? OR a.description LIKE ? OR a.package_id LIKE ?)" countQuery += " AND (name LIKE ? OR description LIKE ? OR package_id LIKE ?)" searchTerm := "%" + query + "%" args = append(args, searchTerm, searchTerm, searchTerm) countArgs = append(countArgs, searchTerm, searchTerm, searchTerm) } // Category filter if category != "" { baseQuery += " AND a.category = ?" countQuery += " AND category = ?" args = append(args, category) countArgs = append(countArgs, category) } // Group by to handle multiple versions (pick latest) baseQuery += " GROUP BY a.id" // Sorting switch sort { case "name": baseQuery += " ORDER BY a.name ASC" case "recent": baseQuery += " ORDER BY a.updated_at DESC" default: // popular baseQuery += " ORDER BY a.updated_at DESC" // TODO: implement download count sorting } baseQuery += " LIMIT ? OFFSET ?" args = append(args, limit, offset) // Get total count var total int db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total) // Get apps rows, err := db.QueryContext(ctx, baseQuery, args...) if err != nil { return nil, 0, err } defer rows.Close() var apps []*PublicApp for rows.Next() { var app PublicApp var desc, cat, tagsJSON, latestVersion sql.NullString var createdAt, updatedAt string err := rows.Scan(&app.PackageID, &app.Name, &desc, &cat, &tagsJSON, &app.AuthorName, &latestVersion, &app.DownloadCount, &createdAt, &updatedAt) if err != nil { continue } app.Description = desc.String app.Category = cat.String app.LatestVersion = latestVersion.String app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) app.Tags = []string{} if tagsJSON.Valid && tagsJSON.String != "" { json.Unmarshal([]byte(tagsJSON.String), &app.Tags) } apps = append(apps, &app) } return apps, total, nil } // GetPublishedApp retrieves a published app by package ID for the store func (db *DB) GetPublishedApp(ctx context.Context, packageID string) (*PublicApp, error) { row := db.QueryRowContext(ctx, ` SELECT a.package_id, a.name, a.description, a.category, a.tags, d.name as author_name, COALESCE(v.version_name, '') as latest_version, 0 as download_count, a.created_at, a.updated_at FROM apps a JOIN developers d ON d.id = a.developer_id LEFT JOIN ( SELECT app_id, version_name FROM app_versions WHERE status = 'published' ORDER BY version_code DESC LIMIT 1 ) v ON v.app_id = a.id WHERE a.package_id = ? AND a.status = 'published' `, packageID) var app PublicApp var desc, cat, tagsJSON, latestVersion sql.NullString var createdAt, updatedAt string err := row.Scan(&app.PackageID, &app.Name, &desc, &cat, &tagsJSON, &app.AuthorName, &latestVersion, &app.DownloadCount, &createdAt, &updatedAt) if err != nil { return nil, err } app.Description = desc.String app.Category = cat.String app.LatestVersion = latestVersion.String app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) app.Tags = []string{} if tagsJSON.Valid && tagsJSON.String != "" { json.Unmarshal([]byte(tagsJSON.String), &app.Tags) } return &app, nil } // GetLatestPublishedVersion retrieves the latest published version for an app by package ID func (db *DB) GetLatestPublishedVersion(ctx context.Context, packageID string) (*AppVersion, error) { row := db.QueryRowContext(ctx, ` SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature, v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at FROM app_versions v JOIN apps a ON a.id = v.app_id WHERE a.package_id = ? AND v.status = 'published' ORDER BY v.version_code DESC LIMIT 1 `, packageID) return scanVersion(row) } // GetPublishedVersionByCode retrieves a specific published version by package ID and version code func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, versionCode int) (*AppVersion, error) { row := db.QueryRowContext(ctx, ` SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature, v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at FROM app_versions v JOIN apps a ON a.id = v.app_id WHERE a.package_id = ? AND v.version_code = ? AND v.status = 'published' `, packageID, versionCode) return scanVersion(row) }