create-feltdb 0.4.20 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.4.20';
3
+ export const FELTDB_PACKAGE_VERSION = '0.5.0';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -524,6 +524,7 @@ dependencies = [
524
524
  "tower-http",
525
525
  "tracing",
526
526
  "tracing-subscriber",
527
+ "uuid",
527
528
  ]
528
529
 
529
530
  [[package]]
@@ -1904,6 +1905,17 @@ version = "1.0.4"
1904
1905
  source = "registry+https://github.com/rust-lang/crates.io-index"
1905
1906
  checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1906
1907
 
1908
+ [[package]]
1909
+ name = "uuid"
1910
+ version = "1.25.0"
1911
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1912
+ checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
1913
+ dependencies = [
1914
+ "getrandom 0.4.3",
1915
+ "js-sys",
1916
+ "wasm-bindgen",
1917
+ ]
1918
+
1907
1919
  [[package]]
1908
1920
  name = "valuable"
1909
1921
  version = "0.1.1"
@@ -21,3 +21,4 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
21
21
  tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }
22
22
  tracing = "0.1"
23
23
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }
24
+ uuid = { version = "1.6", features = ["v4"] }
@@ -20,6 +20,8 @@ pub mod portable_bundle;
20
20
  pub mod principals;
21
21
  pub mod providers;
22
22
  pub mod releases;
23
+ pub mod request_telemetry;
23
24
  pub mod sessions;
24
25
  pub mod tenancy;
26
+ pub mod transaction_idempotency;
25
27
  pub mod versions;
@@ -0,0 +1,381 @@
1
+ use std::sync::{Arc, Mutex};
2
+ use std::time::{Instant, SystemTime};
3
+ use serde::{Deserialize, Serialize};
4
+ use uuid::Uuid;
5
+
6
+ /// Request lifecycle telemetry: captures all timing and context for every /v1/ request.
7
+ /// This is the production correctness contract for managed FeltDB concurrency.
8
+ #[derive(Debug, Clone, Serialize, Deserialize)]
9
+ pub struct RequestTelemetry {
10
+ pub request_id: String,
11
+ pub transaction_id: Option<String>,
12
+ pub application_id: String,
13
+ pub revision_id: String,
14
+
15
+ // Timing breakdown (milliseconds)
16
+ pub queue_wait_ms: u64,
17
+ pub lock_wait_ms: u64,
18
+ pub execution_ms: u64,
19
+ pub persistence_ms: u64,
20
+ pub total_ms: u64,
21
+
22
+ // Contention signals
23
+ pub lock_name: String,
24
+ pub queue_depth: usize,
25
+ pub max_queue_depth: usize,
26
+
27
+ // Cancellation state
28
+ pub deadline_ms: Option<u64>,
29
+ pub cancelled: bool,
30
+
31
+ // HTTP response
32
+ pub http_status: u16,
33
+ pub feltdb_code: String,
34
+ pub error_message: Option<String>,
35
+
36
+ // Resource tracking
37
+ pub orphaned_tasks: usize,
38
+ pub timestamp_ms: u64,
39
+ }
40
+
41
+ impl RequestTelemetry {
42
+ pub fn new(application_id: String, revision_id: String) -> Self {
43
+ let now = SystemTime::now()
44
+ .duration_since(SystemTime::UNIX_EPOCH)
45
+ .unwrap_or_default()
46
+ .as_millis() as u64;
47
+
48
+ Self {
49
+ request_id: Uuid::new_v4().to_string(),
50
+ transaction_id: None,
51
+ application_id,
52
+ revision_id,
53
+ queue_wait_ms: 0,
54
+ lock_wait_ms: 0,
55
+ execution_ms: 0,
56
+ persistence_ms: 0,
57
+ total_ms: 0,
58
+ lock_name: String::new(),
59
+ queue_depth: 0,
60
+ max_queue_depth: 0,
61
+ deadline_ms: None,
62
+ cancelled: false,
63
+ http_status: 200,
64
+ feltdb_code: "OK".to_string(),
65
+ error_message: None,
66
+ orphaned_tasks: 0,
67
+ timestamp_ms: now,
68
+ }
69
+ }
70
+
71
+ pub fn with_transaction_id(mut self, tx_id: String) -> Self {
72
+ self.transaction_id = Some(tx_id);
73
+ self
74
+ }
75
+ }
76
+
77
+ /// Request telemetry store: thread-safe collection of all request measurements.
78
+ /// Enables production diagnostics without external observability dependency.
79
+ pub struct RequestTelemetryStore {
80
+ records: Arc<Mutex<Vec<RequestTelemetry>>>,
81
+ max_capacity: usize,
82
+ }
83
+
84
+ impl RequestTelemetryStore {
85
+ pub fn new(max_capacity: usize) -> Self {
86
+ Self {
87
+ records: Arc::new(Mutex::new(Vec::with_capacity(max_capacity))),
88
+ max_capacity,
89
+ }
90
+ }
91
+
92
+ pub fn record(&self, telemetry: RequestTelemetry) {
93
+ if let Ok(mut records) = self.records.lock() {
94
+ records.push(telemetry);
95
+ // Keep only the most recent records
96
+ if records.len() > self.max_capacity {
97
+ let to_remove = records.len() - self.max_capacity;
98
+ records.drain(0..to_remove);
99
+ }
100
+ }
101
+ }
102
+
103
+ pub fn all(&self) -> Vec<RequestTelemetry> {
104
+ self.records
105
+ .lock()
106
+ .map(|r| r.clone())
107
+ .unwrap_or_default()
108
+ }
109
+
110
+ pub fn recent(&self, count: usize) -> Vec<RequestTelemetry> {
111
+ self.records
112
+ .lock()
113
+ .map(|r| {
114
+ let start = if r.len() > count { r.len() - count } else { 0 };
115
+ r[start..].to_vec()
116
+ })
117
+ .unwrap_or_default()
118
+ }
119
+
120
+ pub fn by_transaction_id(&self, tx_id: &str) -> Vec<RequestTelemetry> {
121
+ self.records
122
+ .lock()
123
+ .map(|r| r.iter().filter(|t| t.transaction_id.as_deref() == Some(tx_id)).cloned().collect())
124
+ .unwrap_or_default()
125
+ }
126
+
127
+ pub fn statistics(&self) -> TelemetryStatistics {
128
+ if let Ok(records) = self.records.lock() {
129
+ let mut stats = TelemetryStatistics::default();
130
+ let mut total_latencies = Vec::new();
131
+ let mut queue_waits = Vec::new();
132
+ let mut lock_waits = Vec::new();
133
+ let mut execution_times = Vec::new();
134
+ let mut persistence_times = Vec::new();
135
+
136
+ for telemetry in records.iter() {
137
+ total_latencies.push(telemetry.total_ms);
138
+ queue_waits.push(telemetry.queue_wait_ms);
139
+ lock_waits.push(telemetry.lock_wait_ms);
140
+ execution_times.push(telemetry.execution_ms);
141
+ persistence_times.push(telemetry.persistence_ms);
142
+ stats.total_requests += 1;
143
+ stats.max_queue_depth = stats.max_queue_depth.max(telemetry.max_queue_depth);
144
+ stats.total_orphaned_tasks += telemetry.orphaned_tasks;
145
+
146
+ match telemetry.http_status {
147
+ 200..=299 => stats.successful_requests += 1,
148
+ 400..=499 => stats.client_errors += 1,
149
+ 500..=599 => stats.server_errors += 1,
150
+ _ => {}
151
+ }
152
+
153
+ if telemetry.cancelled {
154
+ stats.cancelled_requests += 1;
155
+ }
156
+
157
+ if telemetry.feltdb_code == "CONFLICT" {
158
+ stats.conflicts += 1;
159
+ } else if telemetry.feltdb_code == "TOO_BUSY" {
160
+ stats.too_busy_count += 1;
161
+ }
162
+ }
163
+
164
+ // Calculate percentiles
165
+ if !total_latencies.is_empty() {
166
+ total_latencies.sort();
167
+ stats.latency_p50_ms = calculate_percentile(&total_latencies, 50);
168
+ stats.latency_p95_ms = calculate_percentile(&total_latencies, 95);
169
+ stats.latency_p99_ms = calculate_percentile(&total_latencies, 99);
170
+ stats.latency_max_ms = *total_latencies.last().unwrap_or(&0);
171
+ }
172
+
173
+ if !queue_waits.is_empty() {
174
+ queue_waits.sort();
175
+ stats.queue_wait_p99_ms = calculate_percentile(&queue_waits, 99);
176
+ stats.queue_wait_max_ms = *queue_waits.last().unwrap_or(&0);
177
+ }
178
+
179
+ if !lock_waits.is_empty() {
180
+ lock_waits.sort();
181
+ stats.lock_wait_p99_ms = calculate_percentile(&lock_waits, 99);
182
+ stats.lock_wait_max_ms = *lock_waits.last().unwrap_or(&0);
183
+ }
184
+
185
+ if !execution_times.is_empty() {
186
+ stats.execution_p99_ms = calculate_percentile(&execution_times, 99);
187
+ }
188
+
189
+ if !persistence_times.is_empty() {
190
+ stats.persistence_p99_ms = calculate_percentile(&persistence_times, 99);
191
+ }
192
+
193
+ stats
194
+ } else {
195
+ TelemetryStatistics::default()
196
+ }
197
+ }
198
+
199
+ pub fn clear(&self) {
200
+ if let Ok(mut records) = self.records.lock() {
201
+ records.clear();
202
+ }
203
+ }
204
+ }
205
+
206
+ #[derive(Debug, Clone, Serialize, Default)]
207
+ pub struct TelemetryStatistics {
208
+ pub total_requests: usize,
209
+ pub successful_requests: usize,
210
+ pub client_errors: usize,
211
+ pub server_errors: usize,
212
+ pub cancelled_requests: usize,
213
+ pub conflicts: usize,
214
+ pub too_busy_count: usize,
215
+
216
+ pub latency_p50_ms: u64,
217
+ pub latency_p95_ms: u64,
218
+ pub latency_p99_ms: u64,
219
+ pub latency_max_ms: u64,
220
+
221
+ pub queue_wait_p99_ms: u64,
222
+ pub queue_wait_max_ms: u64,
223
+
224
+ pub lock_wait_p99_ms: u64,
225
+ pub lock_wait_max_ms: u64,
226
+
227
+ pub execution_p99_ms: u64,
228
+ pub persistence_p99_ms: u64,
229
+
230
+ pub max_queue_depth: usize,
231
+ pub total_orphaned_tasks: usize,
232
+ }
233
+
234
+ fn calculate_percentile(sorted: &[u64], percentile: u32) -> u64 {
235
+ if sorted.is_empty() {
236
+ return 0;
237
+ }
238
+ let index = ((sorted.len() as u32 * percentile / 100).max(1) - 1) as usize;
239
+ sorted[index.min(sorted.len() - 1)]
240
+ }
241
+
242
+ /// Request lifecycle timer: tracks all phases of request execution.
243
+ pub struct RequestTimer {
244
+ start: Instant,
245
+ queue_start: Option<Instant>,
246
+ lock_start: Option<Instant>,
247
+ execution_start: Option<Instant>,
248
+ persistence_start: Option<Instant>,
249
+ }
250
+
251
+ impl RequestTimer {
252
+ pub fn new() -> Self {
253
+ Self {
254
+ start: Instant::now(),
255
+ queue_start: None,
256
+ lock_start: None,
257
+ execution_start: None,
258
+ persistence_start: None,
259
+ }
260
+ }
261
+
262
+ pub fn begin_queue_wait(&mut self) {
263
+ self.queue_start = Some(Instant::now());
264
+ }
265
+
266
+ pub fn end_queue_wait(&mut self) -> u64 {
267
+ self.queue_start
268
+ .take()
269
+ .map(|s| s.elapsed().as_millis() as u64)
270
+ .unwrap_or(0)
271
+ }
272
+
273
+ pub fn begin_lock_wait(&mut self) {
274
+ self.lock_start = Some(Instant::now());
275
+ }
276
+
277
+ pub fn end_lock_wait(&mut self) -> u64 {
278
+ self.lock_start
279
+ .take()
280
+ .map(|s| s.elapsed().as_millis() as u64)
281
+ .unwrap_or(0)
282
+ }
283
+
284
+ pub fn begin_execution(&mut self) {
285
+ self.execution_start = Some(Instant::now());
286
+ }
287
+
288
+ pub fn end_execution(&mut self) -> u64 {
289
+ self.execution_start
290
+ .take()
291
+ .map(|s| s.elapsed().as_millis() as u64)
292
+ .unwrap_or(0)
293
+ }
294
+
295
+ pub fn begin_persistence(&mut self) {
296
+ self.persistence_start = Some(Instant::now());
297
+ }
298
+
299
+ pub fn end_persistence(&mut self) -> u64 {
300
+ self.persistence_start
301
+ .take()
302
+ .map(|s| s.elapsed().as_millis() as u64)
303
+ .unwrap_or(0)
304
+ }
305
+
306
+ pub fn total_elapsed(&self) -> u64 {
307
+ self.start.elapsed().as_millis() as u64
308
+ }
309
+ }
310
+
311
+ impl Default for RequestTimer {
312
+ fn default() -> Self {
313
+ Self::new()
314
+ }
315
+ }
316
+
317
+ #[cfg(test)]
318
+ mod tests {
319
+ use super::*;
320
+
321
+ #[test]
322
+ fn test_telemetry_creation() {
323
+ let telemetry = RequestTelemetry::new("app-1".to_string(), "rev-1".to_string());
324
+ assert!(!telemetry.request_id.is_empty());
325
+ assert_eq!(telemetry.application_id, "app-1");
326
+ assert_eq!(telemetry.revision_id, "rev-1");
327
+ }
328
+
329
+ #[test]
330
+ fn test_telemetry_store() {
331
+ let store = RequestTelemetryStore::new(100);
332
+ let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
333
+ telemetry.total_ms = 50;
334
+ telemetry.http_status = 200;
335
+
336
+ store.record(telemetry.clone());
337
+ let all = store.all();
338
+ assert_eq!(all.len(), 1);
339
+ assert_eq!(all[0].total_ms, 50);
340
+ }
341
+
342
+ #[test]
343
+ fn test_timer_lifecycle() {
344
+ let mut timer = RequestTimer::new();
345
+
346
+ timer.begin_queue_wait();
347
+ std::thread::sleep(Duration::from_millis(10));
348
+ let queue_ms = timer.end_queue_wait();
349
+ assert!(queue_ms >= 10);
350
+
351
+ timer.begin_lock_wait();
352
+ std::thread::sleep(Duration::from_millis(5));
353
+ let lock_ms = timer.end_lock_wait();
354
+ assert!(lock_ms >= 5);
355
+
356
+ let total = timer.total_elapsed();
357
+ assert!(total >= 15);
358
+ }
359
+
360
+ #[test]
361
+ fn test_statistics_calculation() {
362
+ let store = RequestTelemetryStore::new(1000);
363
+
364
+ // Record 101 requests with increasing latency
365
+ for i in 0..101 {
366
+ let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
367
+ telemetry.total_ms = (i * 10) as u64; // 0, 10, 20, ..., 1000
368
+ telemetry.http_status = if i % 10 == 0 { 409 } else { 200 };
369
+ if i % 10 == 0 {
370
+ telemetry.feltdb_code = "CONFLICT".to_string();
371
+ }
372
+ store.record(telemetry);
373
+ }
374
+
375
+ let stats = store.statistics();
376
+ assert_eq!(stats.total_requests, 101);
377
+ assert!(stats.latency_p50_ms > 0);
378
+ assert!(stats.latency_p99_ms > stats.latency_p50_ms);
379
+ assert_eq!(stats.conflicts, 11);
380
+ }
381
+ }
@@ -0,0 +1,280 @@
1
+ use std::collections::HashMap;
2
+ use std::sync::{Arc, Mutex};
3
+ use serde::{Deserialize, Serialize};
4
+ use sha2::{Digest, Sha256};
5
+
6
+ /// Durable transaction idempotency: prevents re-execution of already-committed transactions.
7
+ /// Every committed transaction is stored with its result. If the client retries with the
8
+ /// same transaction_id + payload hash, the server returns the original result without re-executing.
9
+
10
+ #[derive(Debug, Clone, Serialize, Deserialize)]
11
+ pub struct CommittedTransaction {
12
+ pub transaction_id: String,
13
+ pub transaction_hash: String,
14
+ pub status: TransactionStatus,
15
+ pub committed_result: serde_json::Value,
16
+ pub commit_metadata: CommitMetadata,
17
+ }
18
+
19
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20
+ pub enum TransactionStatus {
21
+ #[serde(rename = "COMMITTED")]
22
+ Committed,
23
+ #[serde(rename = "CONFLICT")]
24
+ Conflict,
25
+ #[serde(rename = "VALIDATION_FAILED")]
26
+ ValidationFailed,
27
+ #[serde(rename = "INTERNAL_ERROR")]
28
+ InternalError,
29
+ }
30
+
31
+ #[derive(Debug, Clone, Serialize, Deserialize)]
32
+ pub struct CommitMetadata {
33
+ pub timestamp_ms: u64,
34
+ pub revision_id: String,
35
+ pub application_id: String,
36
+ pub state_before: u64,
37
+ pub state_after: u64,
38
+ pub retry_count: usize,
39
+ }
40
+
41
+ pub struct TransactionIdempotencyStore {
42
+ // Map: transaction_id -> list of committed transactions (to detect hash mismatches)
43
+ store: Arc<Mutex<HashMap<String, Vec<CommittedTransaction>>>>,
44
+ max_per_transaction: usize,
45
+ }
46
+
47
+ impl TransactionIdempotencyStore {
48
+ pub fn new(max_per_transaction: usize) -> Self {
49
+ Self {
50
+ store: Arc::new(Mutex::new(HashMap::new())),
51
+ max_per_transaction,
52
+ }
53
+ }
54
+
55
+ /// Record a committed transaction for idempotency.
56
+ /// If the same transaction_id is seen again with the same hash, return the original result.
57
+ /// If the same transaction_id is seen with a different hash, return CONFLICT.
58
+ pub fn record_committed(
59
+ &self,
60
+ transaction_id: String,
61
+ transaction_hash: String,
62
+ status: TransactionStatus,
63
+ result: serde_json::Value,
64
+ metadata: CommitMetadata,
65
+ ) {
66
+ if let Ok(mut store) = self.store.lock() {
67
+ let tx_id_clone = transaction_id.clone();
68
+ let committed = CommittedTransaction {
69
+ transaction_id,
70
+ transaction_hash,
71
+ status,
72
+ committed_result: result,
73
+ commit_metadata: metadata,
74
+ };
75
+
76
+ store
77
+ .entry(tx_id_clone.clone())
78
+ .or_insert_with(Vec::new)
79
+ .push(committed);
80
+
81
+ // Keep only the most recent attempts
82
+ if let Some(entries) = store.get_mut(&tx_id_clone) {
83
+ if entries.len() > self.max_per_transaction {
84
+ let to_remove = entries.len() - self.max_per_transaction;
85
+ entries.drain(0..to_remove);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /// Check if a transaction has been seen before.
92
+ /// Returns:
93
+ /// - Some(Ok(result)) if same transaction_id + same hash (idempotent replay)
94
+ /// - Some(Err("CONFLICT")) if same transaction_id + different hash (reuse with different payload)
95
+ /// - None if this is a new transaction_id
96
+ pub fn check_idempotent(
97
+ &self,
98
+ transaction_id: &str,
99
+ transaction_hash: &str,
100
+ ) -> Option<Result<serde_json::Value, String>> {
101
+ self.store
102
+ .lock()
103
+ .ok()
104
+ .and_then(|store| store.get(transaction_id).map(|entries| entries.clone()))
105
+ .and_then(|entries| {
106
+ if entries.is_empty() {
107
+ return None;
108
+ }
109
+
110
+ let first_entry = &entries[0];
111
+
112
+ // If hashes match, return the original result
113
+ if first_entry.transaction_hash == transaction_hash {
114
+ return Some(Ok(first_entry.committed_result.clone()));
115
+ }
116
+
117
+ // If hashes differ, it's a conflict (transaction ID reused with different payload)
118
+ Some(Err(format!(
119
+ "Transaction ID {} reused with different payload",
120
+ transaction_id
121
+ )))
122
+ })
123
+ }
124
+
125
+ /// Get all committed transactions for a transaction_id
126
+ pub fn get_history(&self, transaction_id: &str) -> Vec<CommittedTransaction> {
127
+ self.store
128
+ .lock()
129
+ .ok()
130
+ .and_then(|store| store.get(transaction_id).cloned())
131
+ .unwrap_or_default()
132
+ }
133
+
134
+ /// Clear all idempotency records (for testing)
135
+ pub fn clear(&self) {
136
+ if let Ok(mut store) = self.store.lock() {
137
+ store.clear();
138
+ }
139
+ }
140
+
141
+ /// Get statistics about idempotency store
142
+ pub fn statistics(&self) -> IdempotencyStatistics {
143
+ self.store
144
+ .lock()
145
+ .ok()
146
+ .map(|store| {
147
+ let total_transactions = store.len();
148
+ let total_entries = store.values().map(|v| v.len()).sum();
149
+ let replay_protected = store
150
+ .values()
151
+ .filter(|entries| !entries.is_empty() && entries[0].status == TransactionStatus::Committed)
152
+ .count();
153
+
154
+ IdempotencyStatistics {
155
+ total_transaction_ids: total_transactions,
156
+ total_committed_entries: total_entries,
157
+ replay_protected_count: replay_protected,
158
+ }
159
+ })
160
+ .unwrap_or_default()
161
+ }
162
+ }
163
+
164
+ #[derive(Debug, Clone, Serialize, Default)]
165
+ pub struct IdempotencyStatistics {
166
+ pub total_transaction_ids: usize,
167
+ pub total_committed_entries: usize,
168
+ pub replay_protected_count: usize,
169
+ }
170
+
171
+ /// Compute a hash of a transaction request for idempotency detection.
172
+ pub fn hash_transaction_request(request: &serde_json::Value) -> String {
173
+ let mut hasher = Sha256::new();
174
+ let json_str = serde_json::to_string(request).unwrap_or_default();
175
+ hasher.update(json_str.as_bytes());
176
+ format!("{:x}", hasher.finalize())
177
+ }
178
+
179
+ #[cfg(test)]
180
+ mod tests {
181
+ use super::*;
182
+
183
+ #[test]
184
+ fn test_idempotency_store_same_hash() {
185
+ let store = TransactionIdempotencyStore::new(10);
186
+ let tx_id = "tx-123".to_string();
187
+ let tx_hash = "hash-abc".to_string();
188
+ let result = serde_json::json!({ "status": "committed" });
189
+
190
+ store.record_committed(
191
+ tx_id.clone(),
192
+ tx_hash.clone(),
193
+ TransactionStatus::Committed,
194
+ result.clone(),
195
+ CommitMetadata {
196
+ timestamp_ms: 1000,
197
+ revision_id: "rev-1".to_string(),
198
+ application_id: "app-1".to_string(),
199
+ state_before: 1,
200
+ state_after: 2,
201
+ retry_count: 0,
202
+ },
203
+ );
204
+
205
+ // First replay should return the same result
206
+ let replay = store.check_idempotent(&tx_id, &tx_hash);
207
+ assert!(replay.is_some());
208
+ assert!(replay.unwrap().is_ok());
209
+ }
210
+
211
+ #[test]
212
+ fn test_idempotency_store_different_hash() {
213
+ let store = TransactionIdempotencyStore::new(10);
214
+ let tx_id = "tx-456".to_string();
215
+ let tx_hash_1 = "hash-1".to_string();
216
+ let tx_hash_2 = "hash-2".to_string();
217
+ let result = serde_json::json!({ "status": "committed" });
218
+
219
+ store.record_committed(
220
+ tx_id.clone(),
221
+ tx_hash_1.clone(),
222
+ TransactionStatus::Committed,
223
+ result,
224
+ CommitMetadata {
225
+ timestamp_ms: 1000,
226
+ revision_id: "rev-1".to_string(),
227
+ application_id: "app-1".to_string(),
228
+ state_before: 1,
229
+ state_after: 2,
230
+ retry_count: 0,
231
+ },
232
+ );
233
+
234
+ // Replay with different hash should fail
235
+ let replay = store.check_idempotent(&tx_id, &tx_hash_2);
236
+ assert!(replay.is_some());
237
+ assert!(replay.unwrap().is_err());
238
+ }
239
+
240
+ #[test]
241
+ fn test_hash_transaction_request() {
242
+ let request1 = serde_json::json!({ "id": "test", "value": 123 });
243
+ let request2 = serde_json::json!({ "id": "test", "value": 123 });
244
+ let request3 = serde_json::json!({ "id": "test", "value": 456 });
245
+
246
+ let hash1 = hash_transaction_request(&request1);
247
+ let hash2 = hash_transaction_request(&request2);
248
+ let hash3 = hash_transaction_request(&request3);
249
+
250
+ assert_eq!(hash1, hash2); // Same content = same hash
251
+ assert_ne!(hash1, hash3); // Different content = different hash
252
+ }
253
+
254
+ #[test]
255
+ fn test_idempotency_statistics() {
256
+ let store = TransactionIdempotencyStore::new(10);
257
+
258
+ for i in 0..5 {
259
+ store.record_committed(
260
+ format!("tx-{}", i),
261
+ "hash-abc".to_string(),
262
+ TransactionStatus::Committed,
263
+ serde_json::json!({}),
264
+ CommitMetadata {
265
+ timestamp_ms: 1000,
266
+ revision_id: "rev-1".to_string(),
267
+ application_id: "app-1".to_string(),
268
+ state_before: 1,
269
+ state_after: 2,
270
+ retry_count: 0,
271
+ },
272
+ );
273
+ }
274
+
275
+ let stats = store.statistics();
276
+ assert_eq!(stats.total_transaction_ids, 5);
277
+ assert_eq!(stats.total_committed_entries, 5);
278
+ assert_eq!(stats.replay_protected_count, 5);
279
+ }
280
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.4.20",
5
+ "version": "0.5.0",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"