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:
Senior Frontend Engineer
2026-03-31 10:14:13 +00:00
parent 8b6689c445
commit e83c518081
7 changed files with 288 additions and 8 deletions

View File

@@ -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 {