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

0xmichalis / nftbk / 27233185475

09 Jun 2026 08:17PM UTC coverage: 47.619% (-0.3%) from 47.891%
27233185475

push

github

web-flow
refactor: move status types from strings to enums (#108)

* refactor: move status types from strings to enums

Define ArchiveStatus and IpfsStatus enums with serde snake_case
serialization (matching existing DB string values). Update BackupTask,
PinRow, Database trait, Db impl, workers, handlers, recovery module,
pruner, pin monitor, CLI, and all tests to use the new types instead
of raw strings.

No DB migration needed — enum serde produces the same strings:
- ArchiveStatus: in_progress, done, error, expired
- IpfsStatus: in_progress, done, error
- PinRow.pin_status uses existing PinResponseStatus enum

Closes #80

* fix: complete status enum migration to fix CI

Resolve compile, clippy, and fmt failures from the string->enum refactor:
- Import ArchiveStatus/IpfsStatus in test modules that use them; drop the
  now-unused top-level import in handle_backup_create
- Import IpfsStatus in handle_backup_delete_pins (used by the handler)
- Borrow statuses instead of moving out of &BackupTask in retry validation
- Pass status refs to validate_status_for_scope; use PinResponseStatus::Pinned
  literals instead of "pinned".into()
- Drop unused PinResponseStatus import and simplify and_then(|x| Ok(..)) to map
- Apply rustfmt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pin_monitor): map pin status via as_str instead of JSON round-trip

Addresses review feedback: serializing PinResponseStatus through serde_json
and falling back to unwrap_or_default() wrote an empty string to the DB on
any error and could panic in as_str().unwrap(). Add PinResponseStatus::as_str
(matching ArchiveStatus/IpfsStatus) and use it directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Hermes Agent <agent@hermes.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

29 of 106 new or added lines in 12 files covered. (27.36%)

2 existing lines in 1 file now uncovered.

2360 of 4956 relevant lines covered (47.62%)

9.01 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