@feltdb/core 0.5.7 → 0.6.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.
Files changed (48) hide show
  1. package/dist/cli/commands.js +68 -1
  2. package/dist/cli/workspace-integration.js +96 -0
  3. package/dist/create/cli.js +1 -1
  4. package/dist/create/create.js +21 -0
  5. package/dist/create/package-versions.js +1 -1
  6. package/dist/create/server-source/Cargo.lock +10 -0
  7. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
  8. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -1
  9. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
  10. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
  12. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +9 -0
  14. package/dist/create/server-source/crates/feltdb-server/src/main.rs +157 -9
  15. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +49 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
  21. package/dist/create/template/dot-feltdb-README.md +58 -0
  22. package/dist/create/workspace-initialization.js +77 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -0
  26. package/dist/studio-app/assets/{feltdb_wasm-h9mxesnH.js → feltdb_wasm-B4wq4mqp.js} +1 -1
  27. package/dist/studio-app/assets/feltdb_wasm_bg-Ceyi7l21.wasm +0 -0
  28. package/dist/studio-app/assets/{index-B_EMnTaE.js → index-LQmvJSq6.js} +2 -2
  29. package/dist/studio-app/index.html +1 -1
  30. package/dist/telemetry.js +1 -1
  31. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  32. package/dist/workspace/development-node.d.ts +42 -0
  33. package/dist/workspace/development-node.d.ts.map +1 -0
  34. package/dist/workspace/development-node.js +208 -0
  35. package/dist/workspace/index.d.ts +20 -0
  36. package/dist/workspace/index.d.ts.map +1 -0
  37. package/dist/workspace/index.js +16 -0
  38. package/dist/workspace/workspace-connection.d.ts +174 -0
  39. package/dist/workspace/workspace-connection.d.ts.map +1 -0
  40. package/dist/workspace/workspace-connection.js +290 -0
  41. package/dist/workspace/workspace-identity.d.ts +13 -0
  42. package/dist/workspace/workspace-identity.d.ts.map +1 -0
  43. package/dist/workspace/workspace-identity.js +70 -0
  44. package/dist/workspace/workspace-types.d.ts +82 -0
  45. package/dist/workspace/workspace-types.d.ts.map +1 -0
  46. package/dist/workspace/workspace-types.js +7 -0
  47. package/package.json +5 -1
  48. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
@@ -0,0 +1,992 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use serde_json::Value;
3
+ use std::collections::HashMap;
4
+ use std::sync::{Arc, RwLock};
5
+ use std::time::{SystemTime, UNIX_EPOCH};
6
+
7
+ /// Operation state lifecycle.
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9
+ #[serde(rename_all = "lowercase")]
10
+ pub enum OperationState {
11
+ /// Operation created, awaiting worker claim.
12
+ Pending,
13
+ /// Operation claimed by a worker (lease active).
14
+ Claimed,
15
+ /// Operation being executed by worker.
16
+ Running,
17
+ /// Operation completed successfully.
18
+ Completed,
19
+ /// Operation failed.
20
+ Failed,
21
+ /// Operation state unknown (after lease expiration).
22
+ Unknown,
23
+ }
24
+
25
+ /// Durable operation for work coordination and recovery.
26
+ #[derive(Debug, Clone, Serialize, Deserialize)]
27
+ pub struct DurableOperation {
28
+ /// Unique operation ID.
29
+ pub operation_id: String,
30
+
31
+ /// Type of operation (defines behavior/retry policy).
32
+ pub operation_type: String,
33
+
34
+ /// Tenant ID for isolation.
35
+ pub tenant_id: String,
36
+
37
+ /// Current state of the operation.
38
+ pub state: OperationState,
39
+
40
+ /// Worker claiming the operation (if claimed).
41
+ pub worker_id: Option<String>,
42
+
43
+ /// Lease expiration time (unix timestamp).
44
+ pub lease_expires_at: u64,
45
+
46
+ /// When operation was created.
47
+ pub created_at: u64,
48
+
49
+ /// Last state change time.
50
+ pub updated_at: u64,
51
+
52
+ /// Operation payload (caller-defined work).
53
+ pub payload: Value,
54
+
55
+ /// Result (set on completion).
56
+ pub result: Option<Value>,
57
+
58
+ /// Error reason (set on failure).
59
+ pub error: Option<String>,
60
+
61
+ /// Version for optimistic concurrency control.
62
+ pub version: u64,
63
+ }
64
+
65
+ /// Request to create a durable operation.
66
+ #[derive(Debug, Clone, Serialize, Deserialize)]
67
+ pub struct CreateOperationRequest {
68
+ pub operation_id: String,
69
+ pub operation_type: String,
70
+ pub tenant_id: String,
71
+ pub payload: Value,
72
+ }
73
+
74
+ /// Request to claim an operation.
75
+ #[derive(Debug, Clone, Serialize, Deserialize)]
76
+ pub struct ClaimOperationRequest {
77
+ pub operation_id: String,
78
+ pub worker_id: String,
79
+ pub lease_duration_secs: u64,
80
+ }
81
+
82
+ /// Response with operation after claiming.
83
+ #[derive(Debug, Clone, Serialize, Deserialize)]
84
+ pub struct ClaimOperationResponse {
85
+ pub operation: DurableOperation,
86
+ pub version: u64,
87
+ }
88
+
89
+ /// Store for durable operations.
90
+ #[derive(Clone)]
91
+ pub struct DurableOperationStore {
92
+ operations: Arc<RwLock<HashMap<String, DurableOperation>>>,
93
+ }
94
+
95
+ impl DurableOperationStore {
96
+ /// Creates a new durable operation store.
97
+ pub fn new() -> Self {
98
+ Self {
99
+ operations: Arc::new(RwLock::new(HashMap::new())),
100
+ }
101
+ }
102
+
103
+ /// Creates a new durable operation.
104
+ pub fn create_operation(
105
+ &self,
106
+ operation_id: String,
107
+ operation_type: String,
108
+ tenant_id: String,
109
+ payload: Value,
110
+ ) -> Result<DurableOperation, String> {
111
+ let mut ops = self
112
+ .operations
113
+ .write()
114
+ .map_err(|_| "store lock poisoned".to_string())?;
115
+
116
+ if ops.contains_key(&operation_id) {
117
+ return Err(format!(
118
+ "operation already exists: {}",
119
+ operation_id
120
+ ));
121
+ }
122
+
123
+ let now = now();
124
+ let operation = DurableOperation {
125
+ operation_id: operation_id.clone(),
126
+ operation_type,
127
+ tenant_id,
128
+ state: OperationState::Pending,
129
+ worker_id: None,
130
+ lease_expires_at: 0,
131
+ created_at: now,
132
+ updated_at: now,
133
+ payload,
134
+ result: None,
135
+ error: None,
136
+ version: 1,
137
+ };
138
+
139
+ ops.insert(operation_id, operation.clone());
140
+ Ok(operation)
141
+ }
142
+
143
+ /// Claims an operation for a worker.
144
+ /// Returns the claimed operation and current version for optimistic concurrency.
145
+ pub fn claim_operation(
146
+ &self,
147
+ operation_id: &str,
148
+ worker_id: String,
149
+ lease_duration_secs: u64,
150
+ ) -> Result<ClaimOperationResponse, String> {
151
+ let mut ops = self
152
+ .operations
153
+ .write()
154
+ .map_err(|_| "store lock poisoned".to_string())?;
155
+
156
+ let operation = ops
157
+ .get_mut(operation_id)
158
+ .ok_or(format!("operation not found: {}", operation_id))?;
159
+
160
+ // Can only claim Pending or Unknown operations
161
+ if operation.state != OperationState::Pending && operation.state != OperationState::Unknown {
162
+ return Err(format!(
163
+ "cannot claim operation in state {:?}",
164
+ operation.state
165
+ ));
166
+ }
167
+
168
+ let now = now();
169
+
170
+ // Transition to Claimed state
171
+ operation.state = OperationState::Claimed;
172
+ operation.worker_id = Some(worker_id);
173
+ operation.lease_expires_at = now + lease_duration_secs;
174
+ operation.updated_at = now;
175
+ operation.version += 1;
176
+
177
+ Ok(ClaimOperationResponse {
178
+ operation: operation.clone(),
179
+ version: operation.version,
180
+ })
181
+ }
182
+
183
+ /// Renews a worker's lease on an operation (extends expiration).
184
+ /// Requires the worker to prove ownership.
185
+ pub fn renew_operation(
186
+ &self,
187
+ operation_id: &str,
188
+ worker_id: &str,
189
+ lease_duration_secs: u64,
190
+ ) -> Result<DurableOperation, String> {
191
+ let mut ops = self
192
+ .operations
193
+ .write()
194
+ .map_err(|_| "store lock poisoned".to_string())?;
195
+
196
+ let operation = ops
197
+ .get_mut(operation_id)
198
+ .ok_or(format!("operation not found: {}", operation_id))?;
199
+
200
+ let now = now();
201
+
202
+ // Check if lease has expired (worker fencing)
203
+ if operation.lease_expires_at < now {
204
+ return Err(format!(
205
+ "worker lease expired for operation: {}",
206
+ operation_id
207
+ ));
208
+ }
209
+
210
+ // Verify worker ownership
211
+ if operation.worker_id.as_deref() != Some(worker_id) {
212
+ return Err(format!(
213
+ "worker mismatch: expected {:?}, got {}",
214
+ operation.worker_id, worker_id
215
+ ));
216
+ }
217
+
218
+ // Extend lease
219
+ operation.lease_expires_at = now + lease_duration_secs;
220
+ operation.updated_at = now;
221
+ operation.version += 1;
222
+
223
+ Ok(operation.clone())
224
+ }
225
+
226
+ /// Marks an operation as Running.
227
+ pub fn start_operation(
228
+ &self,
229
+ operation_id: &str,
230
+ worker_id: &str,
231
+ ) -> Result<DurableOperation, String> {
232
+ let mut ops = self
233
+ .operations
234
+ .write()
235
+ .map_err(|_| "store lock poisoned".to_string())?;
236
+
237
+ let operation = ops
238
+ .get_mut(operation_id)
239
+ .ok_or(format!("operation not found: {}", operation_id))?;
240
+
241
+ let now = now();
242
+
243
+ // Check lease expiration
244
+ if operation.lease_expires_at < now {
245
+ return Err("worker lease expired".to_string());
246
+ }
247
+
248
+ // Verify worker ownership
249
+ if operation.worker_id.as_deref() != Some(worker_id) {
250
+ return Err("worker mismatch".to_string());
251
+ }
252
+
253
+ // Transition from Claimed to Running
254
+ if operation.state != OperationState::Claimed {
255
+ return Err(format!(
256
+ "cannot start operation in state {:?}",
257
+ operation.state
258
+ ));
259
+ }
260
+
261
+ operation.state = OperationState::Running;
262
+ operation.updated_at = now;
263
+ operation.version += 1;
264
+
265
+ Ok(operation.clone())
266
+ }
267
+
268
+ /// Completes an operation with a result.
269
+ pub fn complete_operation(
270
+ &self,
271
+ operation_id: &str,
272
+ worker_id: &str,
273
+ result: Value,
274
+ ) -> Result<DurableOperation, String> {
275
+ let mut ops = self
276
+ .operations
277
+ .write()
278
+ .map_err(|_| "store lock poisoned".to_string())?;
279
+
280
+ let operation = ops
281
+ .get_mut(operation_id)
282
+ .ok_or(format!("operation not found: {}", operation_id))?;
283
+
284
+ let now = now();
285
+
286
+ // Check lease expiration
287
+ if operation.lease_expires_at < now {
288
+ return Err("worker lease expired".to_string());
289
+ }
290
+
291
+ // Verify worker ownership
292
+ if operation.worker_id.as_deref() != Some(worker_id) {
293
+ return Err("worker mismatch".to_string());
294
+ }
295
+
296
+ // Must be in Running state
297
+ if operation.state != OperationState::Running {
298
+ return Err(format!(
299
+ "cannot complete operation in state {:?}",
300
+ operation.state
301
+ ));
302
+ }
303
+
304
+ operation.state = OperationState::Completed;
305
+ operation.result = Some(result);
306
+ operation.error = None;
307
+ operation.updated_at = now;
308
+ operation.version += 1;
309
+
310
+ Ok(operation.clone())
311
+ }
312
+
313
+ /// Fails an operation with an error.
314
+ pub fn fail_operation(
315
+ &self,
316
+ operation_id: &str,
317
+ worker_id: &str,
318
+ error: String,
319
+ ) -> Result<DurableOperation, String> {
320
+ let mut ops = self
321
+ .operations
322
+ .write()
323
+ .map_err(|_| "store lock poisoned".to_string())?;
324
+
325
+ let operation = ops
326
+ .get_mut(operation_id)
327
+ .ok_or(format!("operation not found: {}", operation_id))?;
328
+
329
+ let now = now();
330
+
331
+ // Check lease expiration
332
+ if operation.lease_expires_at < now {
333
+ return Err("worker lease expired".to_string());
334
+ }
335
+
336
+ // Verify worker ownership
337
+ if operation.worker_id.as_deref() != Some(worker_id) {
338
+ return Err("worker mismatch".to_string());
339
+ }
340
+
341
+ // Must be in Claimed or Running state
342
+ if operation.state != OperationState::Claimed && operation.state != OperationState::Running {
343
+ return Err(format!(
344
+ "cannot fail operation in state {:?}",
345
+ operation.state
346
+ ));
347
+ }
348
+
349
+ operation.state = OperationState::Failed;
350
+ operation.error = Some(error);
351
+ operation.result = None;
352
+ operation.updated_at = now;
353
+ operation.version += 1;
354
+
355
+ Ok(operation.clone())
356
+ }
357
+
358
+ /// Marks an operation as Unknown (when lease expires).
359
+ pub fn mark_unknown(
360
+ &self,
361
+ operation_id: &str,
362
+ ) -> Result<DurableOperation, String> {
363
+ let mut ops = self
364
+ .operations
365
+ .write()
366
+ .map_err(|_| "store lock poisoned".to_string())?;
367
+
368
+ let operation = ops
369
+ .get_mut(operation_id)
370
+ .ok_or(format!("operation not found: {}", operation_id))?;
371
+
372
+ let now = now();
373
+
374
+ // Can only mark Unknown if in Claimed or Running state
375
+ if operation.state != OperationState::Claimed && operation.state != OperationState::Running {
376
+ return Err(format!(
377
+ "cannot mark Unknown operation in state {:?}",
378
+ operation.state
379
+ ));
380
+ }
381
+
382
+ operation.state = OperationState::Unknown;
383
+ operation.updated_at = now;
384
+ operation.version += 1;
385
+
386
+ Ok(operation.clone())
387
+ }
388
+
389
+ /// Reconciles an operation after lease expiration.
390
+ /// Safely transitions from Unknown back to Pending if reconciliation succeeds.
391
+ pub fn reconcile_operation(
392
+ &self,
393
+ operation_id: &str,
394
+ expected_worker_id: Option<&str>,
395
+ ) -> Result<DurableOperation, String> {
396
+ let mut ops = self
397
+ .operations
398
+ .write()
399
+ .map_err(|_| "store lock poisoned".to_string())?;
400
+
401
+ let operation = ops
402
+ .get_mut(operation_id)
403
+ .ok_or(format!("operation not found: {}", operation_id))?;
404
+
405
+ // Can only reconcile if Unknown
406
+ if operation.state != OperationState::Unknown {
407
+ return Err(format!(
408
+ "cannot reconcile operation in state {:?}",
409
+ operation.state
410
+ ));
411
+ }
412
+
413
+ // If expected_worker_id provided, verify it matches (to detect multiple workers)
414
+ if let Some(expected) = expected_worker_id {
415
+ if operation.worker_id.as_deref() != Some(expected) {
416
+ return Err(format!(
417
+ "worker mismatch during reconciliation: expected {}, got {:?}",
418
+ expected, operation.worker_id
419
+ ));
420
+ }
421
+ }
422
+
423
+ let now = now();
424
+
425
+ // Transition back to Pending for retry
426
+ operation.state = OperationState::Pending;
427
+ operation.worker_id = None;
428
+ operation.lease_expires_at = 0;
429
+ operation.updated_at = now;
430
+ operation.version += 1;
431
+
432
+ Ok(operation.clone())
433
+ }
434
+
435
+ /// Gets an operation by ID.
436
+ pub fn get_operation(&self, operation_id: &str) -> Result<DurableOperation, String> {
437
+ let ops = self
438
+ .operations
439
+ .read()
440
+ .map_err(|_| "store lock poisoned".to_string())?;
441
+
442
+ ops.get(operation_id)
443
+ .cloned()
444
+ .ok_or(format!("operation not found: {}", operation_id))
445
+ }
446
+
447
+ /// Lists all operations for a tenant.
448
+ pub fn list_operations(&self, tenant_id: &str) -> Result<Vec<DurableOperation>, String> {
449
+ let ops = self
450
+ .operations
451
+ .read()
452
+ .map_err(|_| "store lock poisoned".to_string())?;
453
+
454
+ Ok(ops
455
+ .values()
456
+ .filter(|op| op.tenant_id == tenant_id)
457
+ .cloned()
458
+ .collect())
459
+ }
460
+
461
+ /// Lists operations by state.
462
+ pub fn list_by_state(
463
+ &self,
464
+ tenant_id: &str,
465
+ state: OperationState,
466
+ ) -> Result<Vec<DurableOperation>, String> {
467
+ let ops = self
468
+ .operations
469
+ .read()
470
+ .map_err(|_| "store lock poisoned".to_string())?;
471
+
472
+ Ok(ops
473
+ .values()
474
+ .filter(|op| op.tenant_id == tenant_id && op.state == state)
475
+ .cloned()
476
+ .collect())
477
+ }
478
+
479
+ /// Cleanup: Removes completed operations older than cutoff time.
480
+ pub fn cleanup_completed_before(&self, cutoff_time: u64) -> Result<usize, String> {
481
+ let mut ops = self
482
+ .operations
483
+ .write()
484
+ .map_err(|_| "store lock poisoned".to_string())?;
485
+
486
+ let before_len = ops.len();
487
+ ops.retain(|_, op| {
488
+ !(op.state == OperationState::Completed && op.updated_at < cutoff_time)
489
+ });
490
+ let after_len = ops.len();
491
+
492
+ Ok(before_len - after_len)
493
+ }
494
+
495
+ /// Returns count of operations by state (for diagnostics).
496
+ pub fn get_state_counts(&self) -> Result<StateCountsResponse, String> {
497
+ let ops = self
498
+ .operations
499
+ .read()
500
+ .map_err(|_| "store lock poisoned".to_string())?;
501
+
502
+ let mut counts = StateCountsResponse::default();
503
+ for op in ops.values() {
504
+ match op.state {
505
+ OperationState::Pending => counts.pending += 1,
506
+ OperationState::Claimed => counts.claimed += 1,
507
+ OperationState::Running => counts.running += 1,
508
+ OperationState::Completed => counts.completed += 1,
509
+ OperationState::Failed => counts.failed += 1,
510
+ OperationState::Unknown => counts.unknown += 1,
511
+ }
512
+ }
513
+
514
+ Ok(counts)
515
+ }
516
+ }
517
+
518
+ impl Default for DurableOperationStore {
519
+ fn default() -> Self {
520
+ Self::new()
521
+ }
522
+ }
523
+
524
+ /// Response with counts of operations by state.
525
+ #[derive(Debug, Clone, Serialize, Deserialize, Default)]
526
+ pub struct StateCountsResponse {
527
+ pub pending: usize,
528
+ pub claimed: usize,
529
+ pub running: usize,
530
+ pub completed: usize,
531
+ pub failed: usize,
532
+ pub unknown: usize,
533
+ }
534
+
535
+ fn now() -> u64 {
536
+ SystemTime::now()
537
+ .duration_since(UNIX_EPOCH)
538
+ .unwrap_or_default()
539
+ .as_secs()
540
+ }
541
+
542
+ #[cfg(test)]
543
+ mod tests {
544
+ use super::*;
545
+
546
+ #[test]
547
+ fn create_operation() {
548
+ let store = DurableOperationStore::new();
549
+ let payload = serde_json::json!({"work": "data"});
550
+
551
+ let op = store
552
+ .create_operation(
553
+ "op_123".into(),
554
+ "process".into(),
555
+ "tenant_prod".into(),
556
+ payload.clone(),
557
+ )
558
+ .expect("create failed");
559
+
560
+ assert_eq!(op.operation_id, "op_123");
561
+ assert_eq!(op.state, OperationState::Pending);
562
+ assert_eq!(op.version, 1);
563
+ assert_eq!(op.payload, payload);
564
+ }
565
+
566
+ #[test]
567
+ fn reject_duplicate_operation_id() {
568
+ let store = DurableOperationStore::new();
569
+ let payload = serde_json::json!({"work": "data"});
570
+
571
+ store
572
+ .create_operation(
573
+ "op_123".into(),
574
+ "process".into(),
575
+ "tenant_prod".into(),
576
+ payload.clone(),
577
+ )
578
+ .expect("first create failed");
579
+
580
+ let result = store.create_operation(
581
+ "op_123".into(),
582
+ "process".into(),
583
+ "tenant_prod".into(),
584
+ payload,
585
+ );
586
+ assert!(result.is_err());
587
+ }
588
+
589
+ #[test]
590
+ fn claim_pending_operation() {
591
+ let store = DurableOperationStore::new();
592
+ let payload = serde_json::json!({"work": "data"});
593
+
594
+ store
595
+ .create_operation(
596
+ "op_123".into(),
597
+ "process".into(),
598
+ "tenant_prod".into(),
599
+ payload,
600
+ )
601
+ .expect("create failed");
602
+
603
+ let response = store
604
+ .claim_operation("op_123", "worker_1".into(), 60)
605
+ .expect("claim failed");
606
+
607
+ assert_eq!(response.operation.state, OperationState::Claimed);
608
+ assert_eq!(response.operation.worker_id, Some("worker_1".into()));
609
+ assert_eq!(response.version, 2);
610
+ }
611
+
612
+ #[test]
613
+ fn reject_claim_non_pending_operation() {
614
+ let store = DurableOperationStore::new();
615
+ let payload = serde_json::json!({"work": "data"});
616
+
617
+ store
618
+ .create_operation(
619
+ "op_123".into(),
620
+ "process".into(),
621
+ "tenant_prod".into(),
622
+ payload,
623
+ )
624
+ .expect("create failed");
625
+
626
+ store
627
+ .claim_operation("op_123", "worker_1".into(), 60)
628
+ .expect("first claim failed");
629
+
630
+ let result = store.claim_operation("op_123", "worker_2".into(), 60);
631
+ assert!(result.is_err());
632
+ }
633
+
634
+ #[test]
635
+ fn renew_lease() {
636
+ let store = DurableOperationStore::new();
637
+ let payload = serde_json::json!({"work": "data"});
638
+
639
+ store
640
+ .create_operation(
641
+ "op_123".into(),
642
+ "process".into(),
643
+ "tenant_prod".into(),
644
+ payload,
645
+ )
646
+ .expect("create failed");
647
+
648
+ store
649
+ .claim_operation("op_123", "worker_1".into(), 60)
650
+ .expect("claim failed");
651
+
652
+ let renewed = store
653
+ .renew_operation("op_123", "worker_1", 120)
654
+ .expect("renew failed");
655
+
656
+ assert_eq!(renewed.version, 3);
657
+ assert_eq!(renewed.state, OperationState::Claimed);
658
+ }
659
+
660
+ #[test]
661
+ fn reject_renew_wrong_worker() {
662
+ let store = DurableOperationStore::new();
663
+ let payload = serde_json::json!({"work": "data"});
664
+
665
+ store
666
+ .create_operation(
667
+ "op_123".into(),
668
+ "process".into(),
669
+ "tenant_prod".into(),
670
+ payload,
671
+ )
672
+ .expect("create failed");
673
+
674
+ store
675
+ .claim_operation("op_123", "worker_1".into(), 60)
676
+ .expect("claim failed");
677
+
678
+ let result = store.renew_operation("op_123", "worker_2", 120);
679
+ assert!(result.is_err());
680
+ }
681
+
682
+ #[test]
683
+ fn complete_operation() {
684
+ let store = DurableOperationStore::new();
685
+ let payload = serde_json::json!({"work": "data"});
686
+
687
+ store
688
+ .create_operation(
689
+ "op_123".into(),
690
+ "process".into(),
691
+ "tenant_prod".into(),
692
+ payload,
693
+ )
694
+ .expect("create failed");
695
+
696
+ store
697
+ .claim_operation("op_123", "worker_1".into(), 60)
698
+ .expect("claim failed");
699
+
700
+ store
701
+ .start_operation("op_123", "worker_1")
702
+ .expect("start failed");
703
+
704
+ let result_value = serde_json::json!({"result": "success"});
705
+ let completed = store
706
+ .complete_operation("op_123", "worker_1", result_value.clone())
707
+ .expect("complete failed");
708
+
709
+ assert_eq!(completed.state, OperationState::Completed);
710
+ assert_eq!(completed.result, Some(result_value));
711
+ assert_eq!(completed.error, None);
712
+ }
713
+
714
+ #[test]
715
+ fn fail_operation() {
716
+ let store = DurableOperationStore::new();
717
+ let payload = serde_json::json!({"work": "data"});
718
+
719
+ store
720
+ .create_operation(
721
+ "op_123".into(),
722
+ "process".into(),
723
+ "tenant_prod".into(),
724
+ payload,
725
+ )
726
+ .expect("create failed");
727
+
728
+ store
729
+ .claim_operation("op_123", "worker_1".into(), 60)
730
+ .expect("claim failed");
731
+
732
+ let failed = store
733
+ .fail_operation("op_123", "worker_1", "operation timeout".into())
734
+ .expect("fail failed");
735
+
736
+ assert_eq!(failed.state, OperationState::Failed);
737
+ assert_eq!(failed.error, Some("operation timeout".into()));
738
+ }
739
+
740
+ #[test]
741
+ fn mark_unknown_and_reconcile() {
742
+ let store = DurableOperationStore::new();
743
+ let payload = serde_json::json!({"work": "data"});
744
+
745
+ store
746
+ .create_operation(
747
+ "op_123".into(),
748
+ "process".into(),
749
+ "tenant_prod".into(),
750
+ payload,
751
+ )
752
+ .expect("create failed");
753
+
754
+ store
755
+ .claim_operation("op_123", "worker_1".into(), 60)
756
+ .expect("claim failed");
757
+
758
+ let unknown = store
759
+ .mark_unknown("op_123")
760
+ .expect("mark unknown failed");
761
+ assert_eq!(unknown.state, OperationState::Unknown);
762
+
763
+ let reconciled = store
764
+ .reconcile_operation("op_123", Some("worker_1"))
765
+ .expect("reconcile failed");
766
+
767
+ assert_eq!(reconciled.state, OperationState::Pending);
768
+ assert_eq!(reconciled.worker_id, None);
769
+ }
770
+
771
+ #[test]
772
+ fn worker_fencing_prevents_renew_after_lease_expiration() {
773
+ let store = DurableOperationStore::new();
774
+ let payload = serde_json::json!({"work": "data"});
775
+
776
+ store
777
+ .create_operation(
778
+ "op_123".into(),
779
+ "process".into(),
780
+ "tenant_prod".into(),
781
+ payload,
782
+ )
783
+ .expect("create failed");
784
+
785
+ store
786
+ .claim_operation("op_123", "worker_1".into(), 1)
787
+ .expect("claim failed");
788
+
789
+ // Wait for lease to expire
790
+ std::thread::sleep(std::time::Duration::from_secs(2));
791
+
792
+ let result = store.renew_operation("op_123", "worker_1", 60);
793
+ assert!(result.is_err());
794
+ assert!(result.unwrap_err().contains("lease expired"));
795
+ }
796
+
797
+ #[test]
798
+ fn list_operations_by_tenant() {
799
+ let store = DurableOperationStore::new();
800
+ let payload = serde_json::json!({"work": "data"});
801
+
802
+ store
803
+ .create_operation(
804
+ "op_1".into(),
805
+ "process".into(),
806
+ "tenant_prod".into(),
807
+ payload.clone(),
808
+ )
809
+ .expect("create failed");
810
+
811
+ store
812
+ .create_operation(
813
+ "op_2".into(),
814
+ "process".into(),
815
+ "tenant_prod".into(),
816
+ payload.clone(),
817
+ )
818
+ .expect("create failed");
819
+
820
+ store
821
+ .create_operation(
822
+ "op_3".into(),
823
+ "process".into(),
824
+ "tenant_other".into(),
825
+ payload,
826
+ )
827
+ .expect("create failed");
828
+
829
+ let ops = store
830
+ .list_operations("tenant_prod")
831
+ .expect("list failed");
832
+ assert_eq!(ops.len(), 2);
833
+ }
834
+
835
+ #[test]
836
+ fn list_operations_by_state() {
837
+ let store = DurableOperationStore::new();
838
+ let payload = serde_json::json!({"work": "data"});
839
+
840
+ store
841
+ .create_operation(
842
+ "op_1".into(),
843
+ "process".into(),
844
+ "tenant_prod".into(),
845
+ payload.clone(),
846
+ )
847
+ .expect("create failed");
848
+
849
+ store
850
+ .create_operation(
851
+ "op_2".into(),
852
+ "process".into(),
853
+ "tenant_prod".into(),
854
+ payload,
855
+ )
856
+ .expect("create failed");
857
+
858
+ store
859
+ .claim_operation("op_2", "worker_1".into(), 60)
860
+ .expect("claim failed");
861
+
862
+ let pending = store
863
+ .list_by_state("tenant_prod", OperationState::Pending)
864
+ .expect("list pending failed");
865
+ assert_eq!(pending.len(), 1);
866
+
867
+ let claimed = store
868
+ .list_by_state("tenant_prod", OperationState::Claimed)
869
+ .expect("list claimed failed");
870
+ assert_eq!(claimed.len(), 1);
871
+ }
872
+
873
+ #[test]
874
+ fn cleanup_completed_operations() {
875
+ let store = DurableOperationStore::new();
876
+ let payload = serde_json::json!({"work": "data"});
877
+
878
+ store
879
+ .create_operation(
880
+ "op_completed".into(),
881
+ "process".into(),
882
+ "tenant_prod".into(),
883
+ payload.clone(),
884
+ )
885
+ .expect("create failed");
886
+
887
+ store
888
+ .claim_operation("op_completed", "worker_1".into(), 60)
889
+ .expect("claim failed");
890
+
891
+ store
892
+ .start_operation("op_completed", "worker_1")
893
+ .expect("start failed");
894
+
895
+ store
896
+ .complete_operation(
897
+ "op_completed",
898
+ "worker_1",
899
+ serde_json::json!({"result": "done"}),
900
+ )
901
+ .expect("complete failed");
902
+
903
+ store
904
+ .create_operation(
905
+ "op_pending".into(),
906
+ "process".into(),
907
+ "tenant_prod".into(),
908
+ payload,
909
+ )
910
+ .expect("create failed");
911
+
912
+ let cutoff = now();
913
+ let deleted = store
914
+ .cleanup_completed_before(cutoff + 1)
915
+ .expect("cleanup failed");
916
+ assert_eq!(deleted, 1);
917
+
918
+ let ops = store
919
+ .list_operations("tenant_prod")
920
+ .expect("list failed");
921
+ assert_eq!(ops.len(), 1);
922
+ assert_eq!(ops[0].operation_id, "op_pending");
923
+ }
924
+
925
+ #[test]
926
+ fn get_state_counts() {
927
+ let store = DurableOperationStore::new();
928
+ let payload = serde_json::json!({"work": "data"});
929
+
930
+ store
931
+ .create_operation(
932
+ "op_1".into(),
933
+ "process".into(),
934
+ "tenant_prod".into(),
935
+ payload.clone(),
936
+ )
937
+ .expect("create failed");
938
+
939
+ store
940
+ .create_operation(
941
+ "op_2".into(),
942
+ "process".into(),
943
+ "tenant_prod".into(),
944
+ payload,
945
+ )
946
+ .expect("create failed");
947
+
948
+ store
949
+ .claim_operation("op_2", "worker_1".into(), 60)
950
+ .expect("claim failed");
951
+
952
+ let counts = store.get_state_counts().expect("get counts failed");
953
+ assert_eq!(counts.pending, 1);
954
+ assert_eq!(counts.claimed, 1);
955
+ }
956
+
957
+ #[test]
958
+ fn state_transitions_are_safe() {
959
+ let store = DurableOperationStore::new();
960
+ let payload = serde_json::json!({"work": "data"});
961
+
962
+ store
963
+ .create_operation(
964
+ "op_123".into(),
965
+ "process".into(),
966
+ "tenant_prod".into(),
967
+ payload,
968
+ )
969
+ .expect("create failed");
970
+
971
+ let claim_response = store
972
+ .claim_operation("op_123", "worker_1".into(), 60)
973
+ .expect("claim failed");
974
+ assert_eq!(claim_response.operation.version, 2);
975
+
976
+ store
977
+ .start_operation("op_123", "worker_1")
978
+ .expect("start failed");
979
+
980
+ store
981
+ .complete_operation(
982
+ "op_123",
983
+ "worker_1",
984
+ serde_json::json!({"result": "done"}),
985
+ )
986
+ .expect("complete failed");
987
+
988
+ let final_op = store.get_operation("op_123").expect("get failed");
989
+ assert_eq!(final_op.state, OperationState::Completed);
990
+ assert_eq!(final_op.version, 4);
991
+ }
992
+ }