use std::sync::Arc;
use std::time::Instant;
use crate::app::cmd::effect::Effect;
use crate::app::model::app_state::AppState;
use crate::app::model::browse::query_execution::PREVIEW_PAGE_SIZE;
use crate::app::model::connection::error::ConnectionErrorInfo;
use crate::app::model::er_state::ErStatus;
use crate::app::model::shared::input_mode::InputMode;
use crate::app::model::sql_editor::modal::FailedPrefetchEntry;
use crate::app::update::action::{Action, TableTarget};
use crate::domain::MetadataState;
const BASE_BACKOFF_SECS: u64 = 1;
const MAX_BACKOFF_SECS: u64 = 4;
const MAX_PREFETCH_RETRIES: u32 = 3;
fn check_er_completion(state: &mut AppState) -> Vec<Effect> {
if state.er_preparation.status != ErStatus::Waiting || !state.er_preparation.is_complete() {
return vec![];
}
if !state.er_preparation.fk_expanded {
return vec![Effect::DispatchActions(vec![
Action::ExpandPrefetchWithFkNeighbors,
])];
}
if !state.er_preparation.has_failures() {
state.er_preparation.status = ErStatus::Idle;
return vec![Effect::DispatchActions(vec![Action::ErGenerateFromCache])];
}
state.er_preparation.status = ErStatus::Idle;
let failed_data: Vec<(String, String)> = state
.er_preparation
.failed_tables
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
state.set_error(format!(
"ER failed: {} table(s) failed. 'e' to retry.",
failed_data.len()
));
vec![Effect::WriteErFailureLog {
failed_tables: failed_data,
}]
}
pub fn reduce_metadata(state: &mut AppState, action: &Action, now: Instant) -> Option<Vec<Effect>> {
match action {
Action::MetadataLoaded(metadata) => {
let has_tables = !metadata.table_summaries.is_empty();
state.session.mark_connected(Arc::clone(metadata));
let mut effects = vec![];
if state.query.pagination.table.is_empty() {
state
.ui
.set_explorer_selection(if has_tables { Some(0) } else { None });
} else {
let prev_schema = &state.query.pagination.schema;
let prev_table = &state.query.pagination.table;
let found_index = metadata
.table_summaries
.iter()
.position(|t| &t.schema == prev_schema && &t.name == prev_table);
if let Some(idx) = found_index {
state.ui.set_explorer_selection(Some(idx));
if let Some(dsn) = &state.session.dsn {
let page = state.query.pagination.current_page;
let generation = state.session.selection_generation();
effects.push(Effect::ExecutePreview {
dsn: dsn.clone(),
schema: state.query.pagination.schema.clone(),
table: state.query.pagination.table.clone(),
generation,
limit: PREVIEW_PAGE_SIZE,
offset: page * PREVIEW_PAGE_SIZE,
target_page: page,
read_only: state.session.read_only,
});
effects.push(Effect::FetchTableDetail {
dsn: dsn.clone(),
schema: state.query.pagination.schema.clone(),
table: state.query.pagination.table.clone(),
generation,
});
}
} else {
state
.ui
.set_explorer_selection(if has_tables { Some(0) } else { None });
state
.session
.clear_table_selection(&mut state.query.pagination);
state.query.clear_current_result();
}
}
state.connection_error.clear();
if state.session.is_reloading {
state.messages.set_success_at("Reloaded!".to_string(), now);
state.session.is_reloading = false;
}
if state.modal.active_mode() == InputMode::SqlModal
&& !state.sql_modal.is_prefetch_started()
{
effects.push(Effect::DispatchActions(vec![Action::StartPrefetchAll]));
}
if state.ui.pending_er_picker && state.modal.active_mode() == InputMode::Normal {
state.ui.pending_er_picker = false;
effects.push(Effect::DispatchActions(vec![Action::OpenErTablePicker]));
} else {
state.ui.pending_er_picker = false;
}
Some(effects)
}
Action::MetadataFailed(error) => {
let error_info = ConnectionErrorInfo::new(error.to_string());
state.connection_error.set_error(error_info);
let was_connected = state.session.connection_state().is_connected();
state.session.mark_connection_failed(error.to_string());
if !was_connected {
state.modal.replace_mode(InputMode::ConnectionError);
}
if state.er_preparation.status == ErStatus::Waiting {
state.er_preparation.status = ErStatus::Idle;
}
Some(vec![])
}
Action::TableDetailLoaded(detail, generation) => {
if state.session.set_table_detail(*detail.clone(), *generation) {
state.ui.inspector_scroll_offset = 0;
}
Some(vec![])
}
Action::TableDetailFailed(error, generation) => {
if *generation == state.session.selection_generation() {
state.set_error(error.to_string());
}
Some(vec![])
}
Action::LoadMetadata => {
if let Some(dsn) = state.session.dsn.clone() {
state.session.set_metadata_state(MetadataState::Loading);
Some(vec![Effect::FetchMetadata { dsn }])
} else {
Some(vec![])
}
}
Action::LoadTableDetail(TableTarget {
schema,
table,
generation,
}) => {
if let Some(dsn) = &state.session.dsn {
Some(vec![Effect::FetchTableDetail {
dsn: dsn.clone(),
schema: schema.clone(),
table: table.clone(),
generation: *generation,
}])
} else {
Some(vec![])
}
}
Action::ReloadMetadata => {
if let Some(dsn) = state.session.dsn.clone() {
state.session.begin_reload();
state.sql_modal.reset_prefetch();
state.er_preparation.reset();
state.ui.er_selected_tables.clear();
state.ui.pending_er_picker = false;
state.messages.last_error = None;
state.messages.last_success = None;
state.messages.expires_at = None;
Some(vec![Effect::Sequence(vec![
Effect::CacheInvalidate { dsn: dsn.clone() },
Effect::ClearCompletionEngineCache,
Effect::FetchMetadata { dsn },
])])
} else {
Some(vec![])
}
}
Action::StartPrefetchAll => {
if !state.sql_modal.is_prefetch_started()
&& let Some(metadata) = state.session.metadata()
{
state.sql_modal.begin_prefetch();
state.er_preparation.pending_tables.clear();
state.er_preparation.fetching_tables.clear();
state.er_preparation.failed_tables.clear();
state.er_preparation.total_tables = metadata.table_summaries.len();
state.er_preparation.fk_expanded = true;
let table_count = metadata.table_summaries.len();
let resize_capacity = table_count.clamp(500, 10_000);
for table_summary in &metadata.table_summaries {
let qualified_name = table_summary.qualified_name();
state
.sql_modal
.prefetch_queue
.push_back(qualified_name.clone());
state.er_preparation.pending_tables.insert(qualified_name);
}
Some(vec![
Effect::ResizeCompletionCache {
capacity: resize_capacity,
},
Effect::ProcessPrefetchQueue,
])
} else {
Some(vec![])
}
}
Action::StartPrefetchScoped { tables } => {
if state.sql_modal.is_prefetch_started() {
Some(vec![])
} else {
state.sql_modal.begin_prefetch();
state.er_preparation.pending_tables.clear();
state.er_preparation.fetching_tables.clear();
state.er_preparation.failed_tables.clear();
state.er_preparation.fk_expanded = false;
state.er_preparation.seed_tables.clone_from(tables);
state.er_preparation.total_tables = tables.len();
for qualified_name in tables {
state
.sql_modal
.prefetch_queue
.push_back(qualified_name.clone());
state
.er_preparation
.pending_tables
.insert(qualified_name.clone());
}
Some(vec![Effect::ProcessPrefetchQueue])
}
}
Action::ExpandPrefetchWithFkNeighbors => {
let seed_tables = state.er_preparation.seed_tables.clone();
Some(vec![Effect::ExtractFkNeighbors { seed_tables }])
}
Action::FkNeighborsDiscovered { tables } => {
state.er_preparation.fk_expanded = true;
if tables.is_empty() {
return Some(check_er_completion(state));
}
for qualified_name in tables {
state
.er_preparation
.pending_tables
.insert(qualified_name.clone());
state
.sql_modal
.prefetch_queue
.push_back(qualified_name.clone());
}
Some(vec![Effect::ProcessPrefetchQueue])
}
Action::ProcessPrefetchQueue => {
const MAX_CONCURRENT_PREFETCH: usize = 4;
let current_in_flight = state.sql_modal.prefetching_tables.len();
let available_slots = MAX_CONCURRENT_PREFETCH.saturating_sub(current_in_flight);
let mut actions = Vec::new();
for _ in 0..available_slots {
if let Some(qualified_name) = state.sql_modal.prefetch_queue.pop_front()
&& let Some((schema, table)) = qualified_name.split_once('.')
{
actions.push(Action::PrefetchTableDetail {
schema: schema.to_string(),
table: table.to_string(),
});
}
}
if actions.is_empty() {
Some(vec![])
} else {
Some(vec![Effect::DispatchActions(actions)])
}
}
Action::PrefetchTableDetail { schema, table } => {
let qualified_name = format!("{schema}.{table}");
if state.sql_modal.prefetching_tables.contains(&qualified_name) {
return Some(vec![]);
}
if let Some(entry) = state.sql_modal.failed_prefetch_tables.get(&qualified_name) {
if entry.retry_count >= MAX_PREFETCH_RETRIES {
state.er_preparation.pending_tables.remove(&qualified_name);
state
.er_preparation
.on_table_failed(&qualified_name, entry.error.clone());
let mut effects = check_er_completion(state);
if effects.is_empty() && state.er_preparation.status == ErStatus::Waiting {
effects.push(Effect::ProcessPrefetchQueue);
}
return Some(effects);
}
let backoff_secs =
(BASE_BACKOFF_SECS * 2u64.pow(entry.retry_count)).min(MAX_BACKOFF_SECS);
let elapsed = entry.failed_at.elapsed().as_secs();
if elapsed < backoff_secs {
let remaining = backoff_secs - elapsed;
state.sql_modal.prefetch_queue.push_back(qualified_name);
return Some(vec![Effect::DelayedProcessPrefetchQueue {
delay_secs: remaining,
}]);
}
}
state
.sql_modal
.prefetching_tables
.insert(qualified_name.clone());
state.er_preparation.pending_tables.remove(&qualified_name);
state
.er_preparation
.fetching_tables
.insert(qualified_name.clone());
if let Some(dsn) = &state.session.dsn {
Some(vec![Effect::PrefetchTableDetail {
dsn: dsn.clone(),
schema: schema.clone(),
table: table.clone(),
}])
} else {
Some(vec![])
}
}
Action::TableDetailCached {
schema,
table,
detail,
} => {
let qualified_name = format!("{schema}.{table}");
state.sql_modal.prefetching_tables.remove(&qualified_name);
state
.sql_modal
.failed_prefetch_tables
.remove(&qualified_name);
state.er_preparation.on_table_cached(&qualified_name);
let mut effects = vec![Effect::CacheTableInCompletionEngine {
qualified_name,
table: detail.clone(),
}];
if !state.sql_modal.prefetch_queue.is_empty() {
effects.push(Effect::ProcessPrefetchQueue);
}
effects.extend(check_er_completion(state));
Some(effects)
}
Action::TableDetailCacheFailed {
schema,
table,
error,
} => {
let qualified_name = format!("{schema}.{table}");
state.sql_modal.prefetching_tables.remove(&qualified_name);
let prev_count = state
.sql_modal
.failed_prefetch_tables
.get(&qualified_name)
.map_or(0, |e| e.retry_count);
state.sql_modal.failed_prefetch_tables.insert(
qualified_name.clone(),
FailedPrefetchEntry {
failed_at: now,
error: error.to_string(),
retry_count: prev_count + 1,
},
);
state
.er_preparation
.on_table_failed(&qualified_name, error.to_string());
let mut effects = Vec::new();
if !state.sql_modal.prefetch_queue.is_empty() {
effects.push(Effect::ProcessPrefetchQueue);
}
effects.extend(check_er_completion(state));
Some(effects)
}
Action::TableDetailAlreadyCached { schema, table } => {
let qualified_name = format!("{schema}.{table}");
state.sql_modal.prefetching_tables.remove(&qualified_name);
state
.sql_modal
.failed_prefetch_tables
.remove(&qualified_name);
state.er_preparation.on_table_cached(&qualified_name);
let mut effects = Vec::new();
if !state.sql_modal.prefetch_queue.is_empty() {
effects.push(Effect::ProcessPrefetchQueue);
}
effects.extend(check_er_completion(state));
Some(effects)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::model::app_state::AppState;
use crate::app::model::sql_editor::modal::FailedPrefetchEntry;
use std::time::{Duration, Instant};
fn state_with_dsn(dsn: &str) -> AppState {
let mut state = AppState::new("test".to_string());
state.session.dsn = Some(dsn.to_string());
state
}
mod prefetch_table_detail {
use super::*;
use crate::app::model::er_state::ErStatus;
#[test]
fn backoff_table_requeued_at_tail_with_process_effect() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
let qualified = "public.users".to_string();
state.sql_modal.failed_prefetch_tables.insert(
qualified.clone(),
FailedPrefetchEntry {
failed_at: Instant::now(),
error: "timeout".to_string(),
retry_count: 1,
},
);
let effects = reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "users".to_string(),
},
Instant::now(),
)
.unwrap();
assert_eq!(state.sql_modal.prefetch_queue.back(), Some(&qualified));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::DelayedProcessPrefetchQueue { .. }))
);
}
#[test]
fn retry_limit_exceeded_gives_up_and_calls_on_table_failed() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
let qualified = "public.users".to_string();
state
.er_preparation
.pending_tables
.insert(qualified.clone());
state.sql_modal.failed_prefetch_tables.insert(
qualified.clone(),
FailedPrefetchEntry {
failed_at: Instant::now(),
error: "timeout".to_string(),
retry_count: MAX_PREFETCH_RETRIES,
},
);
reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "users".to_string(),
},
Instant::now(),
);
assert!(!state.sql_modal.prefetch_queue.contains(&qualified));
assert!(state.er_preparation.failed_tables.contains_key(&qualified));
assert!(!state.er_preparation.pending_tables.contains(&qualified));
}
#[test]
fn retry_limit_exceeded_as_last_table_triggers_er_completion() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.fk_expanded = true;
let qualified = "public.users".to_string();
state
.er_preparation
.pending_tables
.insert(qualified.clone());
state.sql_modal.failed_prefetch_tables.insert(
qualified,
FailedPrefetchEntry {
failed_at: Instant::now(),
error: "timeout".to_string(),
retry_count: MAX_PREFETCH_RETRIES,
},
);
let effects = reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "users".to_string(),
},
Instant::now(),
)
.unwrap();
assert_eq!(state.er_preparation.status, ErStatus::Idle);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::WriteErFailureLog { .. }))
);
}
#[test]
fn retry_limit_exceeded_with_queue_remaining_redrives_queue() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.fk_expanded = true;
let failed = "public.users".to_string();
let remaining = "public.posts".to_string();
state.er_preparation.pending_tables.insert(failed.clone());
state
.er_preparation
.pending_tables
.insert(remaining.clone());
state.sql_modal.prefetch_queue.push_back(remaining);
state.sql_modal.failed_prefetch_tables.insert(
failed,
FailedPrefetchEntry {
failed_at: Instant::now(),
error: "timeout".to_string(),
retry_count: MAX_PREFETCH_RETRIES,
},
);
let effects = reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "users".to_string(),
},
Instant::now(),
)
.unwrap();
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ProcessPrefetchQueue))
);
assert_eq!(state.er_preparation.status, ErStatus::Waiting);
}
#[test]
fn expired_backoff_proceeds_normally() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
let qualified = "public.users".to_string();
state.sql_modal.failed_prefetch_tables.insert(
qualified.clone(),
FailedPrefetchEntry {
failed_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
error: "timeout".to_string(),
retry_count: 1,
},
);
let effects = reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "users".to_string(),
},
Instant::now(),
)
.unwrap();
assert!(state.sql_modal.prefetching_tables.contains(&qualified));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::PrefetchTableDetail { .. }))
);
}
}
mod table_detail_cache_failed {
use super::*;
use crate::app::ports::DbOperationError;
#[test]
fn increments_retry_count() {
let mut state = state_with_dsn("postgres://localhost/test");
let qualified = "public.users".to_string();
state.sql_modal.prefetching_tables.insert(qualified.clone());
state.sql_modal.failed_prefetch_tables.insert(
qualified.clone(),
FailedPrefetchEntry {
failed_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
error: "old error".to_string(),
retry_count: 1,
},
);
let now = Instant::now();
reduce_metadata(
&mut state,
&Action::TableDetailCacheFailed {
schema: "public".to_string(),
table: "users".to_string(),
error: DbOperationError::QueryFailed("new error".to_string()),
},
now,
);
let entry = state
.sql_modal
.failed_prefetch_tables
.get(&qualified)
.unwrap();
assert_eq!(entry.retry_count, 2);
assert_eq!(entry.error, "Query failed: new error");
}
#[test]
fn first_failure_sets_retry_count_1() {
let mut state = state_with_dsn("postgres://localhost/test");
let qualified = "public.users".to_string();
state.sql_modal.prefetching_tables.insert(qualified.clone());
let now = Instant::now();
reduce_metadata(
&mut state,
&Action::TableDetailCacheFailed {
schema: "public".to_string(),
table: "users".to_string(),
error: DbOperationError::Timeout,
},
now,
);
let entry = state
.sql_modal
.failed_prefetch_tables
.get(&qualified)
.unwrap();
assert_eq!(entry.retry_count, 1);
}
}
mod backoff_calculation {
use super::*;
#[test]
fn backoff_values() {
assert_eq!((BASE_BACKOFF_SECS * 2u64.pow(0)).min(MAX_BACKOFF_SECS), 1);
assert_eq!((BASE_BACKOFF_SECS * 2u64.pow(1)).min(MAX_BACKOFF_SECS), 2);
assert_eq!((BASE_BACKOFF_SECS * 2u64.pow(2)).min(MAX_BACKOFF_SECS), 4);
assert_eq!((BASE_BACKOFF_SECS * 2u64.pow(3)).min(MAX_BACKOFF_SECS), 4);
}
}
mod metadata_loaded {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
fn make_metadata(tables: Vec<(&str, &str)>) -> Arc<DatabaseMetadata> {
Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: tables
.into_iter()
.map(|(schema, name)| {
TableSummary::new(schema.to_string(), name.to_string(), None, false)
})
.collect(),
fetched_at: Instant::now(),
})
}
#[test]
fn table_disappeared_clears_pagination_and_result() {
let mut state = state_with_dsn("postgres://localhost/test");
let _ = state
.session
.select_table("public", "users", &mut state.query.pagination);
let metadata = make_metadata(vec![("public", "orders")]);
reduce_metadata(
&mut state,
&Action::MetadataLoaded(metadata),
Instant::now(),
);
assert!(state.query.pagination.table.is_empty());
assert!(state.query.current_result().is_none());
assert!(state.session.table_detail().is_none());
assert!(state.session.selected_table_key().is_none());
assert_eq!(state.ui.explorer_selected, 0);
}
#[test]
fn table_still_exists_preserves_pagination_and_emits_refresh_effects() {
let mut state = state_with_dsn("postgres://localhost/test");
state.query.pagination.schema = "public".to_string();
state.query.pagination.table = "users".to_string();
let metadata = make_metadata(vec![("public", "orders"), ("public", "users")]);
let effects = reduce_metadata(
&mut state,
&Action::MetadataLoaded(metadata),
Instant::now(),
)
.unwrap();
assert_eq!(state.query.pagination.table, "users");
assert_eq!(state.ui.explorer_selected, 1);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ExecutePreview { table, .. } if table == "users"))
);
assert!(
effects.iter().any(
|e| matches!(e, Effect::FetchTableDetail { table, .. } if table == "users")
)
);
}
#[test]
fn no_table_selected_defaults_to_first() {
let mut state = state_with_dsn("postgres://localhost/test");
let metadata = make_metadata(vec![("public", "orders"), ("public", "users")]);
reduce_metadata(
&mut state,
&Action::MetadataLoaded(metadata),
Instant::now(),
);
assert_eq!(state.ui.explorer_selected, 0);
}
#[test]
fn after_connection_switch_pagination_reset_suppresses_auto_preview() {
let mut state = state_with_dsn("postgres://localhost/test");
state.query.pagination.reset();
let metadata = make_metadata(vec![("public", "users")]);
let effects = reduce_metadata(
&mut state,
&Action::MetadataLoaded(metadata),
Instant::now(),
)
.unwrap();
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::ExecutePreview { .. }))
);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::FetchTableDetail { .. }))
);
}
}
mod start_prefetch_all {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
fn make_metadata(table_count: usize) -> Arc<DatabaseMetadata> {
let tables: Vec<TableSummary> = (0..table_count)
.map(|i| TableSummary::new(format!("t{i}"), "public".to_string(), None, false))
.collect();
Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: tables,
fetched_at: Instant::now(),
})
}
#[test]
fn large_db_emits_resize_effect() {
let mut state = state_with_dsn("postgres://localhost/test");
state.session.set_metadata(Some(make_metadata(530)));
let effects =
reduce_metadata(&mut state, &Action::StartPrefetchAll, Instant::now()).unwrap();
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ResizeCompletionCache { capacity: 530 }))
);
}
#[test]
fn small_db_uses_floor_capacity() {
let mut state = state_with_dsn("postgres://localhost/test");
state.session.set_metadata(Some(make_metadata(50)));
let effects =
reduce_metadata(&mut state, &Action::StartPrefetchAll, Instant::now()).unwrap();
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ResizeCompletionCache { capacity: 500 }))
);
}
#[test]
fn sets_fk_expanded_true() {
let mut state = state_with_dsn("postgres://localhost/test");
state.session.set_metadata(Some(make_metadata(10)));
reduce_metadata(&mut state, &Action::StartPrefetchAll, Instant::now());
assert!(state.er_preparation.fk_expanded);
}
}
mod start_prefetch_scoped {
use super::*;
#[test]
fn second_call_while_running_is_ignored() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
state
.er_preparation
.pending_tables
.insert("public.users".to_string());
let effects = reduce_metadata(
&mut state,
&Action::StartPrefetchScoped {
tables: vec!["public.posts".to_string()],
},
Instant::now(),
)
.unwrap();
assert!(state.er_preparation.pending_tables.contains("public.users"));
assert!(effects.is_empty());
}
#[test]
fn only_selected_tables_in_queue() {
let mut state = state_with_dsn("postgres://localhost/test");
let tables = vec!["public.users".to_string(), "public.orders".to_string()];
let effects = reduce_metadata(
&mut state,
&Action::StartPrefetchScoped {
tables: tables.clone(),
},
Instant::now(),
)
.unwrap();
assert_eq!(state.sql_modal.prefetch_queue.len(), 2);
assert!(state.er_preparation.pending_tables.contains("public.users"));
assert!(
state
.er_preparation
.pending_tables
.contains("public.orders")
);
assert!(!state.er_preparation.fk_expanded);
assert_eq!(state.er_preparation.seed_tables, tables);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ProcessPrefetchQueue))
);
}
}
mod completion_check {
use super::*;
use crate::app::model::er_state::ErStatus;
#[test]
fn complete_not_fk_expanded_dispatches_expand() {
let mut state = state_with_dsn("postgres://localhost/test");
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.fk_expanded = false;
let effects = check_er_completion(&mut state);
assert!(effects.iter().any(|e| matches!(
e,
Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::ExpandPrefetchWithFkNeighbors))
)));
}
#[test]
fn complete_fk_expanded_dispatches_generate() {
let mut state = state_with_dsn("postgres://localhost/test");
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.fk_expanded = true;
let effects = check_er_completion(&mut state);
assert!(effects.iter().any(|e| matches!(
e,
Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::ErGenerateFromCache))
)));
}
}
mod fk_neighbors_discovered {
use super::*;
use crate::app::model::er_state::ErStatus;
#[test]
fn empty_neighbors_dispatches_generate() {
let mut state = state_with_dsn("postgres://localhost/test");
state.er_preparation.status = ErStatus::Waiting;
let effects = reduce_metadata(
&mut state,
&Action::FkNeighborsDiscovered { tables: vec![] },
Instant::now(),
)
.unwrap();
assert!(state.er_preparation.fk_expanded);
assert!(effects.iter().any(|e| matches!(
e,
Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::ErGenerateFromCache))
)));
}
#[test]
fn non_empty_neighbors_adds_to_queue() {
let mut state = state_with_dsn("postgres://localhost/test");
state.er_preparation.status = ErStatus::Waiting;
let effects = reduce_metadata(
&mut state,
&Action::FkNeighborsDiscovered {
tables: vec!["public.posts".to_string(), "public.tags".to_string()],
},
Instant::now(),
)
.unwrap();
assert!(state.er_preparation.fk_expanded);
assert!(state.er_preparation.pending_tables.contains("public.posts"));
assert!(state.er_preparation.pending_tables.contains("public.tags"));
assert_eq!(state.sql_modal.prefetch_queue.len(), 2);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ProcessPrefetchQueue))
);
}
#[test]
fn phase2_table_retry_limit_triggers_completion() {
let mut state = state_with_dsn("postgres://localhost/test");
state.sql_modal.begin_prefetch();
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.fk_expanded = true;
let neighbor = "public.posts".to_string();
state.er_preparation.pending_tables.insert(neighbor.clone());
state.sql_modal.failed_prefetch_tables.insert(
neighbor,
FailedPrefetchEntry {
failed_at: Instant::now(),
error: "timeout".to_string(),
retry_count: MAX_PREFETCH_RETRIES,
},
);
let effects = reduce_metadata(
&mut state,
&Action::PrefetchTableDetail {
schema: "public".to_string(),
table: "posts".to_string(),
},
Instant::now(),
)
.unwrap();
assert_eq!(state.er_preparation.status, ErStatus::Idle);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::WriteErFailureLog { .. }))
);
}
}
}