1use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use sqlx::PgConnection;
24use sqlx::postgres::PgRow;
25use turnframe_core::case::CaseRef;
26use turnframe_core::command::IdempotencyKey;
27use turnframe_core::ids::{AccountId, CommandId, TurnId};
28use turnframe_store::error::StoreError;
29use turnframe_store::journal::{
30 CommandJournalEntry, CommandJournalReader, CommandJournalStatus, CommandJournalWriter,
31 JournalAdmission, JournalOutcome,
32};
33use uuid::Uuid;
34
35use crate::codec::{
36 column, from_json, from_label, label, now, revision_from_sql, revision_to_sql, to_json,
37};
38use crate::error::store_error;
39use crate::store::{PgStores, commit};
40
41const ENTRY_COLUMNS: &str = "account_id, command_id, idempotency_key, turn_id, workflow_key, \
43 case_id, expected_revision, command_type, command_payload, origin, status, result, \
44 created_at, completed_at";
45
46const PENDING_STATUSES: &str = "('pending', 'executing')";
48
49pub(crate) async fn begin(
51 conn: &mut PgConnection,
52 entry: CommandJournalEntry,
53) -> Result<JournalAdmission, StoreError> {
54 if let Some(existing) =
55 read_by_key(&mut *conn, &entry.account_id, &entry.idempotency_key).await?
56 {
57 return Ok(JournalAdmission::replay(existing));
58 }
59 let inserted = sqlx::query(
60 "INSERT INTO tf_command_journal (
61 account_id, command_id, idempotency_key, turn_id, workflow_key, case_id,
62 expected_revision, command_type, command_payload, origin, status, result,
63 created_at, completed_at
64 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
65 ON CONFLICT ON CONSTRAINT tf_command_journal_idempotency_key DO NOTHING",
66 )
67 .bind(entry.account_id.as_str())
68 .bind(entry.command_id.as_uuid())
69 .bind(entry.idempotency_key.as_str())
70 .bind(entry.turn_id.as_uuid())
71 .bind(entry.case_ref.workflow.as_str())
72 .bind(entry.case_ref.case_id.as_str())
73 .bind(revision_to_sql(entry.case_ref.expected_revision)?)
74 .bind(entry.command_type.as_str())
75 .bind(entry.command_payload.clone())
76 .bind(to_json(&entry.origin)?)
77 .bind(label(&entry.status)?)
78 .bind(entry.result.as_ref().map(to_json).transpose()?)
79 .bind(entry.created_at)
80 .bind(entry.completed_at)
81 .execute(&mut *conn)
82 .await
83 .map_err(|error| store_error(&error))?;
84 if inserted.rows_affected() == 1 {
85 return Ok(JournalAdmission::Fresh);
86 }
87 read_by_key(conn, &entry.account_id, &entry.idempotency_key)
90 .await?
91 .map(JournalAdmission::replay)
92 .ok_or(StoreError::Corrupt)
93}
94
95async fn read_by_key(
97 conn: &mut PgConnection,
98 account: &AccountId,
99 key: &IdempotencyKey,
100) -> Result<Option<CommandJournalEntry>, StoreError> {
101 let statement = format!(
102 "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
103 WHERE account_id = $1 AND idempotency_key = $2"
104 );
105 let row = sqlx::query(&statement)
106 .bind(account.as_str())
107 .bind(key.as_str())
108 .fetch_optional(conn)
109 .await
110 .map_err(|error| store_error(&error))?;
111 row.as_ref().map(decode_entry).transpose()
112}
113
114pub(crate) async fn mark_executing(
117 conn: &mut PgConnection,
118 account: &AccountId,
119 command_id: &CommandId,
120) -> Result<(), StoreError> {
121 let updated = sqlx::query(
122 "UPDATE tf_command_journal SET status = 'executing'
123 WHERE account_id = $1 AND command_id = $2
124 AND status IN ('pending', 'awaiting_confirmation')",
125 )
126 .bind(account.as_str())
127 .bind(command_id.as_uuid())
128 .execute(&mut *conn)
129 .await
130 .map_err(|error| store_error(&error))?;
131 if updated.rows_affected() == 1 {
132 return Ok(());
133 }
134 match read_entry(conn, account, command_id).await? {
135 None => Err(StoreError::NotFound),
136 Some(entry) if entry.status == CommandJournalStatus::Executing => Ok(()),
137 Some(_) => Err(StoreError::Conflict),
138 }
139}
140
141pub(crate) async fn complete(
149 conn: &mut PgConnection,
150 account: &AccountId,
151 command_id: &CommandId,
152 outcome: JournalOutcome,
153 at: DateTime<Utc>,
154) -> Result<(), StoreError> {
155 let target = outcome.status();
156 let sources = legal_sources(target)?;
157 let updated = sqlx::query(
158 "UPDATE tf_command_journal SET status = $3, result = $4, completed_at = $5
159 WHERE account_id = $1 AND command_id = $2 AND status = ANY($6)",
160 )
161 .bind(account.as_str())
162 .bind(command_id.as_uuid())
163 .bind(label(&target)?)
164 .bind(to_json(&outcome)?)
165 .bind(at)
166 .bind(sources)
167 .execute(&mut *conn)
168 .await
169 .map_err(|error| store_error(&error))?;
170 if updated.rows_affected() == 1 {
171 return Ok(());
172 }
173 match read_entry(conn, account, command_id).await? {
174 None => Err(StoreError::NotFound),
175 Some(entry) if entry.status == target && entry.result.as_ref() == Some(&outcome) => Ok(()),
176 Some(_) => Err(StoreError::Conflict),
177 }
178}
179
180fn legal_sources(target: CommandJournalStatus) -> Result<Vec<String>, StoreError> {
182 CommandJournalStatus::ALL
183 .into_iter()
184 .filter(|from| CommandJournalStatus::can_transition(*from, target))
185 .map(|from| label(&from))
186 .collect()
187}
188
189pub(crate) async fn get(
191 conn: &mut PgConnection,
192 account: &AccountId,
193 command_id: &CommandId,
194) -> Result<CommandJournalEntry, StoreError> {
195 read_entry(conn, account, command_id)
196 .await?
197 .ok_or(StoreError::NotFound)
198}
199
200async fn read_entry(
202 conn: &mut PgConnection,
203 account: &AccountId,
204 command_id: &CommandId,
205) -> Result<Option<CommandJournalEntry>, StoreError> {
206 let statement = format!(
207 "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
208 WHERE account_id = $1 AND command_id = $2"
209 );
210 let row = sqlx::query(&statement)
211 .bind(account.as_str())
212 .bind(command_id.as_uuid())
213 .fetch_optional(conn)
214 .await
215 .map_err(|error| store_error(&error))?;
216 row.as_ref().map(decode_entry).transpose()
217}
218
219pub(crate) async fn for_turn(
222 conn: &mut PgConnection,
223 account: &AccountId,
224 turn_id: &TurnId,
225 only_pending: bool,
226) -> Result<Vec<CommandJournalEntry>, StoreError> {
227 let filter = if only_pending {
228 format!("AND status IN {PENDING_STATUSES}")
229 } else {
230 String::new()
231 };
232 let statement = format!(
233 "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
234 WHERE account_id = $1 AND turn_id = $2 {filter}
235 ORDER BY created_at, command_id"
236 );
237 let rows = sqlx::query(&statement)
238 .bind(account.as_str())
239 .bind(turn_id.as_uuid())
240 .fetch_all(conn)
241 .await
242 .map_err(|error| store_error(&error))?;
243 rows.iter().map(decode_entry).collect()
244}
245
246fn decode_entry(row: &PgRow) -> Result<CommandJournalEntry, StoreError> {
248 let account: String = column(row, "account_id")?;
249 let command_id: Uuid = column(row, "command_id")?;
250 let idempotency_key: String = column(row, "idempotency_key")?;
251 let turn_id: Uuid = column(row, "turn_id")?;
252 let workflow_key: String = column(row, "workflow_key")?;
253 let case_id: String = column(row, "case_id")?;
254 let revision: i64 = column(row, "expected_revision")?;
255 let status: String = column(row, "status")?;
256 let result: Option<serde_json::Value> = column(row, "result")?;
257 Ok(CommandJournalEntry {
258 command_id: CommandId::from(command_id),
259 account_id: AccountId::from(account),
260 idempotency_key: IdempotencyKey::new(idempotency_key),
261 turn_id: TurnId::from(turn_id),
262 case_ref: CaseRef::new(workflow_key, case_id, revision_from_sql(revision)?),
263 command_type: column(row, "command_type")?,
264 command_payload: column(row, "command_payload")?,
265 origin: from_json(column(row, "origin")?)?,
266 status: from_label(&status)?,
267 result: result.map(from_json).transpose()?,
268 created_at: column(row, "created_at")?,
269 completed_at: column(row, "completed_at")?,
270 })
271}
272
273#[async_trait]
274impl CommandJournalReader for PgStores {
275 async fn get(
276 &self,
277 account: &AccountId,
278 command_id: &CommandId,
279 ) -> Result<CommandJournalEntry, StoreError> {
280 let mut conn = self.connection().await?;
281 get(&mut conn, account, command_id).await
282 }
283
284 async fn for_turn(
285 &self,
286 account: &AccountId,
287 turn_id: &TurnId,
288 ) -> Result<Vec<CommandJournalEntry>, StoreError> {
289 let mut conn = self.connection().await?;
290 for_turn(&mut conn, account, turn_id, false).await
291 }
292
293 async fn pending_for_turn(
294 &self,
295 account: &AccountId,
296 turn_id: &TurnId,
297 ) -> Result<Vec<CommandJournalEntry>, StoreError> {
298 let mut conn = self.connection().await?;
299 for_turn(&mut conn, account, turn_id, true).await
300 }
301}
302
303#[async_trait]
304impl CommandJournalWriter for PgStores {
305 async fn begin(&self, entry: CommandJournalEntry) -> Result<JournalAdmission, StoreError> {
306 let mut transaction = self.transaction().await?;
307 let admission = begin(&mut transaction, entry).await?;
308 commit(transaction).await?;
309 Ok(admission)
310 }
311
312 async fn mark_executing(
313 &self,
314 account: &AccountId,
315 command_id: &CommandId,
316 ) -> Result<(), StoreError> {
317 let mut transaction = self.transaction().await?;
318 mark_executing(&mut transaction, account, command_id).await?;
319 commit(transaction).await
320 }
321
322 async fn complete(
323 &self,
324 account: &AccountId,
325 command_id: &CommandId,
326 outcome: JournalOutcome,
327 ) -> Result<(), StoreError> {
328 let mut transaction = self.transaction().await?;
329 complete(&mut transaction, account, command_id, outcome, now()).await?;
330 commit(transaction).await
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn the_legal_sources_are_the_contract_transition_table() {
340 assert_eq!(
343 legal_sources(CommandJournalStatus::Committed).unwrap(),
344 vec![
345 "pending",
346 "awaiting_confirmation",
347 "executing",
348 "outcome_unknown"
349 ]
350 );
351 assert_eq!(
352 legal_sources(CommandJournalStatus::Failed).unwrap(),
353 vec![
354 "pending",
355 "awaiting_confirmation",
356 "executing",
357 "outcome_unknown"
358 ]
359 );
360 assert_eq!(
361 legal_sources(CommandJournalStatus::OutcomeUnknown).unwrap(),
362 vec!["pending", "awaiting_confirmation", "executing"]
363 );
364 assert_eq!(
365 legal_sources(CommandJournalStatus::Executing).unwrap(),
366 vec!["pending", "awaiting_confirmation"]
367 );
368 }
369}