1use codlet_core::hashing::{KeyVersion, LookupKey};
4use codlet_core::secret::CodeId;
5use codlet_core::state::{ClaimOutcome, classify_claim};
6use codlet_core::store::code::{ClaimRequest, CodeRecord, CodeStore, RedeemableCode};
7use codlet_core::store::error::StoreError;
8
9use crate::SqliteStore;
10
11type CodeRow = (String, String, String, Option<String>, Option<String>, i64);
14
15impl CodeStore for SqliteStore {
16 async fn find_redeemable(
17 &self,
18 candidates: &[LookupKey],
19 now: u64,
20 scope: Option<&str>,
21 ) -> Result<Option<RedeemableCode>, StoreError> {
22 for candidate in candidates {
25 let row = find_one(&self.pool, candidate.as_str(), now, scope).await?;
26 if row.is_some() {
27 return Ok(row);
28 }
29 }
30 Ok(None)
31 }
32
33 async fn claim_code(&self, req: &ClaimRequest<'_>) -> Result<ClaimOutcome, StoreError> {
34 let now = req.now as i64;
35 let id = req.code_id.as_str();
36 let subject = req.subject.as_str();
37
38 let result = sqlx::query(
39 "UPDATE codlet_codes
40 SET used_at = ?, used_by_subject = ?
41 WHERE id = ?
42 AND used_at IS NULL
43 AND revoked_at IS NULL
44 AND expires_at > ?",
45 )
46 .bind(now)
47 .bind(subject)
48 .bind(id)
49 .bind(now)
50 .execute(&self.pool)
51 .await
52 .map_err(|e| StoreError::Backend(e.to_string()))?;
53
54 let changed = result.rows_affected() as usize;
55 if changed > 1 {
56 return Err(StoreError::InvariantViolation(format!(
57 "claim_code changed {changed} rows for id={id}"
58 )));
59 }
60 Ok(classify_claim(changed))
61 }
62
63 async fn insert_code(&self, record: CodeRecord) -> Result<(), StoreError> {
64 sqlx::query(
65 "INSERT INTO codlet_codes
66 (id, lookup_key, key_version, purpose, scope, grant_payload, created_at, expires_at)
67 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
68 )
69 .bind(record.id.as_str())
70 .bind(record.lookup_key.as_str())
71 .bind(record.key_version.as_str())
72 .bind(record.purpose.as_deref())
73 .bind(record.scope.as_deref())
74 .bind(record.grant.as_deref())
75 .bind(record.created_at as i64)
76 .bind(record.expires_at as i64)
77 .execute(&self.pool)
78 .await
79 .map_err(|e| {
80 if e.to_string().contains("UNIQUE") {
81 StoreError::Backend("duplicate lookup key (unique constraint)".into())
82 } else {
83 StoreError::Backend(e.to_string())
84 }
85 })?;
86 Ok(())
87 }
88
89 async fn revoke_code(
90 &self,
91 code_id: &CodeId,
92 scope: Option<&str>,
93 now: u64,
94 ) -> Result<(), StoreError> {
95 let now_i = now as i64;
96 let id = code_id.as_str();
97
98 if let Some(scope_val) = scope {
99 sqlx::query(
100 "UPDATE codlet_codes
101 SET revoked_at = ?
102 WHERE id = ? AND scope = ?
103 AND used_at IS NULL AND revoked_at IS NULL",
104 )
105 .bind(now_i)
106 .bind(id)
107 .bind(scope_val)
108 .execute(&self.pool)
109 .await
110 .map_err(|e| StoreError::Backend(e.to_string()))?;
111 } else {
112 sqlx::query(
113 "UPDATE codlet_codes
114 SET revoked_at = ?
115 WHERE id = ?
116 AND used_at IS NULL AND revoked_at IS NULL",
117 )
118 .bind(now_i)
119 .bind(id)
120 .execute(&self.pool)
121 .await
122 .map_err(|e| StoreError::Backend(e.to_string()))?;
123 }
124 Ok(())
125 }
126}
127
128async fn find_one(
129 pool: &sqlx::SqlitePool,
130 lookup_key: &str,
131 now: u64,
132 scope: Option<&str>,
133) -> Result<Option<RedeemableCode>, StoreError> {
134 let now_i = now as i64;
135
136 let row: Option<CodeRow> = if let Some(s) = scope {
138 sqlx::query_as(
139 "SELECT id, lookup_key, key_version, grant_payload, scope, expires_at
140 FROM codlet_codes
141 WHERE lookup_key = ?
142 AND scope = ?
143 AND used_at IS NULL
144 AND revoked_at IS NULL
145 AND expires_at > ?
146 LIMIT 1",
147 )
148 .bind(lookup_key)
149 .bind(s)
150 .bind(now_i)
151 .fetch_optional(pool)
152 .await
153 .map_err(|e| StoreError::Backend(e.to_string()))?
154 } else {
155 sqlx::query_as(
156 "SELECT id, lookup_key, key_version, grant_payload, scope, expires_at
157 FROM codlet_codes
158 WHERE lookup_key = ?
159 AND used_at IS NULL
160 AND revoked_at IS NULL
161 AND expires_at > ?
162 LIMIT 1",
163 )
164 .bind(lookup_key)
165 .bind(now_i)
166 .fetch_optional(pool)
167 .await
168 .map_err(|e| StoreError::Backend(e.to_string()))?
169 };
170
171 Ok(
172 row.map(|(id, _lk, kv, grant, scope_val, exp)| RedeemableCode {
173 id: CodeId::new(id),
174 key_version: KeyVersion::new(kv),
175 grant,
176 scope: scope_val,
177 expires_at: exp as u64,
178 }),
179 )
180}