Skip to content

Commit 5c55048

Browse files
committed
feat: sdk improvements
1 parent 50502ad commit 5c55048

File tree

3 files changed

+172
-58
lines changed

3 files changed

+172
-58
lines changed

crates/sdk/src/client.rs

Lines changed: 79 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,6 +1753,32 @@ impl Operation {
17531753
}
17541754

17551755
impl SetCondition {
1756+
/// Creates a condition for compare-and-set operations from an expected
1757+
/// previous value.
1758+
///
1759+
/// - `None` → [`SetCondition::NotExists`] (create-if-absent)
1760+
/// - `Some(value)` → [`SetCondition::ValueEquals`] (update-if-unchanged)
1761+
///
1762+
/// # Examples
1763+
///
1764+
/// ```no_run
1765+
/// use inferadb_ledger_sdk::SetCondition;
1766+
///
1767+
/// // Insert only if key doesn't exist
1768+
/// let cond = SetCondition::from_expected(None::<Vec<u8>>);
1769+
/// assert!(matches!(cond, SetCondition::NotExists));
1770+
///
1771+
/// // Update only if current value matches
1772+
/// let cond = SetCondition::from_expected(Some(b"old-value".to_vec()));
1773+
/// assert!(matches!(cond, SetCondition::ValueEquals(_)));
1774+
/// ```
1775+
pub fn from_expected(expected: Option<impl Into<Vec<u8>>>) -> Self {
1776+
match expected {
1777+
None => SetCondition::NotExists,
1778+
Some(value) => SetCondition::ValueEquals(value.into()),
1779+
}
1780+
}
1781+
17561782
/// Converts to protobuf set condition.
17571783
fn to_proto(&self) -> proto::SetCondition {
17581784
let condition = match self {
@@ -3191,7 +3217,7 @@ impl LedgerClient {
31913217
// Single-Operation Convenience Methods
31923218
// =============================================================================
31933219

3194-
/// Writes a single entity (set).
3220+
/// Writes a single entity (set), optionally with an expiration timestamp.
31953221
///
31963222
/// Convenience wrapper around [`write`](Self::write) for the common case of
31973223
/// setting a single key-value pair. Generates an idempotency key
@@ -3203,6 +3229,8 @@ impl LedgerClient {
32033229
/// * `vault` - Optional vault slug (omit for organization-level entities).
32043230
/// * `key` - The entity key.
32053231
/// * `value` - The entity value.
3232+
/// * `expires_at` - Optional Unix timestamp (seconds) when the entity expires. Pass `None` for
3233+
/// no expiration.
32063234
///
32073235
/// # Errors
32083236
///
@@ -3216,8 +3244,14 @@ impl LedgerClient {
32163244
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32173245
/// # let client = LedgerClient::connect("http://localhost:50051", "my-service").await?;
32183246
/// # let (organization, vault) = (OrganizationSlug::new(1), VaultSlug::new(1));
3247+
/// // Without expiration:
32193248
/// let result = client
3220-
/// .set_entity(organization, Some(vault), "user:123", b"data".to_vec())
3249+
/// .set_entity(organization, Some(vault), "user:123", b"data".to_vec(), None)
3250+
/// .await?;
3251+
///
3252+
/// // With expiration:
3253+
/// let result = client
3254+
/// .set_entity(organization, Some(vault), "session:abc", b"token".to_vec(), Some(1700000000))
32213255
/// .await?;
32223256
/// # Ok(())
32233257
/// # }
@@ -3228,8 +3262,14 @@ impl LedgerClient {
32283262
vault: Option<VaultSlug>,
32293263
key: impl Into<String>,
32303264
value: Vec<u8>,
3265+
expires_at: Option<u64>,
32313266
) -> Result<WriteSuccess> {
3232-
self.write(organization, vault, vec![Operation::set_entity(key, value)]).await
3267+
self.write(
3268+
organization,
3269+
vault,
3270+
vec![Operation::SetEntity { key: key.into(), value, expires_at, condition: None }],
3271+
)
3272+
.await
32333273
}
32343274

32353275
/// Deletes a single entity.
@@ -3268,7 +3308,7 @@ impl LedgerClient {
32683308
self.write(organization, vault, vec![Operation::delete_entity(key)]).await
32693309
}
32703310

3271-
/// Sets a single entity with a conditional write.
3311+
/// Sets a single entity with a conditional write, optionally with an expiration timestamp.
32723312
///
32733313
/// Convenience wrapper around [`write`](Self::write) for conditional
32743314
/// set operations (compare-and-swap). The write only succeeds if the
@@ -3281,6 +3321,8 @@ impl LedgerClient {
32813321
/// * `key` - The entity key.
32823322
/// * `value` - The entity value.
32833323
/// * `condition` - The condition that must be satisfied for the write to succeed.
3324+
/// * `expires_at` - Optional Unix timestamp (seconds) when the entity expires. Pass `None` for
3325+
/// no expiration.
32843326
///
32853327
/// # Errors
32863328
///
@@ -3301,6 +3343,7 @@ impl LedgerClient {
33013343
/// "user:123",
33023344
/// b"new-data".to_vec(),
33033345
/// SetCondition::NotExists,
3346+
/// None,
33043347
/// )
33053348
/// .await?;
33063349
/// # Ok(())
@@ -3313,61 +3356,17 @@ impl LedgerClient {
33133356
key: impl Into<String>,
33143357
value: Vec<u8>,
33153358
condition: SetCondition,
3316-
) -> Result<WriteSuccess> {
3317-
self.write(organization, vault, vec![Operation::set_entity_if(key, value, condition)]).await
3318-
}
3319-
3320-
/// Sets a single entity with an expiration timestamp.
3321-
///
3322-
/// Convenience wrapper around [`write`](Self::write) for setting a
3323-
/// key-value pair with a TTL. The entity is automatically removed after
3324-
/// the expiration time.
3325-
///
3326-
/// # Arguments
3327-
///
3328-
/// * `organization` - Organization slug (external identifier).
3329-
/// * `vault` - Optional vault slug (omit for organization-level entities).
3330-
/// * `key` - The entity key.
3331-
/// * `value` - The entity value.
3332-
/// * `expires_at` - Unix timestamp (seconds) when the entity expires.
3333-
///
3334-
/// # Errors
3335-
///
3336-
/// Returns an error if validation fails or the write fails after retry
3337-
/// attempts.
3338-
///
3339-
/// # Example
3340-
///
3341-
/// ```no_run
3342-
/// # use inferadb_ledger_sdk::{LedgerClient, OrganizationSlug, VaultSlug};
3343-
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
3344-
/// # let client = LedgerClient::connect("http://localhost:50051", "my-service").await?;
3345-
/// # let (organization, vault) = (OrganizationSlug::new(1), VaultSlug::new(1));
3346-
/// let expires_at = 1700000000; // Unix timestamp
3347-
/// let result = client
3348-
/// .set_entity_with_expiry(
3349-
/// organization,
3350-
/// Some(vault),
3351-
/// "session:abc",
3352-
/// b"session-data".to_vec(),
3353-
/// expires_at,
3354-
/// )
3355-
/// .await?;
3356-
/// # Ok(())
3357-
/// # }
3358-
/// ```
3359-
pub async fn set_entity_with_expiry(
3360-
&self,
3361-
organization: OrganizationSlug,
3362-
vault: Option<VaultSlug>,
3363-
key: impl Into<String>,
3364-
value: Vec<u8>,
3365-
expires_at: u64,
3359+
expires_at: Option<u64>,
33663360
) -> Result<WriteSuccess> {
33673361
self.write(
33683362
organization,
33693363
vault,
3370-
vec![Operation::set_entity_with_expiry(key, value, expires_at)],
3364+
vec![Operation::SetEntity {
3365+
key: key.into(),
3366+
value,
3367+
expires_at,
3368+
condition: Some(condition),
3369+
}],
33713370
)
33723371
.await
33733372
}
@@ -5769,6 +5768,31 @@ mod tests {
57695768
}
57705769
}
57715770

5771+
#[test]
5772+
fn test_set_condition_from_expected_none() {
5773+
let cond = SetCondition::from_expected(None::<Vec<u8>>);
5774+
assert!(matches!(cond, SetCondition::NotExists));
5775+
}
5776+
5777+
#[test]
5778+
fn test_set_condition_from_expected_some_vec() {
5779+
let cond = SetCondition::from_expected(Some(b"old-value".to_vec()));
5780+
match cond {
5781+
SetCondition::ValueEquals(v) => assert_eq!(v, b"old-value"),
5782+
other => panic!("Expected ValueEquals, got: {other:?}"),
5783+
}
5784+
}
5785+
5786+
#[test]
5787+
fn test_set_condition_from_expected_some_slice() {
5788+
let slice: &[u8] = b"expected";
5789+
let cond = SetCondition::from_expected(Some(slice.to_vec()));
5790+
match cond {
5791+
SetCondition::ValueEquals(v) => assert_eq!(v, b"expected"),
5792+
other => panic!("Expected ValueEquals, got: {other:?}"),
5793+
}
5794+
}
5795+
57725796
// =========================================================================
57735797
// WriteSuccess Tests
57745798
// =========================================================================

crates/sdk/src/error.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,30 @@ impl SdkError {
432432
}
433433
}
434434

435+
/// Returns `true` if the error represents a CAS (compare-and-set)
436+
/// conflict — the precondition check failed because the entity was
437+
/// modified since it was last read.
438+
///
439+
/// This matches only [`Code::FailedPrecondition`], which the server
440+
/// returns when a [`SetCondition`](crate::SetCondition) evaluates to
441+
/// false. Use this to distinguish CAS conflicts from other error types
442+
/// without importing [`tonic::Code`] at call sites.
443+
///
444+
/// # Examples
445+
///
446+
/// ```no_run
447+
/// # use inferadb_ledger_sdk::SdkError;
448+
/// # fn example(err: SdkError) {
449+
/// if err.is_cas_conflict() {
450+
/// // Re-read current value and retry the compare-and-set
451+
/// }
452+
/// # }
453+
/// ```
454+
#[must_use]
455+
pub fn is_cas_conflict(&self) -> bool {
456+
matches!(self, Self::Rpc { code: Code::FailedPrecondition, .. })
457+
}
458+
435459
/// Returns a short classification string for this error, suitable for
436460
/// use as a metrics label.
437461
#[must_use]
@@ -1397,4 +1421,43 @@ mod tests {
13971421
}
13981422
assert!(err.is_retryable());
13991423
}
1424+
1425+
#[test]
1426+
fn test_is_cas_conflict_failed_precondition() {
1427+
let err = SdkError::Rpc {
1428+
code: Code::FailedPrecondition,
1429+
message: "condition not met".to_owned(),
1430+
request_id: None,
1431+
trace_id: None,
1432+
error_details: None,
1433+
};
1434+
assert!(err.is_cas_conflict());
1435+
}
1436+
1437+
#[test]
1438+
fn test_is_cas_conflict_aborted_is_false() {
1439+
let err = SdkError::Rpc {
1440+
code: Code::Aborted,
1441+
message: "transaction conflict".to_owned(),
1442+
request_id: None,
1443+
trace_id: None,
1444+
error_details: None,
1445+
};
1446+
assert!(!err.is_cas_conflict());
1447+
}
1448+
1449+
#[test]
1450+
fn test_is_cas_conflict_other_errors_are_false() {
1451+
let err = SdkError::Connection { message: "network down".to_owned() };
1452+
assert!(!err.is_cas_conflict());
1453+
1454+
let err = SdkError::Rpc {
1455+
code: Code::NotFound,
1456+
message: "not found".to_owned(),
1457+
request_id: None,
1458+
trace_id: None,
1459+
error_details: None,
1460+
};
1461+
assert!(!err.is_cas_conflict());
1462+
}
14001463
}

crates/sdk/src/mock.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,8 +2166,10 @@ mod tests {
21662166
let server = MockLedgerServer::start().await.unwrap();
21672167
let client = create_client_for_mock(&server).await;
21682168

2169-
let result =
2170-
client.set_entity(ORG, Some(VAULT), "entity:1", b"data".to_vec()).await.unwrap();
2169+
let result = client
2170+
.set_entity(ORG, Some(VAULT), "entity:1", b"data".to_vec(), None)
2171+
.await
2172+
.unwrap();
21712173

21722174
assert!(!result.tx_id.is_empty());
21732175
assert_eq!(
@@ -2201,6 +2203,7 @@ mod tests {
22012203
"cond:1",
22022204
b"value".to_vec(),
22032205
crate::SetCondition::NotExists,
2206+
None,
22042207
)
22052208
.await
22062209
.unwrap();
@@ -2218,7 +2221,7 @@ mod tests {
22182221
let client = create_client_for_mock(&server).await;
22192222

22202223
let result = client
2221-
.set_entity_with_expiry(ORG, Some(VAULT), "ttl:1", b"temp".to_vec(), 1_700_000_000)
2224+
.set_entity(ORG, Some(VAULT), "ttl:1", b"temp".to_vec(), Some(1_700_000_000))
22222225
.await
22232226
.unwrap();
22242227

@@ -2229,6 +2232,30 @@ mod tests {
22292232
);
22302233
}
22312234

2235+
#[tokio::test]
2236+
async fn test_set_entity_if_with_expiry_convenience() {
2237+
let server = MockLedgerServer::start().await.unwrap();
2238+
let client = create_client_for_mock(&server).await;
2239+
2240+
let result = client
2241+
.set_entity_if(
2242+
ORG,
2243+
Some(VAULT),
2244+
"cond_ttl:1",
2245+
b"conditional-temp".to_vec(),
2246+
crate::SetCondition::NotExists,
2247+
Some(1_700_000_000),
2248+
)
2249+
.await
2250+
.unwrap();
2251+
2252+
assert!(!result.tx_id.is_empty());
2253+
assert_eq!(
2254+
client.read(ORG, Some(VAULT), "cond_ttl:1").await.unwrap(),
2255+
Some(b"conditional-temp".to_vec())
2256+
);
2257+
}
2258+
22322259
// ==================== Idempotency ====================
22332260

22342261
#[tokio::test]

0 commit comments

Comments
 (0)