audit_trails/core/records/
mod.rs1use std::collections::{BTreeMap, HashMap, HashSet};
7use std::marker::PhantomData;
8
9use iota_interaction::move_core_types::annotated_value::MoveValue;
10use iota_interaction::rpc_types::IotaMoveValue;
11use iota_interaction::types::collection_types::LinkedTable;
12use iota_interaction::types::dynamic_field::DynamicFieldName;
13use iota_interaction::{IotaKeySignature, OptionalSync};
14use iota_sdk_types::{ObjectId, TypeTag};
15use product_common::core_client::{CoreClient, CoreClientReadOnly};
16use product_common::transaction::transaction_builder::TransactionBuilder;
17use secret_storage::Signer;
18use serde::de::DeserializeOwned;
19
20use crate::core::internal::{linked_table, trail as trail_reader};
21use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly};
22use crate::core::types::{Data, PaginatedRecord, Record, RecordInput};
23use crate::error::Error;
24mod operations;
25mod transactions;
26
27pub use transactions::{AddRecord, CorrectRecord, DeleteRecord, DeleteRecordsBatch};
28
29use self::operations::RecordsOps;
30
31const MAX_LIST_PAGE_LIMIT: usize = 1_000;
32
33#[derive(Debug, Clone)]
37pub struct TrailRecords<'a, C, D = Data> {
38 pub(crate) client: &'a C,
39 pub(crate) trail_id: ObjectId,
40 pub(crate) selected_capability_id: Option<ObjectId>,
41 pub(crate) _phantom: PhantomData<D>,
42}
43
44impl<'a, C, D> TrailRecords<'a, C, D> {
45 pub(crate) fn new(client: &'a C, trail_id: ObjectId, selected_capability_id: Option<ObjectId>) -> Self {
46 Self {
47 client,
48 trail_id,
49 selected_capability_id,
50 _phantom: PhantomData,
51 }
52 }
53
54 pub fn using_capability(mut self, capability_id: ObjectId) -> Self {
56 self.selected_capability_id = Some(capability_id);
57 self
58 }
59
60 pub async fn get(&self, sequence_number: u64) -> Result<Record<D>, Error>
66 where
67 C: AuditTrailReadOnly,
68 D: DeserializeOwned,
69 {
70 let tx = RecordsOps::get_record(self.client, self.trail_id, sequence_number).await?;
71 self.client.execute_read_only_transaction(tx).await
72 }
73
74 pub fn add<S>(&self, data: D, metadata: Option<String>, tag: Option<String>) -> TransactionBuilder<AddRecord>
79 where
80 C: AuditTrailFull + CoreClient<S>,
81 S: Signer<IotaKeySignature> + OptionalSync,
82 D: Into<Data>,
83 {
84 let owner = self.client.sender_address();
85 TransactionBuilder::new(AddRecord::new(
86 self.trail_id,
87 owner,
88 data.into(),
89 metadata,
90 tag,
91 self.selected_capability_id,
92 ))
93 }
94
95 pub fn delete<S>(&self, sequence_number: u64) -> TransactionBuilder<DeleteRecord>
99 where
100 C: AuditTrailFull + CoreClient<S>,
101 S: Signer<IotaKeySignature> + OptionalSync,
102 {
103 let owner = self.client.sender_address();
104 TransactionBuilder::new(DeleteRecord::new(
105 self.trail_id,
106 owner,
107 sequence_number,
108 self.selected_capability_id,
109 ))
110 }
111
112 pub fn delete_records_batch<S>(&self, limit: u64) -> TransactionBuilder<DeleteRecordsBatch>
138 where
139 C: AuditTrailFull + CoreClient<S>,
140 S: Signer<IotaKeySignature> + OptionalSync,
141 {
142 let owner = self.client.sender_address();
143 TransactionBuilder::new(DeleteRecordsBatch::new(
144 self.trail_id,
145 owner,
146 limit,
147 self.selected_capability_id,
148 ))
149 }
150
151 pub fn correct<S>(&self, sequence_number: u64, record: RecordInput<D>) -> TransactionBuilder<CorrectRecord>
163 where
164 C: AuditTrailFull + CoreClient<S>,
165 S: Signer<IotaKeySignature> + OptionalSync,
166 D: Into<Data>,
167 {
168 let owner = self.client.sender_address();
169 TransactionBuilder::new(CorrectRecord::new(
170 self.trail_id,
171 owner,
172 sequence_number,
173 record.data.into(),
174 record.metadata,
175 record.tag,
176 self.selected_capability_id,
177 ))
178 }
179
180 pub async fn resolve_current(&self, sequence_number: u64) -> Result<Record<D>, Error>
195 where
196 C: AuditTrailReadOnly,
197 D: DeserializeOwned,
198 {
199 let mut current = sequence_number;
200 let mut visited = HashSet::new();
201
202 loop {
203 if !visited.insert(current) {
204 return Err(Error::UnexpectedApiResponse(format!(
205 "cycle detected while resolving correction chain at record {current}"
206 )));
207 }
208
209 let record = self.get(current).await?;
210 let Some(next) = record.correction.is_replaced_by else {
211 return Ok(record);
212 };
213 current = next;
214 }
215 }
216
217 pub async fn record_count(&self) -> Result<u64, Error>
223 where
224 C: AuditTrailReadOnly,
225 {
226 let tx = RecordsOps::record_count(self.client, self.trail_id).await?;
227 self.client.execute_read_only_transaction(tx).await
228 }
229
230 pub async fn list(&self) -> Result<HashMap<u64, Record<D>>, Error>
235 where
236 C: AuditTrailReadOnly,
237 D: DeserializeOwned,
238 {
239 let records_table = self.load_records_table().await?;
240 list_linked_table::<_, Record<D>>(self.client, &records_table, None).await
241 }
242
243 pub async fn list_with_limit(&self, max_entries: usize) -> Result<HashMap<u64, Record<D>>, Error>
245 where
246 C: AuditTrailReadOnly,
247 D: DeserializeOwned,
248 {
249 let records_table = self.load_records_table().await?;
250 list_linked_table::<_, Record<D>>(self.client, &records_table, Some(max_entries)).await
251 }
252
253 pub async fn list_page(&self, cursor: Option<u64>, limit: usize) -> Result<PaginatedRecord<D>, Error>
257 where
258 C: AuditTrailReadOnly,
259 D: DeserializeOwned,
260 {
261 if limit > MAX_LIST_PAGE_LIMIT {
262 return Err(Error::InvalidArgument(format!(
263 "page limit {limit} exceeds max supported page size {MAX_LIST_PAGE_LIMIT}"
264 )));
265 }
266
267 let records_table = self.load_records_table().await?;
268 let (records, next_cursor) =
269 list_linked_table_page::<_, Record<D>>(self.client, &records_table, cursor, limit).await?;
270
271 Ok(PaginatedRecord {
272 has_next_page: next_cursor.is_some(),
273 next_cursor,
274 records,
275 })
276 }
277
278 async fn load_records_table(&self) -> Result<LinkedTable<u64>, Error>
279 where
280 C: AuditTrailReadOnly,
281 {
282 trail_reader::get_audit_trail(self.trail_id, self.client)
283 .await
284 .map(|on_chain_trail| on_chain_trail.records)
285 }
286}
287
288async fn list_linked_table_page<C, V>(
289 client: &C,
290 table: &LinkedTable<u64>,
291 start_key: Option<u64>,
292 limit: usize,
293) -> Result<(BTreeMap<u64, V>, Option<u64>), Error>
294where
295 C: CoreClientReadOnly + OptionalSync,
296 V: DeserializeOwned,
297{
298 if limit == 0 {
300 return Ok((BTreeMap::new(), start_key.or(table.head)));
301 }
302
303 let mut cursor = start_key.or(table.head);
304 let mut items = BTreeMap::new();
305
306 for _ in 0..limit {
307 let Some(key) = cursor else { break };
308
309 if items.contains_key(&key) {
310 return Err(Error::UnexpectedApiResponse(format!(
311 "cycle detected while traversing linked-table {table_id}; repeated key {key}",
312 table_id = table.id
313 )));
314 }
315
316 let node = linked_table::fetch_node::<_, u64, V>(
317 client,
318 table.id,
319 DynamicFieldName {
320 type_: TypeTag::U64,
321 value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(),
322 },
323 )
324 .await?;
325
326 cursor = node.next;
327 items.insert(key, node.value);
328 }
329
330 Ok((items, cursor))
331}
332
333async fn list_linked_table<C, V>(
334 client: &C,
335 table: &LinkedTable<u64>,
336 max_entries: Option<usize>,
337) -> Result<HashMap<u64, V>, Error>
338where
339 C: CoreClientReadOnly + OptionalSync,
340 V: DeserializeOwned,
341{
342 let expected = table.size as usize;
344 let cap = max_entries.unwrap_or(expected);
345
346 if expected > cap {
347 return Err(Error::InvalidArgument(format!(
348 "linked-table size {expected} exceeds max_entries {cap}"
349 )));
350 }
351
352 let (entries, next_key) = list_linked_table_page(client, table, None, expected).await?;
353
354 if entries.len() != expected {
355 return Err(Error::UnexpectedApiResponse(format!(
356 "linked-table traversal mismatch; expected {expected} entries, got {}",
357 entries.len()
358 )));
359 }
360
361 if next_key.is_some() {
362 return Err(Error::UnexpectedApiResponse(format!(
363 "linked-table traversal has extra entries beyond declared size {expected}"
364 )));
365 }
366
367 Ok(entries.into_iter().collect())
368}