create-feltdb 0.5.5 → 0.5.7
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.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/Cargo.lock +10 -0
- package/dist/server-source/crates/feltdb-server/Cargo.toml +1 -0
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +5 -1
- package/dist/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
- package/dist/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
- package/dist/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
- package/dist/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
- package/dist/server-source/crates/feltdb-server/src/lib.rs +9 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +62 -5
- package/dist/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
- package/dist/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
- package/dist/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
- package/dist/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
- package/dist/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
- package/package.json +1 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use sha2::{Digest, Sha256};
|
|
3
|
+
use std::collections::HashMap;
|
|
4
|
+
use std::sync::{Arc, RwLock};
|
|
5
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
6
|
+
|
|
7
|
+
/// Transaction status for idempotency and recovery.
|
|
8
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
9
|
+
#[serde(rename_all = "lowercase")]
|
|
10
|
+
pub enum TransactionStatus {
|
|
11
|
+
/// Transaction status is unknown (may be in-flight or lost).
|
|
12
|
+
Unknown,
|
|
13
|
+
/// Transaction successfully committed.
|
|
14
|
+
Committed,
|
|
15
|
+
/// Transaction was rejected (validation failure).
|
|
16
|
+
Rejected,
|
|
17
|
+
/// Transaction conflicted (concurrent write or version mismatch).
|
|
18
|
+
Conflicted,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Record of a transaction for idempotency and recovery.
|
|
22
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
23
|
+
pub struct TransactionRecord {
|
|
24
|
+
/// Unique transaction ID.
|
|
25
|
+
pub transaction_id: String,
|
|
26
|
+
|
|
27
|
+
/// Current status of the transaction.
|
|
28
|
+
pub status: TransactionStatus,
|
|
29
|
+
|
|
30
|
+
/// Hash of the payload for idempotency checking.
|
|
31
|
+
/// Same ID + same payload = retry is safe
|
|
32
|
+
/// Same ID + different payload = conflict
|
|
33
|
+
pub payload_hash: String,
|
|
34
|
+
|
|
35
|
+
/// State version after transaction (if committed).
|
|
36
|
+
pub state_version: u64,
|
|
37
|
+
|
|
38
|
+
/// When this transaction was recorded.
|
|
39
|
+
pub recorded_at: u64,
|
|
40
|
+
|
|
41
|
+
/// When the transaction was committed (if successful).
|
|
42
|
+
pub committed_at: Option<u64>,
|
|
43
|
+
|
|
44
|
+
/// Reason for rejection or conflict (if applicable).
|
|
45
|
+
pub error: Option<String>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Request to check transaction status.
|
|
49
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
50
|
+
pub struct TransactionStatusRequest {
|
|
51
|
+
pub transaction_id: String,
|
|
52
|
+
pub payload_hash: Option<String>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Response with transaction status.
|
|
56
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
57
|
+
pub struct TransactionStatusResponse {
|
|
58
|
+
pub transaction_id: String,
|
|
59
|
+
pub status: TransactionStatus,
|
|
60
|
+
pub payload_hash: String,
|
|
61
|
+
pub state_version: u64,
|
|
62
|
+
pub committed_at: Option<u64>,
|
|
63
|
+
pub error: Option<String>,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Recoverable transaction store.
|
|
67
|
+
#[derive(Clone)]
|
|
68
|
+
pub struct TransactionRecoveryStore {
|
|
69
|
+
records: Arc<RwLock<HashMap<String, TransactionRecord>>>,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
impl TransactionRecoveryStore {
|
|
73
|
+
/// Creates a new recovery store.
|
|
74
|
+
pub fn new() -> Self {
|
|
75
|
+
Self {
|
|
76
|
+
records: Arc::new(RwLock::new(HashMap::new())),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Records a transaction being processed.
|
|
81
|
+
pub fn record_unknown(
|
|
82
|
+
&self,
|
|
83
|
+
transaction_id: String,
|
|
84
|
+
payload_hash: String,
|
|
85
|
+
) -> Result<(), String> {
|
|
86
|
+
let mut records = self
|
|
87
|
+
.records
|
|
88
|
+
.write()
|
|
89
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
90
|
+
|
|
91
|
+
let id = transaction_id.clone();
|
|
92
|
+
records.insert(
|
|
93
|
+
transaction_id,
|
|
94
|
+
TransactionRecord {
|
|
95
|
+
transaction_id: id,
|
|
96
|
+
status: TransactionStatus::Unknown,
|
|
97
|
+
payload_hash,
|
|
98
|
+
state_version: 0,
|
|
99
|
+
recorded_at: now(),
|
|
100
|
+
committed_at: None,
|
|
101
|
+
error: None,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
Ok(())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Records a successful commit.
|
|
109
|
+
pub fn record_committed(
|
|
110
|
+
&self,
|
|
111
|
+
transaction_id: String,
|
|
112
|
+
payload_hash: String,
|
|
113
|
+
state_version: u64,
|
|
114
|
+
) -> Result<(), String> {
|
|
115
|
+
let mut records = self
|
|
116
|
+
.records
|
|
117
|
+
.write()
|
|
118
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
119
|
+
|
|
120
|
+
let txn_id = transaction_id.clone();
|
|
121
|
+
records.insert(
|
|
122
|
+
txn_id.clone(),
|
|
123
|
+
TransactionRecord {
|
|
124
|
+
transaction_id: txn_id,
|
|
125
|
+
status: TransactionStatus::Committed,
|
|
126
|
+
payload_hash,
|
|
127
|
+
state_version,
|
|
128
|
+
recorded_at: now(),
|
|
129
|
+
committed_at: Some(now()),
|
|
130
|
+
error: None,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
Ok(())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Records a rejection.
|
|
138
|
+
pub fn record_rejected(
|
|
139
|
+
&self,
|
|
140
|
+
transaction_id: String,
|
|
141
|
+
payload_hash: String,
|
|
142
|
+
error: String,
|
|
143
|
+
) -> Result<(), String> {
|
|
144
|
+
let mut records = self
|
|
145
|
+
.records
|
|
146
|
+
.write()
|
|
147
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
148
|
+
|
|
149
|
+
let id = transaction_id.clone();
|
|
150
|
+
records.insert(
|
|
151
|
+
id.clone(),
|
|
152
|
+
TransactionRecord {
|
|
153
|
+
transaction_id: id,
|
|
154
|
+
status: TransactionStatus::Rejected,
|
|
155
|
+
payload_hash,
|
|
156
|
+
state_version: 0,
|
|
157
|
+
recorded_at: now(),
|
|
158
|
+
committed_at: None,
|
|
159
|
+
error: Some(error),
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
Ok(())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Records a conflict.
|
|
167
|
+
pub fn record_conflicted(
|
|
168
|
+
&self,
|
|
169
|
+
transaction_id: String,
|
|
170
|
+
payload_hash: String,
|
|
171
|
+
error: String,
|
|
172
|
+
) -> Result<(), String> {
|
|
173
|
+
let mut records = self
|
|
174
|
+
.records
|
|
175
|
+
.write()
|
|
176
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
177
|
+
|
|
178
|
+
let id = transaction_id.clone();
|
|
179
|
+
records.insert(
|
|
180
|
+
id.clone(),
|
|
181
|
+
TransactionRecord {
|
|
182
|
+
transaction_id: id,
|
|
183
|
+
status: TransactionStatus::Conflicted,
|
|
184
|
+
payload_hash,
|
|
185
|
+
state_version: 0,
|
|
186
|
+
recorded_at: now(),
|
|
187
|
+
committed_at: None,
|
|
188
|
+
error: Some(error),
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
Ok(())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Gets transaction status.
|
|
196
|
+
pub fn get_status(&self, transaction_id: &str) -> Result<TransactionStatusResponse, String> {
|
|
197
|
+
let records = self
|
|
198
|
+
.records
|
|
199
|
+
.read()
|
|
200
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
201
|
+
|
|
202
|
+
let record = records
|
|
203
|
+
.get(transaction_id)
|
|
204
|
+
.ok_or(format!("transaction not found: {}", transaction_id))?;
|
|
205
|
+
|
|
206
|
+
Ok(TransactionStatusResponse {
|
|
207
|
+
transaction_id: record.transaction_id.clone(),
|
|
208
|
+
status: record.status,
|
|
209
|
+
payload_hash: record.payload_hash.clone(),
|
|
210
|
+
state_version: record.state_version,
|
|
211
|
+
committed_at: record.committed_at,
|
|
212
|
+
error: record.error.clone(),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Checks if a retry is safe (idempotent).
|
|
217
|
+
pub fn is_retry_safe(&self, transaction_id: &str, payload_hash: &str) -> Result<bool, String> {
|
|
218
|
+
let records = self
|
|
219
|
+
.records
|
|
220
|
+
.read()
|
|
221
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
222
|
+
|
|
223
|
+
let record = records.get(transaction_id);
|
|
224
|
+
|
|
225
|
+
match record {
|
|
226
|
+
None => Ok(true), // Unknown transaction - safe to retry
|
|
227
|
+
Some(r) => {
|
|
228
|
+
// Same payload = safe to retry
|
|
229
|
+
if r.payload_hash == payload_hash {
|
|
230
|
+
Ok(r.status == TransactionStatus::Committed || r.status == TransactionStatus::Unknown)
|
|
231
|
+
} else {
|
|
232
|
+
// Different payload = conflict
|
|
233
|
+
Ok(false)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Clears old transaction records (for cleanup).
|
|
240
|
+
pub fn cleanup_before(&self, cutoff_time: u64) -> Result<usize, String> {
|
|
241
|
+
let mut records = self
|
|
242
|
+
.records
|
|
243
|
+
.write()
|
|
244
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
245
|
+
|
|
246
|
+
let before_len = records.len();
|
|
247
|
+
records.retain(|_, r| r.recorded_at >= cutoff_time);
|
|
248
|
+
let after_len = records.len();
|
|
249
|
+
|
|
250
|
+
Ok(before_len - after_len)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
impl Default for TransactionRecoveryStore {
|
|
255
|
+
fn default() -> Self {
|
|
256
|
+
Self::new()
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Computes payload hash for transaction idempotency.
|
|
261
|
+
pub fn compute_payload_hash(payload: &[u8]) -> String {
|
|
262
|
+
let mut hasher = Sha256::new();
|
|
263
|
+
hasher.update(payload);
|
|
264
|
+
format!("{:x}", hasher.finalize())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn now() -> u64 {
|
|
268
|
+
SystemTime::now()
|
|
269
|
+
.duration_since(UNIX_EPOCH)
|
|
270
|
+
.unwrap_or_default()
|
|
271
|
+
.as_secs()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[cfg(test)]
|
|
275
|
+
mod tests {
|
|
276
|
+
use super::*;
|
|
277
|
+
|
|
278
|
+
fn test_payload_hash() -> String {
|
|
279
|
+
compute_payload_hash(b"test payload")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn record_and_retrieve_committed_transaction() {
|
|
284
|
+
let store = TransactionRecoveryStore::new();
|
|
285
|
+
|
|
286
|
+
store
|
|
287
|
+
.record_committed("txn_123".into(), test_payload_hash(), 42)
|
|
288
|
+
.expect("record failed");
|
|
289
|
+
|
|
290
|
+
let status = store.get_status("txn_123").expect("get failed");
|
|
291
|
+
assert_eq!(status.status, TransactionStatus::Committed);
|
|
292
|
+
assert_eq!(status.state_version, 42);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#[test]
|
|
296
|
+
fn unknown_transaction_not_found() {
|
|
297
|
+
let store = TransactionRecoveryStore::new();
|
|
298
|
+
|
|
299
|
+
let result = store.get_status("txn_unknown");
|
|
300
|
+
assert!(result.is_err());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[test]
|
|
304
|
+
fn same_payload_is_safe_to_retry() {
|
|
305
|
+
let store = TransactionRecoveryStore::new();
|
|
306
|
+
let hash = test_payload_hash();
|
|
307
|
+
|
|
308
|
+
store
|
|
309
|
+
.record_committed("txn_123".into(), hash.clone(), 42)
|
|
310
|
+
.expect("record failed");
|
|
311
|
+
|
|
312
|
+
let is_safe = store.is_retry_safe("txn_123", &hash).expect("check failed");
|
|
313
|
+
assert!(is_safe);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[test]
|
|
317
|
+
fn different_payload_is_conflict() {
|
|
318
|
+
let store = TransactionRecoveryStore::new();
|
|
319
|
+
let hash1 = test_payload_hash();
|
|
320
|
+
let hash2 = compute_payload_hash(b"different payload");
|
|
321
|
+
|
|
322
|
+
store
|
|
323
|
+
.record_committed("txn_123".into(), hash1, 42)
|
|
324
|
+
.expect("record failed");
|
|
325
|
+
|
|
326
|
+
let is_safe = store.is_retry_safe("txn_123", &hash2).expect("check failed");
|
|
327
|
+
assert!(!is_safe);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#[test]
|
|
331
|
+
fn unknown_transaction_is_safe_to_retry() {
|
|
332
|
+
let store = TransactionRecoveryStore::new();
|
|
333
|
+
let hash = test_payload_hash();
|
|
334
|
+
|
|
335
|
+
let is_safe = store.is_retry_safe("txn_unknown", &hash).expect("check failed");
|
|
336
|
+
assert!(is_safe);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn rejected_transaction_with_same_payload_is_not_safe() {
|
|
341
|
+
let store = TransactionRecoveryStore::new();
|
|
342
|
+
let hash = test_payload_hash();
|
|
343
|
+
|
|
344
|
+
store
|
|
345
|
+
.record_rejected("txn_123".into(), hash.clone(), "validation error".into())
|
|
346
|
+
.expect("record failed");
|
|
347
|
+
|
|
348
|
+
let is_safe = store.is_retry_safe("txn_123", &hash).expect("check failed");
|
|
349
|
+
assert!(!is_safe);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn conflicted_transaction_with_same_payload_is_not_safe() {
|
|
354
|
+
let store = TransactionRecoveryStore::new();
|
|
355
|
+
let hash = test_payload_hash();
|
|
356
|
+
|
|
357
|
+
store
|
|
358
|
+
.record_conflicted("txn_123".into(), hash.clone(), "version mismatch".into())
|
|
359
|
+
.expect("record failed");
|
|
360
|
+
|
|
361
|
+
let is_safe = store.is_retry_safe("txn_123", &hash).expect("check failed");
|
|
362
|
+
assert!(!is_safe);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn record_unknown_transaction() {
|
|
367
|
+
let store = TransactionRecoveryStore::new();
|
|
368
|
+
let hash = test_payload_hash();
|
|
369
|
+
|
|
370
|
+
store
|
|
371
|
+
.record_unknown("txn_123".into(), hash)
|
|
372
|
+
.expect("record failed");
|
|
373
|
+
|
|
374
|
+
let status = store.get_status("txn_123").expect("get failed");
|
|
375
|
+
assert_eq!(status.status, TransactionStatus::Unknown);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#[test]
|
|
379
|
+
fn record_rejected_transaction() {
|
|
380
|
+
let store = TransactionRecoveryStore::new();
|
|
381
|
+
let hash = test_payload_hash();
|
|
382
|
+
|
|
383
|
+
store
|
|
384
|
+
.record_rejected(
|
|
385
|
+
"txn_123".into(),
|
|
386
|
+
hash,
|
|
387
|
+
"required field missing".into(),
|
|
388
|
+
)
|
|
389
|
+
.expect("record failed");
|
|
390
|
+
|
|
391
|
+
let status = store.get_status("txn_123").expect("get failed");
|
|
392
|
+
assert_eq!(status.status, TransactionStatus::Rejected);
|
|
393
|
+
assert!(status.error.is_some());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[test]
|
|
397
|
+
fn record_conflicted_transaction() {
|
|
398
|
+
let store = TransactionRecoveryStore::new();
|
|
399
|
+
let hash = test_payload_hash();
|
|
400
|
+
|
|
401
|
+
store
|
|
402
|
+
.record_conflicted("txn_123".into(), hash, "concurrent write".into())
|
|
403
|
+
.expect("record failed");
|
|
404
|
+
|
|
405
|
+
let status = store.get_status("txn_123").expect("get failed");
|
|
406
|
+
assert_eq!(status.status, TransactionStatus::Conflicted);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn cleanup_removes_old_records() {
|
|
411
|
+
let store = TransactionRecoveryStore::new();
|
|
412
|
+
let hash = test_payload_hash();
|
|
413
|
+
let old_time = now() - 86400; // 1 day ago
|
|
414
|
+
|
|
415
|
+
store
|
|
416
|
+
.record_committed("txn_old".into(), hash.clone(), 1)
|
|
417
|
+
.expect("record failed");
|
|
418
|
+
|
|
419
|
+
// Manually insert an old record (normally would be done by recovery mechanism)
|
|
420
|
+
{
|
|
421
|
+
let mut records = store.records.write().unwrap();
|
|
422
|
+
if let Some(r) = records.get_mut("txn_old") {
|
|
423
|
+
r.recorded_at = old_time;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let cutoff = now() - 3600; // 1 hour ago
|
|
428
|
+
let deleted = store.cleanup_before(cutoff).expect("cleanup failed");
|
|
429
|
+
assert_eq!(deleted, 1);
|
|
430
|
+
|
|
431
|
+
// Old record should be gone
|
|
432
|
+
assert!(store.get_status("txn_old").is_err());
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#[test]
|
|
436
|
+
fn payload_hash_is_deterministic() {
|
|
437
|
+
let hash1 = compute_payload_hash(b"test payload");
|
|
438
|
+
let hash2 = compute_payload_hash(b"test payload");
|
|
439
|
+
assert_eq!(hash1, hash2);
|
|
440
|
+
|
|
441
|
+
let hash3 = compute_payload_hash(b"different");
|
|
442
|
+
assert_ne!(hash1, hash3);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#[test]
|
|
446
|
+
fn transaction_record_includes_timestamps() {
|
|
447
|
+
let store = TransactionRecoveryStore::new();
|
|
448
|
+
let hash = test_payload_hash();
|
|
449
|
+
|
|
450
|
+
let before = now();
|
|
451
|
+
store
|
|
452
|
+
.record_committed("txn_123".into(), hash, 42)
|
|
453
|
+
.expect("record failed");
|
|
454
|
+
let after = now();
|
|
455
|
+
|
|
456
|
+
let status = store.get_status("txn_123").expect("get failed");
|
|
457
|
+
assert!(status.committed_at.is_some());
|
|
458
|
+
let committed = status.committed_at.unwrap();
|
|
459
|
+
assert!(committed >= before && committed <= after + 1);
|
|
460
|
+
}
|
|
461
|
+
}
|