Skip to main content

max / makenotwork

5.2 KB · 205 lines History Blame Raw
1 //! OTA release management: releases, artifacts, and app slug assignment.
2
3 use sqlx::PgPool;
4
5 use super::models::*;
6 use super::{OtaReleaseId, SyncAppId};
7 use crate::error::Result;
8
9 // ── App slug ──
10
11 /// Set the URL-friendly slug for a sync app.
12 #[tracing::instrument(skip_all)]
13 pub async fn set_app_slug(pool: &PgPool, app_id: SyncAppId, slug: &str) -> Result<()> {
14 sqlx::query("UPDATE sync_apps SET slug = $2 WHERE id = $1")
15 .bind(app_id)
16 .bind(slug)
17 .execute(pool)
18 .await?;
19
20 Ok(())
21 }
22
23 /// Look up a sync app by its slug.
24 #[tracing::instrument(skip_all)]
25 pub async fn get_app_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbSyncApp>> {
26 let app = sqlx::query_as::<_, DbSyncApp>(
27 "SELECT * FROM sync_apps WHERE slug = $1 AND is_active = true",
28 )
29 .bind(slug)
30 .fetch_optional(pool)
31 .await?;
32
33 Ok(app)
34 }
35
36 // ── Releases ──
37
38 /// Create a new OTA release for an app.
39 ///
40 /// Returns `Conflict` if a release with the same version already exists for
41 /// this app (enforced by the UNIQUE(app_id, version) constraint in migration
42 /// 033).
43 #[tracing::instrument(skip_all)]
44 pub async fn create_release(
45 pool: &PgPool,
46 app_id: SyncAppId,
47 version: &str,
48 notes: &str,
49 signature: &str,
50 ) -> Result<DbOtaRelease> {
51 let release = sqlx::query_as::<_, DbOtaRelease>(
52 r#"
53 INSERT INTO ota_releases (app_id, version, notes, signature)
54 VALUES ($1, $2, $3, $4)
55 ON CONFLICT (app_id, version) DO NOTHING
56 RETURNING *
57 "#,
58 )
59 .bind(app_id)
60 .bind(version)
61 .bind(notes)
62 .bind(signature)
63 .fetch_optional(pool)
64 .await?;
65
66 release.ok_or_else(|| {
67 crate::error::AppError::Conflict(format!(
68 "OTA release version {version} already exists for this app"
69 ))
70 })
71 }
72
73 /// List all releases for an app, newest first.
74 #[tracing::instrument(skip_all)]
75 pub async fn list_releases(pool: &PgPool, app_id: SyncAppId) -> Result<Vec<DbOtaRelease>> {
76 let releases = sqlx::query_as::<_, DbOtaRelease>(
77 "SELECT * FROM ota_releases WHERE app_id = $1 ORDER BY pub_date DESC LIMIT 100",
78 )
79 .bind(app_id)
80 .fetch_all(pool)
81 .await?;
82
83 Ok(releases)
84 }
85
86 /// Get the latest release for an app by semantic version (highest version wins).
87 ///
88 /// Falls back to pub_date ordering if version parts aren't numeric.
89 #[tracing::instrument(skip_all)]
90 pub async fn get_latest_release(
91 pool: &PgPool,
92 app_id: SyncAppId,
93 ) -> Result<Option<DbOtaRelease>> {
94 let release = sqlx::query_as::<_, DbOtaRelease>(
95 r#"
96 SELECT * FROM ota_releases WHERE app_id = $1
97 ORDER BY
98 CASE WHEN split_part(version, '-', 1) ~ '^\d+(\.\d+)*$'
99 THEN (string_to_array(split_part(version, '-', 1), '.'))::int[]
100 ELSE ARRAY[0]
101 END DESC,
102 pub_date DESC
103 LIMIT 1
104 "#,
105 )
106 .bind(app_id)
107 .fetch_optional(pool)
108 .await?;
109
110 Ok(release)
111 }
112
113 /// Delete a release (cascades to artifacts).
114 #[tracing::instrument(skip_all)]
115 pub async fn delete_release(pool: &PgPool, release_id: OtaReleaseId) -> Result<bool> {
116 let result = sqlx::query("DELETE FROM ota_releases WHERE id = $1")
117 .bind(release_id)
118 .execute(pool)
119 .await?;
120
121 Ok(result.rows_affected() > 0)
122 }
123
124 /// Get artifact S3 keys for a release, verifying it belongs to the given app.
125 /// Returns None if the release doesn't exist or doesn't belong to the app.
126 #[tracing::instrument(skip_all)]
127 pub async fn get_release_artifact_keys(
128 pool: &PgPool,
129 app_id: SyncAppId,
130 release_id: OtaReleaseId,
131 ) -> Result<Option<Vec<String>>> {
132 // Verify release belongs to app
133 let exists: bool = sqlx::query_scalar(
134 "SELECT EXISTS(SELECT 1 FROM ota_releases WHERE id = $1 AND app_id = $2)",
135 )
136 .bind(release_id)
137 .bind(app_id)
138 .fetch_one(pool)
139 .await?;
140
141 if !exists {
142 return Ok(None);
143 }
144
145 let keys: Vec<String> = sqlx::query_scalar(
146 "SELECT s3_key FROM ota_artifacts WHERE release_id = $1",
147 )
148 .bind(release_id)
149 .fetch_all(pool)
150 .await?;
151
152 Ok(Some(keys))
153 }
154
155 // ── Artifacts ──
156
157 /// Create an artifact record for a release.
158 #[tracing::instrument(skip_all)]
159 pub async fn create_artifact(
160 pool: &PgPool,
161 release_id: OtaReleaseId,
162 target: &str,
163 arch: &str,
164 s3_key: &str,
165 file_size: i64,
166 ) -> Result<DbOtaArtifact> {
167 let artifact = sqlx::query_as::<_, DbOtaArtifact>(
168 r#"
169 INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size)
170 VALUES ($1, $2, $3, $4, $5)
171 RETURNING *
172 "#,
173 )
174 .bind(release_id)
175 .bind(target)
176 .bind(arch)
177 .bind(s3_key)
178 .bind(file_size)
179 .fetch_one(pool)
180 .await?;
181
182 Ok(artifact)
183 }
184
185 /// Get an artifact by release, target, and arch.
186 #[tracing::instrument(skip_all)]
187 pub async fn get_artifact(
188 pool: &PgPool,
189 release_id: OtaReleaseId,
190 target: &str,
191 arch: &str,
192 ) -> Result<Option<DbOtaArtifact>> {
193 let artifact = sqlx::query_as::<_, DbOtaArtifact>(
194 "SELECT * FROM ota_artifacts WHERE release_id = $1 AND target = $2 AND arch = $3",
195 )
196 .bind(release_id)
197 .bind(target)
198 .bind(arch)
199 .fetch_optional(pool)
200 .await?;
201
202 Ok(artifact)
203 }
204
205