• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 26838940254

02 Jun 2026 06:08PM UTC coverage: 54.6% (-0.1%) from 54.747%
26838940254

push

github

0xmichalis
fix(server): retry Postgres connection with backoff on startup

0 of 19 new or added lines in 1 file covered. (0.0%)

355 existing lines in 1 file now uncovered.

3252 of 5956 relevant lines covered (54.6%)

10.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/server/database/mod.rs
1
use std::time::Duration;
2

3
use chrono::{DateTime, Utc};
4
use serde::{Deserialize, Serialize};
5
use sqlx::{postgres::PgPoolOptions, PgPool, Row};
6
use tracing::{info, warn};
7

8
use crate::server::database::r#trait::Database;
9
use crate::server::StorageMode;
10

11
pub mod r#trait;
12

13
#[derive(Debug, Serialize, Deserialize, Clone)]
14
pub struct BackupTask {
15
    pub task_id: String,
16
    pub created_at: DateTime<Utc>,
17
    pub updated_at: DateTime<Utc>,
18
    pub requestor: String,
19
    pub nft_count: i32,
20
    pub tokens: serde_json::Value,
21
    pub archive_status: Option<String>,
22
    pub ipfs_status: Option<String>,
23
    pub archive_error_log: Option<String>,
24
    pub ipfs_error_log: Option<String>,
25
    pub archive_fatal_error: Option<String>,
26
    pub ipfs_fatal_error: Option<String>,
27
    pub storage_mode: String,
28
    pub archive_format: Option<String>,
29
    pub expires_at: Option<DateTime<Utc>>,
30
    pub archive_deleted_at: Option<DateTime<Utc>>,
31
    pub pins_deleted_at: Option<DateTime<Utc>>,
32
}
33

34
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
35
#[schema(description = "IPFS pin information for a specific CID")]
36
pub struct PinInfo {
37
    /// Content Identifier (CID) of the pinned content
38
    #[schema(example = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")]
39
    pub cid: String,
40
    /// IPFS provider type where the content is pinned
41
    #[schema(example = "pinata")]
42
    pub provider_type: String,
43
    /// IPFS provider URL where the content is pinned
44
    #[schema(example = "https://api.pinata.cloud")]
45
    pub provider_url: String,
46
    /// Pin status (pinned, pinning, failed, queued)
47
    #[schema(example = "pinned")]
48
    pub status: String,
49
    /// When the pin was created (ISO 8601 timestamp)
50
    #[schema(example = "2024-01-01T12:00:00Z")]
51
    pub created_at: DateTime<Utc>,
52
}
53

54
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
55
#[schema(description = "Token information with associated pin requests")]
56
pub struct TokenWithPins {
57
    /// Blockchain identifier (e.g., ethereum, tezos)
58
    #[schema(example = "ethereum")]
59
    pub chain: String,
60
    /// NFT contract address
61
    #[schema(example = "0x1234567890123456789012345678901234567890")]
62
    pub contract_address: String,
63
    /// NFT token ID
64
    #[schema(example = "123")]
65
    pub token_id: String,
66
    /// List of IPFS pins for this token
67
    pub pins: Vec<PinInfo>,
68
}
69

70
#[derive(Debug, Serialize, Deserialize, Clone)]
71
pub struct PinRow {
72
    pub id: i64,
73
    pub task_id: String,
74
    pub provider_type: String,
75
    pub provider_url: Option<String>,
76
    pub cid: String,
77
    pub request_id: String,
78
    pub pin_status: String,
79
    pub created_at: DateTime<Utc>,
80
}
81

82
#[derive(Debug, Clone)]
83
pub struct ExpiredBackup {
84
    pub task_id: String,
85
    pub archive_format: String,
86
}
87

88
#[derive(Clone)]
89
pub struct Db {
90
    pub pool: PgPool,
91
}
92

93
impl Db {
94
    pub async fn new(database_url: &str, max_connections: u32) -> Self {
×
NEW
95
        let pool = Self::connect_with_retry(database_url, max_connections).await;
×
96
        tracing::info!("Postgres connection is healthy");
×
97

98
        // Apply any pending migrations so a fresh deployment self-provisions its
99
        // schema. Migrations are embedded into the binary at compile time, so this
100
        // works in the distroless runtime image without sqlx-cli or the migrations
101
        // directory present. Already-applied migrations are skipped via the
102
        // _sqlx_migrations table, so this is consistent with `sqlx migrate run`.
UNCOV
103
        sqlx::migrate!("./migrations")
×
UNCOV
104
            .run(&pool)
×
UNCOV
105
            .await
×
106
            .expect("Failed to run database migrations");
UNCOV
107
        tracing::info!("Database migrations applied");
×
108

109
        Db { pool }
110
    }
111

112
    /// Connect to Postgres, retrying with a fixed backoff to tolerate a database
113
    /// that is still starting up (e.g. `make run` brings up the DB container in
114
    /// parallel with the server). Panics once attempts are exhausted.
NEW
115
    async fn connect_with_retry(database_url: &str, max_connections: u32) -> PgPool {
×
116
        const MAX_ATTEMPTS: u32 = 10;
117
        const RETRY_DELAY: Duration = Duration::from_secs(1);
118

NEW
119
        for attempt in 1..=MAX_ATTEMPTS {
×
NEW
120
            match Self::try_connect(database_url, max_connections).await {
×
NEW
121
                Ok(pool) => return pool,
×
NEW
122
                Err(e) if attempt < MAX_ATTEMPTS => {
×
NEW
123
                    warn!(
×
NEW
124
                        "Postgres not ready (attempt {attempt}/{MAX_ATTEMPTS}): {e}. \
×
NEW
125
                         Retrying in {RETRY_DELAY:?}..."
×
126
                    );
NEW
127
                    tokio::time::sleep(RETRY_DELAY).await;
×
128
                }
NEW
129
                Err(e) => {
×
NEW
130
                    panic!("Failed to connect to Postgres after {MAX_ATTEMPTS} attempts: {e}")
×
131
                }
132
            }
133
        }
134
        unreachable!("loop either returns a pool or panics on the final attempt")
135
    }
136

137
    /// Open a pool and verify the connection is usable with a trivial query.
NEW
138
    async fn try_connect(database_url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
×
NEW
139
        let pool = PgPoolOptions::new()
×
NEW
140
            .max_connections(max_connections)
×
NEW
141
            .connect(database_url)
×
NEW
142
            .await?;
×
NEW
143
        sqlx::query("SELECT 1").execute(&pool).await?;
×
NEW
144
        Ok(pool)
×
145
    }
146

147
    #[allow(clippy::too_many_arguments)]
UNCOV
148
    pub async fn insert_backup_task(
×
149
        &self,
150
        task_id: &str,
151
        requestor: &str,
152
        nft_count: i32,
153
        tokens: &serde_json::Value,
154
        storage_mode: &str,
155
        archive_format: Option<&str>,
156
        retention_days: Option<u64>,
157
    ) -> Result<(), sqlx::Error> {
UNCOV
158
        let mut tx = self.pool.begin().await?;
×
159

160
        // Insert into backup_tasks (tokens JSON removed; tokens are stored in tokens table)
161
        sqlx::query(
162
            r#"
163
            INSERT INTO backup_tasks (
164
                task_id, created_at, updated_at, requestor, nft_count, storage_mode
165
            ) VALUES (
166
                $1, NOW(), NOW(), $2, $3, $4
167
            )
168
            ON CONFLICT (task_id) DO UPDATE SET
169
                updated_at = NOW(),
170
                nft_count = EXCLUDED.nft_count,
171
                storage_mode = EXCLUDED.storage_mode
172
            "#,
173
        )
174
        .bind(task_id)
×
175
        .bind(requestor)
×
176
        .bind(nft_count)
×
UNCOV
177
        .bind(storage_mode)
×
UNCOV
178
        .execute(&mut *tx)
×
UNCOV
179
        .await?;
×
180

181
        // Replace tokens for this task with the provided list (idempotent)
182
        // Expecting JSON shape: Vec<crate::server::api::Tokens>
183
        let token_entries: Vec<crate::server::api::Tokens> =
×
184
            serde_json::from_value(tokens.clone()).unwrap_or_default();
×
185

186
        for entry in &token_entries {
×
187
            for token_str in &entry.tokens {
×
UNCOV
188
                if let Some((contract_address, token_id)) = token_str.split_once(':') {
×
189
                    sqlx::query(
190
                        r#"INSERT INTO tokens (task_id, chain, contract_address, token_id)
191
                           VALUES ($1, $2, $3, $4)
192
                           ON CONFLICT (task_id, chain, contract_address, token_id) DO NOTHING"#,
193
                    )
194
                    .bind(task_id)
×
UNCOV
195
                    .bind(&entry.chain)
×
196
                    .bind(contract_address)
×
UNCOV
197
                    .bind(token_id)
×
UNCOV
198
                    .execute(&mut *tx)
×
UNCOV
199
                    .await?;
×
200
                }
201
            }
202
        }
203

204
        // Insert into archive_requests if storage mode includes archive
UNCOV
205
        if storage_mode == "archive" || storage_mode == "full" {
×
206
            let archive_fmt = archive_format.unwrap_or("zip");
×
207

208
            if let Some(days) = retention_days {
×
209
                sqlx::query(
210
                    r#"
211
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
212
                    VALUES ($1, $2, NOW() + make_interval(days => $3::int), 'in_progress')
213
                    ON CONFLICT (task_id) DO UPDATE SET
214
                        archive_format = EXCLUDED.archive_format,
215
                        expires_at = EXCLUDED.expires_at
216
                    "#,
217
                )
UNCOV
218
                .bind(task_id)
×
UNCOV
219
                .bind(archive_fmt)
×
UNCOV
220
                .bind(days as i64)
×
221
                .execute(&mut *tx)
×
222
                .await?;
×
223
            } else {
224
                sqlx::query(
225
                    r#"
226
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
227
                    VALUES ($1, $2, NULL, 'in_progress')
228
                    ON CONFLICT (task_id) DO UPDATE SET
229
                        archive_format = EXCLUDED.archive_format,
230
                        expires_at = EXCLUDED.expires_at
231
                    "#,
232
                )
UNCOV
233
                .bind(task_id)
×
UNCOV
234
                .bind(archive_fmt)
×
UNCOV
235
                .execute(&mut *tx)
×
UNCOV
236
                .await?;
×
237
            }
238
        }
239

240
        // Insert into pin_requests if storage mode includes IPFS
UNCOV
241
        if storage_mode == "ipfs" || storage_mode == "full" {
×
242
            sqlx::query(
243
                r#"
244
                INSERT INTO pin_requests (task_id, status)
245
                VALUES ($1, 'in_progress')
246
                ON CONFLICT (task_id) DO UPDATE SET
247
                    status = EXCLUDED.status
248
                "#,
249
            )
250
            .bind(task_id)
×
251
            .execute(&mut *tx)
×
252
            .await?;
×
253
        }
254

255
        tx.commit().await?;
×
UNCOV
256
        Ok(())
×
257
    }
258

UNCOV
259
    pub async fn delete_backup_task(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
260
        // CASCADE will delete associated archive_requests row if it exists
261
        sqlx::query!("DELETE FROM backup_tasks WHERE task_id = $1", task_id)
×
262
            .execute(&self.pool)
×
263
            .await?;
×
264
        Ok(())
×
265
    }
266

267
    pub async fn set_error_logs(
×
268
        &self,
269
        task_id: &str,
270
        archive_error_log: Option<&str>,
271
        ipfs_error_log: Option<&str>,
272
    ) -> Result<(), sqlx::Error> {
UNCOV
273
        let mut tx = self.pool.begin().await?;
×
UNCOV
274
        if let Some(a) = archive_error_log {
×
UNCOV
275
            sqlx::query("UPDATE archive_requests SET error_log = $2 WHERE task_id = $1")
×
UNCOV
276
                .bind(task_id)
×
277
                .bind(a)
×
278
                .execute(&mut *tx)
×
279
                .await?;
×
280
        }
UNCOV
281
        if let Some(i) = ipfs_error_log {
×
282
            sqlx::query(
283
                r#"
284
                    UPDATE pin_requests
285
                    SET error_log = $2
286
                    WHERE task_id = $1
287
                    "#,
288
            )
UNCOV
289
            .bind(task_id)
×
UNCOV
290
            .bind(i)
×
UNCOV
291
            .execute(&mut *tx)
×
UNCOV
292
            .await?;
×
293
        }
UNCOV
294
        tx.commit().await?;
×
UNCOV
295
        Ok(())
×
296
    }
297

298
    pub async fn update_archive_request_error_log(
×
299
        &self,
300
        task_id: &str,
301
        error_log: &str,
302
    ) -> Result<(), sqlx::Error> {
303
        sqlx::query(
304
            r#"
305
            UPDATE archive_requests
306
            SET error_log = $2
307
            WHERE task_id = $1
308
            "#,
309
        )
UNCOV
310
        .bind(task_id)
×
UNCOV
311
        .bind(error_log)
×
UNCOV
312
        .execute(&self.pool)
×
UNCOV
313
        .await?;
×
UNCOV
314
        Ok(())
×
315
    }
316

317
    pub async fn update_pin_request_error_log(
×
318
        &self,
319
        task_id: &str,
320
        error_log: &str,
321
    ) -> Result<(), sqlx::Error> {
322
        sqlx::query(
323
            r#"
324
            UPDATE pin_requests
325
            SET error_log = $2
326
            WHERE task_id = $1
327
            "#,
328
        )
329
        .bind(task_id)
×
UNCOV
330
        .bind(error_log)
×
UNCOV
331
        .execute(&self.pool)
×
UNCOV
332
        .await?;
×
333
        Ok(())
×
334
    }
335

336
    /// Log a human-friendly backup status based on a machine status value and storage mode
UNCOV
337
    fn log_status(task_id: &str, status: &str, mode: &StorageMode) {
×
UNCOV
338
        match status {
×
UNCOV
339
            "done" => info!("Backup {} ready (storage: {})", task_id, mode.as_str()),
×
UNCOV
340
            "error" => warn!("Backup {} errored (storage: {})", task_id, mode.as_str()),
×
UNCOV
341
            _ => (),
×
342
        }
343
    }
344

345
    pub async fn update_pin_request_status(
×
346
        &self,
347
        task_id: &str,
348
        status: &str,
349
    ) -> Result<(), sqlx::Error> {
350
        sqlx::query(
351
            r#"
352
            UPDATE pin_requests
353
            SET status = $2
354
            WHERE task_id = $1
355
            "#,
356
        )
UNCOV
357
        .bind(task_id)
×
UNCOV
358
        .bind(status)
×
UNCOV
359
        .execute(&self.pool)
×
UNCOV
360
        .await?;
×
361

UNCOV
362
        Self::log_status(task_id, status, &StorageMode::Ipfs);
×
363

UNCOV
364
        Ok(())
×
365
    }
366

367
    pub async fn update_archive_request_status(
×
368
        &self,
369
        task_id: &str,
370
        status: &str,
371
    ) -> Result<(), sqlx::Error> {
372
        sqlx::query(
373
            r#"
374
            UPDATE archive_requests
375
            SET status = $2
376
            WHERE task_id = $1
377
            "#,
378
        )
UNCOV
379
        .bind(task_id)
×
UNCOV
380
        .bind(status)
×
UNCOV
381
        .execute(&self.pool)
×
382
        .await?;
×
383

UNCOV
384
        Self::log_status(task_id, status, &StorageMode::Archive);
×
385

UNCOV
386
        Ok(())
×
387
    }
388

UNCOV
389
    pub async fn update_archive_request_statuses(
×
390
        &self,
391
        task_ids: &[String],
392
        status: &str,
393
    ) -> Result<(), sqlx::Error> {
394
        if task_ids.is_empty() {
×
395
            return Ok(());
×
396
        }
397

398
        // Use a transaction for atomicity
399
        let mut tx = self.pool.begin().await?;
×
400

401
        // Update each task_id individually with a prepared statement
402
        for task_id in task_ids {
×
UNCOV
403
            sqlx::query("UPDATE archive_requests SET status = $1 WHERE task_id = $2")
×
UNCOV
404
                .bind(status)
×
UNCOV
405
                .bind(task_id)
×
UNCOV
406
                .execute(&mut *tx)
×
UNCOV
407
                .await?;
×
408
        }
409

UNCOV
410
        tx.commit().await?;
×
411
        Ok(())
×
412
    }
413

UNCOV
414
    pub async fn retry_backup(
×
415
        &self,
416
        task_id: &str,
417
        scope: &str,
418
        retention_days: u64,
419
    ) -> Result<(), sqlx::Error> {
420
        let mut tx = self.pool.begin().await?;
×
421

422
        // Reset statuses per requested scope
UNCOV
423
        if scope == "archive" || scope == "full" {
×
424
            sqlx::query(
425
                r#"
426
                UPDATE archive_requests
427
                SET status = 'in_progress', fatal_error = NULL, error_log = NULL
428
                WHERE task_id = $1
429
                "#,
430
            )
431
            .bind(task_id)
×
432
            .execute(&mut *tx)
×
UNCOV
433
            .await?;
×
434
            sqlx::query(
435
                r#"
436
                UPDATE archive_requests
437
                SET expires_at = NOW() + make_interval(days => $2::int)
438
                WHERE task_id = $1
439
                "#,
440
            )
UNCOV
441
            .bind(task_id)
×
442
            .bind(retention_days as i64)
×
443
            .execute(&mut *tx)
×
444
            .await?;
×
445
        }
UNCOV
446
        if scope == "ipfs" || scope == "full" {
×
447
            sqlx::query(
448
                r#"
449
                UPDATE pin_requests
450
                SET status = 'in_progress', fatal_error = NULL, error_log = NULL
451
                WHERE task_id = $1
452
                "#,
453
            )
UNCOV
454
            .bind(task_id)
×
UNCOV
455
            .execute(&mut *tx)
×
UNCOV
456
            .await?;
×
457
        }
458

UNCOV
459
        tx.commit().await?;
×
UNCOV
460
        Ok(())
×
461
    }
462

463
    pub async fn clear_backup_errors(&self, task_id: &str, scope: &str) -> Result<(), sqlx::Error> {
×
464
        let mut tx = self.pool.begin().await?;
×
465
        // Clear archive errors if scope includes archive
466
        sqlx::query(
467
            r#"
468
            UPDATE archive_requests
469
            SET error_log = NULL, fatal_error = NULL
470
            WHERE task_id = $1 AND ($2 IN ('archive', 'full'))
471
            "#,
472
        )
473
        .bind(task_id)
×
474
        .bind(scope)
×
475
        .execute(&mut *tx)
×
476
        .await?;
×
477
        // Clear IPFS errors if scope includes ipfs
478
        sqlx::query(
479
            r#"
480
            UPDATE pin_requests
481
            SET error_log = NULL, fatal_error = NULL
482
            WHERE task_id = $1 AND ($2 IN ('ipfs', 'full'))
483
            "#,
484
        )
UNCOV
485
        .bind(task_id)
×
UNCOV
486
        .bind(scope)
×
UNCOV
487
        .execute(&mut *tx)
×
UNCOV
488
        .await?;
×
UNCOV
489
        tx.commit().await?;
×
UNCOV
490
        Ok(())
×
491
    }
492

493
    pub async fn set_archive_request_error(
×
494
        &self,
495
        task_id: &str,
496
        fatal_error: &str,
497
    ) -> Result<(), sqlx::Error> {
498
        sqlx::query(
499
            r#"
500
            UPDATE archive_requests
501
            SET status = 'error', fatal_error = $2
502
            WHERE task_id = $1
503
            "#,
504
        )
UNCOV
505
        .bind(task_id)
×
UNCOV
506
        .bind(fatal_error)
×
UNCOV
507
        .execute(&self.pool)
×
UNCOV
508
        .await?;
×
UNCOV
509
        Ok(())
×
510
    }
511

512
    pub async fn set_pin_request_error(
×
513
        &self,
514
        task_id: &str,
515
        fatal_error: &str,
516
    ) -> Result<(), sqlx::Error> {
517
        sqlx::query(
518
            r#"
519
            UPDATE pin_requests
520
            SET status = 'error', fatal_error = $2
521
            WHERE task_id = $1
522
            "#,
523
        )
524
        .bind(task_id)
×
525
        .bind(fatal_error)
×
UNCOV
526
        .execute(&self.pool)
×
527
        .await?;
×
528
        Ok(())
×
529
    }
530

531
    pub async fn start_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
UNCOV
532
        let sql = r#"
×
533
            WITH task_mode AS (
×
534
                SELECT storage_mode FROM backup_tasks WHERE task_id = $1
×
535
            ),
536
            touch AS (
×
537
                UPDATE backup_tasks SET updated_at = NOW() WHERE task_id = $1 RETURNING 1
×
538
            ),
539
            ar_inprog AS (
×
540
                SELECT 1 FROM archive_requests ar, task_mode tm
×
541
                WHERE ar.task_id = $1 AND ar.status = 'in_progress'
×
542
                  AND tm.storage_mode IN ('archive','full')
×
543
                LIMIT 1
×
544
            ),
545
            pr_inprog AS (
×
UNCOV
546
                SELECT 1 FROM pin_requests pr, task_mode tm
×
547
                WHERE pr.task_id = $1 AND pr.status = 'in_progress'
×
548
                  AND tm.storage_mode IN ('ipfs','full')
×
549
                LIMIT 1
×
550
            ),
551
            upd_archive AS (
×
552
                UPDATE archive_requests ar
×
553
                SET deleted_at = NOW()
×
UNCOV
554
                WHERE ar.task_id = $1 AND ar.deleted_at IS NULL
×
555
                  AND EXISTS (SELECT 1 FROM task_mode tm WHERE tm.storage_mode IN ('archive','full'))
×
556
                  AND NOT EXISTS (SELECT 1 FROM ar_inprog)
×
557
                RETURNING 1
×
558
            ),
559
            upd_pins AS (
×
560
                UPDATE pin_requests pr
×
561
                SET deleted_at = NOW()
×
562
                WHERE pr.task_id = $1 AND pr.deleted_at IS NULL
×
563
                  AND EXISTS (SELECT 1 FROM task_mode tm WHERE tm.storage_mode IN ('ipfs','full'))
×
564
                  AND NOT EXISTS (SELECT 1 FROM pr_inprog)
×
UNCOV
565
                RETURNING 1
×
566
            )
567
            SELECT EXISTS(SELECT 1 FROM ar_inprog) AS ar_blocked,
×
UNCOV
568
                   EXISTS(SELECT 1 FROM pr_inprog) AS pr_blocked
×
UNCOV
569
        "#;
×
570

571
        let row = sqlx::query(sql).bind(task_id).fetch_one(&self.pool).await?;
×
UNCOV
572
        let ar_blocked: bool = row.get("ar_blocked");
×
UNCOV
573
        let pr_blocked: bool = row.get("pr_blocked");
×
UNCOV
574
        if ar_blocked || pr_blocked {
×
UNCOV
575
            return Err(sqlx::Error::Protocol(
×
UNCOV
576
                "in_progress task cannot be deleted".into(),
×
577
            ));
578
        }
UNCOV
579
        Ok(())
×
580
    }
581

582
    /// Mark archive as being deleted (similar to start_deletion but for archive subresource)
UNCOV
583
    pub async fn start_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
584
        let row = sqlx::query(
585
            r#"
586
            WITH ar_inprog AS (
587
                SELECT 1 FROM archive_requests WHERE task_id = $1 AND status = 'in_progress' LIMIT 1
588
            ), upd AS (
589
                UPDATE archive_requests
590
                SET deleted_at = NOW()
591
                WHERE task_id = $1 AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM ar_inprog)
592
                RETURNING 1
593
            )
594
            SELECT EXISTS(SELECT 1 FROM ar_inprog) AS blocked
595
            "#,
596
        )
UNCOV
597
        .bind(task_id)
×
598
        .fetch_one(&self.pool)
×
UNCOV
599
        .await?;
×
UNCOV
600
        let blocked: bool = row.get("blocked");
×
UNCOV
601
        if blocked {
×
UNCOV
602
            return Err(sqlx::Error::Protocol(
×
UNCOV
603
                "in_progress task cannot be deleted".into(),
×
604
            ));
605
        }
UNCOV
606
        Ok(())
×
607
    }
608

609
    /// Mark IPFS pins as being deleted (similar to start_deletion but for IPFS pins subresource)
UNCOV
610
    pub async fn start_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
611
        let row = sqlx::query(
612
            r#"
613
            WITH pr_inprog AS (
614
                SELECT 1 FROM pin_requests WHERE task_id = $1 AND status = 'in_progress' LIMIT 1
615
            ), upd AS (
616
                UPDATE pin_requests
617
                SET deleted_at = NOW()
618
                WHERE task_id = $1 AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM pr_inprog)
619
                RETURNING 1
620
            )
621
            SELECT EXISTS(SELECT 1 FROM pr_inprog) AS blocked
622
            "#,
623
        )
624
        .bind(task_id)
×
UNCOV
625
        .fetch_one(&self.pool)
×
UNCOV
626
        .await?;
×
UNCOV
627
        let blocked: bool = row.get("blocked");
×
UNCOV
628
        if blocked {
×
UNCOV
629
            return Err(sqlx::Error::Protocol(
×
UNCOV
630
                "in_progress task cannot be deleted".into(),
×
631
            ));
632
        }
UNCOV
633
        Ok(())
×
634
    }
635

UNCOV
636
    pub async fn get_backup_task(&self, task_id: &str) -> Result<Option<BackupTask>, sqlx::Error> {
×
637
        let row = sqlx::query(
638
            r#"
639
            SELECT 
640
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
641
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
642
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
643
                ar.error_log as archive_error_log,
644
                pr.status as ipfs_status,
645
                pr.error_log as ipfs_error_log,
646
                pr.fatal_error as ipfs_fatal_error,
647
                pr.deleted_at as pins_deleted_at
648
            FROM backup_tasks b
649
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
650
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
651
            WHERE b.task_id = $1
652
            "#,
653
        )
UNCOV
654
        .bind(task_id)
×
UNCOV
655
        .fetch_optional(&self.pool)
×
656
        .await?;
×
657

658
        if let Some(row) = row {
×
659
            // Fetch tokens for this task from tokens table and aggregate by chain
660
            let token_rows = sqlx::query(
661
                r#"
662
                SELECT chain, contract_address, token_id
663
                FROM tokens
664
                WHERE task_id = $1
665
                ORDER BY chain, contract_address, token_id
666
                "#,
667
            )
UNCOV
668
            .bind(task_id)
×
669
            .fetch_all(&self.pool)
×
UNCOV
670
            .await?;
×
671

672
            use std::collections::BTreeMap;
673
            let mut by_chain: BTreeMap<String, Vec<String>> = BTreeMap::new();
×
674
            for r in token_rows {
×
675
                let chain: String = r.get("chain");
×
UNCOV
676
                let contract_address: String = r.get("contract_address");
×
677
                let token_id: String = r.get("token_id");
×
UNCOV
678
                by_chain
×
679
                    .entry(chain)
×
680
                    .or_default()
681
                    .push(format!("{}:{}", contract_address, token_id));
×
682
            }
683
            let tokens_json = serde_json::json!(by_chain
×
684
                .into_iter()
×
685
                .map(|(chain, toks)| serde_json::json!({
×
686
                    "chain": chain,
×
687
                    "tokens": toks,
×
688
                }))
689
                .collect::<Vec<_>>());
×
690

691
            Ok(Some(BackupTask {
×
692
                task_id: row.get("task_id"),
×
693
                created_at: row.get("created_at"),
×
694
                updated_at: row.get("updated_at"),
×
695
                requestor: row.get("requestor"),
×
696
                nft_count: row.get("nft_count"),
×
697
                tokens: tokens_json,
×
698
                archive_status: row
×
699
                    .try_get::<Option<String>, _>("archive_status")
×
700
                    .ok()
×
701
                    .flatten(),
×
702
                ipfs_status: row
×
703
                    .try_get::<Option<String>, _>("ipfs_status")
×
704
                    .ok()
×
705
                    .flatten(),
×
UNCOV
706
                archive_error_log: row.get("archive_error_log"),
×
UNCOV
707
                ipfs_error_log: row.get("ipfs_error_log"),
×
708
                archive_fatal_error: row.get("fatal_error"),
×
UNCOV
709
                ipfs_fatal_error: row
×
UNCOV
710
                    .try_get::<Option<String>, _>("ipfs_fatal_error")
×
UNCOV
711
                    .ok()
×
UNCOV
712
                    .flatten(),
×
713
                storage_mode: row.get("storage_mode"),
×
UNCOV
714
                archive_format: row.get("archive_format"),
×
UNCOV
715
                expires_at: row.get("expires_at"),
×
UNCOV
716
                archive_deleted_at: row.get("archive_deleted_at"),
×
UNCOV
717
                pins_deleted_at: row.get("pins_deleted_at"),
×
718
            }))
719
        } else {
UNCOV
720
            Ok(None)
×
721
        }
722
    }
723

724
    /// Fetch backup task plus a paginated slice of its tokens; returns (meta, total_token_count)
UNCOV
725
    pub async fn get_backup_task_with_tokens(
×
726
        &self,
727
        task_id: &str,
728
        limit: i64,
729
        offset: i64,
730
    ) -> Result<Option<(BackupTask, u32)>, sqlx::Error> {
731
        // Base metadata (same as get_backup_task)
732
        let row = sqlx::query(
733
            r#"
734
            SELECT 
735
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
736
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
737
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
738
                ar.error_log as archive_error_log,
739
                pr.status as ipfs_status,
740
                pr.error_log as ipfs_error_log,
741
                pr.fatal_error as ipfs_fatal_error,
742
                pr.deleted_at as pins_deleted_at
743
            FROM backup_tasks b
744
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
745
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
746
            WHERE b.task_id = $1
747
            "#,
748
        )
749
        .bind(task_id)
×
750
        .fetch_optional(&self.pool)
×
UNCOV
751
        .await?;
×
752

UNCOV
753
        let Some(row) = row else { return Ok(None) };
×
754

755
        // Total tokens for pagination
UNCOV
756
        let total_row = sqlx::query!(
×
757
            r#"SELECT COUNT(*) as count FROM tokens WHERE task_id = $1"#,
758
            task_id
759
        )
UNCOV
760
        .fetch_one(&self.pool)
×
UNCOV
761
        .await?;
×
762
        let total: u32 = total_row.count.unwrap_or(0) as u32;
×
763

764
        // Page of tokens
765
        let token_rows = sqlx::query(
766
            r#"
767
            SELECT chain, contract_address, token_id
768
            FROM tokens
769
            WHERE task_id = $1
770
            ORDER BY chain, contract_address, token_id
771
            LIMIT $2 OFFSET $3
772
            "#,
773
        )
774
        .bind(task_id)
×
775
        .bind(limit)
×
UNCOV
776
        .bind(offset)
×
777
        .fetch_all(&self.pool)
×
UNCOV
778
        .await?;
×
779

780
        use std::collections::BTreeMap;
781
        let mut by_chain: BTreeMap<String, Vec<String>> = BTreeMap::new();
×
782
        for r in token_rows {
×
UNCOV
783
            let chain: String = r.get("chain");
×
UNCOV
784
            let contract_address: String = r.get("contract_address");
×
785
            let token_id: String = r.get("token_id");
×
786
            by_chain
×
787
                .entry(chain)
×
788
                .or_default()
789
                .push(format!("{}:{}", contract_address, token_id));
×
790
        }
791
        let tokens_json = serde_json::json!(by_chain
×
UNCOV
792
            .into_iter()
×
UNCOV
793
            .map(|(chain, toks)| serde_json::json!({ "chain": chain, "tokens": toks }))
×
UNCOV
794
            .collect::<Vec<_>>());
×
795

796
        let meta = BackupTask {
UNCOV
797
            task_id: row.get("task_id"),
×
UNCOV
798
            created_at: row.get("created_at"),
×
799
            updated_at: row.get("updated_at"),
×
800
            requestor: row.get("requestor"),
×
801
            nft_count: row.get("nft_count"),
×
802
            tokens: tokens_json,
UNCOV
803
            archive_status: row
×
804
                .try_get::<Option<String>, _>("archive_status")
805
                .ok()
806
                .flatten(),
807
            ipfs_status: row
×
808
                .try_get::<Option<String>, _>("ipfs_status")
809
                .ok()
810
                .flatten(),
UNCOV
811
            archive_error_log: row.get("archive_error_log"),
×
UNCOV
812
            ipfs_error_log: row.get("ipfs_error_log"),
×
813
            archive_fatal_error: row.get("fatal_error"),
×
UNCOV
814
            ipfs_fatal_error: row
×
815
                .try_get::<Option<String>, _>("ipfs_fatal_error")
816
                .ok()
817
                .flatten(),
UNCOV
818
            storage_mode: row.get("storage_mode"),
×
UNCOV
819
            archive_format: row.get("archive_format"),
×
UNCOV
820
            expires_at: row.get("expires_at"),
×
UNCOV
821
            archive_deleted_at: row.get("archive_deleted_at"),
×
UNCOV
822
            pins_deleted_at: row.get("pins_deleted_at"),
×
823
        };
824

UNCOV
825
        Ok(Some((meta, total)))
×
826
    }
827

828
    pub async fn list_requestor_backup_tasks_paginated(
×
829
        &self,
830
        requestor: &str,
831
        limit: i64,
832
        offset: i64,
833
    ) -> Result<(Vec<BackupTask>, u32), sqlx::Error> {
834
        // Total count
UNCOV
835
        let total_row = sqlx::query!(
×
836
            r#"SELECT COUNT(*) as count FROM backup_tasks b WHERE b.requestor = $1"#,
837
            requestor
838
        )
UNCOV
839
        .fetch_one(&self.pool)
×
UNCOV
840
        .await?;
×
UNCOV
841
        let total: u32 = total_row.count.unwrap_or(0) as u32;
×
842

843
        let rows = sqlx::query(
844
            r#"
845
            SELECT 
846
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
847
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
848
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
849
                ar.error_log as archive_error_log,
850
                pr.status as ipfs_status,
851
                pr.error_log as ipfs_error_log,
852
                pr.fatal_error as ipfs_fatal_error,
853
                pr.deleted_at as pins_deleted_at
854
            FROM backup_tasks b
855
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
856
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
857
            WHERE b.requestor = $1
858
            ORDER BY b.created_at DESC
859
            LIMIT $2 OFFSET $3
860
            "#,
861
        )
862
        .bind(requestor)
×
863
        .bind(limit)
×
864
        .bind(offset)
×
865
        .fetch_all(&self.pool)
×
866
        .await?;
×
867

UNCOV
868
        let recs = rows
×
869
            .into_iter()
870
            .map(|row| {
×
871
                let task_id: String = row.get("task_id");
×
872

873
                BackupTask {
×
874
                    task_id,
×
875
                    created_at: row.get("created_at"),
×
876
                    updated_at: row.get("updated_at"),
×
877
                    requestor: row.get("requestor"),
×
878
                    nft_count: row.get("nft_count"),
×
879
                    // Client should use get_backup_task to get tokens so the tokens
880
                    // can be properly paginated.
881
                    tokens: serde_json::Value::Null,
×
882
                    archive_status: row
×
883
                        .try_get::<Option<String>, _>("archive_status")
×
884
                        .ok()
×
885
                        .flatten(),
×
886
                    ipfs_status: row
×
887
                        .try_get::<Option<String>, _>("ipfs_status")
×
888
                        .ok()
×
889
                        .flatten(),
×
UNCOV
890
                    archive_error_log: row.get("archive_error_log"),
×
UNCOV
891
                    ipfs_error_log: row.get("ipfs_error_log"),
×
UNCOV
892
                    archive_fatal_error: row.get("fatal_error"),
×
UNCOV
893
                    ipfs_fatal_error: row
×
894
                        .try_get::<Option<String>, _>("ipfs_fatal_error")
×
UNCOV
895
                        .ok()
×
UNCOV
896
                        .flatten(),
×
897
                    storage_mode: row.get("storage_mode"),
×
UNCOV
898
                    archive_format: row.get("archive_format"),
×
UNCOV
899
                    expires_at: row.get("expires_at"),
×
UNCOV
900
                    archive_deleted_at: row.get("archive_deleted_at"),
×
UNCOV
901
                    pins_deleted_at: row.get("pins_deleted_at"),
×
902
                }
903
            })
904
            .collect();
905

UNCOV
906
        Ok((recs, total))
×
907
    }
908

909
    pub async fn list_unprocessed_expired_backups(
×
910
        &self,
911
    ) -> Result<Vec<ExpiredBackup>, sqlx::Error> {
912
        let rows = sqlx::query(
913
            r#"
914
            SELECT b.task_id, ar.archive_format 
915
            FROM backup_tasks b
916
            JOIN archive_requests ar ON b.task_id = ar.task_id
917
            WHERE ar.expires_at IS NOT NULL AND ar.expires_at < NOW() AND ar.status != 'expired'
918
            "#,
919
        )
UNCOV
920
        .fetch_all(&self.pool)
×
UNCOV
921
        .await?;
×
922
        let recs = rows
×
923
            .into_iter()
UNCOV
924
            .map(|row| ExpiredBackup {
×
UNCOV
925
                task_id: row.get("task_id"),
×
UNCOV
926
                archive_format: row.get("archive_format"),
×
927
            })
928
            .collect();
UNCOV
929
        Ok(recs)
×
930
    }
931

932
    /// Retrieve all backup tasks that are in 'in_progress' status
933
    /// This is used to recover incomplete tasks on server restart
UNCOV
934
    pub async fn get_incomplete_backup_tasks(&self) -> Result<Vec<BackupTask>, sqlx::Error> {
×
935
        let rows = sqlx::query(
936
            r#"
937
            SELECT 
938
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
939
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
940
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
941
                ar.error_log as archive_error_log,
942
                pr.status as ipfs_status,
943
                pr.error_log as ipfs_error_log,
944
                pr.deleted_at as pins_deleted_at
945
            FROM backup_tasks b
946
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
947
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
948
            WHERE (
949
                -- Archive-only mode: check archive status (record must exist and be in_progress)
950
                (b.storage_mode = 'archive' AND ar.status = 'in_progress')
951
                OR
952
                -- IPFS-only mode: check IPFS status (record must exist and be in_progress)
953
                (b.storage_mode = 'ipfs' AND pr.status = 'in_progress')
954
                OR
955
                -- Full mode: check both archive and IPFS status (task is incomplete if either is in_progress)
956
                (b.storage_mode = 'full' AND (ar.status = 'in_progress' OR pr.status = 'in_progress'))
957
            )
958
            ORDER BY b.created_at ASC
959
            "#,
960
        )
UNCOV
961
        .fetch_all(&self.pool)
×
962
        .await?;
×
963

964
        // If no incomplete tasks, return early
UNCOV
965
        if rows.is_empty() {
×
UNCOV
966
            return Ok(Vec::new());
×
967
        }
968

969
        // Collect task_ids to fetch tokens in bulk
UNCOV
970
        let task_ids: Vec<String> = rows.iter().map(|r| r.get::<String, _>("task_id")).collect();
×
971

972
        // Fetch all tokens for these tasks and aggregate by task_id and chain
973
        use std::collections::BTreeMap;
974
        let mut tokens_by_task: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
×
975

976
        let token_rows = sqlx::query(
977
            r#"
978
            SELECT task_id, chain, contract_address, token_id
979
            FROM tokens
980
            WHERE task_id = ANY($1)
981
            ORDER BY chain, contract_address, token_id
982
            "#,
983
        )
984
        .bind(&task_ids)
×
UNCOV
985
        .fetch_all(&self.pool)
×
986
        .await?;
×
987

UNCOV
988
        for r in token_rows {
×
989
            let task_id: String = r.get("task_id");
×
UNCOV
990
            let chain: String = r.get("chain");
×
991
            let contract_address: String = r.get("contract_address");
×
992
            let token_id: String = r.get("token_id");
×
993
            tokens_by_task
×
994
                .entry(task_id)
×
995
                .or_default()
996
                .entry(chain)
×
997
                .or_default()
998
                .push(format!("{}:{}", contract_address, token_id));
×
999
        }
1000

UNCOV
1001
        let recs = rows
×
1002
            .into_iter()
1003
            .map(|row| {
×
UNCOV
1004
                let task_id: String = row.get("task_id");
×
UNCOV
1005
                let tokens_json = if let Some(by_chain) = tokens_by_task.get(&task_id) {
×
1006
                    serde_json::json!(by_chain
×
1007
                        .iter()
×
1008
                        .map(|(chain, toks)| serde_json::json!({
×
1009
                            "chain": chain,
×
1010
                            "tokens": toks,
×
1011
                        }))
1012
                        .collect::<Vec<_>>())
×
1013
                } else {
1014
                    // No tokens recorded for this task
1015
                    serde_json::json!([])
×
1016
                };
1017

1018
                BackupTask {
×
1019
                    task_id,
×
1020
                    created_at: row.get("created_at"),
×
1021
                    updated_at: row.get("updated_at"),
×
1022
                    requestor: row.get("requestor"),
×
1023
                    nft_count: row.get("nft_count"),
×
1024
                    tokens: tokens_json,
×
1025
                    archive_status: row
×
1026
                        .try_get::<Option<String>, _>("archive_status")
×
1027
                        .ok()
×
1028
                        .flatten(),
×
1029
                    ipfs_status: row
×
UNCOV
1030
                        .try_get::<Option<String>, _>("ipfs_status")
×
UNCOV
1031
                        .ok()
×
UNCOV
1032
                        .flatten(),
×
UNCOV
1033
                    archive_error_log: row.get("archive_error_log"),
×
1034
                    ipfs_error_log: row.get("ipfs_error_log"),
×
UNCOV
1035
                    archive_fatal_error: row.get("fatal_error"),
×
UNCOV
1036
                    ipfs_fatal_error: None,
×
UNCOV
1037
                    storage_mode: row.get("storage_mode"),
×
1038
                    archive_format: row.get("archive_format"),
×
UNCOV
1039
                    expires_at: row.get("expires_at"),
×
UNCOV
1040
                    archive_deleted_at: row.get("archive_deleted_at"),
×
UNCOV
1041
                    pins_deleted_at: row.get("pins_deleted_at"),
×
1042
                }
1043
            })
1044
            .collect();
1045

UNCOV
1046
        Ok(recs)
×
1047
    }
1048

1049
    /// Insert pins and their associated tokens in a single atomic transaction
1050
    pub async fn insert_pins_with_tokens(
×
1051
        &self,
1052
        task_id: &str,
1053
        token_pin_mappings: &[crate::TokenPinMapping],
1054
    ) -> Result<(), sqlx::Error> {
1055
        // Collect all pin responses and prepare token data
UNCOV
1056
        let mut all_pin_responses = Vec::new();
×
UNCOV
1057
        let mut all_token_data = Vec::new(); // (index_in_pin_responses, chain, contract_address, token_id)
×
1058

UNCOV
1059
        for mapping in token_pin_mappings {
×
UNCOV
1060
            for pin_response in &mapping.pin_responses {
×
1061
                let index = all_pin_responses.len();
×
1062
                all_pin_responses.push(pin_response);
×
UNCOV
1063
                all_token_data.push((
×
UNCOV
1064
                    index,
×
UNCOV
1065
                    mapping.chain.clone(),
×
1066
                    mapping.contract_address.clone(),
×
UNCOV
1067
                    mapping.token_id.clone(),
×
1068
                ));
1069
            }
1070
        }
1071

1072
        // If no pin responses to insert, that's an error condition
1073
        if all_pin_responses.is_empty() {
×
1074
            return Err(sqlx::Error::RowNotFound);
×
1075
        }
1076

1077
        // Start a transaction for atomicity
UNCOV
1078
        let mut tx = self.pool.begin().await?;
×
1079

1080
        // Insert pins with token_id from the start
UNCOV
1081
        for (index, chain, contract_address, token_id) in &all_token_data {
×
UNCOV
1082
            let pin_response = all_pin_responses[*index];
×
1083

1084
            // Map status enum to lowercase string to satisfy CHECK constraint
UNCOV
1085
            let status = match pin_response.status {
×
UNCOV
1086
                crate::ipfs::PinResponseStatus::Queued => "queued",
×
1087
                crate::ipfs::PinResponseStatus::Pinning => "pinning",
×
1088
                crate::ipfs::PinResponseStatus::Pinned => "pinned",
×
1089
                crate::ipfs::PinResponseStatus::Failed => "failed",
×
1090
            };
1091

1092
            // Ensure token row exists and fetch its id
1093
            let inserted = sqlx::query(
1094
                r#"INSERT INTO tokens (task_id, chain, contract_address, token_id)
1095
                   VALUES ($1, $2, $3, $4)
1096
                   ON CONFLICT (task_id, chain, contract_address, token_id) DO NOTHING
1097
                   RETURNING id"#,
1098
            )
1099
            .bind(task_id)
×
1100
            .bind(chain)
×
1101
            .bind(contract_address)
×
1102
            .bind(token_id)
×
1103
            .fetch_optional(&mut *tx)
×
UNCOV
1104
            .await?;
×
1105

UNCOV
1106
            let tok_id: i64 = if let Some(row) = inserted {
×
UNCOV
1107
                row.get("id")
×
1108
            } else {
UNCOV
1109
                sqlx::query("SELECT id FROM tokens WHERE task_id = $1 AND chain = $2 AND contract_address = $3 AND token_id = $4")
×
UNCOV
1110
                    .bind(task_id)
×
1111
                    .bind(chain)
×
1112
                    .bind(contract_address)
×
1113
                    .bind(token_id)
×
1114
                    .fetch_one(&mut *tx)
×
1115
                    .await?
×
1116
                    .get("id")
1117
            };
1118

1119
            // Insert pin with token_id from the start
1120
            sqlx::query(
1121
                "INSERT INTO pins (task_id, token_id, provider_type, provider_url, cid, request_id, pin_status) VALUES ($1, $2, $3, $4, $5, $6, $7)"
1122
            )
1123
            .bind(task_id)
×
1124
            .bind(tok_id)
×
UNCOV
1125
            .bind(&pin_response.provider_type)
×
UNCOV
1126
            .bind(&pin_response.provider_url)
×
UNCOV
1127
            .bind(&pin_response.cid)
×
1128
            .bind(&pin_response.id)
×
UNCOV
1129
            .bind(status)
×
UNCOV
1130
            .execute(&mut *tx)
×
UNCOV
1131
            .await?;
×
1132
        }
1133

1134
        // Commit the transaction
UNCOV
1135
        tx.commit().await?;
×
UNCOV
1136
        Ok(())
×
1137
    }
1138

1139
    /// Get all pins for a specific backup task
UNCOV
1140
    pub async fn get_pins_by_task_id(&self, task_id: &str) -> Result<Vec<PinRow>, sqlx::Error> {
×
1141
        let rows = sqlx::query(
1142
            r#"
1143
            SELECT id, task_id, provider_type, provider_url, cid, request_id, pin_status, created_at
1144
            FROM pins
1145
            WHERE task_id = $1
1146
            ORDER BY id
1147
            "#,
1148
        )
1149
        .bind(task_id)
×
1150
        .fetch_all(&self.pool)
×
1151
        .await?;
×
1152

1153
        Ok(rows
×
1154
            .into_iter()
×
UNCOV
1155
            .map(|row| PinRow {
×
1156
                id: row.get("id"),
×
UNCOV
1157
                task_id: row.get("task_id"),
×
UNCOV
1158
                provider_type: row.get("provider_type"),
×
UNCOV
1159
                provider_url: row
×
1160
                    .try_get::<Option<String>, _>("provider_url")
×
UNCOV
1161
                    .ok()
×
UNCOV
1162
                    .flatten(),
×
UNCOV
1163
                cid: row.get("cid"),
×
UNCOV
1164
                request_id: row.get("request_id"),
×
UNCOV
1165
                pin_status: row.get("pin_status"),
×
UNCOV
1166
                created_at: row.get("created_at"),
×
1167
            })
UNCOV
1168
            .collect())
×
1169
    }
1170

1171
    /// Paginated pinned tokens grouped by (chain, contract_address, token_id)
UNCOV
1172
    pub async fn get_pinned_tokens_by_requestor(
×
1173
        &self,
1174
        requestor: &str,
1175
        limit: i64,
1176
        offset: i64,
1177
    ) -> Result<(Vec<TokenWithPins>, u32), sqlx::Error> {
1178
        // Total distinct tokens for this requestor
1179
        let total_row = sqlx::query(
1180
            r#"
1181
            SELECT COUNT(*) as count
1182
            FROM (
1183
                SELECT DISTINCT t.chain, t.contract_address, t.token_id
1184
                FROM tokens t
1185
                JOIN pins p ON p.token_id = t.id
1186
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1187
                WHERE bt.requestor = $1
1188
            ) t
1189
            "#,
1190
        )
UNCOV
1191
        .bind(requestor)
×
UNCOV
1192
        .fetch_one(&self.pool)
×
UNCOV
1193
        .await?;
×
UNCOV
1194
        let total: u32 = (total_row.get::<i64, _>("count")).max(0) as u32;
×
1195

1196
        // Page of distinct tokens ordered by most recent pin time
1197
        let rows = sqlx::query(
1198
            r#"
1199
            SELECT t.chain, t.contract_address, t.token_id
1200
            FROM (
1201
                SELECT t.chain, t.contract_address, t.token_id, MAX(p.created_at) AS last_created
1202
                FROM tokens t
1203
                JOIN pins p ON p.token_id = t.id
1204
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1205
                WHERE bt.requestor = $1
1206
                GROUP BY t.chain, t.contract_address, t.token_id
1207
            ) t
1208
            ORDER BY last_created DESC
1209
            LIMIT $2 OFFSET $3
1210
            "#,
1211
        )
UNCOV
1212
        .bind(requestor)
×
UNCOV
1213
        .bind(limit)
×
UNCOV
1214
        .bind(offset)
×
UNCOV
1215
        .fetch_all(&self.pool)
×
UNCOV
1216
        .await?;
×
1217

1218
        // For each token key, fetch pins (ordered by created_at desc)
UNCOV
1219
        let mut result: Vec<TokenWithPins> = Vec::new();
×
UNCOV
1220
        for r in rows {
×
1221
            let token_rows = sqlx::query(
1222
                r#"
1223
                SELECT t.chain, t.contract_address, t.token_id,
1224
                       p.cid, p.provider_type, p.provider_url, p.pin_status, p.created_at
1225
                FROM tokens t
1226
                JOIN pins p ON p.token_id = t.id
1227
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1228
                WHERE bt.requestor = $1
1229
                  AND t.chain = $2
1230
                  AND t.contract_address = $3
1231
                  AND t.token_id = $4
1232
                ORDER BY p.created_at DESC
1233
                "#,
1234
            )
1235
            .bind(requestor)
×
1236
            .bind(r.get::<String, _>("chain"))
×
1237
            .bind(r.get::<String, _>("contract_address"))
×
1238
            .bind(r.get::<String, _>("token_id"))
×
1239
            .fetch_all(&self.pool)
×
1240
            .await?;
×
1241

UNCOV
1242
            let mut pins: Vec<PinInfo> = Vec::new();
×
UNCOV
1243
            let mut chain = String::new();
×
UNCOV
1244
            let mut contract_address = String::new();
×
1245
            let mut token_id = String::new();
×
1246
            for row in token_rows {
×
1247
                chain = row.get("chain");
×
1248
                contract_address = row.get("contract_address");
×
1249
                token_id = row.get("token_id");
×
1250
                let cid: String = row.get("cid");
×
1251
                let provider_type: String = row.get("provider_type");
×
1252
                let provider_url: String = row
×
1253
                    .try_get::<Option<String>, _>("provider_url")
1254
                    .ok()
1255
                    .flatten()
1256
                    .unwrap_or_default();
1257
                let status: String = row.get("pin_status");
×
1258
                let created_at: DateTime<Utc> = row.get("created_at");
×
1259
                pins.push(PinInfo {
×
UNCOV
1260
                    cid,
×
UNCOV
1261
                    provider_type,
×
UNCOV
1262
                    provider_url,
×
1263
                    status,
×
UNCOV
1264
                    created_at,
×
1265
                });
1266
            }
1267
            result.push(TokenWithPins {
×
UNCOV
1268
                chain,
×
UNCOV
1269
                contract_address,
×
UNCOV
1270
                token_id,
×
UNCOV
1271
                pins,
×
1272
            });
1273
        }
1274

1275
        Ok((result, total))
×
1276
    }
1277

1278
    /// Get a specific pinned token for a requestor
1279
    pub async fn get_pinned_token_by_requestor(
×
1280
        &self,
1281
        requestor: &str,
1282
        chain: &str,
1283
        contract_address: &str,
1284
        token_id: &str,
1285
    ) -> Result<Option<TokenWithPins>, sqlx::Error> {
UNCOV
1286
        let query = r#"
×
1287
            SELECT t.chain, t.contract_address, t.token_id,
×
1288
                   p.cid, p.provider_type, p.provider_url, p.pin_status, p.created_at
×
1289
            FROM tokens t
×
1290
            JOIN pins p ON p.token_id = t.id
×
1291
            JOIN backup_tasks bt ON bt.task_id = p.task_id
×
1292
            WHERE bt.requestor = $1
×
1293
              AND t.chain = $2
×
UNCOV
1294
              AND t.contract_address = $3
×
1295
              AND t.token_id = $4
×
1296
            ORDER BY p.created_at DESC
×
UNCOV
1297
        "#;
×
1298

1299
        let rows = sqlx::query(query)
×
1300
            .bind(requestor)
×
1301
            .bind(chain)
×
1302
            .bind(contract_address)
×
UNCOV
1303
            .bind(token_id)
×
1304
            .fetch_all(&self.pool)
×
1305
            .await?;
×
1306

1307
        if rows.is_empty() {
×
1308
            return Ok(None);
×
1309
        }
1310

1311
        let mut pins = Vec::new();
×
UNCOV
1312
        let mut token_chain = String::new();
×
UNCOV
1313
        let mut token_contract_address = String::new();
×
UNCOV
1314
        let mut token_token_id = String::new();
×
1315

1316
        for row in rows {
×
1317
            token_chain = row.get("chain");
×
UNCOV
1318
            token_contract_address = row.get("contract_address");
×
1319
            token_token_id = row.get("token_id");
×
1320
            let cid: String = row.get("cid");
×
1321
            let provider_type: String = row.get("provider_type");
×
1322
            // provider_url may be NULL for legacy rows; default to empty string for API stability
1323
            let provider_url: String = row
×
1324
                .try_get::<Option<String>, _>("provider_url")
1325
                .ok()
1326
                .flatten()
1327
                .unwrap_or_default();
1328
            let status: String = row.get("pin_status");
×
1329
            let created_at: DateTime<Utc> = row.get("created_at");
×
1330

1331
            pins.push(PinInfo {
×
1332
                cid,
×
UNCOV
1333
                provider_type,
×
UNCOV
1334
                provider_url,
×
UNCOV
1335
                status,
×
UNCOV
1336
                created_at,
×
1337
            });
1338
        }
1339

UNCOV
1340
        Ok(Some(TokenWithPins {
×
UNCOV
1341
            chain: token_chain,
×
UNCOV
1342
            contract_address: token_contract_address,
×
UNCOV
1343
            token_id: token_token_id,
×
UNCOV
1344
            pins,
×
1345
        }))
1346
    }
1347

1348
    /// Get all pins that are in 'queued' or 'pinning' status
1349
    /// This is used by the pin monitor to check for status updates
1350
    pub async fn get_active_pins(&self) -> Result<Vec<PinRow>, sqlx::Error> {
×
1351
        let rows = sqlx::query(
1352
            r#"
1353
            SELECT id, task_id, provider_type, provider_url, cid, request_id, pin_status, created_at
1354
            FROM pins
1355
            WHERE pin_status IN ('queued', 'pinning')
1356
            ORDER BY id
1357
            "#,
1358
        )
1359
        .fetch_all(&self.pool)
×
1360
        .await?;
×
1361

1362
        Ok(rows
×
1363
            .into_iter()
×
UNCOV
1364
            .map(|row| PinRow {
×
1365
                id: row.get("id"),
×
UNCOV
1366
                task_id: row.get("task_id"),
×
UNCOV
1367
                provider_type: row.get("provider_type"),
×
UNCOV
1368
                provider_url: row
×
UNCOV
1369
                    .try_get::<Option<String>, _>("provider_url")
×
UNCOV
1370
                    .ok()
×
UNCOV
1371
                    .flatten(),
×
1372
                cid: row.get("cid"),
×
UNCOV
1373
                request_id: row.get("request_id"),
×
UNCOV
1374
                pin_status: row.get("pin_status"),
×
UNCOV
1375
                created_at: row.get("created_at"),
×
1376
            })
1377
            .collect())
×
1378
    }
1379

1380
    /// Set backup fatal error for relevant subresources in a single SQL statement.
1381
    /// The update is based on the `storage_mode` value from the `backup_tasks` table for the given `task_id`:
1382
    /// - If storage_mode is 'archive' or 'full': updates archive_requests.status and archive_requests.fatal_error
1383
    /// - If storage_mode is 'ipfs' or 'full': updates pin_requests.status and pin_requests.fatal_error
1384
    pub async fn set_backup_error(
×
1385
        &self,
1386
        task_id: &str,
1387
        fatal_error: &str,
1388
    ) -> Result<(), sqlx::Error> {
1389
        let sql = r#"
×
UNCOV
1390
            WITH task_mode AS (
×
1391
                SELECT storage_mode FROM backup_tasks WHERE task_id = $1
×
1392
            ),
1393
            upd_archive AS (
×
1394
                UPDATE archive_requests ar
×
1395
                SET status = 'error', fatal_error = $2
×
1396
                WHERE ar.task_id = $1
×
1397
                  AND EXISTS (
×
UNCOV
1398
                      SELECT 1 FROM task_mode tm
×
1399
                      WHERE tm.storage_mode IN ('archive', 'full')
×
1400
                  )
1401
                RETURNING 1
×
1402
            ),
1403
            upd_pins AS (
×
1404
                UPDATE pin_requests pr
×
1405
                SET status = 'error', fatal_error = $2
×
1406
                WHERE pr.task_id = $1
×
1407
                  AND EXISTS (
×
1408
                      SELECT 1 FROM task_mode tm
×
1409
                      WHERE tm.storage_mode IN ('ipfs', 'full')
×
1410
                  )
UNCOV
1411
                RETURNING 1
×
1412
            )
UNCOV
1413
            SELECT COALESCE((SELECT COUNT(*) FROM upd_archive), 0) AS archive_updates,
×
UNCOV
1414
                   COALESCE((SELECT COUNT(*) FROM upd_pins), 0)     AS pin_updates
×
1415
        "#;
×
UNCOV
1416
        sqlx::query(sql)
×
UNCOV
1417
            .bind(task_id)
×
UNCOV
1418
            .bind(fatal_error)
×
UNCOV
1419
            .execute(&self.pool)
×
UNCOV
1420
            .await?;
×
UNCOV
1421
        Ok(())
×
1422
    }
1423

1424
    /// Update backup subresource statuses for the task based on its storage mode
1425
    /// - archive or full: updates archive_requests.status
1426
    /// - ipfs or full: updates pin_requests.status
1427
    pub async fn update_backup_statuses(
×
1428
        &self,
1429
        task_id: &str,
1430
        scope: &str,
1431
        archive_status: &str,
1432
        ipfs_status: &str,
1433
    ) -> Result<(), sqlx::Error> {
1434
        let sql = r#"
×
1435
            WITH upd_archive AS (
×
UNCOV
1436
                UPDATE archive_requests ar
×
1437
                SET status = $2
×
1438
                WHERE ar.task_id = $1
×
1439
                  AND ($4 IN ('archive', 'full'))
×
1440
                RETURNING 1
×
1441
            ),
1442
            upd_pins AS (
×
1443
                UPDATE pin_requests pr
×
1444
                SET status = $3
×
1445
                WHERE pr.task_id = $1
×
1446
                  AND ($4 IN ('ipfs', 'full'))
×
1447
                RETURNING 1
×
1448
            )
UNCOV
1449
            SELECT COALESCE((SELECT COUNT(*) FROM upd_archive), 0) AS archive_updates,
×
1450
                   COALESCE((SELECT COUNT(*) FROM upd_pins), 0)     AS pin_updates
×
1451
        "#;
×
1452
        sqlx::query(sql)
×
UNCOV
1453
            .bind(task_id)
×
UNCOV
1454
            .bind(archive_status)
×
1455
            .bind(ipfs_status)
×
UNCOV
1456
            .bind(scope)
×
1457
            .execute(&self.pool)
×
UNCOV
1458
            .await?;
×
UNCOV
1459
        Ok(())
×
1460
    }
1461

UNCOV
1462
    pub async fn update_pin_statuses(&self, updates: &[(i64, String)]) -> Result<(), sqlx::Error> {
×
UNCOV
1463
        if updates.is_empty() {
×
UNCOV
1464
            return Ok(());
×
1465
        }
1466

1467
        let mut tx = self.pool.begin().await?;
×
1468

UNCOV
1469
        for (id, status) in updates {
×
1470
            sqlx::query(
1471
                r#"
1472
                UPDATE pins
1473
                SET pin_status = $2
1474
                WHERE id = $1
1475
                "#,
1476
            )
UNCOV
1477
            .bind(id)
×
1478
            .bind(status)
×
UNCOV
1479
            .execute(&mut *tx)
×
UNCOV
1480
            .await?;
×
1481
        }
1482

UNCOV
1483
        tx.commit().await?;
×
UNCOV
1484
        Ok(())
×
1485
    }
1486

1487
    /// Ensure the missing subresource exists and upgrade the backup to full storage mode.
1488
    /// If `add_archive` is true, create/ensure archive_requests row with provided format/retention.
1489
    /// Otherwise, ensure pin_requests row exists. Always flips backup_tasks.storage_mode to 'full'.
UNCOV
1490
    pub async fn upgrade_backup_to_full(
×
1491
        &self,
1492
        task_id: &str,
1493
        add_archive: bool,
1494
        archive_format: Option<&str>,
1495
        retention_days: Option<u64>,
1496
    ) -> Result<(), sqlx::Error> {
1497
        let mut tx = self.pool.begin().await?;
×
1498

1499
        // Upgrade storage mode to full
1500
        sqlx::query(
1501
            r#"
1502
            UPDATE backup_tasks
1503
            SET storage_mode = 'full', updated_at = NOW()
1504
            WHERE task_id = $1
1505
            "#,
1506
        )
UNCOV
1507
        .bind(task_id)
×
UNCOV
1508
        .execute(&mut *tx)
×
1509
        .await?;
×
1510

1511
        if add_archive {
×
1512
            let fmt = archive_format.unwrap_or("zip");
×
1513
            if let Some(days) = retention_days {
×
1514
                sqlx::query(
1515
                    r#"
1516
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
1517
                    VALUES ($1, $2, NOW() + make_interval(days => $3::int), 'in_progress')
1518
                    ON CONFLICT (task_id) DO NOTHING
1519
                    "#,
1520
                )
UNCOV
1521
                .bind(task_id)
×
1522
                .bind(fmt)
×
1523
                .bind(days as i64)
×
1524
                .execute(&mut *tx)
×
1525
                .await?;
×
1526
            } else {
1527
                sqlx::query(
1528
                    r#"
1529
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
1530
                    VALUES ($1, $2, NULL, 'in_progress')
1531
                    ON CONFLICT (task_id) DO NOTHING
1532
                    "#,
1533
                )
UNCOV
1534
                .bind(task_id)
×
1535
                .bind(fmt)
×
1536
                .execute(&mut *tx)
×
1537
                .await?;
×
1538
            }
1539
        } else {
1540
            sqlx::query(
1541
                r#"
1542
                INSERT INTO pin_requests (task_id, status)
1543
                VALUES ($1, 'in_progress')
1544
                ON CONFLICT (task_id) DO NOTHING
1545
                "#,
1546
            )
1547
            .bind(task_id)
×
UNCOV
1548
            .execute(&mut *tx)
×
UNCOV
1549
            .await?;
×
1550
        }
1551

1552
        tx.commit().await?;
×
1553
        Ok(())
×
1554
    }
1555

1556
    /// Complete archive deletion:
1557
    /// - If current storage_mode is 'archive', delete the whole backup (finalize deletion)
1558
    /// - Else if current storage_mode is 'full', flip to 'ipfs' to reflect archive removed
1559
    pub async fn complete_archive_request_deletion(
×
1560
        &self,
1561
        task_id: &str,
1562
    ) -> Result<(), sqlx::Error> {
1563
        // Atomically: delete when archive-only; else if full, flip to ipfs
1564
        let sql = r#"
×
1565
            WITH del AS (
×
1566
                DELETE FROM backup_tasks
×
1567
                WHERE task_id = $1 AND storage_mode = 'archive'
×
UNCOV
1568
                RETURNING 1
×
UNCOV
1569
            ), upd AS (
×
UNCOV
1570
                UPDATE backup_tasks
×
UNCOV
1571
                SET storage_mode = 'ipfs', updated_at = NOW()
×
UNCOV
1572
                WHERE task_id = $1 AND storage_mode = 'full' AND NOT EXISTS (SELECT 1 FROM del)
×
1573
                RETURNING 1
×
1574
            )
1575
            SELECT COALESCE((SELECT COUNT(*) FROM del), 0) AS deleted,
×
1576
                   COALESCE((SELECT COUNT(*) FROM upd), 0) AS updated
×
1577
        "#;
×
1578
        let _ = sqlx::query(sql).bind(task_id).execute(&self.pool).await?;
×
1579
        Ok(())
×
1580
    }
1581

1582
    /// Complete IPFS pins deletion:
1583
    /// - If current storage_mode is 'ipfs', delete the whole backup (finalize deletion)
1584
    /// - Else if current storage_mode is 'full', flip to 'archive' to reflect pins removed
UNCOV
1585
    pub async fn complete_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1586
        // Atomically: delete when ipfs-only; else if full, flip to archive
1587
        let sql = r#"
×
1588
            WITH del AS (
×
1589
                DELETE FROM backup_tasks
×
1590
                WHERE task_id = $1 AND storage_mode = 'ipfs'
×
UNCOV
1591
                RETURNING 1
×
UNCOV
1592
            ), upd AS (
×
UNCOV
1593
                UPDATE backup_tasks
×
UNCOV
1594
                SET storage_mode = 'archive', updated_at = NOW()
×
UNCOV
1595
                WHERE task_id = $1 AND storage_mode = 'full' AND NOT EXISTS (SELECT 1 FROM del)
×
UNCOV
1596
                RETURNING 1
×
1597
            )
UNCOV
1598
            SELECT COALESCE((SELECT COUNT(*) FROM del), 0) AS deleted,
×
UNCOV
1599
                   COALESCE((SELECT COUNT(*) FROM upd), 0) AS updated
×
UNCOV
1600
        "#;
×
UNCOV
1601
        let _ = sqlx::query(sql).bind(task_id).execute(&self.pool).await?;
×
UNCOV
1602
        Ok(())
×
1603
    }
1604
}
1605

1606
// Implement the unified Database trait for the real Db struct
1607
#[async_trait::async_trait]
1608
impl Database for Db {
1609
    // Backup task operations
1610

1611
    async fn insert_backup_task(
1612
        &self,
1613
        task_id: &str,
1614
        requestor: &str,
1615
        nft_count: i32,
1616
        tokens: &serde_json::Value,
1617
        storage_mode: &str,
1618
        archive_format: Option<&str>,
1619
        retention_days: Option<u64>,
1620
    ) -> Result<(), sqlx::Error> {
1621
        Db::insert_backup_task(
1622
            self,
1623
            task_id,
1624
            requestor,
1625
            nft_count,
1626
            tokens,
1627
            storage_mode,
1628
            archive_format,
1629
            retention_days,
1630
        )
1631
        .await
1632
    }
1633

UNCOV
1634
    async fn get_backup_task(&self, task_id: &str) -> Result<Option<BackupTask>, sqlx::Error> {
×
1635
        Db::get_backup_task(self, task_id).await
1636
    }
1637

1638
    async fn get_backup_task_with_tokens(
1639
        &self,
1640
        task_id: &str,
1641
        limit: i64,
1642
        offset: i64,
1643
    ) -> Result<Option<(BackupTask, u32)>, sqlx::Error> {
1644
        Db::get_backup_task_with_tokens(self, task_id, limit, offset).await
1645
    }
1646

UNCOV
1647
    async fn delete_backup_task(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1648
        Db::delete_backup_task(self, task_id).await
1649
    }
1650

UNCOV
1651
    async fn get_incomplete_backup_tasks(&self) -> Result<Vec<BackupTask>, sqlx::Error> {
×
1652
        Db::get_incomplete_backup_tasks(self).await
1653
    }
1654

1655
    async fn list_requestor_backup_tasks_paginated(
1656
        &self,
1657
        requestor: &str,
1658
        limit: i64,
1659
        offset: i64,
1660
    ) -> Result<(Vec<BackupTask>, u32), sqlx::Error> {
1661
        Db::list_requestor_backup_tasks_paginated(self, requestor, limit, offset).await
1662
    }
1663

UNCOV
1664
    async fn list_unprocessed_expired_backups(&self) -> Result<Vec<ExpiredBackup>, sqlx::Error> {
×
1665
        Db::list_unprocessed_expired_backups(self).await
1666
    }
1667

1668
    // Backup task status and error operations
UNCOV
1669
    async fn clear_backup_errors(&self, task_id: &str, scope: &str) -> Result<(), sqlx::Error> {
×
1670
        Db::clear_backup_errors(self, task_id, scope).await
1671
    }
1672

UNCOV
1673
    async fn set_backup_error(&self, task_id: &str, error: &str) -> Result<(), sqlx::Error> {
×
1674
        Db::set_backup_error(self, task_id, error).await
1675
    }
1676

1677
    async fn set_error_logs(
1678
        &self,
1679
        task_id: &str,
1680
        archive_error_log: Option<&str>,
1681
        ipfs_error_log: Option<&str>,
1682
    ) -> Result<(), sqlx::Error> {
1683
        Db::set_error_logs(self, task_id, archive_error_log, ipfs_error_log).await
1684
    }
1685

1686
    async fn update_archive_request_error_log(
1687
        &self,
1688
        task_id: &str,
1689
        error_log: &str,
1690
    ) -> Result<(), sqlx::Error> {
1691
        Db::update_archive_request_error_log(self, task_id, error_log).await
1692
    }
1693

1694
    async fn update_pin_request_error_log(
1695
        &self,
1696
        task_id: &str,
1697
        error_log: &str,
1698
    ) -> Result<(), sqlx::Error> {
1699
        Db::update_pin_request_error_log(self, task_id, error_log).await
1700
    }
1701

1702
    async fn set_archive_request_error(
1703
        &self,
1704
        task_id: &str,
1705
        fatal_error: &str,
1706
    ) -> Result<(), sqlx::Error> {
1707
        Db::set_archive_request_error(self, task_id, fatal_error).await
1708
    }
1709

1710
    async fn set_pin_request_error(
1711
        &self,
1712
        task_id: &str,
1713
        fatal_error: &str,
1714
    ) -> Result<(), sqlx::Error> {
1715
        Db::set_pin_request_error(self, task_id, fatal_error).await
1716
    }
1717

1718
    // Status update operations
1719
    async fn update_archive_request_status(
1720
        &self,
1721
        task_id: &str,
1722
        status: &str,
1723
    ) -> Result<(), sqlx::Error> {
1724
        Db::update_archive_request_status(self, task_id, status).await
1725
    }
1726

1727
    async fn update_pin_request_status(
1728
        &self,
1729
        task_id: &str,
1730
        status: &str,
1731
    ) -> Result<(), sqlx::Error> {
1732
        Db::update_pin_request_status(self, task_id, status).await
1733
    }
1734

1735
    async fn update_backup_statuses(
1736
        &self,
1737
        task_id: &str,
1738
        scope: &str,
1739
        archive_status: &str,
1740
        ipfs_status: &str,
1741
    ) -> Result<(), sqlx::Error> {
1742
        Db::update_backup_statuses(self, task_id, scope, archive_status, ipfs_status).await
1743
    }
1744

1745
    async fn update_archive_request_statuses(
1746
        &self,
1747
        task_ids: &[String],
1748
        status: &str,
1749
    ) -> Result<(), sqlx::Error> {
1750
        Db::update_archive_request_statuses(self, task_ids, status).await
1751
    }
1752

1753
    async fn upgrade_backup_to_full(
1754
        &self,
1755
        task_id: &str,
1756
        add_archive: bool,
1757
        archive_format: Option<&str>,
1758
        retention_days: Option<u64>,
1759
    ) -> Result<(), sqlx::Error> {
1760
        Db::upgrade_backup_to_full(self, task_id, add_archive, archive_format, retention_days).await
1761
    }
1762

1763
    // Deletion operations
1764
    async fn start_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1765
        Db::start_deletion(self, task_id).await
1766
    }
1767

1768
    async fn start_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1769
        Db::start_archive_request_deletion(self, task_id).await
1770
    }
1771

UNCOV
1772
    async fn start_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1773
        Db::start_pin_request_deletion(self, task_id).await
1774
    }
1775

UNCOV
1776
    async fn complete_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1777
        Db::complete_archive_request_deletion(self, task_id).await
1778
    }
1779

UNCOV
1780
    async fn complete_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1781
        Db::complete_pin_request_deletion(self, task_id).await
1782
    }
1783

1784
    // Retry operations
1785
    async fn retry_backup(
1786
        &self,
1787
        task_id: &str,
1788
        scope: &str,
1789
        retention_days: u64,
1790
    ) -> Result<(), sqlx::Error> {
1791
        Db::retry_backup(self, task_id, scope, retention_days).await
1792
    }
1793

1794
    // Pin operations
1795
    async fn insert_pins_with_tokens(
1796
        &self,
1797
        task_id: &str,
1798
        token_pin_mappings: &[crate::TokenPinMapping],
1799
    ) -> Result<(), sqlx::Error> {
1800
        Db::insert_pins_with_tokens(self, task_id, token_pin_mappings).await
1801
    }
1802

UNCOV
1803
    async fn get_pins_by_task_id(&self, task_id: &str) -> Result<Vec<PinRow>, sqlx::Error> {
×
1804
        Db::get_pins_by_task_id(self, task_id).await
1805
    }
1806

UNCOV
1807
    async fn get_active_pins(&self) -> Result<Vec<PinRow>, sqlx::Error> {
×
1808
        Db::get_active_pins(self).await
1809
    }
1810

UNCOV
1811
    async fn update_pin_statuses(&self, updates: &[(i64, String)]) -> Result<(), sqlx::Error> {
×
1812
        Db::update_pin_statuses(self, updates).await
1813
    }
1814

1815
    // Pinned tokens operations
1816
    async fn get_pinned_tokens_by_requestor(
1817
        &self,
1818
        requestor: &str,
1819
        limit: i64,
1820
        offset: i64,
1821
    ) -> Result<(Vec<TokenWithPins>, u32), sqlx::Error> {
1822
        Db::get_pinned_tokens_by_requestor(self, requestor, limit, offset).await
1823
    }
1824

1825
    async fn get_pinned_token_by_requestor(
1826
        &self,
1827
        requestor: &str,
1828
        chain: &str,
1829
        contract_address: &str,
1830
        token_id: &str,
1831
    ) -> Result<Option<TokenWithPins>, sqlx::Error> {
1832
        Db::get_pinned_token_by_requestor(self, requestor, chain, contract_address, token_id).await
1833
    }
1834
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc