Add idempotency key for create

This commit is contained in:
Andras Schmelczer 2026-03-15 08:06:22 +00:00
parent a63903734d
commit ae590e6fc8
35 changed files with 624 additions and 143 deletions

View file

@ -325,7 +325,8 @@ impl Database {
is_deleted,
user_id,
device_id,
has_been_merged
has_been_merged,
idempotency_key
from latest_document_versions
where relative_path = ? and is_deleted = false
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
@ -365,7 +366,8 @@ impl Database {
is_deleted,
user_id,
device_id,
has_been_merged
has_been_merged,
idempotency_key
from latest_document_versions
where document_id = ?
"#,
@ -400,7 +402,8 @@ impl Database {
is_deleted,
user_id,
device_id,
has_been_merged
has_been_merged,
idempotency_key
from documents
where vault_update_id = ?"#,
vault_update_id
@ -434,9 +437,10 @@ impl Database {
content,
is_deleted,
user_id,
device_id
device_id,
idempotency_key
)
values (?, ?, ?, ?, ?, ?, ?, ?)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
version.vault_update_id,
document_id,
@ -445,7 +449,8 @@ impl Database {
version.content,
version.is_deleted,
version.user_id,
version.device_id
version.device_id,
version.idempotency_key
);
if let Some(mut transaction) = transaction {
@ -481,6 +486,44 @@ impl Database {
Ok(())
}
pub async fn get_document_by_idempotency_key(
&self,
vault: &VaultId,
idempotency_key: &str,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
d.vault_update_id,
d.document_id as "document_id: Hyphenated",
d.relative_path,
d.updated_date as "updated_date: chrono::DateTime<Utc>",
d.content,
d.is_deleted,
d.user_id,
d.device_id,
d.has_been_merged,
d.idempotency_key
from latest_document_versions d
inner join documents d2 on d.document_id = d2.document_id
where d2.idempotency_key = ?
limit 1
"#,
idempotency_key
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch document by idempotency key")
}
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
async fn cleanup_idle_pools(&self) {
let mut pools = self.connection_pools.lock().await;

View file

@ -0,0 +1 @@
ALTER TABLE documents ADD COLUMN idempotency_key TEXT;

View file

@ -22,6 +22,7 @@ pub struct StoredDocumentVersion {
pub device_id: DeviceId,
#[allow(dead_code)] // This is for manual analysis
pub has_been_merged: bool,
pub idempotency_key: Option<String>,
}
impl PartialEq<Self> for StoredDocumentVersion {

View file

@ -9,6 +9,7 @@ mod fetch_latest_documents;
mod index;
mod ping;
mod requests;
mod resolve_keys;
mod responses;
mod update_document;
mod websocket;
@ -108,6 +109,10 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
"/vaults/:vault_id/documents",
post(create_document::create_document),
)
.route(
"/vaults/:vault_id/documents/resolve-keys",
post(resolve_keys::resolve_keys),
)
.route(
"/vaults/:vault_id/documents/:document_id",
get(fetch_latest_document_version::fetch_latest_document_version),

View file

@ -1,3 +1,4 @@
use anyhow::Context;
use axum::{
Extension, Json,
extract::{Path, State},
@ -47,6 +48,25 @@ pub async fn create_document(
.await
.map_err(server_error)?;
if let Some(ref idempotency_key) = request.idempotency_key {
let existing = state
.database
.get_document_by_idempotency_key(&vault_id, idempotency_key, Some(&mut transaction))
.await
.map_err(server_error)?;
if let Some(existing) = existing {
info!("Found existing document with idempotency key `{idempotency_key}`, returning existing document");
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
return Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
existing.into(),
)));
}
}
let sanitized_relative_path = sanitize_path(&request.relative_path);
let latest_version = state
@ -74,6 +94,7 @@ pub async fn create_document(
&sanitized_relative_path,
request.content.contents.to_vec(),
transaction,
request.idempotency_key,
)
.await;
}
@ -111,6 +132,7 @@ pub async fn create_document(
user_id: user.name,
device_id: device_id.0,
has_been_merged: false,
idempotency_key: request.idempotency_key,
};
state

View file

@ -84,6 +84,7 @@ pub async fn delete_document(
user_id: user.name,
device_id: device_id.0,
has_been_merged: false,
idempotency_key: None,
};
state

View file

@ -14,6 +14,8 @@ pub struct CreateDocumentVersion {
#[ts(as = "Vec<u8>")]
#[form_data(limit = "unlimited")]
pub content: FieldData<Bytes>,
pub idempotency_key: Option<String>,
}
#[derive(Debug, TryFromMultipart)]

View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
use axum::{
Json,
extract::{Path, State},
};
use log::debug;
use serde::{Deserialize, Serialize};
use crate::{
app_state::{AppState, database::models::VaultId},
errors::{SyncServerError, server_error},
utils::normalize::normalize,
};
#[derive(Deserialize)]
pub struct ResolveKeysPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveKeysRequest {
pub idempotency_keys: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveKeysResponse {
/// Maps `idempotency_key` -> `document_id` for keys that were found
pub resolved: HashMap<String, String>,
}
#[axum::debug_handler]
pub async fn resolve_keys(
Path(ResolveKeysPathParams { vault_id }): Path<ResolveKeysPathParams>,
State(state): State<AppState>,
Json(request): Json<ResolveKeysRequest>,
) -> Result<Json<ResolveKeysResponse>, SyncServerError> {
debug!(
"Resolving {} idempotency keys in vault `{vault_id}`",
request.idempotency_keys.len()
);
let mut resolved = HashMap::new();
for key in &request.idempotency_keys {
let document = state
.database
.get_document_by_idempotency_key(&vault_id, key, None)
.await
.map_err(server_error)?;
if let Some(doc) = document {
resolved.insert(key.clone(), doc.document_id.to_string());
}
}
debug!("Resolved {}/{} idempotency keys", resolved.len(), request.idempotency_keys.len());
Ok(Json(ResolveKeysResponse { resolved }))
}

View file

@ -182,6 +182,7 @@ async fn update_document(
&sanitized_relative_path,
content,
transaction,
None,
)
.await
}
@ -198,6 +199,7 @@ pub async fn merge_with_stored_version(
sanitized_relative_path: &str,
content: Vec<u8>,
mut transaction: Transaction<'_>,
idempotency_key: Option<String>,
) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
// Return the latest version if the content and path are the same as the latest
// version
@ -290,6 +292,7 @@ pub async fn merge_with_stored_version(
user_id: user.name,
device_id: device_id.0,
has_been_merged: are_all_participants_mergable && is_different_from_request_content,
idempotency_key,
};
state