1use serde::{Deserialize, Serialize};
23
24pub const RUN_JOURNAL_TABLE: &str = "_faucet_run_journal";
28
29pub const PREVIOUS_TABLE_SUFFIX: &str = "__faucet_prev";
32
33pub const DEFAULT_RUN_ID_COLUMN: &str = "_faucet_run_id";
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct RollbackWriteSpec {
44 pub run_id: String,
46 pub run_id_column: String,
49 #[serde(default)]
51 pub journal: bool,
52 #[serde(default)]
54 pub keep_previous: bool,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum RollbackMode {
61 Append,
63 Upsert,
65 Overwrite,
67}
68
69impl RollbackMode {
70 pub fn for_write_mode(mode: crate::write_mode::WriteMode) -> Self {
72 match mode {
73 crate::write_mode::WriteMode::Append => RollbackMode::Append,
74 crate::write_mode::WriteMode::Upsert | crate::write_mode::WriteMode::Delete => {
75 RollbackMode::Upsert
76 }
77 crate::write_mode::WriteMode::Overwrite => RollbackMode::Overwrite,
78 }
79 }
80
81 pub fn as_str(self) -> &'static str {
82 match self {
83 RollbackMode::Append => "append",
84 RollbackMode::Upsert => "upsert",
85 RollbackMode::Overwrite => "overwrite",
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct RollbackOptions {
93 pub run_id_column: String,
95 pub mode: RollbackMode,
96 pub force: bool,
100 pub dry_run: bool,
102 #[serde(default)]
106 pub later_runs: bool,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct RollbackOutcome {
112 pub deleted: u64,
114 pub restored: u64,
117 pub conflicts: u64,
120 pub applied: bool,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub note: Option<String>,
126}
127
128impl RollbackOutcome {
129 pub fn nothing(note: impl Into<String>) -> Self {
131 Self {
132 applied: true,
133 note: Some(note.into()),
134 ..Default::default()
135 }
136 }
137
138 pub fn blocked(conflicts: u64) -> Self {
140 Self {
141 conflicts,
142 applied: false,
143 note: Some(format!(
144 "{conflicts} key(s) were changed by a later run; pass --force to restore them anyway"
145 )),
146 ..Default::default()
147 }
148 }
149}
150
151pub fn key_json(tuple: &crate::write_mode::KeyTuple) -> String {
155 let parts: Vec<String> = tuple
158 .0
159 .iter()
160 .map(|(k, v)| format!("{}:{}", serde_json::Value::String(k.clone()), v))
161 .collect();
162 format!("{{{}}}", parts.join(","))
163}
164
165pub fn canonical_key(tuple: &crate::write_mode::KeyTuple) -> crate::write_mode::KeyTuple {
170 crate::write_mode::KeyTuple(
171 tuple
172 .0
173 .iter()
174 .map(|(k, v)| {
175 let v = match v {
176 serde_json::Value::Null => serde_json::Value::Null,
177 serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
178 serde_json::Value::Bool(b) => serde_json::Value::String(b.to_string()),
179 serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()),
180 other => serde_json::Value::String(crate::util::canonical_json(other)),
181 };
182 (k.clone(), v)
183 })
184 .collect(),
185 )
186}
187
188pub fn plan_keys(
192 plan: &crate::write_mode::WritePlan,
193 key: &[String],
194) -> Vec<crate::write_mode::KeyTuple> {
195 let mut seen = std::collections::HashSet::new();
196 let mut out = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
197 let upserts = plan
198 .upserts
199 .iter()
200 .filter_map(|r| crate::write_mode::record_key(r, key));
201 for t in upserts.chain(plan.deletes.iter().cloned()) {
202 let c = canonical_key(&t);
203 if seen.insert(key_json(&c)) {
204 out.push(c);
205 }
206 }
207 out
208}
209
210#[derive(Clone, Copy)]
220pub struct JournalSql {
221 pub quote: fn(&str) -> String,
222 pub placeholder: fn(usize) -> String,
223 pub before_type: &'static str,
224 pub insert_prefix: &'static str,
225 pub insert_suffix: &'static str,
226 pub now: &'static str,
227 pub key_column: &'static str,
231 pub primary_key: &'static str,
233}
234
235pub const KEY_COLUMN_TEXT: &str = "key_json TEXT NOT NULL";
237pub const PRIMARY_KEY_TEXT: &str = "PRIMARY KEY (run_id, table_name, key_json)";
239
240impl JournalSql {
241 fn t(&self) -> String {
242 (self.quote)(RUN_JOURNAL_TABLE)
243 }
244
245 pub fn create(&self) -> String {
247 format!(
248 "CREATE TABLE IF NOT EXISTS {t} (run_id VARCHAR(64) NOT NULL, table_name VARCHAR(255) NOT NULL, \
249 {key}, before_json {bt}, recorded_at TIMESTAMP DEFAULT {now}, {pk})",
250 t = self.t(),
251 key = self.key_column,
252 bt = self.before_type,
253 now = self.now,
254 pk = self.primary_key,
255 )
256 }
257
258 pub fn insert(&self, rows: usize) -> String {
262 let mut n = 0usize;
263 let tuples: Vec<String> = (0..rows)
264 .map(|_| {
265 let ph: Vec<String> = (0..4)
266 .map(|_| {
267 n += 1;
268 (self.placeholder)(n)
269 })
270 .collect();
271 format!("({})", ph.join(", "))
272 })
273 .collect();
274 format!(
275 "{prefix} INTO {t} (run_id, table_name, key_json, before_json) VALUES {v}{suffix}",
276 prefix = self.insert_prefix,
277 t = self.t(),
278 v = tuples.join(", "),
279 suffix = self.insert_suffix,
280 )
281 }
282
283 pub fn select(&self) -> String {
285 format!(
286 "SELECT key_json, before_json FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
287 t = self.t(),
288 p1 = (self.placeholder)(1),
289 p2 = (self.placeholder)(2),
290 )
291 }
292
293 pub fn delete_table(&self) -> String {
295 format!(
296 "DELETE FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
297 t = self.t(),
298 p1 = (self.placeholder)(1),
299 p2 = (self.placeholder)(2),
300 )
301 }
302
303 pub fn keys_in(&self, key: &[String], tuples: usize, start: usize) -> (String, usize) {
307 let cols: Vec<String> = key.iter().map(|k| (self.quote)(k)).collect();
308 let mut n = start;
309 let groups: Vec<String> = (0..tuples)
310 .map(|_| {
311 let ph: Vec<String> = key
312 .iter()
313 .map(|_| {
314 n += 1;
315 (self.placeholder)(n)
316 })
317 .collect();
318 format!("({})", ph.join(", "))
319 })
320 .collect();
321 (
322 format!("({}) IN ({})", cols.join(", "), groups.join(", ")),
323 n,
324 )
325 }
326
327 pub fn delete_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
329 format!(
330 "DELETE FROM {table_ref} WHERE {c} = {p}",
331 c = (self.quote)(run_id_col),
332 p = (self.placeholder)(1)
333 )
334 }
335
336 pub fn count_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
338 format!(
339 "SELECT count(*) FROM {table_ref} WHERE {c} = {p}",
340 c = (self.quote)(run_id_col),
341 p = (self.placeholder)(1)
342 )
343 }
344}
345
346#[derive(Debug, Clone, PartialEq)]
349pub struct JournalEntry {
350 pub key: serde_json::Map<String, serde_json::Value>,
351 pub before: Option<serde_json::Value>,
352}
353
354impl JournalEntry {
355 pub fn decode(key_json: &str, before_json: Option<&str>) -> Result<Self, crate::FaucetError> {
357 let key: serde_json::Value = serde_json::from_str(key_json).map_err(|e| {
358 crate::FaucetError::Sink(format!("rollback: malformed journal key {key_json:?}: {e}"))
359 })?;
360 let key = match key {
361 serde_json::Value::Object(m) => m,
362 other => {
363 return Err(crate::FaucetError::Sink(format!(
364 "rollback: journal key is not an object: {other}"
365 )));
366 }
367 };
368 let before = match before_json {
369 None => None,
370 Some(b) => Some(serde_json::from_str(b).map_err(|e| {
371 crate::FaucetError::Sink(format!("rollback: malformed before-image: {e}"))
372 })?),
373 };
374 Ok(Self { key, before })
375 }
376
377 pub fn tuple(&self, key: &[String]) -> crate::write_mode::KeyTuple {
379 crate::write_mode::KeyTuple(
380 key.iter()
381 .map(|k| {
382 (
383 k.clone(),
384 self.key.get(k).cloned().unwrap_or(serde_json::Value::Null),
385 )
386 })
387 .collect(),
388 )
389 }
390}
391
392pub fn restorable(entries: &[JournalEntry]) -> Vec<&JournalEntry> {
399 entries.iter().filter(|e| e.before.is_some()).collect()
400}
401
402pub fn plan_restore(entries: &[JournalEntry]) -> (Vec<&JournalEntry>, Vec<serde_json::Value>) {
403 let mut deletes = Vec::new();
404 let mut restores = Vec::new();
405 for e in entries {
406 match &e.before {
407 None => deletes.push(e),
408 Some(b) => restores.push(b.clone()),
409 }
410 }
411 (deletes, restores)
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::write_mode::WriteMode;
418
419 #[test]
420 fn restorable_keeps_only_keys_with_a_before_image() {
421 let entries = vec![
422 JournalEntry::decode(r#"{"id":"1"}"#, None).unwrap(),
423 JournalEntry::decode(r#"{"id":"2"}"#, Some(r#"{"id":2,"v":"a"}"#)).unwrap(),
424 ];
425 let kept = restorable(&entries);
426 assert_eq!(kept.len(), 1);
427 assert_eq!(kept[0].key["id"], "2");
428 }
429
430 #[test]
431 fn later_runs_defaults_off_when_absent_from_the_wire() {
432 let opts: RollbackOptions = serde_json::from_value(serde_json::json!({
433 "run_id_column": "_faucet_run_id",
434 "mode": "upsert",
435 "force": false,
436 "dry_run": false
437 }))
438 .unwrap();
439 assert!(!opts.later_runs);
440 }
441
442 fn q(s: &str) -> String {
443 format!("\"{s}\"")
444 }
445 fn ph(n: usize) -> String {
446 format!("${n}")
447 }
448 fn sql() -> JournalSql {
449 JournalSql {
450 quote: q,
451 placeholder: ph,
452 before_type: "JSONB",
453 insert_prefix: "INSERT",
454 insert_suffix: " ON CONFLICT DO NOTHING",
455 now: "now()",
456 key_column: KEY_COLUMN_TEXT,
457 primary_key: PRIMARY_KEY_TEXT,
458 }
459 }
460
461 #[test]
462 fn journal_sql_shapes() {
463 let j = sql();
464 assert!(
465 j.create()
466 .starts_with("CREATE TABLE IF NOT EXISTS \"_faucet_run_journal\"")
467 );
468 assert!(j.create().contains("before_json JSONB"));
469 assert!(j.create().contains("key_json TEXT NOT NULL"));
470 assert!(
471 j.create()
472 .ends_with("PRIMARY KEY (run_id, table_name, key_json))"),
473 "{}",
474 j.create()
475 );
476 let ins = j.insert(2);
477 assert!(
478 ins.starts_with("INSERT INTO \"_faucet_run_journal\""),
479 "{ins}"
480 );
481 assert!(ins.contains("($1, $2, $3, $4), ($5, $6, $7, $8)"), "{ins}");
482 assert!(ins.ends_with(" ON CONFLICT DO NOTHING"));
483 assert_eq!(
484 j.select(),
485 "SELECT key_json, before_json FROM \"_faucet_run_journal\" WHERE run_id = $1 AND table_name = $2"
486 );
487 assert!(
488 j.delete_table()
489 .starts_with("DELETE FROM \"_faucet_run_journal\" WHERE run_id = $1")
490 );
491 let (pred, next) = j.keys_in(&["a".into(), "b".into()], 2, 1);
492 assert_eq!(pred, "(\"a\", \"b\") IN (($2, $3), ($4, $5))");
493 assert_eq!(next, 5);
494 assert_eq!(
495 j.delete_by_run("\"t\"", "_faucet_run_id"),
496 "DELETE FROM \"t\" WHERE \"_faucet_run_id\" = $1"
497 );
498 assert_eq!(
499 j.count_by_run("\"t\"", "_faucet_run_id"),
500 "SELECT count(*) FROM \"t\" WHERE \"_faucet_run_id\" = $1"
501 );
502 }
503
504 #[test]
505 fn journal_entries_decode_and_plan() {
506 let created = JournalEntry::decode("{\"id\":1}", None).unwrap();
507 let changed = JournalEntry::decode("{\"id\":2}", Some("{\"id\":2,\"v\":\"old\"}")).unwrap();
508 assert_eq!(
509 created.tuple(&["id".into()]).0,
510 vec![("id".to_string(), serde_json::json!(1))]
511 );
512 assert_eq!(
513 created.tuple(&["id".into(), "missing".into()]).0[1],
514 ("missing".to_string(), serde_json::Value::Null)
515 );
516 let entries = [created.clone(), changed.clone()];
517 let (deletes, restores) = plan_restore(&entries);
518 assert_eq!(deletes, vec![&created]);
519 assert_eq!(restores, vec![serde_json::json!({"id": 2, "v": "old"})]);
520 assert!(JournalEntry::decode("nope", None).is_err());
521 assert!(JournalEntry::decode("[1]", None).is_err());
522 assert!(JournalEntry::decode("{\"id\":1}", Some("{broken")).is_err());
523 let kt = crate::write_mode::KeyTuple(vec![
524 ("b".into(), serde_json::json!(2)),
525 ("a".into(), serde_json::json!("x")),
526 ]);
527 assert_eq!(
528 key_json(&kt),
529 "{\"b\":2,\"a\":\"x\"}",
530 "configured key order, not sorted"
531 );
532 }
533
534 #[test]
535 fn plan_keys_are_canonical_and_deduplicated() {
536 let spec = crate::write_mode::WriteSpec {
537 write_mode: WriteMode::Upsert,
538 key: vec!["id".into()],
539 ..Default::default()
540 };
541 let page = vec![
542 serde_json::json!({"id": 7, "v": 1}),
543 serde_json::json!({"id": "7", "v": 2}),
544 serde_json::json!({"id": true}),
545 serde_json::json!({"id": null}),
546 ];
547 let plan = crate::write_mode::plan_writes(&page, &spec);
548 let keys = plan_keys(&plan, &spec.key);
549 assert_eq!(keys.len(), 2);
551 assert_eq!(key_json(&keys[0]), "{\"id\":\"7\"}");
552 assert_eq!(key_json(&keys[1]), "{\"id\":\"true\"}");
553 let nested = canonical_key(&crate::write_mode::KeyTuple(vec![
554 ("k".into(), serde_json::json!([1])),
555 ("n".into(), serde_json::Value::Null),
556 ]));
557 assert_eq!(nested.0[0].1, serde_json::json!("[1]"));
558 assert_eq!(nested.0[1].1, serde_json::Value::Null);
559 let del = crate::write_mode::WriteSpec {
561 write_mode: WriteMode::Delete,
562 key: vec!["id".into()],
563 ..Default::default()
564 };
565 let plan = crate::write_mode::plan_writes(&[serde_json::json!({"id": 3})], &del);
566 assert_eq!(key_json(&plan_keys(&plan, &del.key)[0]), "{\"id\":\"3\"}");
567 }
568
569 #[test]
570 fn mode_follows_the_write_mode() {
571 assert_eq!(
572 RollbackMode::for_write_mode(WriteMode::Append),
573 RollbackMode::Append
574 );
575 assert_eq!(
576 RollbackMode::for_write_mode(WriteMode::Upsert),
577 RollbackMode::Upsert
578 );
579 assert_eq!(
580 RollbackMode::for_write_mode(WriteMode::Delete),
581 RollbackMode::Upsert
582 );
583 assert_eq!(
584 RollbackMode::for_write_mode(WriteMode::Overwrite),
585 RollbackMode::Overwrite
586 );
587 assert_eq!(RollbackMode::Upsert.as_str(), "upsert");
588 assert_eq!(
589 serde_json::to_string(&RollbackMode::Overwrite).unwrap(),
590 "\"overwrite\""
591 );
592 }
593
594 #[test]
595 fn outcomes_carry_their_reason() {
596 let n = RollbackOutcome::nothing("no rows");
597 assert!(n.applied && n.deleted == 0);
598 let b = RollbackOutcome::blocked(3);
599 assert!(!b.applied && b.conflicts == 3);
600 assert!(b.note.unwrap().contains("--force"));
601 let spec: RollbackWriteSpec = serde_json::from_value(serde_json::json!({
602 "run_id": "r1", "run_id_column": "_faucet_run_id"
603 }))
604 .unwrap();
605 assert!(!spec.journal && !spec.keep_previous);
606 assert!(
607 serde_json::from_value::<RollbackWriteSpec>(serde_json::json!({
608 "run_id": "r1", "run_id_column": "c", "bogus": 1
609 }))
610 .is_err()
611 );
612 }
613}