PyReverseETL 3.1.5__tar.gz → 3.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/Cargo.lock +2 -2
  2. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/Cargo.toml +1 -1
  3. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/PKG-INFO +2 -2
  4. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/README.md +1 -1
  5. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/database_advanced.rs +48 -37
  6. pyreverseetl-3.1.7/core/src/dlq.rs +161 -0
  7. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/executor.rs +172 -19
  8. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/lib.rs +2 -0
  9. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/pyproject.toml +1 -1
  10. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/python/pyreverseetl/cli.py +29 -1
  11. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/python/src/lib.rs +16 -1
  12. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/LICENSE +0 -0
  13. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/Cargo.toml +0 -0
  14. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/benches/load_test.rs +0 -0
  15. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/bin/pyreverseetl_dashboard.rs +0 -0
  16. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/activation.rs +0 -0
  17. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/alert_compat.rs +0 -0
  18. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/error.rs +0 -0
  19. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/http_client.rs +0 -0
  20. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/hubspot.rs +0 -0
  21. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/mapping.rs +0 -0
  22. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/marketo.rs +0 -0
  23. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/mod.rs +0 -0
  24. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/oauth_manager.rs +0 -0
  25. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/retry_policy.rs +0 -0
  26. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/salesforce.rs +0 -0
  27. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/schema_detect.rs +0 -0
  28. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/adapters/webhook.rs +0 -0
  29. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/cdc/change_detector.rs +0 -0
  30. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/cdc/changelog.rs +0 -0
  31. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/cdc/checkpoint.rs +0 -0
  32. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/cdc/mod.rs +0 -0
  33. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/config.rs +0 -0
  34. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/connectors_db.rs +0 -0
  35. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/destination.rs +0 -0
  36. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/error.rs +0 -0
  37. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/hdfs.rs +0 -0
  38. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/mod.rs +0 -0
  39. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/mysql.rs +0 -0
  40. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/object_storage.rs +0 -0
  41. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/postgres.rs +0 -0
  42. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/rate_limiting.rs +0 -0
  43. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/registry.rs +0 -0
  44. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/saas.rs +0 -0
  45. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/source.rs +0 -0
  46. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/connectors/web.rs +0 -0
  47. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/destination.rs +0 -0
  48. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/entity.rs +0 -0
  49. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/error.rs +0 -0
  50. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/cached_gate.rs +0 -0
  51. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/compliance_rules.rs +0 -0
  52. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/credentials.rs +0 -0
  53. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/mod.rs +0 -0
  54. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/quality_gate.rs +0 -0
  55. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/retry_policy.rs +0 -0
  56. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/schema_evolution.rs +0 -0
  57. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/governance/statguardian_client.rs +0 -0
  58. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/idempotency.rs +0 -0
  59. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/lineage.rs +0 -0
  60. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/dashboard_launcher.rs +0 -0
  61. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/logs.rs +0 -0
  62. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/metrics.rs +0 -0
  63. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/metrics_server.rs +0 -0
  64. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/mod.rs +0 -0
  65. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/observability/traces.rs +0 -0
  66. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/pipeline/activation_pipeline.rs +0 -0
  67. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/pipeline/backpressure.rs +0 -0
  68. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/pipeline/latency_tracker.rs +0 -0
  69. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/pipeline/mod.rs +0 -0
  70. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/sources/kafka.rs +0 -0
  71. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/sources/mod.rs +0 -0
  72. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/sources/polling.rs +0 -0
  73. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/sources/sync_config.rs +0 -0
  74. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/statguardian.rs +0 -0
  75. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/storage/mod.rs +0 -0
  76. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/storage/repository.rs +0 -0
  77. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/storage/schema.rs +0 -0
  78. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/streaming/event_processor.rs +0 -0
  79. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/streaming/event_schema.rs +0 -0
  80. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/streaming/mod.rs +0 -0
  81. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/streampdf.rs +0 -0
  82. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/streamxl.rs +0 -0
  83. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/sync.rs +0 -0
  84. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/connector_test.rs +0 -0
  85. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/harness.rs +0 -0
  86. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/metrics.rs +0 -0
  87. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/mock_http.rs +0 -0
  88. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/mod.rs +0 -0
  89. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/testing/test_data.rs +0 -0
  90. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/transformers/mod.rs +0 -0
  91. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/transformers/spark.rs +0 -0
  92. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/core/src/workflow.rs +0 -0
  93. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/python/Cargo.toml +0 -0
  94. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/python/pyreverseetl/__init__.py +0 -0
  95. {pyreverseetl-3.1.5 → pyreverseetl-3.1.7}/python/pyreverseetl/server.py +0 -0
@@ -3199,7 +3199,7 @@ dependencies = [
3199
3199
 
3200
3200
  [[package]]
3201
3201
  name = "pyreverseetl"
3202
- version = "3.1.5"
3202
+ version = "3.1.7"
3203
3203
  dependencies = [
3204
3204
  "chrono",
3205
3205
  "pyo3",
@@ -3210,7 +3210,7 @@ dependencies = [
3210
3210
 
3211
3211
  [[package]]
3212
3212
  name = "pyreverseetl-core"
3213
- version = "3.1.5"
3213
+ version = "3.1.7"
3214
3214
  dependencies = [
3215
3215
  "async-trait",
3216
3216
  "aws-config",
@@ -3,7 +3,7 @@ members = ["core", "python"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "3.1.5"
6
+ version = "3.1.7"
7
7
  authors = ["Georgi Mammen Mullassery <mullassery@gmail.com>"]
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/Mullassery/PyReverseETL"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyReverseETL
3
- Version: 3.1.5
3
+ Version: 3.1.7
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -103,7 +103,7 @@ repeated here to avoid implying vendor endorsement.
103
103
 
104
104
  ## Project status
105
105
 
106
- **Beta.** Version 3.1.5. The Postgres/MySQL/S3/webhook paths are real and verified against
106
+ **Beta.** Version 3.1.6. The Postgres/MySQL/S3/webhook paths are real and verified against
107
107
  real services; the `web` source connector (MudFish-backed site crawling) is implemented
108
108
  and unit-tested but not yet exercised end-to-end against a live site; the CRM/marketing
109
109
  adapters are real but mock-verified only; several
@@ -80,7 +80,7 @@ repeated here to avoid implying vendor endorsement.
80
80
 
81
81
  ## Project status
82
82
 
83
- **Beta.** Version 3.1.5. The Postgres/MySQL/S3/webhook paths are real and verified against
83
+ **Beta.** Version 3.1.6. The Postgres/MySQL/S3/webhook paths are real and verified against
84
84
  real services; the `web` source connector (MudFish-backed site crawling) is implemented
85
85
  and unit-tested but not yet exercised end-to-end against a live site; the CRM/marketing
86
86
  adapters are real but mock-verified only; several
@@ -222,27 +222,29 @@ impl super::DestinationConnector for AdvancedDatabaseDestination {
222
222
  }
223
223
 
224
224
  async fn test_connection(&self) -> crate::Result<ConnectionTest> {
225
- Ok(ConnectionTest::success(&format!(
226
- "Connected to {} database",
227
- self.config.database_type
228
- )))
225
+ // Honest: this connector has no real driver, so it cannot connect.
226
+ Ok(ConnectionTest::failure(
227
+ &format!(
228
+ "{} destination is not implemented: no real connection was attempted",
229
+ self.config.database_type
230
+ ),
231
+ Some("advanced database connector is a stub (RECHECK-054)".to_string()),
232
+ ))
229
233
  }
230
234
 
231
235
  async fn write_record(&self, _record: &Record) -> crate::Result<()> {
232
- // In production: execute INSERT/UPDATE based on strategy
233
- Ok(())
236
+ Err(crate::Error::ConnectorError(format!(
237
+ "{} destination write is not implemented; refusing to report a write that did not happen",
238
+ self.config.database_type
239
+ )))
234
240
  }
235
241
 
236
- async fn write_batch(&self, records: &[Record]) -> crate::Result<usize> {
237
- // In production:
238
- // 1. If schema defined: CREATE TABLE IF NOT EXISTS
239
- // 2. Choose strategy:
240
- // - INSERT: simple insert all
241
- // - UPSERT: use ON CONFLICT or MERGE
242
- // - BULK_LOAD: write to S3/GCS, COPY into database
243
- // 3. Handle partitioning for warehouses
244
- // 4. Create clustering indexes for BigQuery
245
- Ok(records.len())
242
+ async fn write_batch(&self, _records: &[Record]) -> crate::Result<usize> {
243
+ Err(crate::Error::ConnectorError(format!(
244
+ "{} destination batch write is not implemented; refusing to report {} rows written when none were",
245
+ self.config.database_type,
246
+ _records.len()
247
+ )))
246
248
  }
247
249
 
248
250
  async fn validate_records(&self, _records: &[Record]) -> crate::Result<()> {
@@ -283,38 +285,42 @@ impl super::SourceConnector for AdvancedDatabaseSource {
283
285
  }
284
286
 
285
287
  async fn test_connection(&self) -> crate::Result<ConnectionTest> {
286
- Ok(ConnectionTest::success(&format!(
287
- "Connected to {} database: {}",
288
- self.config.database_type, self.config.table
289
- )))
288
+ // Honest: this connector has no real driver, so it cannot connect.
289
+ Ok(ConnectionTest::failure(
290
+ &format!(
291
+ "{} source is not implemented: no real connection was attempted",
292
+ self.config.database_type
293
+ ),
294
+ Some("advanced database connector is a stub (RECHECK-054)".to_string()),
295
+ ))
290
296
  }
291
297
 
292
298
  async fn detect_schema(&self) -> crate::Result<super::Schema> {
293
- // In production: query database metadata
294
- // SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?
295
- Ok(super::Schema {
296
- fields: vec![],
297
- sample_records: vec![],
298
- })
299
+ Err(crate::Error::ConnectorError(format!(
300
+ "{} schema detection is not implemented",
301
+ self.config.database_type
302
+ )))
299
303
  }
300
304
 
301
305
  async fn read_all(&self) -> crate::Result<Vec<Record>> {
302
- // In production: SELECT * FROM table
303
- Ok(vec![])
306
+ Err(crate::Error::ConnectorError(format!(
307
+ "{} read is not implemented; refusing to report an empty result as a successful read",
308
+ self.config.database_type
309
+ )))
304
310
  }
305
311
 
306
312
  async fn read_batch(&self, _offset: u64, _limit: u64) -> crate::Result<Vec<Record>> {
307
- // In production: SELECT * FROM table OFFSET ? LIMIT ?
308
- Ok(vec![])
313
+ Err(crate::Error::ConnectorError(format!(
314
+ "{} read_batch is not implemented",
315
+ self.config.database_type
316
+ )))
309
317
  }
310
318
 
311
319
  async fn read_incremental(&self, _last_value: &str) -> crate::Result<Vec<Record>> {
312
- // In production:
313
- // If incremental_column is timestamp:
314
- // SELECT * FROM table WHERE incremental_column > ? ORDER BY incremental_column
315
- // If incremental_column is id:
316
- // SELECT * FROM table WHERE id > ? ORDER BY id LIMIT ?
317
- Ok(vec![])
320
+ Err(crate::Error::ConnectorError(format!(
321
+ "{} read_incremental is not implemented",
322
+ self.config.database_type
323
+ )))
318
324
  }
319
325
 
320
326
  fn capabilities(&self) -> Vec<Capability> {
@@ -370,8 +376,13 @@ mod tests {
370
376
  let dest = AdvancedDatabaseDestination { config };
371
377
  assert_eq!(dest.name(), "Snowflake");
372
378
 
379
+ // The connector is a stub: it must report failure, not fake success.
373
380
  let test = dest.test_connection().await.unwrap();
374
- assert!(test.success);
381
+ assert!(!test.success);
382
+ assert!(test.message.contains("not implemented"));
383
+
384
+ // And a write must error rather than silently claim rows were written.
385
+ assert!(dest.write_batch(&[]).await.is_err());
375
386
  }
376
387
 
377
388
  #[tokio::test]
@@ -0,0 +1,161 @@
1
+ //! Local dead-letter queue (quarantine) for records that failed to write.
2
+ //!
3
+ //! Per the verified audit gap (RECHECK-018): a sync that partially fails left
4
+ //! the operator with only an aggregate `rows_failed` count -- there was no
5
+ //! record of *which* records failed, why, or their payload, so re-driving a
6
+ //! failed batch after a transient destination error (a rate limit, a 5xx, a
7
+ //! single malformed record) meant re-reading the whole source and guessing.
8
+ //!
9
+ //! `DeadLetterQueue` is a real, persisted (SQLite) quarantine: every record
10
+ //! that a destination rejected is stored with its run id, destination key,
11
+ //! the reason for rejection, and its exact payload, so a failed record can be
12
+ //! inspected and replayed deliberately instead of re-running the entire sync.
13
+
14
+ use rusqlite::{params, Connection};
15
+ use serde::Serialize;
16
+ use std::path::Path;
17
+ use std::sync::Mutex;
18
+
19
+ /// One quarantined record: what failed, where it was headed, and why.
20
+ #[derive(Debug, Clone, Serialize)]
21
+ pub struct DeadLetter {
22
+ pub run_id: String,
23
+ pub destination_key: String,
24
+ pub record_id: String,
25
+ pub reason: String,
26
+ /// The exact record payload (compact JSON) that was rejected.
27
+ pub payload: String,
28
+ pub failed_at: String,
29
+ }
30
+
31
+ pub struct DeadLetterQueue {
32
+ conn: Mutex<Connection>,
33
+ }
34
+
35
+ impl DeadLetterQueue {
36
+ /// Open (creating if absent) a real SQLite file as the quarantine store.
37
+ pub fn open(path: &Path) -> crate::Result<Self> {
38
+ let conn = Connection::open(path)?;
39
+ Self::from_connection(conn)
40
+ }
41
+
42
+ /// SQLite-backed but in-memory -- exercises the real SQL code path without
43
+ /// touching disk (tests).
44
+ #[cfg(test)]
45
+ pub fn in_memory() -> crate::Result<Self> {
46
+ let conn = Connection::open_in_memory()?;
47
+ Self::from_connection(conn)
48
+ }
49
+
50
+ fn from_connection(conn: Connection) -> crate::Result<Self> {
51
+ conn.execute(
52
+ "CREATE TABLE IF NOT EXISTS dead_letters (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ run_id TEXT NOT NULL,
55
+ destination_key TEXT NOT NULL,
56
+ record_id TEXT NOT NULL,
57
+ reason TEXT NOT NULL,
58
+ payload TEXT NOT NULL,
59
+ failed_at TEXT NOT NULL
60
+ )",
61
+ [],
62
+ )?;
63
+ conn.execute(
64
+ "CREATE INDEX IF NOT EXISTS idx_dead_letters_run ON dead_letters(run_id)",
65
+ [],
66
+ )?;
67
+ Ok(Self {
68
+ conn: Mutex::new(conn),
69
+ })
70
+ }
71
+
72
+ /// Quarantine one failed record. Appends a new row every time (a record
73
+ /// that fails across several runs leaves a row per run), so the history of
74
+ /// repeated failures is preserved rather than overwritten.
75
+ #[allow(clippy::too_many_arguments)]
76
+ pub fn record(
77
+ &self,
78
+ run_id: &str,
79
+ destination_key: &str,
80
+ record_id: &str,
81
+ reason: &str,
82
+ payload: &str,
83
+ ) -> crate::Result<()> {
84
+ let failed_at = chrono::Utc::now().to_rfc3339();
85
+ let conn = self.conn.lock().unwrap();
86
+ conn.execute(
87
+ "INSERT INTO dead_letters
88
+ (run_id, destination_key, record_id, reason, payload, failed_at)
89
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
90
+ params![run_id, destination_key, record_id, reason, payload, failed_at],
91
+ )?;
92
+ Ok(())
93
+ }
94
+
95
+ /// Every record quarantined by a single run, in insertion order.
96
+ pub fn list_for_run(&self, run_id: &str) -> crate::Result<Vec<DeadLetter>> {
97
+ let conn = self.conn.lock().unwrap();
98
+ let mut stmt = conn.prepare(
99
+ "SELECT run_id, destination_key, record_id, reason, payload, failed_at
100
+ FROM dead_letters WHERE run_id = ?1 ORDER BY id ASC",
101
+ )?;
102
+ let rows = stmt.query_map(params![run_id], |row| {
103
+ Ok(DeadLetter {
104
+ run_id: row.get(0)?,
105
+ destination_key: row.get(1)?,
106
+ record_id: row.get(2)?,
107
+ reason: row.get(3)?,
108
+ payload: row.get(4)?,
109
+ failed_at: row.get(5)?,
110
+ })
111
+ })?;
112
+ let mut out = Vec::new();
113
+ for row in rows {
114
+ out.push(row?);
115
+ }
116
+ Ok(out)
117
+ }
118
+
119
+ /// Total number of quarantined records across every run.
120
+ pub fn count(&self) -> crate::Result<u64> {
121
+ let conn = self.conn.lock().unwrap();
122
+ let count: i64 = conn.query_row("SELECT COUNT(*) FROM dead_letters", [], |row| row.get(0))?;
123
+ Ok(count as u64)
124
+ }
125
+ }
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ use super::*;
130
+
131
+ #[test]
132
+ fn records_and_lists_failures_per_run() {
133
+ let dlq = DeadLetterQueue::in_memory().unwrap();
134
+ dlq.record("run-1", "webhook:x", "rec-1", "HTTP 429", "{\"a\":1}")
135
+ .unwrap();
136
+ dlq.record("run-1", "webhook:x", "rec-2", "HTTP 500", "{\"b\":2}")
137
+ .unwrap();
138
+ dlq.record("run-2", "webhook:x", "rec-3", "boom", "{\"c\":3}")
139
+ .unwrap();
140
+
141
+ let run1 = dlq.list_for_run("run-1").unwrap();
142
+ assert_eq!(run1.len(), 2);
143
+ assert_eq!(run1[0].record_id, "rec-1");
144
+ assert_eq!(run1[0].reason, "HTTP 429");
145
+ assert_eq!(run1[0].payload, "{\"a\":1}");
146
+ assert_eq!(run1[1].record_id, "rec-2");
147
+
148
+ assert_eq!(dlq.list_for_run("run-2").unwrap().len(), 1);
149
+ assert_eq!(dlq.count().unwrap(), 3);
150
+ }
151
+
152
+ #[test]
153
+ fn repeated_failures_are_appended_not_overwritten() {
154
+ let dlq = DeadLetterQueue::in_memory().unwrap();
155
+ dlq.record("run-1", "webhook:x", "rec-1", "HTTP 429", "{}")
156
+ .unwrap();
157
+ dlq.record("run-2", "webhook:x", "rec-1", "HTTP 429", "{}")
158
+ .unwrap();
159
+ assert_eq!(dlq.count().unwrap(), 2);
160
+ }
161
+ }