Skip to main content

ag_store/
repository.rs

1//! Repository bundle wiring for the database layer.
2
3use std::sync::Arc;
4
5use sqlx::SqlitePool;
6
7#[cfg(any(test, feature = "test-utils"))]
8use super::connection::open_in_memory_pool;
9use super::{
10    ActivityRepository, OperationRepository, OrchestrationRepository, ProjectRepository,
11    ReviewRepository, SessionRepository, SettingRepository, SqliteActivityRepository,
12    SqliteOperationRepository, SqliteOrchestrationRepository, SqliteProjectRepository,
13    SqliteReviewRepository, SqliteSessionRepository, SqliteSettingRepository,
14    SqliteUsageRepository, UsageRepository,
15};
16use crate::timestamp::TimestampSource;
17#[cfg(any(test, feature = "test-utils"))]
18use crate::timestamp::system_timestamp_source;
19
20/// App-layer repository bundle used for selective mock injection.
21#[derive(Clone)]
22pub struct AppRepositories {
23    activity: Arc<dyn ActivityRepository>,
24    operation: Arc<dyn OperationRepository>,
25    orchestration: Arc<dyn OrchestrationRepository>,
26    project: Arc<dyn ProjectRepository>,
27    review: Arc<dyn ReviewRepository>,
28    session: Arc<dyn SessionRepository>,
29    setting: Arc<dyn SettingRepository>,
30    usage: Arc<dyn UsageRepository>,
31}
32
33impl AppRepositories {
34    /// Creates a repository bundle backed by one shared `SQLite` pool.
35    #[cfg(any(test, feature = "test-utils"))]
36    pub(crate) fn from_pool(pool: SqlitePool) -> Self {
37        Self::from_pool_and_timestamp_source(pool, system_timestamp_source())
38    }
39
40    /// Creates a repository bundle backed by one pool and timestamp source.
41    pub(crate) fn from_pool_and_timestamp_source(
42        pool: SqlitePool,
43        timestamp_source: Arc<dyn TimestampSource>,
44    ) -> Self {
45        Self::from_parts(AppRepositoryParts {
46            activity: Arc::new(SqliteActivityRepository::new(pool.clone())),
47            operation: Arc::new(SqliteOperationRepository::new(
48                pool.clone(),
49                Arc::clone(&timestamp_source),
50            )),
51            orchestration: Arc::new(SqliteOrchestrationRepository::new(
52                pool.clone(),
53                Arc::clone(&timestamp_source),
54            )),
55            project: Arc::new(SqliteProjectRepository::new(
56                pool.clone(),
57                Arc::clone(&timestamp_source),
58            )),
59            review: Arc::new(SqliteReviewRepository::new(pool.clone())),
60            session: Arc::new(SqliteSessionRepository::new(
61                pool.clone(),
62                Arc::clone(&timestamp_source),
63            )),
64            setting: Arc::new(SqliteSettingRepository::new(pool.clone())),
65            usage: Arc::new(SqliteUsageRepository::new(pool, timestamp_source)),
66        })
67    }
68
69    /// Creates a repository bundle from a complete set of required adapters.
70    pub(crate) fn from_parts(parts: AppRepositoryParts) -> Self {
71        let AppRepositoryParts {
72            activity,
73            operation,
74            orchestration,
75            project,
76            review,
77            session,
78            setting,
79            usage,
80        } = parts;
81
82        Self {
83            activity,
84            operation,
85            orchestration,
86            project,
87            review,
88            session,
89            setting,
90            usage,
91        }
92    }
93
94    /// Opens an isolated in-memory repository bundle for tests.
95    ///
96    /// # Errors
97    /// Returns an error if the database connection or migrations fail.
98    #[cfg(any(test, feature = "test-utils"))]
99    #[doc(hidden)]
100    pub async fn in_memory() -> Result<Self, crate::DbError> {
101        let (repositories, _pool) = Self::in_memory_with_pool().await?;
102
103        Ok(repositories)
104    }
105
106    /// Opens an isolated in-memory repository bundle plus its shared
107    /// `SQLite` pool for tests that need raw SQL setup.
108    ///
109    /// # Errors
110    /// Returns an error if the database connection or migrations fail.
111    #[cfg(any(test, feature = "test-utils"))]
112    #[doc(hidden)]
113    pub async fn in_memory_with_pool() -> Result<(Self, SqlitePool), crate::DbError> {
114        Self::from_new_in_memory_pool().await
115    }
116
117    /// Opens an isolated in-memory repository bundle plus its shared
118    /// `SQLite` pool without depending on `Database`.
119    #[cfg(any(test, feature = "test-utils"))]
120    async fn from_new_in_memory_pool() -> Result<(Self, SqlitePool), crate::DbError> {
121        let pool = open_in_memory_pool(1).await?;
122
123        let repositories = Self::from_pool(pool.clone());
124
125        Ok((repositories, pool))
126    }
127
128    /// Returns the activity-event repository.
129    pub fn activity(&self) -> &dyn ActivityRepository {
130        self.activity.as_ref()
131    }
132
133    /// Returns the session-operation repository.
134    pub fn operations(&self) -> &dyn OperationRepository {
135        self.operation.as_ref()
136    }
137
138    /// Returns the orchestration repository.
139    pub fn orchestrations(&self) -> &dyn OrchestrationRepository {
140        self.orchestration.as_ref()
141    }
142
143    /// Returns a cloneable orchestration repository for background
144    /// reconciliation.
145    pub fn orchestration_repository(&self) -> Arc<dyn OrchestrationRepository> {
146        Arc::clone(&self.orchestration)
147    }
148
149    /// Returns the project repository.
150    pub fn projects(&self) -> &dyn ProjectRepository {
151        self.project.as_ref()
152    }
153
154    /// Returns the session review-request repository.
155    pub fn reviews(&self) -> &dyn ReviewRepository {
156        self.review.as_ref()
157    }
158
159    /// Returns the session repository.
160    pub fn sessions(&self) -> &dyn SessionRepository {
161        self.session.as_ref()
162    }
163
164    /// Returns the settings repository.
165    pub fn settings(&self) -> &dyn SettingRepository {
166        self.setting.as_ref()
167    }
168
169    /// Returns the per-session usage repository.
170    pub fn usage(&self) -> &dyn UsageRepository {
171        self.usage.as_ref()
172    }
173}
174
175/// Complete repository adapter set accepted by [`AppRepositories`].
176///
177/// All fields are required so alternate composition cannot silently fall back
178/// to a production adapter when a focused fake was intended.
179pub(crate) struct AppRepositoryParts {
180    pub(crate) activity: Arc<dyn ActivityRepository>,
181    pub(crate) operation: Arc<dyn OperationRepository>,
182    pub(crate) orchestration: Arc<dyn OrchestrationRepository>,
183    pub(crate) project: Arc<dyn ProjectRepository>,
184    pub(crate) review: Arc<dyn ReviewRepository>,
185    pub(crate) session: Arc<dyn SessionRepository>,
186    pub(crate) setting: Arc<dyn SettingRepository>,
187    pub(crate) usage: Arc<dyn UsageRepository>,
188}
189
190#[cfg(test)]
191mod tests {
192    use std::sync::atomic::{AtomicI64, Ordering};
193
194    use ag_agent::{SessionDiffState, SessionStats};
195    use ag_session::SessionMessageKind;
196
197    use super::super::operation::MockOperationRepository;
198    use super::*;
199
200    /// Timestamp fixture used to verify persistence composition
201    /// deterministically.
202    struct AdvancingTimestampSource {
203        next_timestamp: AtomicI64,
204    }
205
206    impl TimestampSource for AdvancingTimestampSource {
207        fn now_timestamp_seconds(&self) -> i64 {
208            self.next_timestamp.fetch_add(1, Ordering::Relaxed)
209        }
210    }
211
212    /// Timestamp fixture used to expose accidental fallbacks to `SQLite` time.
213    struct FixedTimestampSource;
214
215    impl TimestampSource for FixedTimestampSource {
216        fn now_timestamp_seconds(&self) -> i64 {
217            456
218        }
219    }
220
221    #[tokio::test]
222    async fn injected_clock_drives_all_repository_timestamps() {
223        // Arrange
224        let pool = open_in_memory_pool(1)
225            .await
226            .expect("failed to open in-memory db");
227        let timestamp_source = Arc::new(AdvancingTimestampSource {
228            next_timestamp: AtomicI64::new(120),
229        });
230        let repositories =
231            AppRepositories::from_pool_and_timestamp_source(pool.clone(), timestamp_source);
232
233        // Act
234        let project_id = repositories
235            .projects()
236            .upsert_project("/tmp/injected-clock", Some("main".to_string()))
237            .await
238            .expect("failed to insert project");
239        repositories
240            .sessions()
241            .insert_session("session-a", "gpt-5.6-sol", "main", "Review", project_id)
242            .await
243            .expect("failed to insert session");
244        repositories
245            .sessions()
246            .append_session_message("session-a", SessionMessageKind::UserPrompt, "Persist this")
247            .await
248            .expect("failed to append message");
249        repositories
250            .operations()
251            .insert_session_operation("operation-a", "session-a", "reply")
252            .await
253            .expect("failed to insert operation");
254        let orchestration_id = repositories
255            .orchestrations()
256            .insert_orchestration("session-a", "Running", 2)
257            .await
258            .expect("failed to insert orchestration");
259        repositories
260            .usage()
261            .upsert_session_usage(
262                "session-a",
263                "gpt-5.6-sol",
264                &SessionStats {
265                    added_lines: 0,
266                    deleted_lines: 0,
267                    diff_state: SessionDiffState::Unknown,
268                    input_tokens: 1,
269                    output_tokens: 2,
270                },
271            )
272            .await
273            .expect("failed to insert usage");
274        let timestamps = sqlx::query_as::<_, (i64, i64, i64, i64, i64, i64, i64, i64)>(
275            r"
276SELECT project.created_at,
277       project.updated_at,
278       session.created_at,
279       session.updated_at,
280       session_message.created_at,
281       session_operation.queued_at,
282       session_orchestration.created_at,
283       session_usage.created_at
284FROM project
285INNER JOIN session ON session.project_id = project.id
286INNER JOIN session_message ON session_message.session_id = session.id
287INNER JOIN session_operation ON session_operation.session_id = session.id
288INNER JOIN session_orchestration
289ON session_orchestration.controller_session_id = session.id
290INNER JOIN session_usage ON session_usage.session_id = session.id
291WHERE session_orchestration.id = ?
292",
293        )
294        .bind(orchestration_id)
295        .fetch_one(&pool)
296        .await
297        .expect("failed to load timestamps");
298
299        // Assert
300        assert_eq!(timestamps, (120, 120, 121, 122, 122, 123, 124, 125));
301    }
302
303    #[tokio::test]
304    async fn fixed_clock_drives_session_metadata_status_and_usage_writes() {
305        // Arrange
306        let pool = open_in_memory_pool(1)
307            .await
308            .expect("failed to open in-memory db");
309        let timestamp_source = Arc::new(FixedTimestampSource);
310        let repositories =
311            AppRepositories::from_pool_and_timestamp_source(pool.clone(), timestamp_source);
312        let project_id = repositories
313            .projects()
314            .upsert_project("/tmp/fixed-clock", Some("main".to_string()))
315            .await
316            .expect("failed to insert project");
317        repositories
318            .sessions()
319            .insert_session("session-a", "gpt-5.6-sol", "main", "Review", project_id)
320            .await
321            .expect("failed to insert session");
322        let turn_metadata = super::super::SessionTurnMetadata {
323            applied_personality_id: None,
324            applied_personality_prompt_hash: None,
325            instruction_conversation_id: None,
326            model: "gpt-5.6-sol".to_string(),
327            provider_conversation_id: Some("conversation-a".to_string()),
328            questions_json: "[]".to_string(),
329            summary: "Persisted summary".to_string(),
330            token_usage_delta: SessionStats {
331                added_lines: 0,
332                deleted_lines: 0,
333                diff_state: SessionDiffState::Unknown,
334                input_tokens: 3,
335                output_tokens: 5,
336            },
337        };
338
339        // Act
340        repositories
341            .sessions()
342            .persist_session_turn_metadata("session-a", &turn_metadata)
343            .await
344            .expect("failed to persist turn metadata");
345        repositories
346            .sessions()
347            .update_session_status_with_timing_at("session-a", "InProgress", 789)
348            .await
349            .expect("failed to update status");
350        repositories
351            .usage()
352            .upsert_session_usage(
353                "session-a",
354                "review-model",
355                &SessionStats {
356                    added_lines: 0,
357                    deleted_lines: 0,
358                    diff_state: SessionDiffState::Unknown,
359                    input_tokens: 7,
360                    output_tokens: 11,
361                },
362            )
363            .await
364            .expect("failed to persist usage");
365        let session_timestamps = sqlx::query_as::<_, (i64, i64)>(
366            "SELECT updated_at, in_progress_started_at FROM session WHERE id = ?",
367        )
368        .bind("session-a")
369        .fetch_one(&pool)
370        .await
371        .expect("failed to load session timestamps");
372        let usage_timestamps = sqlx::query_scalar::<_, i64>(
373            "SELECT created_at FROM session_usage WHERE session_id = ? ORDER BY model",
374        )
375        .bind("session-a")
376        .fetch_all(&pool)
377        .await
378        .expect("failed to load usage timestamps");
379
380        // Assert
381        assert_eq!(session_timestamps, (456, 789));
382        assert_eq!(usage_timestamps, vec![456, 456]);
383    }
384
385    #[tokio::test]
386    async fn repository_parts_support_focused_adapter_injection() {
387        // Arrange
388        let pool = open_in_memory_pool(1)
389            .await
390            .expect("failed to open in-memory db");
391        let baseline = AppRepositories::from_pool(pool);
392        let mut operation = MockOperationRepository::new();
393        operation
394            .expect_is_cancel_requested_for_operation()
395            .withf(|operation_id| operation_id == "operation-a")
396            .times(1)
397            .returning(|_| Ok(true));
398        let repositories = AppRepositories::from_parts(AppRepositoryParts {
399            activity: Arc::clone(&baseline.activity),
400            operation: Arc::new(operation),
401            orchestration: Arc::clone(&baseline.orchestration),
402            project: Arc::clone(&baseline.project),
403            review: Arc::clone(&baseline.review),
404            session: Arc::clone(&baseline.session),
405            setting: Arc::clone(&baseline.setting),
406            usage: Arc::clone(&baseline.usage),
407        });
408
409        // Act
410        let is_cancel_requested = repositories
411            .operations()
412            .is_cancel_requested_for_operation("operation-a")
413            .await
414            .expect("mock operation query should succeed");
415
416        // Assert
417        assert!(is_cancel_requested);
418    }
419}