1use std::marker::PhantomData;
17
18use ubiquisync_core::{
19 codec::{CodecError, DecodedEntry, IndexableOp, TAG_EXPUNGED},
20 hlc::Timestamp,
21 log_entry::LogEntry,
22 sync::PeerCursors,
23 uuid::Uuid,
24};
25
26use crate::{
27 db::{Db, DbBatch, DbError, DbType, DbValue, ValueBinder},
28 util::quote_ident,
29};
30
31#[async_trait::async_trait]
48pub trait LogTracker<Op>: Sized + Send + Sync {
49 async fn init(db: &dyn Db, prefix: &str) -> Result<Self, DbError>;
52
53 fn track_one(
57 &self,
58 peer_id: &Uuid,
59 entry_idx: u64,
60 timestamp: Timestamp,
61 server_user_id: Option<Uuid>,
62 op: &Op,
63 batch: &mut dyn DbBatch,
64 ) -> Result<(), LogTrackerError>;
65
66 fn track_expunged(
71 &self,
72 peer_id: &Uuid,
73 entry_idx: u64,
74 hash: &blake3::Hash,
75 batch: &mut dyn DbBatch,
76 ) -> Result<(), LogTrackerError>;
77
78 async fn all_cursors(&self, db: &dyn Db) -> Result<PeerCursors, DbError>;
81}
82
83#[async_trait::async_trait]
87pub trait HistoryTracker<Op>: LogTracker<Op> {
88 async fn read_entries(
91 &self,
92 db: &dyn Db,
93 peer_id: &Uuid,
94 from: u64,
95 limit: u64,
96 ) -> Result<Vec<(u64, DecodedEntry<Op>)>, LogTrackerError>;
97}
98
99#[derive(Debug, thiserror::Error)]
105pub enum LogTrackerError {
106 #[error("codec error: {0}")]
108 Codec(#[from] CodecError),
109 #[error("db error: {0}")]
112 Db(#[from] DbError),
113}
114
115pub struct LogIndexTracker<Op> {
124 quoted_table_name: String,
125 _phantom: PhantomData<Op>,
126}
127
128#[async_trait::async_trait]
129impl<Op: IndexableOp + Send + Sync> LogTracker<Op> for LogIndexTracker<Op> {
130 async fn init(db: &dyn Db, prefix: &str) -> Result<Self, DbError> {
131 let quoted_table_name = quote_ident(&format!("{prefix}__oplog"));
132 let dialect = db.dialect();
133 let int_type = DbType::Integer.sql_type(dialect);
134 let blob_type = DbType::Blob.sql_type(dialect);
135 let uuid_type = DbType::Uuid.sql_type(dialect);
136 let without_rowid = dialect.without_rowid();
137 let sql = format!(
138 "CREATE TABLE IF NOT EXISTS {quoted_table_name} (\
139 peer_id {uuid_type} NOT NULL,\
140 entry_idx {int_type} NOT NULL,\
141 server_user_id {uuid_type} NULL,\
142 ts {int_type} NOT NULL,\
143 tag {int_type} NOT NULL,\
144 index_key {blob_type} NULL,\
145 index_value {blob_type} NULL,\
146 PRIMARY KEY(peer_id, entry_idx))\
147 {without_rowid};"
148 );
149 db.exec(&sql, &[]).await?;
150 Ok(Self {
151 quoted_table_name,
152 _phantom: Default::default(),
153 })
154 }
155
156 fn track_one(
157 &self,
158 peer_id: &Uuid,
159 entry_idx: u64,
160 timestamp: Timestamp,
161 server_user_id: Option<Uuid>,
162 op: &Op,
163 batch: &mut dyn DbBatch,
164 ) -> Result<(), LogTrackerError> {
165 let mut value_binder = ValueBinder::new(batch.dialect());
166
167 let peer_id_bind = value_binder.bind_next(DbValue::Uuid(*peer_id));
168 let entry_idx_bind = value_binder.bind_next(DbValue::from_u64(entry_idx)?);
172 let server_user_id_bind = if let Some(server_user_id) = server_user_id {
173 value_binder.bind_next(DbValue::Uuid(server_user_id))
174 } else {
175 value_binder.bind_next(DbValue::Null)
176 };
177 let ts_bind = value_binder.bind_next(DbValue::from_u64(timestamp.raw())?);
181
182 let index_entry = op.to_index_entry()?;
183 let tag_bind = value_binder.bind_next(DbValue::Integer(index_entry.tag as i64));
184 let index_key_bind = value_binder.bind_next(DbValue::Blob(index_entry.key));
185 let value_bind = value_binder.bind_next(DbValue::Blob(index_entry.value));
186
187 let sql = format!(
188 "INSERT INTO {} (\"peer_id\", \"entry_idx\", \"server_user_id\", \"ts\", \"tag\", \"index_key\", \"index_value\") \
189 VALUES({peer_id_bind}, {entry_idx_bind}, {server_user_id_bind}, {ts_bind}, {tag_bind}, {index_key_bind}, {value_bind})",
190 self.quoted_table_name
191 );
192
193 batch.add_statement(&sql, &value_binder.values());
194 Ok(())
195 }
196
197 async fn all_cursors(&self, db: &dyn Db) -> Result<PeerCursors, DbError> {
198 let sql = format!(
199 "SELECT \"peer_id\", MAX(\"entry_idx\") FROM {} GROUP BY \"peer_id\"",
200 self.quoted_table_name
201 );
202 let rows = db.query(&sql, &[]).await?;
203 let mut cursors = PeerCursors::new();
204 for row in &rows {
205 cursors.insert(row.get_uuid(0)?, row.get_u64(1)? + 1);
208 }
209 Ok(cursors)
210 }
211
212 fn track_expunged(
213 &self,
214 peer_id: &Uuid,
215 entry_idx: u64,
216 hash: &blake3::Hash,
217 batch: &mut dyn DbBatch,
218 ) -> Result<(), LogTrackerError> {
219 let mut value_binder = ValueBinder::new(batch.dialect());
220
221 let peer_id_bind = value_binder.bind_next(DbValue::Uuid(*peer_id));
222 let entry_idx_bind = value_binder.bind_next(DbValue::from_u64(entry_idx)?);
223 let tag_bind = value_binder.bind_next(DbValue::Integer(TAG_EXPUNGED as i64));
228 let hash_bind = value_binder.bind_next(DbValue::Blob(hash.as_bytes().to_vec()));
229
230 let sql = format!(
231 "INSERT INTO {} (\"peer_id\", \"entry_idx\", \"server_user_id\", \"ts\", \"tag\", \"index_key\", \"index_value\") \
232 VALUES({peer_id_bind}, {entry_idx_bind}, NULL, 0, {tag_bind}, NULL, {hash_bind})",
233 self.quoted_table_name
234 );
235
236 batch.add_statement(&sql, &value_binder.values());
237 Ok(())
238 }
239}
240
241#[async_trait::async_trait]
242impl<Op: IndexableOp + Send + Sync> HistoryTracker<Op> for LogIndexTracker<Op> {
243 async fn read_entries(
244 &self,
245 db: &dyn Db,
246 peer_id: &Uuid,
247 from: u64,
248 limit: u64,
249 ) -> Result<Vec<(u64, DecodedEntry<Op>)>, LogTrackerError> {
250 let mut binder = ValueBinder::new(db.dialect());
251 let peer_bind = binder.bind_next(DbValue::Uuid(*peer_id));
252 let from_bind = binder.bind_next(DbValue::from_u64(from)?);
253 let limit_bind = binder.bind_next(DbValue::from_u64(limit)?);
254 let sql = format!(
255 "SELECT \"entry_idx\", \"server_user_id\", \"ts\", \"tag\", \"index_key\", \"index_value\" \
256 FROM {} WHERE \"peer_id\" = {peer_bind} AND \"entry_idx\" >= {from_bind} \
257 ORDER BY \"entry_idx\" ASC LIMIT {limit_bind}",
258 self.quoted_table_name
259 );
260 let rows = db.query(&sql, &binder.values()).await?;
261
262 let mut out = Vec::with_capacity(rows.len());
263 for row in &rows {
264 let idx = row.get_u64(0)?;
265 let tag = u8::try_from(row.get_i64(3)?).map_err(|_| DbError::TypeMismatch {
266 col: 3,
267 expected: "u8 tag",
268 })?;
269 let decoded = if tag == TAG_EXPUNGED {
270 let bytes: [u8; 32] =
273 row.get_blob(5)?
274 .try_into()
275 .map_err(|_| DbError::TypeMismatch {
276 col: 5,
277 expected: "32-byte hash",
278 })?;
279 DecodedEntry::Expunged(blake3::Hash::from_bytes(bytes))
280 } else {
281 let op = Op::from_index_parts(
282 tag,
283 row.get_optional_blob(4)?.unwrap_or_default(),
284 row.get_optional_blob(5)?.unwrap_or_default(),
285 )?;
286 DecodedEntry::LogEntry(LogEntry {
287 server_user_id: row.get_optional_uuid(1)?,
288 timestamp: Timestamp::from_raw(row.get_u64(2)?),
289 op,
290 })
291 };
292 out.push((idx, decoded));
293 }
294 Ok(out)
295 }
296}