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:
@@ -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!())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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