feat: implement database and storage abstractions
- Added `internal/db/store.go` to define the Store interface for database operations, including user, organization, app, channel, release, patch, and artifact management. - Introduced `internal/models/models.go` changes to reflect updated organization and artifact response structures. - Created `internal/storage/factory.go`, `local.go`, and `s3.go` to implement storage backends for local filesystem and S3-compatible services. - Removed deprecated `internal/storage/storage.go` and refactored storage interface into `internal/storage/store.go`. - Updated frontend JavaScript in `web/js/app.js` to display server health information, including storage and database backend details.
This commit is contained in:
+87
-200
@@ -3,7 +3,6 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -11,13 +10,13 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DB wraps the PostgreSQL connection pool with query methods.
|
||||
type DB struct {
|
||||
// PgStore implements Store backed by PostgreSQL.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new database connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
// NewPostgresStore creates a new PostgreSQL-backed Store.
|
||||
func NewPostgresStore(ctx context.Context, databaseURL string) (*PgStore, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse database URL: %w", err)
|
||||
@@ -32,40 +31,37 @@ func New(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
return &PgStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection pool.
|
||||
func (db *DB) Close() {
|
||||
func (db *PgStore) Close() {
|
||||
db.pool.Close()
|
||||
}
|
||||
|
||||
// Pool returns the underlying connection pool.
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
|
||||
// Exec is a convenience wrapper for pool.Exec.
|
||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||
func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||
return db.pool.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// QueryRow is a convenience wrapper for pool.QueryRow.
|
||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
func (db *PgStore) queryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return db.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// Query is a convenience wrapper for pool.Query.
|
||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
func (db *PgStore) query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
return db.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// --- User queries ---
|
||||
|
||||
// CreateUser creates a new user and returns the ID.
|
||||
func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
|
||||
func (db *PgStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
|
||||
var id int
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`,
|
||||
email, name, passwordHash,
|
||||
).Scan(&id)
|
||||
@@ -73,9 +69,9 @@ func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string)
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email.
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||
func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||
row := &UserRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt)
|
||||
@@ -86,9 +82,9 @@ func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID.
|
||||
func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||
func (db *PgStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||
row := &UserRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt)
|
||||
@@ -98,21 +94,12 @@ func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// UserRow represents a user row from the database.
|
||||
type UserRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Name string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Organization queries ---
|
||||
|
||||
// CreateOrganization creates a new organization.
|
||||
func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
|
||||
func (db *PgStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
|
||||
var id int
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`,
|
||||
name, orgType,
|
||||
).Scan(&id)
|
||||
@@ -120,8 +107,8 @@ func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int
|
||||
}
|
||||
|
||||
// GetOrganizationsByUserID returns all organizations a user belongs to.
|
||||
func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT o.id, o.name, o.type, om.role
|
||||
FROM organizations o
|
||||
JOIN organization_memberships om ON o.id = om.organization_id
|
||||
@@ -143,17 +130,10 @@ func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMe
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OrgMembershipRow represents an organization membership query result.
|
||||
type OrgMembershipRow struct {
|
||||
OrgID int
|
||||
OrgName string
|
||||
OrgType string
|
||||
Role string
|
||||
}
|
||||
|
||||
// AddUserToOrganization adds a user to an organization.
|
||||
func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO organization_memberships (user_id, organization_id, role)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
userID, orgID, role)
|
||||
@@ -163,9 +143,9 @@ func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role
|
||||
// --- App queries ---
|
||||
|
||||
// CreateApp creates a new app and returns it.
|
||||
func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
|
||||
func (db *PgStore) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
|
||||
row := &AppRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO apps (organization_id, display_name) VALUES ($1, $2)
|
||||
RETURNING id, organization_id, display_name, created_at, updated_at`,
|
||||
orgID, displayName,
|
||||
@@ -177,8 +157,8 @@ func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*Ap
|
||||
}
|
||||
|
||||
// GetAppsByOrganization returns all apps for an organization.
|
||||
func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT id, organization_id, display_name, created_at, updated_at
|
||||
FROM apps WHERE organization_id = $1 ORDER BY display_name`, orgID)
|
||||
if err != nil {
|
||||
@@ -198,9 +178,9 @@ func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, e
|
||||
}
|
||||
|
||||
// GetAppByID retrieves a single app by ID.
|
||||
func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
|
||||
func (db *PgStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
|
||||
row := &AppRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = $1`,
|
||||
appID,
|
||||
).Scan(&row.ID, &row.OrganizationID, &row.DisplayName, &row.CreatedAt, &row.UpdatedAt)
|
||||
@@ -211,7 +191,7 @@ func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error)
|
||||
}
|
||||
|
||||
// GetAppByIDString retrieves a single app by its string ID.
|
||||
func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
|
||||
func (db *PgStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
|
||||
id, err := uuid.Parse(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid app_id: %w", err)
|
||||
@@ -220,14 +200,14 @@ func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, erro
|
||||
}
|
||||
|
||||
// DeleteApp deletes an app by ID.
|
||||
func (db *DB) DeleteApp(ctx context.Context, appID uuid.UUID) error {
|
||||
_, err := db.Exec(ctx, `DELETE FROM apps WHERE id = $1`, appID)
|
||||
func (db *PgStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
|
||||
_, err := db.exec(ctx, `DELETE FROM apps WHERE id = $1`, appID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAppsByUserID returns all apps the user has access to via org memberships.
|
||||
func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at
|
||||
FROM apps a
|
||||
JOIN organization_memberships om ON a.organization_id = om.organization_id
|
||||
@@ -249,21 +229,13 @@ func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AppRow represents an app row from the database.
|
||||
type AppRow struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID int
|
||||
DisplayName string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Channel queries ---
|
||||
|
||||
// CreateChannel creates a new channel for an app.
|
||||
func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
func (db *PgStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
row := &ChannelRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO channels (app_id, name) VALUES ($1, $2)
|
||||
RETURNING id, app_id, name, created_at`,
|
||||
appID, name,
|
||||
@@ -275,8 +247,8 @@ func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (
|
||||
}
|
||||
|
||||
// GetChannelsByAppID returns all channels for an app.
|
||||
func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 ORDER BY name`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -295,9 +267,9 @@ func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]Channe
|
||||
}
|
||||
|
||||
// GetChannelByAppIDAndName retrieves a specific channel.
|
||||
func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
func (db *PgStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
row := &ChannelRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 AND name = $2`,
|
||||
appID, name,
|
||||
).Scan(&row.ID, &row.AppID, &row.Name, &row.CreatedAt)
|
||||
@@ -307,20 +279,13 @@ func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, nam
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ChannelRow represents a channel row from the database.
|
||||
type ChannelRow struct {
|
||||
ID int
|
||||
AppID uuid.UUID
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Release queries ---
|
||||
|
||||
// CreateRelease creates a new release.
|
||||
func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
|
||||
func (db *PgStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
|
||||
row := &ReleaseRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at`,
|
||||
@@ -333,9 +298,9 @@ func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutt
|
||||
}
|
||||
|
||||
// GetReleaseByID retrieves a release by ID.
|
||||
func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
||||
func (db *PgStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
||||
row := &ReleaseRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
|
||||
FROM releases WHERE id = $1`, releaseID,
|
||||
).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt)
|
||||
@@ -346,9 +311,9 @@ func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, e
|
||||
}
|
||||
|
||||
// GetReleaseByAppIDAndVersion retrieves a release by app and version.
|
||||
func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
|
||||
func (db *PgStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
|
||||
row := &ReleaseRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
|
||||
FROM releases WHERE app_id = $1 AND version = $2`, appID, version,
|
||||
).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt)
|
||||
@@ -359,13 +324,13 @@ func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID,
|
||||
}
|
||||
|
||||
// GetReleasesByAppID returns all releases for an app.
|
||||
func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
|
||||
func (db *PgStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
|
||||
query := `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
|
||||
FROM releases WHERE app_id = $1 ORDER BY created_at DESC`
|
||||
// Note: sideloadable filtering is done via artifact join if needed
|
||||
_ = sideloadableOnly
|
||||
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
rows, err := db.query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,8 +348,8 @@ func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloada
|
||||
}
|
||||
|
||||
// UpdateReleasePlatformStatus upserts a platform status for a release.
|
||||
func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO release_platform_statuses (release_id, platform, status, metadata)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (release_id, platform)
|
||||
@@ -394,8 +359,8 @@ func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, pl
|
||||
}
|
||||
|
||||
// GetReleasePlatformStatuses returns all platform statuses for a release.
|
||||
func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -413,25 +378,13 @@ func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (ma
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReleaseRow represents a release row from the database.
|
||||
type ReleaseRow struct {
|
||||
ID int
|
||||
AppID uuid.UUID
|
||||
Version string
|
||||
FlutterRevision string
|
||||
FlutterVersion *string
|
||||
DisplayName *string
|
||||
Notes *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Release artifact queries ---
|
||||
|
||||
// CreateReleaseArtifact creates a release artifact record.
|
||||
func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
|
||||
func (db *PgStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
|
||||
row := &ArtifactRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at`,
|
||||
@@ -444,7 +397,7 @@ func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, pl
|
||||
}
|
||||
|
||||
// GetReleaseArtifacts returns artifacts for a release, optionally filtered.
|
||||
func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
|
||||
func (db *PgStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
|
||||
query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at
|
||||
FROM release_artifacts WHERE release_id = $1`
|
||||
args := []interface{}{releaseID}
|
||||
@@ -460,7 +413,7 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
|
||||
|
||||
query += " ORDER BY arch"
|
||||
|
||||
rows, err := db.Query(ctx, query, args...)
|
||||
rows, err := db.query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -477,35 +430,22 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ArtifactRow represents a release artifact row.
|
||||
type ArtifactRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Arch string
|
||||
Platform string
|
||||
Hash string
|
||||
Size int64
|
||||
StorageKey string
|
||||
CanSideload bool
|
||||
PodfileLockHash *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Patch queries ---
|
||||
|
||||
// GetNextPatchNumber returns the next patch number for a release.
|
||||
func (db *DB) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) {
|
||||
func (db *PgStore) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) {
|
||||
var num int
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = $1`, releaseID,
|
||||
).Scan(&num)
|
||||
return num, err
|
||||
}
|
||||
|
||||
// CreatePatch creates a new patch.
|
||||
func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
|
||||
func (db *PgStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
|
||||
row := &PatchRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO patches (release_id, number, notes) VALUES ($1, $2, $3)
|
||||
RETURNING id, release_id, number, notes, created_at`,
|
||||
releaseID, number, notes,
|
||||
@@ -517,9 +457,9 @@ func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes
|
||||
}
|
||||
|
||||
// GetPatchByID retrieves a patch by ID.
|
||||
func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
||||
func (db *PgStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
||||
row := &PatchRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, release_id, number, notes, created_at FROM patches WHERE id = $1`, patchID,
|
||||
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
|
||||
if err != nil {
|
||||
@@ -529,9 +469,9 @@ func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error)
|
||||
}
|
||||
|
||||
// GetLatestPatchForRelease returns the latest patch for a release (by number).
|
||||
func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
||||
func (db *PgStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
||||
row := &PatchRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT id, release_id, number, notes, created_at FROM patches
|
||||
WHERE release_id = $1 ORDER BY number DESC LIMIT 1`, releaseID,
|
||||
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
|
||||
@@ -542,8 +482,8 @@ func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*Pat
|
||||
}
|
||||
|
||||
// GetPatchesByReleaseID returns all patches for a release.
|
||||
func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at,
|
||||
COALESCE(pc.channel_id, 0) as channel_id, COALESCE(pc.promoted_at, p.created_at) as promoted_at
|
||||
FROM patches p
|
||||
@@ -567,17 +507,17 @@ func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]Patch
|
||||
}
|
||||
|
||||
// PromotePatch promotes a patch to a channel.
|
||||
func (db *DB) PromotePatch(ctx context.Context, patchID, channelID int) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) PromotePatch(ctx context.Context, patchID, channelID int) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO patch_channels (patch_id, channel_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
patchID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLatestPromotedPatch returns the latest patch promoted to a specific channel for a release.
|
||||
func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
|
||||
func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
|
||||
row := &PatchWithArtifactRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at,
|
||||
pa.hash, pa.storage_key, pa.hash_signature
|
||||
FROM patches p
|
||||
@@ -594,44 +534,12 @@ func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID i
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// PatchRow represents a patch row.
|
||||
type PatchRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PatchWithChannelRow includes channel promotion info.
|
||||
type PatchWithChannelRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt time.Time
|
||||
ChannelID int
|
||||
PromotedAt time.Time
|
||||
}
|
||||
|
||||
// PatchWithArtifactRow includes artifact info for patch check.
|
||||
type PatchWithArtifactRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt time.Time
|
||||
Hash string
|
||||
StorageKey string
|
||||
HashSignature *string
|
||||
}
|
||||
|
||||
// --- Patch artifact queries ---
|
||||
|
||||
// CreatePatchArtifact creates a patch artifact record.
|
||||
func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) {
|
||||
func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) {
|
||||
row := &PatchArtifactRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, created_at`,
|
||||
@@ -643,25 +551,12 @@ func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platfo
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// PatchArtifactRow represents a patch artifact row.
|
||||
type PatchArtifactRow struct {
|
||||
ID int
|
||||
PatchID int
|
||||
Arch string
|
||||
Platform string
|
||||
Hash string
|
||||
Size int64
|
||||
StorageKey string
|
||||
HashSignature *string
|
||||
PodfileLockHash *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Patch events ---
|
||||
|
||||
// InsertPatchEvent records a patch lifecycle event.
|
||||
func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
appID, clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message)
|
||||
@@ -671,16 +566,16 @@ func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, a
|
||||
// --- Rolled back patches ---
|
||||
|
||||
// RollbackPatch marks a patch as rolled back.
|
||||
func (db *DB) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO rolled_back_patches (release_id, patch_number) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
releaseID, patchNumber)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRolledBackPatchNumbers returns all rolled-back patch numbers for a release.
|
||||
func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -701,17 +596,17 @@ func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]i
|
||||
// --- Targeted device patching ---
|
||||
|
||||
// AddPatchTargetDevice adds a device to a patch's target list.
|
||||
func (db *DB) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
|
||||
_, err := db.Exec(ctx,
|
||||
func (db *PgStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
|
||||
_, err := db.exec(ctx,
|
||||
`INSERT INTO patch_target_devices (patch_id, client_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
patchID, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsPatchTargetedToDevice checks if a patch is targeted to a specific device.
|
||||
func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) {
|
||||
func (db *PgStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1 AND client_id = $2`,
|
||||
patchID, clientID,
|
||||
).Scan(&count)
|
||||
@@ -719,8 +614,8 @@ func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID
|
||||
}
|
||||
|
||||
// GetPatchTargetDevices returns all targeted devices for a patch.
|
||||
func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT client_id FROM patch_target_devices WHERE patch_id = $1`, patchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -739,9 +634,9 @@ func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string,
|
||||
}
|
||||
|
||||
// HasTargetDevices checks if a patch has any targeted device restrictions.
|
||||
func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
||||
func (db *PgStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow(ctx,
|
||||
err := db.queryRow(ctx,
|
||||
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID,
|
||||
).Scan(&count)
|
||||
return count > 0, err
|
||||
@@ -750,8 +645,8 @@ func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
||||
// --- Admin queries ---
|
||||
|
||||
// ListAllUsers returns all registered users (admin endpoint).
|
||||
func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
||||
rows, err := db.Query(ctx,
|
||||
func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
||||
rows, err := db.query(ctx,
|
||||
`SELECT u.id, u.email, u.name, u.created_at,
|
||||
COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles
|
||||
FROM users u
|
||||
@@ -774,11 +669,3 @@ func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UserListRow represents a user with role info for admin listing.
|
||||
type UserListRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
Role string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewStore creates the appropriate database backend based on the driver name.
|
||||
// Supported drivers: "sqlite" (default), "postgres".
|
||||
func NewStore(ctx context.Context, driver, dsn string) (Store, error) {
|
||||
switch driver {
|
||||
case "postgres", "postgresql", "pg":
|
||||
return NewPostgresStore(ctx, dsn)
|
||||
default:
|
||||
if driver != "" && driver != "sqlite" {
|
||||
fmt.Printf("db: unknown driver %q, falling back to sqlite\n", driver)
|
||||
}
|
||||
return NewSqliteStore(dsn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// SqliteStore implements Store backed by a local SQLite database.
|
||||
type SqliteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSqliteStore opens (or creates) a SQLite database at the given path.
|
||||
func NewSqliteStore(path string) (*SqliteStore, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("sqlite: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sqlite: open: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
store := &SqliteStore{db: db}
|
||||
if err := store.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("sqlite: migrate: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *SqliteStore) Close() { s.db.Close() }
|
||||
|
||||
// --- Users ---
|
||||
func (s *SqliteStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING id`,
|
||||
email, name, passwordHash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
func (s *SqliteStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||
r := &UserRow{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?`, email).
|
||||
Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||
r := &UserRow{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?`, id).
|
||||
Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT u.id, u.email, u.name, u.created_at, COALESCE(GROUP_CONCAT(DISTINCT om.role), '') FROM users u LEFT JOIN organization_memberships om ON u.id = om.user_id GROUP BY u.id ORDER BY u.created_at DESC`)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []UserListRow
|
||||
for rows.Next() {
|
||||
var r UserListRow
|
||||
if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role); err != nil { return nil, err }
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Organizations ---
|
||||
func (s *SqliteStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO organizations (name, type) VALUES (?, ?) RETURNING id`, name, orgType).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
func (s *SqliteStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT o.id, o.name, o.type, om.role FROM organizations o JOIN organization_memberships om ON o.id = om.organization_id WHERE om.user_id = ? ORDER BY o.name`, userID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []OrgMembershipRow
|
||||
for rows.Next() {
|
||||
var r OrgMembershipRow
|
||||
if err := rows.Scan(&r.OrgID, &r.OrgName, &r.OrgType, &r.Role); err != nil { return nil, err }
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *SqliteStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO organization_memberships (user_id, organization_id, role) VALUES (?, ?, ?)`, userID, orgID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Apps ---
|
||||
// rfcNow returns the current UTC time in RFC3339Nano format for SQLite storage.
|
||||
func rfcNow() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func (s *SqliteStore) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
|
||||
id := uuid.New().String()
|
||||
now := rfcNow()
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO apps (id, organization_id, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, id, orgID, displayName, now, now)
|
||||
if err != nil { return nil, err }
|
||||
uid, _ := uuid.Parse(id)
|
||||
t := time.Now().UTC()
|
||||
return &AppRow{ID: uid, OrganizationID: orgID, DisplayName: displayName, CreatedAt: ShorebirdTime{Time: t}, UpdatedAt: ShorebirdTime{Time: t}}, nil
|
||||
}
|
||||
func (s *SqliteStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE organization_id = ? ORDER BY display_name`, orgID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
return scanApps(rows)
|
||||
}
|
||||
func (s *SqliteStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
|
||||
r := &AppRow{}
|
||||
var idStr string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = ?`, appID.String()).
|
||||
Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil { return nil, err }
|
||||
r.ID, _ = uuid.Parse(idStr)
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
|
||||
id, err := uuid.Parse(appID)
|
||||
if err != nil { return nil, fmt.Errorf("invalid app_id: %w", err) }
|
||||
return s.GetAppByID(ctx, id)
|
||||
}
|
||||
func (s *SqliteStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, appID.String())
|
||||
return err
|
||||
}
|
||||
func (s *SqliteStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at FROM apps a JOIN organization_memberships om ON a.organization_id = om.organization_id WHERE om.user_id = ? ORDER BY a.display_name`, userID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
return scanApps(rows)
|
||||
}
|
||||
func scanApps(rows *sql.Rows) ([]AppRow, error) {
|
||||
var result []AppRow
|
||||
for rows.Next() {
|
||||
var r AppRow
|
||||
var idStr string
|
||||
if err := rows.Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err }
|
||||
r.ID, _ = uuid.Parse(idStr)
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Channels ---
|
||||
func (s *SqliteStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
r := &ChannelRow{AppID: appID, Name: name}
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO channels (app_id, name) VALUES (?, ?) RETURNING id, created_at`, appID.String(), name).Scan(&r.ID, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? ORDER BY name`, appID.String())
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []ChannelRow
|
||||
for rows.Next() {
|
||||
var r ChannelRow; var aid string
|
||||
if err := rows.Scan(&r.ID, &aid, &r.Name, &r.CreatedAt); err != nil { return nil, err }
|
||||
r.AppID, _ = uuid.Parse(aid); result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
r := &ChannelRow{}
|
||||
var aid string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? AND name = ?`, appID.String(), name).Scan(&r.ID, &aid, &r.Name, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
r.AppID, _ = uuid.Parse(aid)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Releases ---
|
||||
func (s *SqliteStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
|
||||
r := &ReleaseRow{AppID: appID, Version: version, FlutterRevision: flutterRevision, FlutterVersion: flutterVersion, DisplayName: displayName}
|
||||
var fv, dn interface{}; if flutterVersion != nil { fv = *flutterVersion }; if displayName != nil { dn = *displayName }
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name) VALUES (?, ?, ?, ?, ?) RETURNING id, notes, created_at, updated_at`, appID.String(), version, flutterRevision, fv, dn).Scan(&r.ID, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
||||
r := &ReleaseRow{}
|
||||
var aid string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE id = ?`, releaseID).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil { return nil, err }
|
||||
r.AppID, _ = uuid.Parse(aid)
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
|
||||
r := &ReleaseRow{}
|
||||
var aid string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? AND version = ?`, appID.String(), version).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil { return nil, err }
|
||||
r.AppID, _ = uuid.Parse(aid)
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
|
||||
_ = sideloadableOnly
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? ORDER BY created_at DESC`, appID.String())
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []ReleaseRow
|
||||
for rows.Next() {
|
||||
var r ReleaseRow; var aid string
|
||||
if err := rows.Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err }
|
||||
r.AppID, _ = uuid.Parse(aid); result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *SqliteStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
|
||||
metaJSON := "{}"
|
||||
if metadata != nil { b, _ := json.Marshal(metadata); metaJSON = string(b) }
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO release_platform_statuses (release_id, platform, status, metadata, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(release_id, platform) DO UPDATE SET status=excluded.status, metadata=excluded.metadata, updated_at=excluded.updated_at`, releaseID, platform, status, metaJSON, rfcNow())
|
||||
return err
|
||||
}
|
||||
func (s *SqliteStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT platform, status FROM release_platform_statuses WHERE release_id = ?`, releaseID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
result := make(map[string]string)
|
||||
for rows.Next() { var p, st string; if err := rows.Scan(&p, &st); err != nil { return nil, err }; result[p] = st }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Release Artifacts ---
|
||||
func (s *SqliteStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
|
||||
r := &ArtifactRow{ReleaseID: releaseID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, CanSideload: canSideload, PodfileLockHash: podfileLockHash}
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, releaseID, arch, platform, hash, size, storageKey, canSideload, podfileLockHash).Scan(&r.ID, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
|
||||
query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at FROM release_artifacts WHERE release_id = ?`
|
||||
args := []interface{}{releaseID}
|
||||
if arch != nil { query += " AND arch = ?"; args = append(args, *arch) }
|
||||
if platform != nil { query += " AND platform = ?"; args = append(args, *platform) }
|
||||
query += " ORDER BY arch"
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []ArtifactRow
|
||||
for rows.Next() {
|
||||
var r ArtifactRow
|
||||
if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Arch, &r.Platform, &r.Hash, &r.Size, &r.StorageKey, &r.CanSideload, &r.PodfileLockHash, &r.CreatedAt); err != nil { return nil, err }
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Patches ---
|
||||
func (s *SqliteStore) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) {
|
||||
var num int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = ?`, releaseID).Scan(&num)
|
||||
return num, err
|
||||
}
|
||||
func (s *SqliteStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
|
||||
r := &PatchRow{ReleaseID: releaseID, Number: number, Notes: notes}
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO patches (release_id, number, notes) VALUES (?, ?, ?) RETURNING id, created_at`, releaseID, number, notes).Scan(&r.ID, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
||||
r := &PatchRow{}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE id = ?`, patchID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
||||
r := &PatchRow{}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE release_id = ? ORDER BY number DESC LIMIT 1`, releaseID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
func (s *SqliteStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, COALESCE(pc.channel_id, 0), COALESCE(pc.promoted_at, p.created_at) FROM patches p LEFT JOIN patch_channels pc ON p.id = pc.patch_id WHERE p.release_id = ? ORDER BY p.number DESC`, releaseID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []PatchWithChannelRow
|
||||
for rows.Next() {
|
||||
var r PatchWithChannelRow
|
||||
if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.ChannelID, &r.PromotedAt); err != nil { return nil, err }
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *SqliteStore) PromotePatch(ctx context.Context, patchID, channelID int) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO patch_channels (patch_id, channel_id) VALUES (?, ?)`, patchID, channelID)
|
||||
return err
|
||||
}
|
||||
func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
|
||||
r := &PatchWithArtifactRow{}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id LEFT JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND pc.channel_id = ? ORDER BY p.number DESC LIMIT 1`, releaseID, channelID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Patch Artifacts ---
|
||||
func (s *SqliteStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) {
|
||||
r := &PatchArtifactRow{PatchID: patchID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, HashSignature: hashSignature, PodfileLockHash: podfileLockHash}
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash).Scan(&r.ID, &r.CreatedAt)
|
||||
if err != nil { return nil, err }
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Patch Events ---
|
||||
func (s *SqliteStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appID.String(), clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Rollbacks ---
|
||||
func (s *SqliteStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO rolled_back_patches (release_id, patch_number) VALUES (?, ?)`, releaseID, patchNumber)
|
||||
return err
|
||||
}
|
||||
func (s *SqliteStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT patch_number FROM rolled_back_patches WHERE release_id = ?`, releaseID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []int
|
||||
for rows.Next() { var n int; if err := rows.Scan(&n); err != nil { return nil, err }; result = append(result, n) }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Targeted Devices ---
|
||||
func (s *SqliteStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO patch_target_devices (patch_id, client_id) VALUES (?, ?)`, patchID, clientID)
|
||||
return err
|
||||
}
|
||||
func (s *SqliteStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) {
|
||||
var c int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = ? AND client_id = ?`, patchID, clientID).Scan(&c)
|
||||
return c > 0, err
|
||||
}
|
||||
func (s *SqliteStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT client_id FROM patch_target_devices WHERE patch_id = ?`, patchID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var result []string
|
||||
for rows.Next() { var cid string; if err := rows.Scan(&cid); err != nil { return nil, err }; result = append(result, cid) }
|
||||
return result, nil
|
||||
}
|
||||
func (s *SqliteStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
||||
var c int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = ?`, patchID).Scan(&c)
|
||||
return c > 0, err
|
||||
}
|
||||
|
||||
// --- Migration ---
|
||||
func (s *SqliteStore) migrate(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS organizations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'team', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS organization_memberships (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(user_id, organization_id));
|
||||
CREATE TABLE IF NOT EXISTS apps (id TEXT PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, display_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS channels (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(app_id, name));
|
||||
CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version TEXT NOT NULL, flutter_revision TEXT NOT NULL, flutter_version TEXT, display_name TEXT, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS release_platform_statuses (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, platform TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, platform));
|
||||
CREATE TABLE IF NOT EXISTS release_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, can_sideload INTEGER NOT NULL DEFAULT 0, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, number INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, number));
|
||||
CREATE TABLE IF NOT EXISTS patch_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, hash_signature TEXT, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS patch_channels (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, promoted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(patch_id, channel_id));
|
||||
CREATE TABLE IF NOT EXISTS patch_events (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, client_id TEXT NOT NULL, arch TEXT NOT NULL, patch_number INTEGER NOT NULL, platform TEXT NOT NULL, release_version TEXT NOT NULL, event_type TEXT NOT NULL, timestamp INTEGER NOT NULL, message TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
|
||||
CREATE TABLE IF NOT EXISTS rolled_back_patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, patch_number INTEGER NOT NULL, rolled_back_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, patch_number));
|
||||
CREATE TABLE IF NOT EXISTS patch_target_devices (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, client_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(patch_id, client_id));
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_app_id ON releases(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_app_version ON releases(app_id, version);
|
||||
CREATE INDEX IF NOT EXISTS idx_patches_release_id ON patches(release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_channels_channel ON patch_channels(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_channels_patch ON patch_channels(patch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_events_app ON patch_events(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_events_client ON patch_events(app_id, client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rolled_back_release ON rolled_back_patches(release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_target_devices_patch ON patch_target_devices(patch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patch_target_devices_client ON patch_target_devices(patch_id, client_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ShorebirdTime wraps time.Time with sql.Scanner support for both
|
||||
// PostgreSQL (native time.Time) and SQLite (RFC3339Nano TEXT).
|
||||
type ShorebirdTime struct{ time.Time }
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (st *ShorebirdTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
st.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
st.Time = val
|
||||
return nil
|
||||
case string:
|
||||
t, err := time.Parse(time.RFC3339Nano, val)
|
||||
if err != nil {
|
||||
t, err = time.Parse("2006-01-02 15:04:05", val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ShorebirdTime: cannot parse %q", val)
|
||||
}
|
||||
}
|
||||
st.Time = t
|
||||
return nil
|
||||
case []byte:
|
||||
return st.Scan(string(val))
|
||||
default:
|
||||
return fmt.Errorf("ShorebirdTime: unsupported type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer for SQLite INSERTs.
|
||||
func (st ShorebirdTime) Value() (interface{}, error) {
|
||||
if st.Time.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return st.Time.Format(time.RFC3339Nano), nil
|
||||
}
|
||||
|
||||
// NullShorebirdTime is a nullable variant.
|
||||
type NullShorebirdTime struct {
|
||||
Time ShorebirdTime
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner for nullable time.
|
||||
func (nst *NullShorebirdTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
nst.Time = ShorebirdTime{}
|
||||
nst.Valid = false
|
||||
return nil
|
||||
}
|
||||
nst.Valid = true
|
||||
return nst.Time.Scan(v)
|
||||
}
|
||||
|
||||
// Ensure unused import warnings are suppressed.
|
||||
var _ sql.Scanner = (*ShorebirdTime)(nil)
|
||||
|
||||
// Store is the database abstraction. Both PostgreSQL and SQLite
|
||||
// backends implement this interface.
|
||||
type Store interface {
|
||||
Close()
|
||||
|
||||
// --- Users ---
|
||||
CreateUser(ctx context.Context, email, name, passwordHash string) (int, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRow, error)
|
||||
GetUserByID(ctx context.Context, id int) (*UserRow, error)
|
||||
ListAllUsers(ctx context.Context) ([]UserListRow, error)
|
||||
|
||||
// --- Organizations ---
|
||||
CreateOrganization(ctx context.Context, name, orgType string) (int, error)
|
||||
GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error)
|
||||
AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error
|
||||
|
||||
// --- Apps ---
|
||||
CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error)
|
||||
GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error)
|
||||
GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error)
|
||||
GetAppByIDString(ctx context.Context, appID string) (*AppRow, error)
|
||||
DeleteApp(ctx context.Context, appID uuid.UUID) error
|
||||
GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error)
|
||||
|
||||
// --- Channels ---
|
||||
CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error)
|
||||
GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error)
|
||||
GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error)
|
||||
|
||||
// --- Releases ---
|
||||
CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error)
|
||||
GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error)
|
||||
GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error)
|
||||
GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error)
|
||||
UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error
|
||||
GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error)
|
||||
|
||||
// --- Release Artifacts ---
|
||||
CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error)
|
||||
GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error)
|
||||
|
||||
// --- Patches ---
|
||||
GetNextPatchNumber(ctx context.Context, releaseID int) (int, error)
|
||||
CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error)
|
||||
GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error)
|
||||
GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error)
|
||||
GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error)
|
||||
PromotePatch(ctx context.Context, patchID, channelID int) error
|
||||
GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error)
|
||||
|
||||
// --- Patch Artifacts ---
|
||||
CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error)
|
||||
|
||||
// --- Patch Events ---
|
||||
InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error
|
||||
|
||||
// --- Rollbacks ---
|
||||
RollbackPatch(ctx context.Context, releaseID, patchNumber int) error
|
||||
GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error)
|
||||
|
||||
// --- Targeted Devices ---
|
||||
AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error
|
||||
IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error)
|
||||
GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error)
|
||||
HasTargetDevices(ctx context.Context, patchID int) (bool, error)
|
||||
}
|
||||
|
||||
// --- Shared row types (used by both backends) ---
|
||||
|
||||
// UserRow represents a user row from the database.
|
||||
type UserRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Name string
|
||||
PasswordHash string
|
||||
CreatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// OrgMembershipRow represents an organization membership query result.
|
||||
type OrgMembershipRow struct {
|
||||
OrgID int
|
||||
OrgName string
|
||||
OrgType string
|
||||
Role string
|
||||
}
|
||||
|
||||
// AppRow represents an app row.
|
||||
type AppRow struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID int
|
||||
DisplayName string
|
||||
CreatedAt ShorebirdTime
|
||||
UpdatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// ChannelRow represents a channel row.
|
||||
type ChannelRow struct {
|
||||
ID int
|
||||
AppID uuid.UUID
|
||||
Name string
|
||||
CreatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// ReleaseRow represents a release row.
|
||||
type ReleaseRow struct {
|
||||
ID int
|
||||
AppID uuid.UUID
|
||||
Version string
|
||||
FlutterRevision string
|
||||
FlutterVersion *string
|
||||
DisplayName *string
|
||||
Notes *string
|
||||
CreatedAt ShorebirdTime
|
||||
UpdatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// ArtifactRow represents a release artifact row.
|
||||
type ArtifactRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Arch string
|
||||
Platform string
|
||||
Hash string
|
||||
Size int64
|
||||
StorageKey string
|
||||
CanSideload bool
|
||||
PodfileLockHash *string
|
||||
CreatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// PatchRow represents a patch row.
|
||||
type PatchRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// PatchWithChannelRow includes channel promotion info.
|
||||
type PatchWithChannelRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt ShorebirdTime
|
||||
ChannelID int
|
||||
PromotedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// PatchWithArtifactRow includes artifact info for patch check.
|
||||
type PatchWithArtifactRow struct {
|
||||
ID int
|
||||
ReleaseID int
|
||||
Number int
|
||||
Notes *string
|
||||
CreatedAt ShorebirdTime
|
||||
Hash string
|
||||
StorageKey string
|
||||
HashSignature *string
|
||||
}
|
||||
|
||||
// PatchArtifactRow represents a patch artifact row.
|
||||
type PatchArtifactRow struct {
|
||||
ID int
|
||||
PatchID int
|
||||
Arch string
|
||||
Platform string
|
||||
Hash string
|
||||
Size int64
|
||||
StorageKey string
|
||||
HashSignature *string
|
||||
PodfileLockHash *string
|
||||
CreatedAt ShorebirdTime
|
||||
}
|
||||
|
||||
// UserListRow is used by the admin user listing endpoint.
|
||||
type UserListRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt ShorebirdTime
|
||||
Role string
|
||||
}
|
||||
Reference in New Issue
Block a user