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

@@ -2,8 +2,9 @@ use redis_core::{
BackendBootstrap, BackendError, CommandExecutionResult, ConnectionTestResult,
RedisCommandRequest, RedisConnectionRequest, RedisKeyBrowseRequest, RedisKeyBrowseResult,
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult,
RedisValueReadRequest, RedisValueRecord, RedisValueWriteRequest, browse_keys, execute_command,
inspect_key, read_value, test_connection, update_key_ttl, write_value,
RedisValueCreateRequest, RedisValueCreateResult, RedisValueReadRequest, RedisValueRecord,
RedisValueWriteRequest, browse_keys, create_value, execute_command, inspect_key, read_value,
test_connection, update_key_ttl, write_value,
};
#[tauri::command]
@@ -45,6 +46,13 @@ fn write_redis_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord
write_value(request)
}
#[tauri::command]
fn create_redis_value(
request: RedisValueCreateRequest,
) -> Result<RedisValueCreateResult, BackendError> {
create_value(request)
}
#[tauri::command]
fn update_redis_key_ttl(
request: RedisKeyTtlUpdateRequest,
@@ -62,6 +70,7 @@ fn main() {
inspect_redis_key,
read_redis_value,
write_redis_value,
create_redis_value,
update_redis_key_ttl
])
.run(tauri::generate_context!())

View File

@@ -65,6 +65,28 @@ describe("operator notices", () => {
);
});
it("maps duplicate key creation into a stable neutral notice", () => {
expect(
toBackendErrorNotice(
{
code: "already_exists",
message: "Redis key creation does not overwrite an existing key.",
detail: "key `draft:key` already exists",
},
{
operation: "key_create",
connectionName: "redis-local-fixture",
databaseLabel: "db0",
keyName: "draft:key",
},
),
).toEqual(
createNeutralNotice(
"Redis refused to proceed while creating draft:key on redis-local-fixture / db0 because the key already exists. Choose a new key name or inspect the existing key before retrying. Backend detail: Redis key creation does not overwrite an existing key. (key `draft:key` already exists).",
),
);
});
it("describes command failures with command and scope context", () => {
expect(
toBackendErrorNotice(

View File

@@ -9,6 +9,7 @@ export type BackendErrorCode =
| "invalid_connection_config"
| "unsupported_tls_mode"
| "unsupported_operation"
| "already_exists"
| "connection_failed"
| "authentication_failed"
| "command_failed"
@@ -21,6 +22,12 @@ export type BackendError = {
};
export type BackendErrorContext =
| {
operation: "key_create";
connectionName: string;
databaseLabel: string;
keyName: string;
}
| {
operation: "connection_test";
connectionName: string;
@@ -96,6 +103,7 @@ export function toBackendErrorNotice(
const backendError = toBackendError(error);
const tone =
backendError.code === "invalid_connection_config" ||
backendError.code === "already_exists" ||
backendError.code === "unsupported_operation" ||
backendError.code === "unsupported_tls_mode"
? "neutral"
@@ -115,6 +123,7 @@ function normalizeBackendErrorCode(value: unknown): BackendErrorCode {
switch (value) {
case "invalid_connection_config":
case "unsupported_tls_mode":
case "already_exists":
case "unsupported_operation":
case "connection_failed":
case "authentication_failed":
@@ -134,6 +143,8 @@ function buildHeadline(error: BackendError, context: BackendErrorContext) {
return `The request for ${target} was rejected before Redis was contacted.`;
case "unsupported_tls_mode":
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
case "already_exists":
return `Redis refused to proceed while ${target} because the key already exists.`;
case "unsupported_operation":
return `The current desktop contract does not allow ${target} yet.`;
case "connection_failed":
@@ -155,6 +166,8 @@ function buildHint(code: BackendErrorCode, context: BackendErrorContext) {
return "Check host, port, database, search pattern, and key input before retrying.";
case "unsupported_tls_mode":
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
case "already_exists":
return "Choose a new key name or inspect the existing key before retrying.";
case "unsupported_operation":
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
case "connection_failed":
@@ -181,6 +194,8 @@ function describeContext(context: BackendErrorContext) {
switch (context.operation) {
case "connection_test":
return `testing ${context.connectionName} on ${context.databaseLabel}`;
case "key_create":
return `creating ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "key_browse":
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
case "value_read":

View File

@@ -117,6 +117,15 @@ pub struct RedisValueWriteRequest {
pub value: RedisValueWriteInput,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedisValueCreateRequest {
pub connection: RedisConnectionRequest,
pub key: String,
pub value: RedisValueCreateInput,
#[serde(default)]
pub ttl_millis: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedisKeyTtlUpdateRequest {
pub connection: RedisConnectionRequest,
@@ -154,6 +163,11 @@ pub struct RedisKeyTtlUpdateResult {
pub metadata: RedisKeyMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedisValueCreateResult {
pub record: RedisValueRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedisFieldValueEntry {
pub field: RedisValueContent,
@@ -197,6 +211,12 @@ pub enum RedisValueWriteInput {
String { value: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RedisValueCreateInput {
String { value: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RedisValueWriteCapability {

View File

@@ -5,6 +5,7 @@ use thiserror::Error;
#[serde(rename_all = "snake_case")]
pub enum BackendErrorCode {
InvalidConnectionConfig,
AlreadyExists,
UnsupportedTlsMode,
UnsupportedOperation,
ConnectionFailed,
@@ -30,6 +31,10 @@ impl BackendError {
Self::new(BackendErrorCode::UnsupportedTlsMode, message, None)
}
pub fn already_exists(message: impl Into<String>, detail: Option<String>) -> Self {
Self::new(BackendErrorCode::AlreadyExists, message, detail)
}
pub fn connection_failed(message: impl Into<String>, detail: Option<String>) -> Self {
Self::new(BackendErrorCode::ConnectionFailed, message, detail)
}

View File

@@ -9,14 +9,15 @@ pub use connection::{
RedisCommandRequest, RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest,
RedisKeyBrowseResult, RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl,
RedisKeyTtlUpdate, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse,
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueData,
RedisValueReadRequest, RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput,
RedisValueWriteRequest, TlsMode,
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueCreateInput,
RedisValueCreateRequest, RedisValueCreateResult, RedisValueData, RedisValueReadRequest,
RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest,
TlsMode,
};
pub use error::{BackendError, BackendErrorCode};
pub use service::{
browse_keys, execute_command, inspect_key, read_value, test_connection, update_key_ttl,
write_value,
browse_keys, create_value, execute_command, inspect_key, read_value, test_connection,
update_key_ttl, write_value,
};
#[cfg(test)]

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 {