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

0xmichalis / nftbk / 27232624233

09 Jun 2026 08:07PM UTC coverage: 47.577% (-0.3%) from 47.891%
27232624233

Pull #108

github

web-flow
Merge 4d6e4404d into 83f6dbaac
Pull Request #108: refactor: move status types from strings to enums

23 of 99 new or added lines in 11 files covered. (23.23%)

23 existing lines in 2 files now uncovered.

2356 of 4952 relevant lines covered (47.58%)

9.03 hits per line

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

1.26
/src/server/database/mod.rs
1
use std::fmt;
2
use std::time::Duration;
3

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

9
use crate::ipfs::PinResponseStatus;
10
use crate::server::database::r#trait::Database;
11
use crate::server::StorageMode;
12

13
pub mod r#trait;
14

15
/// Status of an archive request (backup archive stored on disk)
16
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17
#[serde(rename_all = "snake_case")]
18
pub enum ArchiveStatus {
19
    InProgress,
20
    Done,
21
    Error,
22
    Expired,
23
}
24

25
impl ArchiveStatus {
26
    pub fn as_str(&self) -> &'static str {
6✔
27
        match self {
6✔
28
            ArchiveStatus::InProgress => "in_progress",
1✔
29
            ArchiveStatus::Done => "done",
5✔
NEW
30
            ArchiveStatus::Error => "error",
×
NEW
31
            ArchiveStatus::Expired => "expired",
×
32
        }
33
    }
34
}
35

36
impl fmt::Display for ArchiveStatus {
NEW
37
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
NEW
38
        f.write_str(self.as_str())
×
39
    }
40
}
41

42
/// Status of an IPFS pin request
43
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44
#[serde(rename_all = "snake_case")]
45
pub enum IpfsStatus {
46
    InProgress,
47
    Done,
48
    Error,
49
}
50

51
impl IpfsStatus {
52
    pub fn as_str(&self) -> &'static str {
2✔
53
        match self {
2✔
54
            IpfsStatus::InProgress => "in_progress",
2✔
NEW
55
            IpfsStatus::Done => "done",
×
NEW
56
            IpfsStatus::Error => "error",
×
57
        }
58
    }
59
}
60

61
impl fmt::Display for IpfsStatus {
NEW
62
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
NEW
63
        f.write_str(self.as_str())
×
64
    }
65
}
66

67
/// Parse an optional status string from the database into an enum variant.
NEW
68
fn parse_archive_status(s: Option<String>) -> Option<ArchiveStatus> {
×
NEW
69
    s.as_deref().and_then(|s| match s {
×
NEW
70
        "in_progress" => Some(ArchiveStatus::InProgress),
×
NEW
71
        "done" => Some(ArchiveStatus::Done),
×
NEW
72
        "error" => Some(ArchiveStatus::Error),
×
NEW
73
        "expired" => Some(ArchiveStatus::Expired),
×
74
        _ => {
NEW
75
            warn!("Unknown archive status string: {s}");
×
76
            None
77
        }
78
    })
79
}
80

81
/// Parse an optional IPFS status string from the database into an enum variant.
NEW
82
fn parse_ipfs_status(s: Option<String>) -> Option<IpfsStatus> {
×
NEW
83
    s.as_deref().and_then(|s| match s {
×
NEW
84
        "in_progress" => Some(IpfsStatus::InProgress),
×
NEW
85
        "done" => Some(IpfsStatus::Done),
×
NEW
86
        "error" => Some(IpfsStatus::Error),
×
87
        _ => {
NEW
88
            warn!("Unknown IPFS status string: {s}");
×
89
            None
90
        }
91
    })
92
}
93

94
/// Parse a pin status string from the database into a PinResponseStatus.
NEW
95
fn parse_pin_status(s: String) -> PinResponseStatus {
×
NEW
96
    match s.as_str() {
×
NEW
97
        "queued" => PinResponseStatus::Queued,
×
NEW
98
        "pinning" => PinResponseStatus::Pinning,
×
NEW
99
        "pinned" => PinResponseStatus::Pinned,
×
NEW
100
        "failed" => PinResponseStatus::Failed,
×
NEW
101
        other => {
×
NEW
102
            warn!("Unknown pin status string: {other}");
×
103
            // Default to a safe fallback
104
            PinResponseStatus::Failed
105
        }
106
    }
107
}
108

109
#[derive(Debug, Serialize, Deserialize, Clone)]
110
pub struct BackupTask {
111
    pub task_id: String,
112
    pub created_at: DateTime<Utc>,
113
    pub updated_at: DateTime<Utc>,
114
    pub requestor: String,
115
    pub nft_count: i32,
116
    pub tokens: serde_json::Value,
117
    pub archive_status: Option<ArchiveStatus>,
118
    pub ipfs_status: Option<IpfsStatus>,
119
    pub archive_error_log: Option<String>,
120
    pub ipfs_error_log: Option<String>,
121
    pub archive_fatal_error: Option<String>,
122
    pub ipfs_fatal_error: Option<String>,
123
    pub storage_mode: String,
124
    pub archive_format: Option<String>,
125
    pub expires_at: Option<DateTime<Utc>>,
126
    pub archive_deleted_at: Option<DateTime<Utc>>,
127
    pub pins_deleted_at: Option<DateTime<Utc>>,
128
}
129

130
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
131
#[schema(description = "IPFS pin information for a specific CID")]
132
pub struct PinInfo {
133
    /// Content Identifier (CID) of the pinned content
134
    #[schema(example = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")]
135
    pub cid: String,
136
    /// IPFS provider type where the content is pinned
137
    #[schema(example = "pinata")]
138
    pub provider_type: String,
139
    /// IPFS provider URL where the content is pinned
140
    #[schema(example = "https://api.pinata.cloud")]
141
    pub provider_url: String,
142
    /// Pin status (pinned, pinning, failed, queued)
143
    #[schema(example = "pinned")]
144
    pub status: String,
145
    /// When the pin was created (ISO 8601 timestamp)
146
    #[schema(example = "2024-01-01T12:00:00Z")]
147
    pub created_at: DateTime<Utc>,
148
}
149

150
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
151
#[schema(description = "Token information with associated pin requests")]
152
pub struct TokenWithPins {
153
    /// Blockchain identifier (e.g., ethereum, tezos)
154
    #[schema(example = "ethereum")]
155
    pub chain: String,
156
    /// NFT contract address
157
    #[schema(example = "0x1234567890123456789012345678901234567890")]
158
    pub contract_address: String,
159
    /// NFT token ID
160
    #[schema(example = "123")]
161
    pub token_id: String,
162
    /// List of IPFS pins for this token
163
    pub pins: Vec<PinInfo>,
164
}
165

166
#[derive(Debug, Serialize, Deserialize, Clone)]
167
pub struct PinRow {
168
    pub id: i64,
169
    pub task_id: String,
170
    pub provider_type: String,
171
    pub provider_url: Option<String>,
172
    pub cid: String,
173
    pub request_id: String,
174
    pub pin_status: PinResponseStatus,
175
    pub created_at: DateTime<Utc>,
176
}
177

178
#[derive(Debug, Clone)]
179
pub struct ExpiredBackup {
180
    pub task_id: String,
181
    pub archive_format: String,
182
}
183

184
#[derive(Clone)]
185
pub struct Db {
186
    pub pool: PgPool,
187
}
188

189
impl Db {
190
    pub async fn new(database_url: &str, max_connections: u32) -> Self {
×
191
        let pool = Self::connect_with_retry(database_url, max_connections).await;
×
192
        tracing::info!("Postgres connection is healthy");
×
193

194
        // Apply any pending migrations so a fresh deployment self-provisions its
195
        // schema. Migrations are embedded into the binary at compile time, so this
196
        // works in the distroless runtime image without sqlx-cli or the migrations
197
        // directory present. Already-applied migrations are skipped via the
198
        // _sqlx_migrations table, so this is consistent with `sqlx migrate run`.
199
        sqlx::migrate!("./migrations")
×
200
            .run(&pool)
×
201
            .await
×
202
            .expect("Failed to run database migrations");
203
        tracing::info!("Database migrations applied");
×
204

205
        Db { pool }
206
    }
207

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

215
        for attempt in 1..=MAX_ATTEMPTS {
×
216
            match Self::try_connect(database_url, max_connections).await {
×
217
                Ok(pool) => return pool,
×
218
                Err(e) if attempt < MAX_ATTEMPTS => {
×
219
                    warn!(
×
220
                        "Postgres not ready (attempt {attempt}/{MAX_ATTEMPTS}): {e}. \
×
221
                         Retrying in {RETRY_DELAY:?}..."
×
222
                    );
223
                    tokio::time::sleep(RETRY_DELAY).await;
×
224
                }
225
                Err(e) => {
×
226
                    panic!("Failed to connect to Postgres after {MAX_ATTEMPTS} attempts: {e}")
227
                }
228
            }
229
        }
230
        unreachable!("loop either returns a pool or panics on the final attempt")
231
    }
232

233
    /// Open a pool and verify the connection is usable with a trivial query.
234
    async fn try_connect(database_url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
×
235
        let pool = PgPoolOptions::new()
×
236
            .max_connections(max_connections)
×
237
            .connect(database_url)
×
238
            .await?;
×
239
        sqlx::query("SELECT 1").execute(&pool).await?;
×
240
        Ok(pool)
×
241
    }
242

243
    #[allow(clippy::too_many_arguments)]
244
    pub async fn insert_backup_task(
×
245
        &self,
246
        task_id: &str,
247
        requestor: &str,
248
        nft_count: i32,
249
        tokens: &serde_json::Value,
250
        storage_mode: &str,
251
        archive_format: Option<&str>,
252
        retention_days: Option<u64>,
253
    ) -> Result<(), sqlx::Error> {
254
        let mut tx = self.pool.begin().await?;
×
255

256
        // Insert into backup_tasks (tokens JSON removed; tokens are stored in tokens table)
257
        sqlx::query(
258
            r#"
259
            INSERT INTO backup_tasks (
260
                task_id, created_at, updated_at, requestor, nft_count, storage_mode
261
            ) VALUES (
262
                $1, NOW(), NOW(), $2, $3, $4
263
            )
264
            ON CONFLICT (task_id) DO UPDATE SET
265
                updated_at = NOW(),
266
                nft_count = EXCLUDED.nft_count,
267
                storage_mode = EXCLUDED.storage_mode
268
            "#,
269
        )
270
        .bind(task_id)
271
        .bind(requestor)
272
        .bind(nft_count)
273
        .bind(storage_mode)
274
        .execute(&mut *tx)
275
        .await?;
×
276

277
        // Replace tokens for this task with the provided list (idempotent)
278
        // Expecting JSON shape: Vec<crate::server::api::Tokens>
279
        let token_entries: Vec<crate::server::api::Tokens> =
×
280
            serde_json::from_value(tokens.clone()).unwrap_or_default();
281

282
        for entry in &token_entries {
×
283
            for token_str in &entry.tokens {
×
284
                if let Some((contract_address, token_id)) = token_str.split_once(':') {
×
285
                    sqlx::query(
286
                        r#"INSERT INTO tokens (task_id, chain, contract_address, token_id)
287
                           VALUES ($1, $2, $3, $4)
288
                           ON CONFLICT (task_id, chain, contract_address, token_id) DO NOTHING"#,
289
                    )
290
                    .bind(task_id)
291
                    .bind(&entry.chain)
292
                    .bind(contract_address)
293
                    .bind(token_id)
294
                    .execute(&mut *tx)
295
                    .await?;
×
296
                }
297
            }
298
        }
299

300
        // Insert into archive_requests if storage mode includes archive
301
        if storage_mode == "archive" || storage_mode == "full" {
×
302
            let archive_fmt = archive_format.unwrap_or("zip");
×
303

304
            if let Some(days) = retention_days {
×
305
                sqlx::query(
306
                    r#"
307
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
308
                    VALUES ($1, $2, NOW() + make_interval(days => $3::int), 'in_progress')
309
                    ON CONFLICT (task_id) DO UPDATE SET
310
                        archive_format = EXCLUDED.archive_format,
311
                        expires_at = EXCLUDED.expires_at
312
                    "#,
313
                )
314
                .bind(task_id)
315
                .bind(archive_fmt)
316
                .bind(days as i64)
317
                .execute(&mut *tx)
318
                .await?;
×
319
            } else {
320
                sqlx::query(
321
                    r#"
322
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
323
                    VALUES ($1, $2, NULL, 'in_progress')
324
                    ON CONFLICT (task_id) DO UPDATE SET
325
                        archive_format = EXCLUDED.archive_format,
326
                        expires_at = EXCLUDED.expires_at
327
                    "#,
328
                )
329
                .bind(task_id)
×
330
                .bind(archive_fmt)
×
331
                .execute(&mut *tx)
×
332
                .await?;
×
333
            }
334
        }
335

336
        // Insert into pin_requests if storage mode includes IPFS
337
        if storage_mode == "ipfs" || storage_mode == "full" {
×
338
            sqlx::query(
339
                r#"
340
                INSERT INTO pin_requests (task_id, status)
341
                VALUES ($1, 'in_progress')
342
                ON CONFLICT (task_id) DO UPDATE SET
343
                    status = EXCLUDED.status
344
                "#,
345
            )
346
            .bind(task_id)
347
            .execute(&mut *tx)
348
            .await?;
×
349
        }
350

351
        tx.commit().await?;
×
352
        Ok(())
×
353
    }
354

355
    pub async fn delete_backup_task(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
356
        // CASCADE will delete associated archive_requests row if it exists
357
        sqlx::query!("DELETE FROM backup_tasks WHERE task_id = $1", task_id)
×
358
            .execute(&self.pool)
×
359
            .await?;
×
360
        Ok(())
×
361
    }
362

363
    pub async fn set_error_logs(
×
364
        &self,
365
        task_id: &str,
366
        archive_error_log: Option<&str>,
367
        ipfs_error_log: Option<&str>,
368
    ) -> Result<(), sqlx::Error> {
369
        let mut tx = self.pool.begin().await?;
×
370
        if let Some(a) = archive_error_log {
×
371
            sqlx::query("UPDATE archive_requests SET error_log = $2 WHERE task_id = $1")
372
                .bind(task_id)
373
                .bind(a)
374
                .execute(&mut *tx)
375
                .await?;
×
376
        }
377
        if let Some(i) = ipfs_error_log {
×
378
            sqlx::query(
379
                r#"
380
                    UPDATE pin_requests
381
                    SET error_log = $2
382
                    WHERE task_id = $1
383
                    "#,
384
            )
385
            .bind(task_id)
386
            .bind(i)
387
            .execute(&mut *tx)
388
            .await?;
×
389
        }
390
        tx.commit().await?;
×
391
        Ok(())
×
392
    }
393

394
    pub async fn update_archive_request_error_log(
×
395
        &self,
396
        task_id: &str,
397
        error_log: &str,
398
    ) -> Result<(), sqlx::Error> {
399
        sqlx::query(
400
            r#"
401
            UPDATE archive_requests
402
            SET error_log = $2
403
            WHERE task_id = $1
404
            "#,
405
        )
406
        .bind(task_id)
×
407
        .bind(error_log)
×
408
        .execute(&self.pool)
×
409
        .await?;
×
410
        Ok(())
×
411
    }
412

413
    pub async fn update_pin_request_error_log(
×
414
        &self,
415
        task_id: &str,
416
        error_log: &str,
417
    ) -> Result<(), sqlx::Error> {
418
        sqlx::query(
419
            r#"
420
            UPDATE pin_requests
421
            SET error_log = $2
422
            WHERE task_id = $1
423
            "#,
424
        )
425
        .bind(task_id)
×
426
        .bind(error_log)
×
427
        .execute(&self.pool)
×
428
        .await?;
×
429
        Ok(())
×
430
    }
431

432
    /// Log a human-friendly backup status based on a machine status value and storage mode
433
    fn log_status(task_id: &str, status: &str, mode: &StorageMode) {
×
434
        match status {
×
435
            "done" => info!("Backup {} ready (storage: {})", task_id, mode.as_str()),
×
436
            "error" => warn!("Backup {} errored (storage: {})", task_id, mode.as_str()),
×
437
            _ => (),
×
438
        }
439
    }
440

441
    pub async fn update_pin_request_status(
×
442
        &self,
443
        task_id: &str,
444
        status: &IpfsStatus,
445
    ) -> Result<(), sqlx::Error> {
446
        sqlx::query(
447
            r#"
448
            UPDATE pin_requests
449
            SET status = $2
450
            WHERE task_id = $1
451
            "#,
452
        )
453
        .bind(task_id)
×
NEW
454
        .bind(status.as_str())
×
455
        .execute(&self.pool)
×
456
        .await?;
×
457

NEW
458
        Self::log_status(task_id, status.as_str(), &StorageMode::Ipfs);
×
459

460
        Ok(())
461
    }
462

463
    pub async fn update_archive_request_status(
×
464
        &self,
465
        task_id: &str,
466
        status: &ArchiveStatus,
467
    ) -> Result<(), sqlx::Error> {
468
        sqlx::query(
469
            r#"
470
            UPDATE archive_requests
471
            SET status = $2
472
            WHERE task_id = $1
473
            "#,
474
        )
475
        .bind(task_id)
×
NEW
476
        .bind(status.as_str())
×
477
        .execute(&self.pool)
×
478
        .await?;
×
479

NEW
480
        Self::log_status(task_id, status.as_str(), &StorageMode::Archive);
×
481

482
        Ok(())
483
    }
484

485
    pub async fn update_archive_request_statuses(
×
486
        &self,
487
        task_ids: &[String],
488
        status: &ArchiveStatus,
489
    ) -> Result<(), sqlx::Error> {
490
        if task_ids.is_empty() {
×
491
            return Ok(());
×
492
        }
493

494
        // Use a transaction for atomicity
495
        let mut tx = self.pool.begin().await?;
×
496

497
        // Update each task_id individually with a prepared statement
498
        for task_id in task_ids {
×
499
            sqlx::query("UPDATE archive_requests SET status = $1 WHERE task_id = $2")
×
NEW
500
                .bind(status.as_str())
×
501
                .bind(task_id)
×
502
                .execute(&mut *tx)
×
503
                .await?;
×
504
        }
505

506
        tx.commit().await?;
×
507
        Ok(())
×
508
    }
509

510
    pub async fn retry_backup(
×
511
        &self,
512
        task_id: &str,
513
        scope: &str,
514
        retention_days: u64,
515
    ) -> Result<(), sqlx::Error> {
516
        let mut tx = self.pool.begin().await?;
×
517

518
        // Reset statuses per requested scope
519
        if scope == "archive" || scope == "full" {
×
520
            sqlx::query(
521
                r#"
522
                UPDATE archive_requests
523
                SET status = 'in_progress', fatal_error = NULL, error_log = NULL
524
                WHERE task_id = $1
525
                "#,
526
            )
527
            .bind(task_id)
528
            .execute(&mut *tx)
529
            .await?;
×
530
            sqlx::query(
531
                r#"
532
                UPDATE archive_requests
533
                SET expires_at = NOW() + make_interval(days => $2::int)
534
                WHERE task_id = $1
535
                "#,
536
            )
537
            .bind(task_id)
538
            .bind(retention_days as i64)
539
            .execute(&mut *tx)
540
            .await?;
×
541
        }
542
        if scope == "ipfs" || scope == "full" {
×
543
            sqlx::query(
544
                r#"
545
                UPDATE pin_requests
546
                SET status = 'in_progress', fatal_error = NULL, error_log = NULL
547
                WHERE task_id = $1
548
                "#,
549
            )
550
            .bind(task_id)
551
            .execute(&mut *tx)
552
            .await?;
×
553
        }
554

555
        tx.commit().await?;
×
556
        Ok(())
×
557
    }
558

559
    pub async fn clear_backup_errors(&self, task_id: &str, scope: &str) -> Result<(), sqlx::Error> {
×
560
        let mut tx = self.pool.begin().await?;
×
561
        // Clear archive errors if scope includes archive
562
        sqlx::query(
563
            r#"
564
            UPDATE archive_requests
565
            SET error_log = NULL, fatal_error = NULL
566
            WHERE task_id = $1 AND ($2 IN ('archive', 'full'))
567
            "#,
568
        )
569
        .bind(task_id)
570
        .bind(scope)
571
        .execute(&mut *tx)
572
        .await?;
×
573
        // Clear IPFS errors if scope includes ipfs
574
        sqlx::query(
575
            r#"
576
            UPDATE pin_requests
577
            SET error_log = NULL, fatal_error = NULL
578
            WHERE task_id = $1 AND ($2 IN ('ipfs', 'full'))
579
            "#,
580
        )
581
        .bind(task_id)
582
        .bind(scope)
583
        .execute(&mut *tx)
584
        .await?;
×
585
        tx.commit().await?;
×
586
        Ok(())
×
587
    }
588

589
    pub async fn set_archive_request_error(
×
590
        &self,
591
        task_id: &str,
592
        fatal_error: &str,
593
    ) -> Result<(), sqlx::Error> {
594
        sqlx::query(
595
            r#"
596
            UPDATE archive_requests
597
            SET status = 'error', fatal_error = $2
598
            WHERE task_id = $1
599
            "#,
600
        )
601
        .bind(task_id)
×
602
        .bind(fatal_error)
×
603
        .execute(&self.pool)
×
604
        .await?;
×
605
        Ok(())
×
606
    }
607

608
    pub async fn set_pin_request_error(
×
609
        &self,
610
        task_id: &str,
611
        fatal_error: &str,
612
    ) -> Result<(), sqlx::Error> {
613
        sqlx::query(
614
            r#"
615
            UPDATE pin_requests
616
            SET status = 'error', fatal_error = $2
617
            WHERE task_id = $1
618
            "#,
619
        )
620
        .bind(task_id)
×
621
        .bind(fatal_error)
×
622
        .execute(&self.pool)
×
623
        .await?;
×
624
        Ok(())
×
625
    }
626

627
    pub async fn start_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
628
        let sql = r#"
×
629
            WITH task_mode AS (
×
630
                SELECT storage_mode FROM backup_tasks WHERE task_id = $1
×
631
            ),
632
            touch AS (
×
633
                UPDATE backup_tasks SET updated_at = NOW() WHERE task_id = $1 RETURNING 1
×
634
            ),
635
            ar_inprog AS (
×
636
                SELECT 1 FROM archive_requests ar, task_mode tm
×
637
                WHERE ar.task_id = $1 AND ar.status = 'in_progress'
×
638
                  AND tm.storage_mode IN ('archive','full')
×
639
                LIMIT 1
×
640
            ),
641
            pr_inprog AS (
×
642
                SELECT 1 FROM pin_requests pr, task_mode tm
×
643
                WHERE pr.task_id = $1 AND pr.status = 'in_progress'
×
644
                  AND tm.storage_mode IN ('ipfs','full')
×
645
                LIMIT 1
×
646
            ),
647
            upd_archive AS (
×
648
                UPDATE archive_requests ar
×
649
                SET deleted_at = NOW()
×
650
                WHERE ar.task_id = $1 AND ar.deleted_at IS NULL
×
651
                  AND EXISTS (SELECT 1 FROM task_mode tm WHERE tm.storage_mode IN ('archive','full'))
×
652
                  AND NOT EXISTS (SELECT 1 FROM ar_inprog)
×
653
                RETURNING 1
×
654
            ),
655
            upd_pins AS (
×
656
                UPDATE pin_requests pr
×
657
                SET deleted_at = NOW()
×
658
                WHERE pr.task_id = $1 AND pr.deleted_at IS NULL
×
659
                  AND EXISTS (SELECT 1 FROM task_mode tm WHERE tm.storage_mode IN ('ipfs','full'))
×
660
                  AND NOT EXISTS (SELECT 1 FROM pr_inprog)
×
661
                RETURNING 1
×
662
            )
663
            SELECT EXISTS(SELECT 1 FROM ar_inprog) AS ar_blocked,
×
664
                   EXISTS(SELECT 1 FROM pr_inprog) AS pr_blocked
×
665
        "#;
×
666

667
        let row = sqlx::query(sql).bind(task_id).fetch_one(&self.pool).await?;
×
668
        let ar_blocked: bool = row.get("ar_blocked");
669
        let pr_blocked: bool = row.get("pr_blocked");
670
        if ar_blocked || pr_blocked {
×
671
            return Err(sqlx::Error::Protocol(
×
672
                "in_progress task cannot be deleted".into(),
×
673
            ));
674
        }
675
        Ok(())
676
    }
677

678
    /// Mark archive as being deleted (similar to start_deletion but for archive subresource)
679
    pub async fn start_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
680
        let row = sqlx::query(
681
            r#"
682
            WITH ar_inprog AS (
683
                SELECT 1 FROM archive_requests WHERE task_id = $1 AND status = 'in_progress' LIMIT 1
684
            ), upd AS (
685
                UPDATE archive_requests
686
                SET deleted_at = NOW()
687
                WHERE task_id = $1 AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM ar_inprog)
688
                RETURNING 1
689
            )
690
            SELECT EXISTS(SELECT 1 FROM ar_inprog) AS blocked
691
            "#,
692
        )
693
        .bind(task_id)
×
694
        .fetch_one(&self.pool)
×
695
        .await?;
×
696
        let blocked: bool = row.get("blocked");
697
        if blocked {
698
            return Err(sqlx::Error::Protocol(
×
699
                "in_progress task cannot be deleted".into(),
×
700
            ));
701
        }
702
        Ok(())
703
    }
704

705
    /// Mark IPFS pins as being deleted (similar to start_deletion but for IPFS pins subresource)
706
    pub async fn start_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
707
        let row = sqlx::query(
708
            r#"
709
            WITH pr_inprog AS (
710
                SELECT 1 FROM pin_requests WHERE task_id = $1 AND status = 'in_progress' LIMIT 1
711
            ), upd AS (
712
                UPDATE pin_requests
713
                SET deleted_at = NOW()
714
                WHERE task_id = $1 AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM pr_inprog)
715
                RETURNING 1
716
            )
717
            SELECT EXISTS(SELECT 1 FROM pr_inprog) AS blocked
718
            "#,
719
        )
720
        .bind(task_id)
×
721
        .fetch_one(&self.pool)
×
722
        .await?;
×
723
        let blocked: bool = row.get("blocked");
724
        if blocked {
725
            return Err(sqlx::Error::Protocol(
×
726
                "in_progress task cannot be deleted".into(),
×
727
            ));
728
        }
729
        Ok(())
730
    }
731

732
    pub async fn get_backup_task(&self, task_id: &str) -> Result<Option<BackupTask>, sqlx::Error> {
×
733
        let row = sqlx::query(
734
            r#"
735
            SELECT 
736
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
737
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
738
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
739
                ar.error_log as archive_error_log,
740
                pr.status as ipfs_status,
741
                pr.error_log as ipfs_error_log,
742
                pr.fatal_error as ipfs_fatal_error,
743
                pr.deleted_at as pins_deleted_at
744
            FROM backup_tasks b
745
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
746
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
747
            WHERE b.task_id = $1
748
            "#,
749
        )
750
        .bind(task_id)
×
751
        .fetch_optional(&self.pool)
×
752
        .await?;
×
753

754
        if let Some(row) = row {
×
755
            // Fetch tokens for this task from tokens table and aggregate by chain
756
            let token_rows = sqlx::query(
757
                r#"
758
                SELECT chain, contract_address, token_id
759
                FROM tokens
760
                WHERE task_id = $1
761
                ORDER BY chain, contract_address, token_id
762
                "#,
763
            )
764
            .bind(task_id)
765
            .fetch_all(&self.pool)
766
            .await?;
×
767

768
            use std::collections::BTreeMap;
769
            let mut by_chain: BTreeMap<String, Vec<String>> = BTreeMap::new();
770
            for r in token_rows {
×
771
                let chain: String = r.get("chain");
772
                let contract_address: String = r.get("contract_address");
773
                let token_id: String = r.get("token_id");
774
                by_chain
775
                    .entry(chain)
776
                    .or_default()
777
                    .push(format!("{}:{}", contract_address, token_id));
778
            }
779
            let tokens_json = serde_json::json!(by_chain
780
                .into_iter()
781
                .map(|(chain, toks)| serde_json::json!({
×
782
                    "chain": chain,
×
783
                    "tokens": toks,
×
784
                }))
785
                .collect::<Vec<_>>());
786

787
            Ok(Some(BackupTask {
788
                task_id: row.get("task_id"),
789
                created_at: row.get("created_at"),
790
                updated_at: row.get("updated_at"),
791
                requestor: row.get("requestor"),
792
                nft_count: row.get("nft_count"),
793
                tokens: tokens_json,
794
                archive_status: parse_archive_status(
795
                    row.try_get::<Option<String>, _>("archive_status")
796
                        .ok()
797
                        .flatten(),
798
                ),
799
                ipfs_status: parse_ipfs_status(
800
                    row.try_get::<Option<String>, _>("ipfs_status")
801
                        .ok()
802
                        .flatten(),
803
                ),
804
                archive_error_log: row.get("archive_error_log"),
805
                ipfs_error_log: row.get("ipfs_error_log"),
806
                archive_fatal_error: row.get("fatal_error"),
807
                ipfs_fatal_error: row
808
                    .try_get::<Option<String>, _>("ipfs_fatal_error")
809
                    .ok()
810
                    .flatten(),
811
                storage_mode: row.get("storage_mode"),
812
                archive_format: row.get("archive_format"),
813
                expires_at: row.get("expires_at"),
814
                archive_deleted_at: row.get("archive_deleted_at"),
815
                pins_deleted_at: row.get("pins_deleted_at"),
816
            }))
817
        } else {
818
            Ok(None)
×
819
        }
820
    }
821

822
    /// Fetch backup task plus a paginated slice of its tokens; returns (meta, total_token_count)
823
    pub async fn get_backup_task_with_tokens(
×
824
        &self,
825
        task_id: &str,
826
        limit: i64,
827
        offset: i64,
828
    ) -> Result<Option<(BackupTask, u32)>, sqlx::Error> {
829
        // Base metadata (same as get_backup_task)
830
        let row = sqlx::query(
831
            r#"
832
            SELECT 
833
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
834
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
835
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
836
                ar.error_log as archive_error_log,
837
                pr.status as ipfs_status,
838
                pr.error_log as ipfs_error_log,
839
                pr.fatal_error as ipfs_fatal_error,
840
                pr.deleted_at as pins_deleted_at
841
            FROM backup_tasks b
842
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
843
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
844
            WHERE b.task_id = $1
845
            "#,
846
        )
847
        .bind(task_id)
×
848
        .fetch_optional(&self.pool)
×
849
        .await?;
×
850

851
        let Some(row) = row else { return Ok(None) };
×
852

853
        // Total tokens for pagination
854
        let total_row = sqlx::query!(
×
855
            r#"SELECT COUNT(*) as count FROM tokens WHERE task_id = $1"#,
856
            task_id
857
        )
858
        .fetch_one(&self.pool)
859
        .await?;
×
860
        let total: u32 = total_row.count.unwrap_or(0) as u32;
861

862
        // Page of tokens
863
        let token_rows = sqlx::query(
864
            r#"
865
            SELECT chain, contract_address, token_id
866
            FROM tokens
867
            WHERE task_id = $1
868
            ORDER BY chain, contract_address, token_id
869
            LIMIT $2 OFFSET $3
870
            "#,
871
        )
872
        .bind(task_id)
873
        .bind(limit)
874
        .bind(offset)
875
        .fetch_all(&self.pool)
876
        .await?;
×
877

878
        use std::collections::BTreeMap;
879
        let mut by_chain: BTreeMap<String, Vec<String>> = BTreeMap::new();
880
        for r in token_rows {
×
881
            let chain: String = r.get("chain");
882
            let contract_address: String = r.get("contract_address");
883
            let token_id: String = r.get("token_id");
884
            by_chain
885
                .entry(chain)
886
                .or_default()
887
                .push(format!("{}:{}", contract_address, token_id));
888
        }
889
        let tokens_json = serde_json::json!(by_chain
890
            .into_iter()
891
            .map(|(chain, toks)| serde_json::json!({ "chain": chain, "tokens": toks }))
×
892
            .collect::<Vec<_>>());
893

894
        let meta = BackupTask {
895
            task_id: row.get("task_id"),
896
            created_at: row.get("created_at"),
897
            updated_at: row.get("updated_at"),
898
            requestor: row.get("requestor"),
899
            nft_count: row.get("nft_count"),
900
            tokens: tokens_json,
901
            archive_status: parse_archive_status(
902
                row.try_get::<Option<String>, _>("archive_status")
903
                    .ok()
904
                    .flatten(),
905
            ),
906
            ipfs_status: parse_ipfs_status(
907
                row.try_get::<Option<String>, _>("ipfs_status")
908
                    .ok()
909
                    .flatten(),
910
            ),
911
            archive_error_log: row.get("archive_error_log"),
912
            ipfs_error_log: row.get("ipfs_error_log"),
913
            archive_fatal_error: row.get("fatal_error"),
914
            ipfs_fatal_error: row
915
                .try_get::<Option<String>, _>("ipfs_fatal_error")
916
                .ok()
917
                .flatten(),
918
            storage_mode: row.get("storage_mode"),
919
            archive_format: row.get("archive_format"),
920
            expires_at: row.get("expires_at"),
921
            archive_deleted_at: row.get("archive_deleted_at"),
922
            pins_deleted_at: row.get("pins_deleted_at"),
923
        };
924

925
        Ok(Some((meta, total)))
926
    }
927

928
    pub async fn list_requestor_backup_tasks_paginated(
×
929
        &self,
930
        requestor: &str,
931
        limit: i64,
932
        offset: i64,
933
    ) -> Result<(Vec<BackupTask>, u32), sqlx::Error> {
934
        // Total count
935
        let total_row = sqlx::query!(
×
936
            r#"SELECT COUNT(*) as count FROM backup_tasks b WHERE b.requestor = $1"#,
937
            requestor
938
        )
939
        .fetch_one(&self.pool)
×
940
        .await?;
×
941
        let total: u32 = total_row.count.unwrap_or(0) as u32;
942

943
        let rows = sqlx::query(
944
            r#"
945
            SELECT 
946
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
947
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
948
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
949
                ar.error_log as archive_error_log,
950
                pr.status as ipfs_status,
951
                pr.error_log as ipfs_error_log,
952
                pr.fatal_error as ipfs_fatal_error,
953
                pr.deleted_at as pins_deleted_at
954
            FROM backup_tasks b
955
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
956
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
957
            WHERE b.requestor = $1
958
            ORDER BY b.created_at DESC
959
            LIMIT $2 OFFSET $3
960
            "#,
961
        )
962
        .bind(requestor)
963
        .bind(limit)
964
        .bind(offset)
965
        .fetch_all(&self.pool)
966
        .await?;
×
967

968
        let recs = rows
969
            .into_iter()
970
            .map(|row| {
×
971
                let task_id: String = row.get("task_id");
×
972

973
                BackupTask {
×
974
                    task_id,
×
975
                    created_at: row.get("created_at"),
×
976
                    updated_at: row.get("updated_at"),
×
977
                    requestor: row.get("requestor"),
×
978
                    nft_count: row.get("nft_count"),
×
979
                    // Client should use get_backup_task to get tokens so the tokens
980
                    // can be properly paginated.
981
                    tokens: serde_json::Value::Null,
×
NEW
982
                    archive_status: parse_archive_status(
×
NEW
983
                        row.try_get::<Option<String>, _>("archive_status")
×
NEW
984
                            .ok()
×
NEW
985
                            .flatten(),
×
986
                    ),
NEW
987
                    ipfs_status: parse_ipfs_status(
×
NEW
988
                        row.try_get::<Option<String>, _>("ipfs_status")
×
NEW
989
                            .ok()
×
NEW
990
                            .flatten(),
×
991
                    ),
992
                    archive_error_log: row.get("archive_error_log"),
×
993
                    ipfs_error_log: row.get("ipfs_error_log"),
×
994
                    archive_fatal_error: row.get("fatal_error"),
×
995
                    ipfs_fatal_error: row
×
996
                        .try_get::<Option<String>, _>("ipfs_fatal_error")
×
997
                        .ok()
×
998
                        .flatten(),
×
999
                    storage_mode: row.get("storage_mode"),
×
1000
                    archive_format: row.get("archive_format"),
×
1001
                    expires_at: row.get("expires_at"),
×
1002
                    archive_deleted_at: row.get("archive_deleted_at"),
×
1003
                    pins_deleted_at: row.get("pins_deleted_at"),
×
1004
                }
1005
            })
1006
            .collect();
1007

1008
        Ok((recs, total))
1009
    }
1010

1011
    pub async fn list_unprocessed_expired_backups(
×
1012
        &self,
1013
    ) -> Result<Vec<ExpiredBackup>, sqlx::Error> {
1014
        let rows = sqlx::query(
1015
            r#"
1016
            SELECT b.task_id, ar.archive_format 
1017
            FROM backup_tasks b
1018
            JOIN archive_requests ar ON b.task_id = ar.task_id
1019
            WHERE ar.expires_at IS NOT NULL AND ar.expires_at < NOW() AND ar.status != 'expired'
1020
            "#,
1021
        )
1022
        .fetch_all(&self.pool)
×
1023
        .await?;
×
1024
        let recs = rows
1025
            .into_iter()
1026
            .map(|row| ExpiredBackup {
1027
                task_id: row.get("task_id"),
×
1028
                archive_format: row.get("archive_format"),
×
1029
            })
1030
            .collect();
1031
        Ok(recs)
1032
    }
1033

1034
    /// Retrieve all backup tasks that are in 'in_progress' status
1035
    /// This is used to recover incomplete tasks on server restart
1036
    pub async fn get_incomplete_backup_tasks(&self) -> Result<Vec<BackupTask>, sqlx::Error> {
×
1037
        let rows = sqlx::query(
1038
            r#"
1039
            SELECT 
1040
                b.task_id, b.created_at, b.updated_at, b.requestor, b.nft_count,
1041
                ar.status as archive_status, ar.fatal_error, b.storage_mode,
1042
                ar.archive_format, ar.expires_at, ar.deleted_at as archive_deleted_at,
1043
                ar.error_log as archive_error_log,
1044
                pr.status as ipfs_status,
1045
                pr.error_log as ipfs_error_log,
1046
                pr.deleted_at as pins_deleted_at
1047
            FROM backup_tasks b
1048
            LEFT JOIN archive_requests ar ON b.task_id = ar.task_id
1049
            LEFT JOIN pin_requests pr ON b.task_id = pr.task_id
1050
            WHERE (
1051
                -- Archive-only mode: check archive status (record must exist and be in_progress)
1052
                (b.storage_mode = 'archive' AND ar.status = 'in_progress')
1053
                OR
1054
                -- IPFS-only mode: check IPFS status (record must exist and be in_progress)
1055
                (b.storage_mode = 'ipfs' AND pr.status = 'in_progress')
1056
                OR
1057
                -- Full mode: check both archive and IPFS status (task is incomplete if either is in_progress)
1058
                (b.storage_mode = 'full' AND (ar.status = 'in_progress' OR pr.status = 'in_progress'))
1059
            )
1060
            ORDER BY b.created_at ASC
1061
            "#,
1062
        )
1063
        .fetch_all(&self.pool)
×
1064
        .await?;
×
1065

1066
        // If no incomplete tasks, return early
1067
        if rows.is_empty() {
1068
            return Ok(Vec::new());
×
1069
        }
1070

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

1074
        // Fetch all tokens for these tasks and aggregate by task_id and chain
1075
        use std::collections::BTreeMap;
1076
        let mut tokens_by_task: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
1077

1078
        let token_rows = sqlx::query(
1079
            r#"
1080
            SELECT task_id, chain, contract_address, token_id
1081
            FROM tokens
1082
            WHERE task_id = ANY($1)
1083
            ORDER BY chain, contract_address, token_id
1084
            "#,
1085
        )
1086
        .bind(&task_ids)
1087
        .fetch_all(&self.pool)
1088
        .await?;
×
1089

1090
        for r in token_rows {
×
1091
            let task_id: String = r.get("task_id");
1092
            let chain: String = r.get("chain");
1093
            let contract_address: String = r.get("contract_address");
1094
            let token_id: String = r.get("token_id");
1095
            tokens_by_task
1096
                .entry(task_id)
1097
                .or_default()
1098
                .entry(chain)
1099
                .or_default()
1100
                .push(format!("{}:{}", contract_address, token_id));
1101
        }
1102

1103
        let recs = rows
1104
            .into_iter()
1105
            .map(|row| {
×
1106
                let task_id: String = row.get("task_id");
×
1107
                let tokens_json = if let Some(by_chain) = tokens_by_task.get(&task_id) {
×
1108
                    serde_json::json!(by_chain
1109
                        .iter()
1110
                        .map(|(chain, toks)| serde_json::json!({
×
1111
                            "chain": chain,
×
1112
                            "tokens": toks,
×
1113
                        }))
1114
                        .collect::<Vec<_>>())
1115
                } else {
1116
                    // No tokens recorded for this task
1117
                    serde_json::json!([])
×
1118
                };
1119

1120
                BackupTask {
×
1121
                    task_id,
×
1122
                    created_at: row.get("created_at"),
×
1123
                    updated_at: row.get("updated_at"),
×
1124
                    requestor: row.get("requestor"),
×
1125
                    nft_count: row.get("nft_count"),
×
1126
                    tokens: tokens_json,
×
NEW
1127
                    archive_status: parse_archive_status(
×
NEW
1128
                        row.try_get::<Option<String>, _>("archive_status")
×
NEW
1129
                            .ok()
×
NEW
1130
                            .flatten(),
×
1131
                    ),
NEW
1132
                    ipfs_status: parse_ipfs_status(
×
NEW
1133
                        row.try_get::<Option<String>, _>("ipfs_status")
×
NEW
1134
                            .ok()
×
NEW
1135
                            .flatten(),
×
1136
                    ),
1137
                    archive_error_log: row.get("archive_error_log"),
×
1138
                    ipfs_error_log: row.get("ipfs_error_log"),
×
1139
                    archive_fatal_error: row.get("fatal_error"),
×
1140
                    ipfs_fatal_error: None,
×
1141
                    storage_mode: row.get("storage_mode"),
×
1142
                    archive_format: row.get("archive_format"),
×
1143
                    expires_at: row.get("expires_at"),
×
1144
                    archive_deleted_at: row.get("archive_deleted_at"),
×
1145
                    pins_deleted_at: row.get("pins_deleted_at"),
×
1146
                }
1147
            })
1148
            .collect();
1149

1150
        Ok(recs)
1151
    }
1152

1153
    /// Insert pins and their associated tokens in a single atomic transaction
1154
    pub async fn insert_pins_with_tokens(
×
1155
        &self,
1156
        task_id: &str,
1157
        token_pin_mappings: &[crate::TokenPinMapping],
1158
    ) -> Result<(), sqlx::Error> {
1159
        // Collect all pin responses and prepare token data
1160
        let mut all_pin_responses = Vec::new();
×
1161
        let mut all_token_data = Vec::new(); // (index_in_pin_responses, chain, contract_address, token_id)
×
1162

1163
        for mapping in token_pin_mappings {
×
1164
            for pin_response in &mapping.pin_responses {
×
1165
                let index = all_pin_responses.len();
1166
                all_pin_responses.push(pin_response);
1167
                all_token_data.push((
1168
                    index,
1169
                    mapping.chain.clone(),
1170
                    mapping.contract_address.clone(),
1171
                    mapping.token_id.clone(),
1172
                ));
1173
            }
1174
        }
1175

1176
        // If no pin responses to insert, that's an error condition
1177
        if all_pin_responses.is_empty() {
×
1178
            return Err(sqlx::Error::RowNotFound);
×
1179
        }
1180

1181
        // Start a transaction for atomicity
1182
        let mut tx = self.pool.begin().await?;
×
1183

1184
        // Insert pins with token_id from the start
1185
        for (index, chain, contract_address, token_id) in &all_token_data {
×
1186
            let pin_response = all_pin_responses[*index];
×
1187

1188
            // Map status enum to lowercase string to satisfy CHECK constraint
1189
            let status = match pin_response.status {
×
1190
                crate::ipfs::PinResponseStatus::Queued => "queued",
×
1191
                crate::ipfs::PinResponseStatus::Pinning => "pinning",
×
1192
                crate::ipfs::PinResponseStatus::Pinned => "pinned",
×
1193
                crate::ipfs::PinResponseStatus::Failed => "failed",
×
1194
            };
1195

1196
            // Ensure token row exists and fetch its id
1197
            let inserted = sqlx::query(
1198
                r#"INSERT INTO tokens (task_id, chain, contract_address, token_id)
1199
                   VALUES ($1, $2, $3, $4)
1200
                   ON CONFLICT (task_id, chain, contract_address, token_id) DO NOTHING
1201
                   RETURNING id"#,
1202
            )
1203
            .bind(task_id)
×
1204
            .bind(chain)
×
1205
            .bind(contract_address)
×
1206
            .bind(token_id)
×
1207
            .fetch_optional(&mut *tx)
×
1208
            .await?;
×
1209

1210
            let tok_id: i64 = if let Some(row) = inserted {
×
1211
                row.get("id")
1212
            } else {
1213
                sqlx::query("SELECT id FROM tokens WHERE task_id = $1 AND chain = $2 AND contract_address = $3 AND token_id = $4")
×
1214
                    .bind(task_id)
×
1215
                    .bind(chain)
×
1216
                    .bind(contract_address)
×
1217
                    .bind(token_id)
×
1218
                    .fetch_one(&mut *tx)
×
1219
                    .await?
×
1220
                    .get("id")
1221
            };
1222

1223
            // Insert pin with token_id from the start
1224
            sqlx::query(
1225
                "INSERT INTO pins (task_id, token_id, provider_type, provider_url, cid, request_id, pin_status) VALUES ($1, $2, $3, $4, $5, $6, $7)"
1226
            )
1227
            .bind(task_id)
1228
            .bind(tok_id)
1229
            .bind(&pin_response.provider_type)
1230
            .bind(&pin_response.provider_url)
1231
            .bind(&pin_response.cid)
1232
            .bind(&pin_response.id)
1233
            .bind(status)
1234
            .execute(&mut *tx)
1235
            .await?;
×
1236
        }
1237

1238
        // Commit the transaction
1239
        tx.commit().await?;
×
1240
        Ok(())
×
1241
    }
1242

1243
    /// Get all pins for a specific backup task
1244
    pub async fn get_pins_by_task_id(&self, task_id: &str) -> Result<Vec<PinRow>, sqlx::Error> {
×
1245
        let rows = sqlx::query(
1246
            r#"
1247
            SELECT id, task_id, provider_type, provider_url, cid, request_id, pin_status, created_at
1248
            FROM pins
1249
            WHERE task_id = $1
1250
            ORDER BY id
1251
            "#,
1252
        )
1253
        .bind(task_id)
×
1254
        .fetch_all(&self.pool)
×
1255
        .await?;
×
1256

1257
        Ok(rows
1258
            .into_iter()
1259
            .map(|row| PinRow {
1260
                id: row.get("id"),
×
1261
                task_id: row.get("task_id"),
×
1262
                provider_type: row.get("provider_type"),
×
1263
                provider_url: row
×
1264
                    .try_get::<Option<String>, _>("provider_url")
×
1265
                    .ok()
×
1266
                    .flatten(),
×
1267
                cid: row.get("cid"),
×
1268
                request_id: row.get("request_id"),
×
NEW
1269
                pin_status: parse_pin_status(row.get("pin_status")),
×
1270
                created_at: row.get("created_at"),
×
1271
            })
1272
            .collect())
1273
    }
1274

1275
    /// Paginated pinned tokens grouped by (chain, contract_address, token_id)
1276
    pub async fn get_pinned_tokens_by_requestor(
×
1277
        &self,
1278
        requestor: &str,
1279
        limit: i64,
1280
        offset: i64,
1281
    ) -> Result<(Vec<TokenWithPins>, u32), sqlx::Error> {
1282
        // Total distinct tokens for this requestor
1283
        let total_row = sqlx::query(
1284
            r#"
1285
            SELECT COUNT(*) as count
1286
            FROM (
1287
                SELECT DISTINCT t.chain, t.contract_address, t.token_id
1288
                FROM tokens t
1289
                JOIN pins p ON p.token_id = t.id
1290
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1291
                WHERE bt.requestor = $1
1292
            ) t
1293
            "#,
1294
        )
1295
        .bind(requestor)
×
1296
        .fetch_one(&self.pool)
×
1297
        .await?;
×
1298
        let total: u32 = (total_row.get::<i64, _>("count")).max(0) as u32;
1299

1300
        // Page of distinct tokens ordered by most recent pin time
1301
        let rows = sqlx::query(
1302
            r#"
1303
            SELECT t.chain, t.contract_address, t.token_id
1304
            FROM (
1305
                SELECT t.chain, t.contract_address, t.token_id, MAX(p.created_at) AS last_created
1306
                FROM tokens t
1307
                JOIN pins p ON p.token_id = t.id
1308
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1309
                WHERE bt.requestor = $1
1310
                GROUP BY t.chain, t.contract_address, t.token_id
1311
            ) t
1312
            ORDER BY last_created DESC
1313
            LIMIT $2 OFFSET $3
1314
            "#,
1315
        )
1316
        .bind(requestor)
1317
        .bind(limit)
1318
        .bind(offset)
1319
        .fetch_all(&self.pool)
1320
        .await?;
×
1321

1322
        // For each token key, fetch pins (ordered by created_at desc)
1323
        let mut result: Vec<TokenWithPins> = Vec::new();
1324
        for r in rows {
×
1325
            let token_rows = sqlx::query(
1326
                r#"
1327
                SELECT t.chain, t.contract_address, t.token_id,
1328
                       p.cid, p.provider_type, p.provider_url, p.pin_status, p.created_at
1329
                FROM tokens t
1330
                JOIN pins p ON p.token_id = t.id
1331
                JOIN backup_tasks bt ON bt.task_id = p.task_id
1332
                WHERE bt.requestor = $1
1333
                  AND t.chain = $2
1334
                  AND t.contract_address = $3
1335
                  AND t.token_id = $4
1336
                ORDER BY p.created_at DESC
1337
                "#,
1338
            )
1339
            .bind(requestor)
×
1340
            .bind(r.get::<String, _>("chain"))
×
1341
            .bind(r.get::<String, _>("contract_address"))
×
1342
            .bind(r.get::<String, _>("token_id"))
×
1343
            .fetch_all(&self.pool)
×
1344
            .await?;
×
1345

1346
            let mut pins: Vec<PinInfo> = Vec::new();
1347
            let mut chain = String::new();
1348
            let mut contract_address = String::new();
1349
            let mut token_id = String::new();
1350
            for row in token_rows {
×
1351
                chain = row.get("chain");
1352
                contract_address = row.get("contract_address");
1353
                token_id = row.get("token_id");
1354
                let cid: String = row.get("cid");
1355
                let provider_type: String = row.get("provider_type");
1356
                let provider_url: String = row
1357
                    .try_get::<Option<String>, _>("provider_url")
1358
                    .ok()
1359
                    .flatten()
1360
                    .unwrap_or_default();
1361
                let status: String = row.get("pin_status");
1362
                let created_at: DateTime<Utc> = row.get("created_at");
1363
                pins.push(PinInfo {
1364
                    cid,
1365
                    provider_type,
1366
                    provider_url,
1367
                    status,
1368
                    created_at,
1369
                });
1370
            }
1371
            result.push(TokenWithPins {
1372
                chain,
1373
                contract_address,
1374
                token_id,
1375
                pins,
1376
            });
1377
        }
1378

1379
        Ok((result, total))
×
1380
    }
1381

1382
    /// Get a specific pinned token for a requestor
1383
    pub async fn get_pinned_token_by_requestor(
×
1384
        &self,
1385
        requestor: &str,
1386
        chain: &str,
1387
        contract_address: &str,
1388
        token_id: &str,
1389
    ) -> Result<Option<TokenWithPins>, sqlx::Error> {
1390
        let query = r#"
×
1391
            SELECT t.chain, t.contract_address, t.token_id,
×
1392
                   p.cid, p.provider_type, p.provider_url, p.pin_status, p.created_at
×
1393
            FROM tokens t
×
1394
            JOIN pins p ON p.token_id = t.id
×
1395
            JOIN backup_tasks bt ON bt.task_id = p.task_id
×
1396
            WHERE bt.requestor = $1
×
1397
              AND t.chain = $2
×
1398
              AND t.contract_address = $3
×
1399
              AND t.token_id = $4
×
1400
            ORDER BY p.created_at DESC
×
1401
        "#;
×
1402

1403
        let rows = sqlx::query(query)
×
1404
            .bind(requestor)
×
1405
            .bind(chain)
×
1406
            .bind(contract_address)
×
1407
            .bind(token_id)
×
1408
            .fetch_all(&self.pool)
×
1409
            .await?;
×
1410

1411
        if rows.is_empty() {
×
1412
            return Ok(None);
×
1413
        }
1414

1415
        let mut pins = Vec::new();
×
1416
        let mut token_chain = String::new();
×
1417
        let mut token_contract_address = String::new();
×
1418
        let mut token_token_id = String::new();
×
1419

1420
        for row in rows {
×
1421
            token_chain = row.get("chain");
×
1422
            token_contract_address = row.get("contract_address");
×
1423
            token_token_id = row.get("token_id");
×
1424
            let cid: String = row.get("cid");
×
1425
            let provider_type: String = row.get("provider_type");
×
1426
            // provider_url may be NULL for legacy rows; default to empty string for API stability
1427
            let provider_url: String = row
×
1428
                .try_get::<Option<String>, _>("provider_url")
1429
                .ok()
1430
                .flatten()
1431
                .unwrap_or_default();
1432
            let status: String = row.get("pin_status");
×
1433
            let created_at: DateTime<Utc> = row.get("created_at");
×
1434

1435
            pins.push(PinInfo {
×
1436
                cid,
×
1437
                provider_type,
×
1438
                provider_url,
×
1439
                status,
×
1440
                created_at,
×
1441
            });
1442
        }
1443

1444
        Ok(Some(TokenWithPins {
×
1445
            chain: token_chain,
×
1446
            contract_address: token_contract_address,
×
1447
            token_id: token_token_id,
×
1448
            pins,
×
1449
        }))
1450
    }
1451

1452
    /// Get all pins that are in 'queued' or 'pinning' status
1453
    /// This is used by the pin monitor to check for status updates
1454
    pub async fn get_active_pins(&self) -> Result<Vec<PinRow>, sqlx::Error> {
×
1455
        let rows = sqlx::query(
1456
            r#"
1457
            SELECT id, task_id, provider_type, provider_url, cid, request_id, pin_status, created_at
1458
            FROM pins
1459
            WHERE pin_status IN ('queued', 'pinning')
1460
            ORDER BY id
1461
            "#,
1462
        )
1463
        .fetch_all(&self.pool)
×
1464
        .await?;
×
1465

1466
        Ok(rows
1467
            .into_iter()
1468
            .map(|row| PinRow {
1469
                id: row.get("id"),
×
1470
                task_id: row.get("task_id"),
×
1471
                provider_type: row.get("provider_type"),
×
1472
                provider_url: row
×
1473
                    .try_get::<Option<String>, _>("provider_url")
×
1474
                    .ok()
×
1475
                    .flatten(),
×
1476
                cid: row.get("cid"),
×
1477
                request_id: row.get("request_id"),
×
NEW
1478
                pin_status: parse_pin_status(row.get("pin_status")),
×
1479
                created_at: row.get("created_at"),
×
1480
            })
1481
            .collect())
1482
    }
1483

1484
    /// Set backup fatal error for relevant subresources in a single SQL statement.
1485
    /// The update is based on the `storage_mode` value from the `backup_tasks` table for the given `task_id`:
1486
    /// - If storage_mode is 'archive' or 'full': updates archive_requests.status and archive_requests.fatal_error
1487
    /// - If storage_mode is 'ipfs' or 'full': updates pin_requests.status and pin_requests.fatal_error
1488
    pub async fn set_backup_error(
×
1489
        &self,
1490
        task_id: &str,
1491
        fatal_error: &str,
1492
    ) -> Result<(), sqlx::Error> {
1493
        let sql = r#"
×
1494
            WITH task_mode AS (
×
1495
                SELECT storage_mode FROM backup_tasks WHERE task_id = $1
×
1496
            ),
1497
            upd_archive AS (
×
1498
                UPDATE archive_requests ar
×
1499
                SET status = 'error', fatal_error = $2
×
1500
                WHERE ar.task_id = $1
×
1501
                  AND EXISTS (
×
1502
                      SELECT 1 FROM task_mode tm
×
1503
                      WHERE tm.storage_mode IN ('archive', 'full')
×
1504
                  )
1505
                RETURNING 1
×
1506
            ),
1507
            upd_pins AS (
×
1508
                UPDATE pin_requests pr
×
1509
                SET status = 'error', fatal_error = $2
×
1510
                WHERE pr.task_id = $1
×
1511
                  AND EXISTS (
×
1512
                      SELECT 1 FROM task_mode tm
×
1513
                      WHERE tm.storage_mode IN ('ipfs', 'full')
×
1514
                  )
1515
                RETURNING 1
×
1516
            )
1517
            SELECT COALESCE((SELECT COUNT(*) FROM upd_archive), 0) AS archive_updates,
×
1518
                   COALESCE((SELECT COUNT(*) FROM upd_pins), 0)     AS pin_updates
×
1519
        "#;
×
1520
        sqlx::query(sql)
×
1521
            .bind(task_id)
×
1522
            .bind(fatal_error)
×
1523
            .execute(&self.pool)
×
1524
            .await?;
×
1525
        Ok(())
×
1526
    }
1527

1528
    /// Update backup subresource statuses for the task based on its storage mode
1529
    /// - archive or full: updates archive_requests.status
1530
    /// - ipfs or full: updates pin_requests.status
1531
    pub async fn update_backup_statuses(
×
1532
        &self,
1533
        task_id: &str,
1534
        scope: &str,
1535
        archive_status: &ArchiveStatus,
1536
        ipfs_status: &IpfsStatus,
1537
    ) -> Result<(), sqlx::Error> {
1538
        let sql = r#"
×
1539
            WITH upd_archive AS (
×
1540
                UPDATE archive_requests ar
×
1541
                SET status = $2
×
1542
                WHERE ar.task_id = $1
×
1543
                  AND ($4 IN ('archive', 'full'))
×
1544
                RETURNING 1
×
1545
            ),
1546
            upd_pins AS (
×
1547
                UPDATE pin_requests pr
×
1548
                SET status = $3
×
1549
                WHERE pr.task_id = $1
×
1550
                  AND ($4 IN ('ipfs', 'full'))
×
1551
                RETURNING 1
×
1552
            )
1553
            SELECT COALESCE((SELECT COUNT(*) FROM upd_archive), 0) AS archive_updates,
×
1554
                   COALESCE((SELECT COUNT(*) FROM upd_pins), 0)     AS pin_updates
×
1555
        "#;
×
1556
        sqlx::query(sql)
×
1557
            .bind(task_id)
×
NEW
1558
            .bind(archive_status.as_str())
×
NEW
1559
            .bind(ipfs_status.as_str())
×
1560
            .bind(scope)
×
1561
            .execute(&self.pool)
×
1562
            .await?;
×
1563
        Ok(())
×
1564
    }
1565

1566
    pub async fn update_pin_statuses(&self, updates: &[(i64, String)]) -> Result<(), sqlx::Error> {
×
1567
        if updates.is_empty() {
×
1568
            return Ok(());
×
1569
        }
1570

1571
        let mut tx = self.pool.begin().await?;
×
1572

1573
        for (id, status) in updates {
×
1574
            sqlx::query(
1575
                r#"
1576
                UPDATE pins
1577
                SET pin_status = $2
1578
                WHERE id = $1
1579
                "#,
1580
            )
1581
            .bind(id)
×
1582
            .bind(status)
×
1583
            .execute(&mut *tx)
×
1584
            .await?;
×
1585
        }
1586

1587
        tx.commit().await?;
×
1588
        Ok(())
×
1589
    }
1590

1591
    /// Ensure the missing subresource exists and upgrade the backup to full storage mode.
1592
    /// If `add_archive` is true, create/ensure archive_requests row with provided format/retention.
1593
    /// Otherwise, ensure pin_requests row exists. Always flips backup_tasks.storage_mode to 'full'.
1594
    pub async fn upgrade_backup_to_full(
×
1595
        &self,
1596
        task_id: &str,
1597
        add_archive: bool,
1598
        archive_format: Option<&str>,
1599
        retention_days: Option<u64>,
1600
    ) -> Result<(), sqlx::Error> {
1601
        let mut tx = self.pool.begin().await?;
×
1602

1603
        // Upgrade storage mode to full
1604
        sqlx::query(
1605
            r#"
1606
            UPDATE backup_tasks
1607
            SET storage_mode = 'full', updated_at = NOW()
1608
            WHERE task_id = $1
1609
            "#,
1610
        )
1611
        .bind(task_id)
1612
        .execute(&mut *tx)
1613
        .await?;
×
1614

1615
        if add_archive {
×
1616
            let fmt = archive_format.unwrap_or("zip");
×
1617
            if let Some(days) = retention_days {
×
1618
                sqlx::query(
1619
                    r#"
1620
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
1621
                    VALUES ($1, $2, NOW() + make_interval(days => $3::int), 'in_progress')
1622
                    ON CONFLICT (task_id) DO NOTHING
1623
                    "#,
1624
                )
1625
                .bind(task_id)
1626
                .bind(fmt)
1627
                .bind(days as i64)
1628
                .execute(&mut *tx)
1629
                .await?;
×
1630
            } else {
1631
                sqlx::query(
1632
                    r#"
1633
                    INSERT INTO archive_requests (task_id, archive_format, expires_at, status)
1634
                    VALUES ($1, $2, NULL, 'in_progress')
1635
                    ON CONFLICT (task_id) DO NOTHING
1636
                    "#,
1637
                )
1638
                .bind(task_id)
×
1639
                .bind(fmt)
×
1640
                .execute(&mut *tx)
×
1641
                .await?;
×
1642
            }
1643
        } else {
1644
            sqlx::query(
1645
                r#"
1646
                INSERT INTO pin_requests (task_id, status)
1647
                VALUES ($1, 'in_progress')
1648
                ON CONFLICT (task_id) DO NOTHING
1649
                "#,
1650
            )
1651
            .bind(task_id)
1652
            .execute(&mut *tx)
1653
            .await?;
×
1654
        }
1655

1656
        tx.commit().await?;
×
1657
        Ok(())
×
1658
    }
1659

1660
    /// Complete archive deletion:
1661
    /// - If current storage_mode is 'archive', delete the whole backup (finalize deletion)
1662
    /// - Else if current storage_mode is 'full', flip to 'ipfs' to reflect archive removed
1663
    pub async fn complete_archive_request_deletion(
×
1664
        &self,
1665
        task_id: &str,
1666
    ) -> Result<(), sqlx::Error> {
1667
        // Atomically: delete when archive-only; else if full, flip to ipfs
1668
        let sql = r#"
×
1669
            WITH del AS (
×
1670
                DELETE FROM backup_tasks
×
1671
                WHERE task_id = $1 AND storage_mode = 'archive'
×
1672
                RETURNING 1
×
1673
            ), upd AS (
×
1674
                UPDATE backup_tasks
×
1675
                SET storage_mode = 'ipfs', updated_at = NOW()
×
1676
                WHERE task_id = $1 AND storage_mode = 'full' AND NOT EXISTS (SELECT 1 FROM del)
×
1677
                RETURNING 1
×
1678
            )
1679
            SELECT COALESCE((SELECT COUNT(*) FROM del), 0) AS deleted,
×
1680
                   COALESCE((SELECT COUNT(*) FROM upd), 0) AS updated
×
1681
        "#;
×
1682
        let _ = sqlx::query(sql).bind(task_id).execute(&self.pool).await?;
×
1683
        Ok(())
×
1684
    }
1685

1686
    /// Complete IPFS pins deletion:
1687
    /// - If current storage_mode is 'ipfs', delete the whole backup (finalize deletion)
1688
    /// - Else if current storage_mode is 'full', flip to 'archive' to reflect pins removed
1689
    pub async fn complete_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1690
        // Atomically: delete when ipfs-only; else if full, flip to archive
1691
        let sql = r#"
×
1692
            WITH del AS (
×
1693
                DELETE FROM backup_tasks
×
1694
                WHERE task_id = $1 AND storage_mode = 'ipfs'
×
1695
                RETURNING 1
×
1696
            ), upd AS (
×
1697
                UPDATE backup_tasks
×
1698
                SET storage_mode = 'archive', updated_at = NOW()
×
1699
                WHERE task_id = $1 AND storage_mode = 'full' AND NOT EXISTS (SELECT 1 FROM del)
×
1700
                RETURNING 1
×
1701
            )
1702
            SELECT COALESCE((SELECT COUNT(*) FROM del), 0) AS deleted,
×
1703
                   COALESCE((SELECT COUNT(*) FROM upd), 0) AS updated
×
1704
        "#;
×
1705
        let _ = sqlx::query(sql).bind(task_id).execute(&self.pool).await?;
×
1706
        Ok(())
×
1707
    }
1708
}
1709

1710
// Implement the unified Database trait for the real Db struct
1711
#[async_trait::async_trait]
1712
impl Database for Db {
1713
    // Backup task operations
1714

1715
    async fn insert_backup_task(
1716
        &self,
1717
        task_id: &str,
1718
        requestor: &str,
1719
        nft_count: i32,
1720
        tokens: &serde_json::Value,
1721
        storage_mode: &str,
1722
        archive_format: Option<&str>,
1723
        retention_days: Option<u64>,
1724
    ) -> Result<(), sqlx::Error> {
1725
        Db::insert_backup_task(
1726
            self,
1727
            task_id,
1728
            requestor,
1729
            nft_count,
1730
            tokens,
1731
            storage_mode,
1732
            archive_format,
1733
            retention_days,
1734
        )
1735
        .await
1736
    }
1737

1738
    async fn get_backup_task(&self, task_id: &str) -> Result<Option<BackupTask>, sqlx::Error> {
×
1739
        Db::get_backup_task(self, task_id).await
1740
    }
1741

1742
    async fn get_backup_task_with_tokens(
1743
        &self,
1744
        task_id: &str,
1745
        limit: i64,
1746
        offset: i64,
1747
    ) -> Result<Option<(BackupTask, u32)>, sqlx::Error> {
1748
        Db::get_backup_task_with_tokens(self, task_id, limit, offset).await
1749
    }
1750

1751
    async fn delete_backup_task(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1752
        Db::delete_backup_task(self, task_id).await
1753
    }
1754

1755
    async fn get_incomplete_backup_tasks(&self) -> Result<Vec<BackupTask>, sqlx::Error> {
×
1756
        Db::get_incomplete_backup_tasks(self).await
1757
    }
1758

1759
    async fn list_requestor_backup_tasks_paginated(
1760
        &self,
1761
        requestor: &str,
1762
        limit: i64,
1763
        offset: i64,
1764
    ) -> Result<(Vec<BackupTask>, u32), sqlx::Error> {
1765
        Db::list_requestor_backup_tasks_paginated(self, requestor, limit, offset).await
1766
    }
1767

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

1772
    // Backup task status and error operations
1773
    async fn clear_backup_errors(&self, task_id: &str, scope: &str) -> Result<(), sqlx::Error> {
×
1774
        Db::clear_backup_errors(self, task_id, scope).await
1775
    }
1776

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

1781
    async fn set_error_logs(
1782
        &self,
1783
        task_id: &str,
1784
        archive_error_log: Option<&str>,
1785
        ipfs_error_log: Option<&str>,
1786
    ) -> Result<(), sqlx::Error> {
1787
        Db::set_error_logs(self, task_id, archive_error_log, ipfs_error_log).await
1788
    }
1789

1790
    async fn update_archive_request_error_log(
1791
        &self,
1792
        task_id: &str,
1793
        error_log: &str,
1794
    ) -> Result<(), sqlx::Error> {
1795
        Db::update_archive_request_error_log(self, task_id, error_log).await
1796
    }
1797

1798
    async fn update_pin_request_error_log(
1799
        &self,
1800
        task_id: &str,
1801
        error_log: &str,
1802
    ) -> Result<(), sqlx::Error> {
1803
        Db::update_pin_request_error_log(self, task_id, error_log).await
1804
    }
1805

1806
    async fn set_archive_request_error(
1807
        &self,
1808
        task_id: &str,
1809
        fatal_error: &str,
1810
    ) -> Result<(), sqlx::Error> {
1811
        Db::set_archive_request_error(self, task_id, fatal_error).await
1812
    }
1813

1814
    async fn set_pin_request_error(
1815
        &self,
1816
        task_id: &str,
1817
        fatal_error: &str,
1818
    ) -> Result<(), sqlx::Error> {
1819
        Db::set_pin_request_error(self, task_id, fatal_error).await
1820
    }
1821

1822
    // Status update operations
1823
    async fn update_archive_request_status(
1824
        &self,
1825
        task_id: &str,
1826
        status: &ArchiveStatus,
1827
    ) -> Result<(), sqlx::Error> {
1828
        Db::update_archive_request_status(self, task_id, status).await
1829
    }
1830

1831
    async fn update_pin_request_status(
1832
        &self,
1833
        task_id: &str,
1834
        status: &IpfsStatus,
1835
    ) -> Result<(), sqlx::Error> {
1836
        Db::update_pin_request_status(self, task_id, status).await
1837
    }
1838

1839
    async fn update_backup_statuses(
1840
        &self,
1841
        task_id: &str,
1842
        scope: &str,
1843
        archive_status: &ArchiveStatus,
1844
        ipfs_status: &IpfsStatus,
1845
    ) -> Result<(), sqlx::Error> {
1846
        Db::update_backup_statuses(self, task_id, scope, archive_status, ipfs_status).await
1847
    }
1848

1849
    async fn update_archive_request_statuses(
1850
        &self,
1851
        task_ids: &[String],
1852
        status: &ArchiveStatus,
1853
    ) -> Result<(), sqlx::Error> {
1854
        Db::update_archive_request_statuses(self, task_ids, status).await
1855
    }
1856

1857
    async fn upgrade_backup_to_full(
1858
        &self,
1859
        task_id: &str,
1860
        add_archive: bool,
1861
        archive_format: Option<&str>,
1862
        retention_days: Option<u64>,
1863
    ) -> Result<(), sqlx::Error> {
1864
        Db::upgrade_backup_to_full(self, task_id, add_archive, archive_format, retention_days).await
1865
    }
1866

1867
    // Deletion operations
1868
    async fn start_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1869
        Db::start_deletion(self, task_id).await
1870
    }
1871

1872
    async fn start_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1873
        Db::start_archive_request_deletion(self, task_id).await
1874
    }
1875

1876
    async fn start_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1877
        Db::start_pin_request_deletion(self, task_id).await
1878
    }
1879

1880
    async fn complete_archive_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1881
        Db::complete_archive_request_deletion(self, task_id).await
1882
    }
1883

1884
    async fn complete_pin_request_deletion(&self, task_id: &str) -> Result<(), sqlx::Error> {
×
1885
        Db::complete_pin_request_deletion(self, task_id).await
1886
    }
1887

1888
    // Retry operations
1889
    async fn retry_backup(
1890
        &self,
1891
        task_id: &str,
1892
        scope: &str,
1893
        retention_days: u64,
1894
    ) -> Result<(), sqlx::Error> {
1895
        Db::retry_backup(self, task_id, scope, retention_days).await
1896
    }
1897

1898
    // Pin operations
1899
    async fn insert_pins_with_tokens(
1900
        &self,
1901
        task_id: &str,
1902
        token_pin_mappings: &[crate::TokenPinMapping],
1903
    ) -> Result<(), sqlx::Error> {
1904
        Db::insert_pins_with_tokens(self, task_id, token_pin_mappings).await
1905
    }
1906

1907
    async fn get_pins_by_task_id(&self, task_id: &str) -> Result<Vec<PinRow>, sqlx::Error> {
×
1908
        Db::get_pins_by_task_id(self, task_id).await
1909
    }
1910

1911
    async fn get_active_pins(&self) -> Result<Vec<PinRow>, sqlx::Error> {
×
1912
        Db::get_active_pins(self).await
1913
    }
1914

1915
    async fn update_pin_statuses(&self, updates: &[(i64, String)]) -> Result<(), sqlx::Error> {
×
1916
        Db::update_pin_statuses(self, updates).await
1917
    }
1918

1919
    // Pinned tokens operations
1920
    async fn get_pinned_tokens_by_requestor(
1921
        &self,
1922
        requestor: &str,
1923
        limit: i64,
1924
        offset: i64,
1925
    ) -> Result<(Vec<TokenWithPins>, u32), sqlx::Error> {
1926
        Db::get_pinned_tokens_by_requestor(self, requestor, limit, offset).await
1927
    }
1928

1929
    async fn get_pinned_token_by_requestor(
1930
        &self,
1931
        requestor: &str,
1932
        chain: &str,
1933
        contract_address: &str,
1934
        token_id: &str,
1935
    ) -> Result<Option<TokenWithPins>, sqlx::Error> {
1936
        Db::get_pinned_token_by_requestor(self, requestor, chain, contract_address, token_id).await
1937
    }
1938
}
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