feat(core): add bounded key creation contract
Expose create_redis_value through the Tauri bridge, add NX-based string creation in redis-core, and surface duplicate-key handling through stable operator notices. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -9,7 +9,8 @@ use crate::{
|
||||
RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
||||
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl, RedisKeyTtlUpdate,
|
||||
RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse, RedisSortedSetEntry,
|
||||
RedisStreamEntry, RedisValueContent, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
||||
RedisStreamEntry, RedisValueContent, RedisValueCreateInput, RedisValueCreateRequest,
|
||||
RedisValueCreateResult, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
||||
RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest, TlsMode,
|
||||
};
|
||||
|
||||
@@ -141,6 +142,26 @@ pub fn write_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord,
|
||||
read_value_record(&mut connection, database, &request.key)
|
||||
}
|
||||
|
||||
pub fn create_value(
|
||||
request: RedisValueCreateRequest,
|
||||
) -> Result<RedisValueCreateResult, BackendError> {
|
||||
validate_key_name(&request.key)?;
|
||||
validate_optional_ttl_millis(request.ttl_millis)?;
|
||||
|
||||
let database = request.connection.target.database;
|
||||
let mut connection = open_connection(&request.connection)?;
|
||||
|
||||
match &request.value {
|
||||
RedisValueCreateInput::String { value } => {
|
||||
create_string_value(&mut connection, &request.key, value, request.ttl_millis)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RedisValueCreateResult {
|
||||
record: read_value_record(&mut connection, database, &request.key)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_key_ttl(
|
||||
request: RedisKeyTtlUpdateRequest,
|
||||
) -> Result<RedisKeyTtlUpdateResult, BackendError> {
|
||||
@@ -490,6 +511,28 @@ fn write_string_value(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_string_value(
|
||||
connection: &mut RedisConnection,
|
||||
key_name: &str,
|
||||
value: &str,
|
||||
ttl_millis: Option<u64>,
|
||||
) -> Result<(), BackendError> {
|
||||
let mut command = vec![
|
||||
"SET".to_string(),
|
||||
key_name.to_string(),
|
||||
value.to_string(),
|
||||
"NX".to_string(),
|
||||
];
|
||||
|
||||
if let Some(ttl_millis) = ttl_millis {
|
||||
command.push("PX".to_string());
|
||||
command.push(ttl_millis.to_string());
|
||||
}
|
||||
|
||||
let response = connection.execute_owned(command)?;
|
||||
expect_set_create_applied(response, key_name)
|
||||
}
|
||||
|
||||
fn parse_resp_string(value: RespValue) -> Result<String, BackendError> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) => Ok(value),
|
||||
@@ -708,6 +751,16 @@ fn validate_key_name(key: &str) -> Result<(), BackendError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_optional_ttl_millis(ttl_millis: Option<u64>) -> Result<(), BackendError> {
|
||||
if matches!(ttl_millis, Some(0)) {
|
||||
return Err(BackendError::invalid_connection_config(
|
||||
"Redis key creation TTL must be greater than zero when provided.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_ok(value: RespValue) -> Result<(), RespValue> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||
@@ -751,6 +804,24 @@ fn expect_set_write_applied(value: RespValue) -> Result<(), BackendError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_set_create_applied(value: RespValue, key_name: &str) -> Result<(), BackendError> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||
RespValue::BulkString(None) => Err(BackendError::already_exists(
|
||||
"Redis key creation does not overwrite an existing key.",
|
||||
Some(format!("key `{key_name}` already exists")),
|
||||
)),
|
||||
RespValue::Error(message) => Err(BackendError::command_failed(
|
||||
"Redis string key creation failed.",
|
||||
Some(message),
|
||||
)),
|
||||
other => Err(BackendError::command_failed(
|
||||
"Redis string key creation returned an unexpected response.",
|
||||
Some(format!("{other:?}")),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_auth_error(error: RespValue) -> BackendError {
|
||||
match error {
|
||||
RespValue::Error(message) => BackendError::authentication_failed(
|
||||
@@ -1530,6 +1601,137 @@ mod tests {
|
||||
assert_eq!(error.code, BackendErrorCode::UnsupportedOperation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_string_value_without_ttl() {
|
||||
let server = MockRedisServer::spawn(vec![
|
||||
ServerStep::expect(
|
||||
["SET", "draft:key", "hello world", "NX"],
|
||||
Response::simple("OK"),
|
||||
),
|
||||
ServerStep::expect(["TYPE", "draft:key"], Response::simple("string")),
|
||||
ServerStep::expect(["PTTL", "draft:key"], Response::integer(-1)),
|
||||
ServerStep::expect(["GET", "draft:key"], Response::bulk("hello world")),
|
||||
]);
|
||||
|
||||
let result = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: None,
|
||||
})
|
||||
.expect("key create should succeed");
|
||||
|
||||
assert_eq!(result.record.metadata.name, "draft:key");
|
||||
assert_eq!(result.record.metadata.ttl, RedisKeyTtl::Persistent);
|
||||
assert_eq!(
|
||||
result.record.write_capability,
|
||||
RedisValueWriteCapability::ReplaceString
|
||||
);
|
||||
assert_eq!(
|
||||
result.record.data,
|
||||
RedisValueData::String {
|
||||
value: RedisValueContent::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_string_value_with_ttl() {
|
||||
let server = MockRedisServer::spawn(vec![
|
||||
ServerStep::expect(
|
||||
["SET", "draft:ttl", "expires soon", "NX", "PX", "60000"],
|
||||
Response::simple("OK"),
|
||||
),
|
||||
ServerStep::expect(["TYPE", "draft:ttl"], Response::simple("string")),
|
||||
ServerStep::expect(["PTTL", "draft:ttl"], Response::integer(59_000)),
|
||||
ServerStep::expect(["GET", "draft:ttl"], Response::bulk("expires soon")),
|
||||
]);
|
||||
|
||||
let result = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:ttl".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "expires soon".to_string(),
|
||||
},
|
||||
ttl_millis: Some(60_000),
|
||||
})
|
||||
.expect("ttl create should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.record.metadata.ttl,
|
||||
RedisKeyTtl::ExpiresInMillis { value: 59_000 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_key_creation_with_stable_conflict_error() {
|
||||
let server = MockRedisServer::spawn(vec![ServerStep::expect(
|
||||
["SET", "draft:key", "hello world", "NX"],
|
||||
Response::null_bulk(),
|
||||
)]);
|
||||
|
||||
let error = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: None,
|
||||
})
|
||||
.expect_err("duplicate create should fail");
|
||||
|
||||
assert_eq!(error.code, BackendErrorCode::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_ttl_for_created_key_before_network_access() {
|
||||
let error = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget::default(),
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: Some(0),
|
||||
})
|
||||
.expect_err("zero ttl should fail");
|
||||
|
||||
assert_eq!(error.code, BackendErrorCode::InvalidConnectionConfig);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_ttl_for_existing_keys() {
|
||||
let expiring_server = MockRedisServer::spawn(vec![
|
||||
@@ -1656,6 +1858,7 @@ mod tests {
|
||||
Error(String),
|
||||
Integer(i64),
|
||||
Bulk(Vec<u8>),
|
||||
NullBulk,
|
||||
Array(Vec<Response>),
|
||||
}
|
||||
|
||||
@@ -1676,6 +1879,10 @@ mod tests {
|
||||
Self::Bulk(value.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn null_bulk() -> Self {
|
||||
Self::NullBulk
|
||||
}
|
||||
|
||||
fn array<const N: usize>(items: [Response; N]) -> Self {
|
||||
Self::Array(items.into())
|
||||
}
|
||||
@@ -1691,6 +1898,7 @@ mod tests {
|
||||
encoded.extend(b"\r\n");
|
||||
encoded
|
||||
}
|
||||
Self::NullBulk => b"$-1\r\n".to_vec(),
|
||||
Self::Array(items) => {
|
||||
let mut encoded = format!("*{}\r\n", items.len()).into_bytes();
|
||||
for item in items {
|
||||
|
||||
Reference in New Issue
Block a user