1use crate::destination::gcs::GcsStore;
13use crate::types::target::{TargetColumnSpec, TargetStatus};
14use anyhow::{Context, Result, bail};
15
16mod bigquery;
17pub mod cdc;
18pub mod plan;
19pub mod reconcile;
20mod snowflake;
21
22pub use bigquery::BigQueryLoader;
23pub use snowflake::SnowflakeLoader;
24
25#[derive(Debug, Clone)]
27pub struct LoadReport {
28 pub rows_loaded: u64,
29 pub target_table: String,
30 pub source_cleaned: bool,
32}
33
34#[derive(Debug, Clone)]
37pub struct CdcLoadReport {
38 pub rows_appended: u64,
39 pub changes_table: String,
40 pub view: String,
41 pub source_cleaned: bool,
45}
46
47pub trait TargetLoader {
55 fn fqtn(&self, table: &str) -> String;
58
59 fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64>;
62
63 fn append_changelog(
67 &self,
68 table: &str,
69 specs: &[TargetColumnSpec],
70 uris: &[String],
71 pk: &[String],
72 ) -> Result<u64>;
73
74 fn warehouse(&self) -> cdc::Warehouse;
78
79 fn create_view(&self, table: &str, view_sql: &str) -> Result<()>;
84}
85
86fn is_safe_load_ident(s: &str) -> bool {
91 !s.is_empty()
92 && s.chars()
93 .next()
94 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
95 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
96}
97
98fn ensure_safe_load_uris(uris: &[String]) -> Result<()> {
113 for u in uris {
114 if let Some(bad) = u
115 .chars()
116 .find(|&c| c == '\'' || c == '\\' || c.is_control())
117 {
118 bail!(
119 "refusing to load: Parquet URI `{}` contains {:?}, which is unsafe to splice \
120 into the warehouse load statement — the loader single-quotes URIs without \
121 escaping. rivet names its own parts safely, so this URI was not produced by a \
122 normal export; investigate the staging bucket before re-running.",
123 u.escape_default(),
124 bad
125 );
126 }
127 }
128 Ok(())
129}
130
131fn validate_specs(table: &str, specs: &[TargetColumnSpec]) -> Result<()> {
134 if specs.is_empty() {
135 bail!("no column specs for `{table}` — nothing to build a schema from");
136 }
137 if table.is_empty() || !table.split('.').all(is_safe_load_ident) {
141 bail!(
142 "cannot load: target table `{}` is not a plain (optionally dotted) SQL identifier — \
143 the loader splices it into DDL/COPY.",
144 table.escape_default()
145 );
146 }
147 for s in specs {
152 if !is_safe_load_ident(&s.column_name) {
153 bail!(
154 "cannot load `{table}`: column name `{}` is not a plain SQL identifier \
155 ([A-Za-z_][A-Za-z0-9_]*) — the warehouse loader splices it into DDL/COPY. \
156 Rename or alias the column in the export query.",
157 s.column_name.escape_default()
158 );
159 }
160 }
161 let failed: Vec<&str> = specs
162 .iter()
163 .filter(|s| s.status == TargetStatus::Fail)
164 .map(|s| s.column_name.as_str())
165 .collect();
166 if !failed.is_empty() {
167 bail!(
168 "cannot load `{table}`: {} column(s) do not map to the warehouse: {}",
169 failed.len(),
170 failed.join(", ")
171 );
172 }
173 Ok(())
174}
175
176fn maybe_cleanup(cleanup: Option<(&GcsStore, &str)>) -> bool {
181 match cleanup {
182 Some((store, prefix)) => match delete_under(store, prefix) {
183 Ok(()) => true,
184 Err(e) => {
185 eprintln!("warning: source cleanup failed (data is safely loaded): {e:#}");
186 false
187 }
188 },
189 None => false,
190 }
191}
192
193#[allow(private_interfaces)]
202pub fn run_load(
203 loader: &dyn TargetLoader,
204 table: &str,
205 specs: &[TargetColumnSpec],
206 uris: &[String],
207 expected_rows: Option<u64>,
208 cleanup: Option<(&GcsStore, &str)>,
209) -> Result<LoadReport> {
210 if uris.is_empty() {
211 bail!("no Parquet URIs to load into `{table}`");
212 }
213 ensure_safe_load_uris(uris)?;
214 validate_specs(table, specs)?;
215
216 let rows_loaded = loader.materialize(table, specs, uris)?;
217
218 if let Some(expected) = expected_rows
219 && rows_loaded != expected
220 {
221 bail!(
222 "count validation failed for `{}`: loaded {rows_loaded} rows, expected {expected} — \
223 NOT cleaning up source; investigate before re-running",
224 loader.fqtn(table)
225 );
226 }
227
228 let source_cleaned = maybe_cleanup(cleanup);
229 Ok(LoadReport {
230 rows_loaded,
231 target_table: loader.fqtn(table),
232 source_cleaned,
233 })
234}
235
236#[allow(clippy::too_many_arguments, private_interfaces)]
244fn append_and_view(
250 loader: &dyn TargetLoader,
251 table: &str,
252 specs: &[TargetColumnSpec],
253 uris: &[String],
254 pk: &[String],
255 expected_delta: Option<u64>,
256 cleanup: Option<(&GcsStore, &str)>,
257 label: &str,
258 build_view: impl FnOnce(&dyn TargetLoader) -> Result<()>,
259) -> Result<CdcLoadReport> {
260 if uris.is_empty() {
261 bail!("no Parquet URIs to append into `{table}__changes`");
262 }
263 ensure_safe_load_uris(uris)?;
264 if pk.is_empty() {
265 bail!("{label} load of `{table}` needs a primary key for the dedup view (pass --pk)");
266 }
267 validate_specs(&format!("{table}__changes"), specs)?;
268
269 let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
270
271 if let Some(expected) = expected_delta
272 && rows_appended != expected
273 {
274 bail!(
275 "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
276 expected {expected} from the run manifests — investigate before trusting the view",
277 table
278 );
279 }
280
281 build_view(loader)?;
282 let source_cleaned = maybe_cleanup(cleanup);
288
289 Ok(CdcLoadReport {
290 rows_appended,
291 changes_table: loader.fqtn(&format!("{table}__changes")),
292 view: loader.fqtn(table),
293 source_cleaned,
294 })
295}
296
297#[allow(clippy::too_many_arguments, private_interfaces)]
298pub fn run_load_cdc(
299 loader: &dyn TargetLoader,
300 table: &str,
301 specs: &[TargetColumnSpec],
302 uris: &[String],
303 pk: &[String],
304 engine: cdc::SourceEngine,
305 expected_delta: Option<u64>,
306 cleanup: Option<(&GcsStore, &str)>,
307) -> Result<CdcLoadReport> {
308 for c in pk {
311 if !is_safe_load_ident(c) {
312 bail!(
313 "cannot load `{table}`: CDC primary-key column `{}` is not a plain SQL \
314 identifier — it is spliced into the dedup view. Rename/alias it.",
315 c.escape_default()
316 );
317 }
318 }
319 append_and_view(
320 loader,
321 table,
322 specs,
323 uris,
324 pk,
325 expected_delta,
326 cleanup,
327 "CDC",
328 |l| {
329 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
330 let sql = cdc::dedup_view_sql(
331 l.warehouse(),
332 &l.fqtn(table),
333 &l.fqtn(&format!("{table}__changes")),
334 &pk_refs,
335 engine,
336 );
337 l.create_view(table, &sql)
338 },
339 )
340}
341
342#[allow(clippy::too_many_arguments, private_interfaces)]
351pub fn run_load_incremental(
352 loader: &dyn TargetLoader,
353 table: &str,
354 specs: &[TargetColumnSpec],
355 uris: &[String],
356 pk: &[String],
357 cursor_column: &str,
358 expected_delta: Option<u64>,
359 cleanup: Option<(&GcsStore, &str)>,
360) -> Result<CdcLoadReport> {
361 if cursor_column.is_empty() {
363 bail!(
364 "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
365 for the dedup view's latest-per-PK ordering"
366 );
367 }
368 if !specs.iter().any(|s| s.column_name == cursor_column) {
375 let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
376 bail!(
377 "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
378 exported columns [{}] — add it to the export's SELECT so the dedup view can order \
379 the change log by it",
380 cols.join(", ")
381 );
382 }
383 append_and_view(
384 loader,
385 table,
386 specs,
387 uris,
388 pk,
389 expected_delta,
390 cleanup,
391 "incremental",
392 |l| {
393 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
394 let sql = cdc::inc_dedup_view_sql(
395 l.warehouse(),
396 &l.fqtn(table),
397 &l.fqtn(&format!("{table}__changes")),
398 &pk_refs,
399 cursor_column,
400 );
401 l.create_view(table, &sql)
402 },
403 )
404}
405
406pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
409 let (bucket, key) = uri
410 .strip_prefix("gs://")
411 .and_then(|rest| rest.split_once('/'))
412 .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))?;
413 if key.trim_matches('/').is_empty() {
422 anyhow::bail!(
423 "refusing a bucket-root staging prefix `{uri}`: a GCS load stages into and cleans up a \
424 DEDICATED prefix, so an empty prefix would list/delete the whole bucket. Set a \
425 non-empty `destination.prefix`, and put any `{{partition}}` token AFTER a literal \
426 segment (e.g. `exports/{{partition}}/`, not `{{partition}}/`)."
427 );
428 }
429 Ok((bucket, key))
430}
431
432pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
438 let (_, rel) = split_gs_uri(gs_prefix)?;
439 store
440 .remove_all(rel)
441 .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
442}
443
444#[allow(private_interfaces)]
453pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
454 GcsStore::new(dest)
455}
456
457pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
462 use plan::LoadTarget;
463 let load = &plan.load;
464 match &load.target {
465 LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
466 project,
467 dataset,
468 plan.partition_by.as_deref(),
469 &load.cluster_by,
470 run_id,
471 )),
472 LoadTarget::Snowflake {
473 connection,
474 warehouse,
475 database,
476 schema,
477 storage_integration,
478 } => {
479 let mut l = SnowflakeLoader::new(connection.clone());
480 l.warehouse = warehouse.clone();
481 l.database = database.clone();
482 l.schema = schema.clone();
483 l.storage_integration = storage_integration.clone();
484 l.cluster_by = load.cluster_by.clone();
485 l.run_id = Some(run_id.to_string());
486 l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
488 l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
490 Box::new(l)
491 }
492 }
493}
494
495fn build_bigquery_loader(
501 project: &str,
502 dataset: &str,
503 partition_by: Option<&str>,
504 cluster_by: &[String],
505 run_id: &str,
506) -> BigQueryLoader {
507 let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
508 if let Some(part) = partition_by {
509 l = l.partition_by(part);
510 }
511 if !cluster_by.is_empty() {
512 l = l.cluster_by(cluster_by.to_vec());
513 }
514 l
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use std::cell::RefCell;
521
522 #[derive(Default)]
525 struct FakeLoader {
526 rows: u64,
527 materialized: RefCell<Vec<String>>,
528 appended: RefCell<Vec<String>>,
529 views: RefCell<Vec<String>>,
530 }
531
532 impl TargetLoader for FakeLoader {
533 fn fqtn(&self, table: &str) -> String {
534 format!("db.{table}")
535 }
536 fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
537 self.materialized.borrow_mut().push(table.into());
538 Ok(self.rows)
539 }
540 fn append_changelog(
541 &self,
542 table: &str,
543 _: &[TargetColumnSpec],
544 _: &[String],
545 _: &[String],
546 ) -> Result<u64> {
547 self.appended.borrow_mut().push(table.into());
548 Ok(self.rows)
549 }
550 fn warehouse(&self) -> cdc::Warehouse {
551 cdc::Warehouse::BigQuery
552 }
553 fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
554 self.views.borrow_mut().push(table.into());
555 Ok(())
556 }
557 }
558
559 fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
564 let obj = dir.path().join(rel).join("x.parquet");
565 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
566 std::fs::write(obj, b"x").unwrap();
567 GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
568 }
569
570 fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
572 !store.list_files(rel).unwrap().is_empty()
573 }
574
575 #[test]
576 fn delete_under_and_gc_orphans_refuse_the_bucket_root_and_spare_siblings() {
577 let dir = tempfile::tempdir().unwrap();
583 for rel in ["exportA/part.parquet", "innocent-neighbour/keep.parquet"] {
584 let obj = dir.path().join(rel);
585 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
586 std::fs::write(obj, b"x").unwrap();
587 }
588 let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
589
590 assert!(
592 delete_under(&store, "gs://bucket/").is_err(),
593 "cleanup_source must REFUSE a bucket-root prefix, not remove_all(\"\")"
594 );
595 assert!(
596 reconcile::gc_orphans(&store, "gs://bucket/", &[]).is_err(),
597 "gc_orphans must REFUSE a bucket-root prefix, not list+delete the whole bucket"
598 );
599 assert!(prefix_populated(&store, "exportA"), "exportA must survive");
601 assert!(
602 prefix_populated(&store, "innocent-neighbour"),
603 "an unrelated neighbour export must survive the refused root cleanup"
604 );
605
606 delete_under(&store, "gs://bucket/exportA").unwrap();
609 assert!(
610 !prefix_populated(&store, "exportA"),
611 "a real prefix cleanup still drains its own export"
612 );
613 assert!(
614 prefix_populated(&store, "innocent-neighbour"),
615 "a scoped cleanup spares the sibling"
616 );
617 }
618
619 fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
620 vec![TargetColumnSpec {
621 column_name: "id".into(),
622 target_type: "INT64".into(),
623 autoload_type: String::new(),
624 status,
625 note: None,
626 cast_sql: None,
627 }]
628 }
629 fn uris() -> Vec<String> {
630 vec!["gs://b/p/x.parquet".into()]
631 }
632 const PREFIX: &str = "gs://b/p";
635 const REL: &str = "p";
636
637 #[test]
638 fn empty_uris_bail_before_materialize() {
639 let f = FakeLoader {
640 rows: 10,
641 ..Default::default()
642 };
643 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
644 assert!(f.materialized.borrow().is_empty());
645 }
646
647 #[test]
648 fn fail_spec_bails_before_materialize() {
649 let f = FakeLoader::default();
650 assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
651 assert!(f.materialized.borrow().is_empty());
652 }
653
654 #[test]
655 fn count_mismatch_bails_without_cleanup() {
656 let f = FakeLoader {
657 rows: 7,
658 ..Default::default()
659 };
660 let dir = tempfile::tempdir().unwrap();
661 let store = fs_store_with_prefix(&dir, REL);
662 let err = run_load(
663 &f,
664 "t",
665 &spec(TargetStatus::Ok),
666 &uris(),
667 Some(10),
668 Some((&store, PREFIX)),
669 )
670 .unwrap_err()
671 .to_string();
672 assert!(err.contains("count validation failed"), "{err}");
673 assert!(
674 prefix_populated(&store, REL),
675 "cleanup must not run on a failed gate — the source prefix stays intact"
676 );
677 }
678
679 #[test]
680 fn match_with_prefix_cleans_once() {
681 let f = FakeLoader {
682 rows: 10,
683 ..Default::default()
684 };
685 let dir = tempfile::tempdir().unwrap();
686 let store = fs_store_with_prefix(&dir, REL);
687 let r = run_load(
688 &f,
689 "t",
690 &spec(TargetStatus::Ok),
691 &uris(),
692 Some(10),
693 Some((&store, PREFIX)),
694 )
695 .unwrap();
696 assert!(r.source_cleaned);
697 assert!(
698 !prefix_populated(&store, REL),
699 "a passed gate drains the source prefix through the injected store"
700 );
701 assert_eq!(r.target_table, "db.t");
702 }
703
704 #[test]
705 fn match_without_prefix_does_not_clean() {
706 let f = FakeLoader {
707 rows: 10,
708 ..Default::default()
709 };
710 let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
711 assert!(!r.source_cleaned);
712 }
713
714 #[test]
715 fn none_expected_skips_the_gate() {
716 let f = FakeLoader {
717 rows: 999,
718 ..Default::default()
719 };
720 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
722 }
723
724 #[test]
725 fn cdc_delta_mismatch_bails_without_view() {
726 let f = FakeLoader {
727 rows: 3,
728 ..Default::default()
729 };
730 let dir = tempfile::tempdir().unwrap();
731 let store = fs_store_with_prefix(&dir, REL);
732 let err = run_load_cdc(
733 &f,
734 "t",
735 &spec(TargetStatus::Ok),
736 &uris(),
737 &["id".into()],
738 cdc::SourceEngine::MySql,
739 Some(5),
740 Some((&store, PREFIX)),
741 )
742 .unwrap_err()
743 .to_string();
744 assert!(err.contains("CDC count validation failed"), "{err}");
745 assert!(
746 f.views.borrow().is_empty(),
747 "view must not be built on a failed gate"
748 );
749 assert!(
750 prefix_populated(&store, REL),
751 "cleanup must not run on a failed gate"
752 );
753 }
754
755 #[test]
756 fn cdc_match_builds_view_then_cleans() {
757 let f = FakeLoader {
758 rows: 5,
759 ..Default::default()
760 };
761 let dir = tempfile::tempdir().unwrap();
762 let store = fs_store_with_prefix(&dir, REL);
763 let r = run_load_cdc(
764 &f,
765 "t",
766 &spec(TargetStatus::Ok),
767 &uris(),
768 &["id".into()],
769 cdc::SourceEngine::MySql,
770 Some(5),
771 Some((&store, PREFIX)),
772 )
773 .unwrap();
774 assert_eq!(r.rows_appended, 5);
775 assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
776 assert!(
777 !prefix_populated(&store, REL),
778 "a passed CDC gate drains the source prefix after the view is built"
779 );
780 assert_eq!(r.changes_table, "db.t__changes");
781 }
782
783 #[test]
784 fn delete_under_drains_the_prefix_through_the_store() {
785 let dir = tempfile::tempdir().unwrap();
786 let store = fs_store_with_prefix(&dir, REL);
787 assert!(prefix_populated(&store, REL), "seeded object is present");
788 delete_under(&store, PREFIX).unwrap();
789 assert!(
790 !prefix_populated(&store, REL),
791 "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
792 );
793 }
794
795 #[test]
796 fn build_bigquery_loader_wires_partition_and_cluster_keys() {
797 let l = build_bigquery_loader(
802 "proj",
803 "ds",
804 Some("day"),
805 &["customer_id".to_string(), "region".to_string()],
806 "run-1",
807 );
808 assert_eq!(l.cluster_by, ["customer_id", "region"]);
809 assert_eq!(l.partition_by.as_deref(), Some("day"));
810
811 let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
813 assert!(bare.cluster_by.is_empty());
814 assert!(bare.partition_by.is_none());
815 }
816
817 #[test]
818 fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
819 assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
825 assert_eq!(
826 split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
827 ("bucket", "a/b/c.parquet"),
828 "only the FIRST '/' splits bucket from key; the rest is the key"
829 );
830 assert!(
831 split_gs_uri("s3://b/p").is_err(),
832 "a non-gs scheme is rejected"
833 );
834 assert!(
835 split_gs_uri("gs://bucket-only").is_err(),
836 "a bucket with no '/' has no (bucket, key) split"
837 );
838 for root in ["gs://bucket/", "gs://bucket//", "gs://bucket///"] {
844 assert!(
845 split_gs_uri(root).is_err(),
846 "bucket-root prefix {root:?} must be refused, not parsed to an empty (root) key"
847 );
848 }
849 assert_eq!(split_gs_uri("gs://b/exports/").unwrap(), ("b", "exports/"));
851 }
852
853 #[test]
854 fn cdc_empty_pk_bails() {
855 let f = FakeLoader::default();
856 assert!(
857 run_load_cdc(
858 &f,
859 "t",
860 &spec(TargetStatus::Ok),
861 &uris(),
862 &[],
863 cdc::SourceEngine::MySql,
864 None,
865 None
866 )
867 .is_err()
868 );
869 assert!(f.appended.borrow().is_empty());
870 }
871
872 #[test]
873 fn a_uri_with_a_quote_backslash_or_control_char_bails_before_the_driver_runs() {
874 for bad in [
880 "gs://b/p/x'; drop table t --.parquet", "gs://b/p/x\\.parquet", "gs://b/p/x\n.parquet", ] {
884 let f = FakeLoader {
885 rows: 1,
886 ..Default::default()
887 };
888 let uris = vec![bad.to_string()];
889 assert!(
890 run_load(&f, "t", &spec(TargetStatus::Ok), &uris, Some(1), None).is_err(),
891 "run_load must reject the injection URI {bad:?}"
892 );
893 assert!(
894 f.materialized.borrow().is_empty(),
895 "the driver must not be reached for {bad:?}"
896 );
897 assert!(
898 run_load_cdc(
899 &f,
900 "t",
901 &spec(TargetStatus::Ok),
902 &uris,
903 &["id".to_string()],
904 cdc::SourceEngine::MySql,
905 Some(1),
906 None,
907 )
908 .is_err(),
909 "run_load_cdc must reject the injection URI {bad:?}"
910 );
911 assert!(
912 f.appended.borrow().is_empty(),
913 "the CDC driver must not be reached for {bad:?}"
914 );
915 }
916 assert!(ensure_safe_load_uris(&uris()).is_ok());
918 }
919
920 #[test]
921 fn incremental_cursor_not_in_specs_bails_before_append() {
922 let f = FakeLoader::default();
923 let err = run_load_incremental(
928 &f,
929 "t",
930 &spec(TargetStatus::Ok),
931 &uris(),
932 &["id".to_string()],
933 "updated_at",
934 None,
935 None,
936 )
937 .unwrap_err()
938 .to_string();
939 assert!(
940 err.contains("updated_at") && err.contains("not one of the exported columns"),
941 "{err}"
942 );
943 assert!(
944 f.appended.borrow().is_empty(),
945 "nothing appended before the bail"
946 );
947 }
948}