From e83c518081d06e47954a8413580d547f2546c620 Mon Sep 17 00:00:00 2001 From: Senior Frontend Engineer Date: Tue, 31 Mar 2026 10:14:13 +0000 Subject: [PATCH] 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 --- apps/desktop/src-tauri/src/main.rs | 13 +- apps/desktop/src/lib/operator-notices.test.ts | 22 ++ apps/desktop/src/lib/operator-notices.ts | 15 ++ crates/redis-core/src/connection.rs | 20 ++ crates/redis-core/src/error.rs | 5 + crates/redis-core/src/lib.rs | 11 +- crates/redis-core/src/service.rs | 210 +++++++++++++++++- 7 files changed, 288 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index f0279d7..52a0836 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -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 Result { + 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!()) diff --git a/apps/desktop/src/lib/operator-notices.test.ts b/apps/desktop/src/lib/operator-notices.test.ts index 8393c81..790f647 100644 --- a/apps/desktop/src/lib/operator-notices.test.ts +++ b/apps/desktop/src/lib/operator-notices.test.ts @@ -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( diff --git a/apps/desktop/src/lib/operator-notices.ts b/apps/desktop/src/lib/operator-notices.ts index aaafa94..0e07ee3 100644 --- a/apps/desktop/src/lib/operator-notices.ts +++ b/apps/desktop/src/lib/operator-notices.ts @@ -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": diff --git a/crates/redis-core/src/connection.rs b/crates/redis-core/src/connection.rs index 2a986e2..23eaa01 100644 --- a/crates/redis-core/src/connection.rs +++ b/crates/redis-core/src/connection.rs @@ -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, +} + #[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 { diff --git a/crates/redis-core/src/error.rs b/crates/redis-core/src/error.rs index 2eab94e..91a7336 100644 --- a/crates/redis-core/src/error.rs +++ b/crates/redis-core/src/error.rs @@ -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, detail: Option) -> Self { + Self::new(BackendErrorCode::AlreadyExists, message, detail) + } + pub fn connection_failed(message: impl Into, detail: Option) -> Self { Self::new(BackendErrorCode::ConnectionFailed, message, detail) } diff --git a/crates/redis-core/src/lib.rs b/crates/redis-core/src/lib.rs index 805012b..ba4b083 100644 --- a/crates/redis-core/src/lib.rs +++ b/crates/redis-core/src/lib.rs @@ -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)] diff --git a/crates/redis-core/src/service.rs b/crates/redis-core/src/service.rs index b0650f7..2312482 100644 --- a/crates/redis-core/src/service.rs +++ b/crates/redis-core/src/service.rs @@ -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 Result { + 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 { @@ -490,6 +511,28 @@ fn write_string_value( Ok(()) } +fn create_string_value( + connection: &mut RedisConnection, + key_name: &str, + value: &str, + ttl_millis: Option, +) -> 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 { 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) -> 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), + NullBulk, Array(Vec), } @@ -1676,6 +1879,10 @@ mod tests { Self::Bulk(value.as_bytes().to_vec()) } + fn null_bulk() -> Self { + Self::NullBulk + } + fn array(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 {