diff --git a/server/crates/kvitt-server/Cargo.toml b/server/crates/kvitt-server/Cargo.toml index ccdd2c8..5e23e29 100644 --- a/server/crates/kvitt-server/Cargo.toml +++ b/server/crates/kvitt-server/Cargo.toml @@ -21,7 +21,7 @@ axum = "0.8.9" base64 = "0.23.1" chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } chrono-tz = "0.10.4" -clap = { version = "4.6.7", features = ["derive", "env"] } +clap = { version = "4.6.7", features = ["derive"] } getrandom = "0.4.3" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } diff --git a/server/crates/kvitt-server/src/sync/push.rs b/server/crates/kvitt-server/src/sync/push.rs index 1aa3efa..b5dd024 100644 --- a/server/crates/kvitt-server/src/sync/push.rs +++ b/server/crates/kvitt-server/src/sync/push.rs @@ -462,6 +462,46 @@ async fn audit( Ok(()) } +/// Step 7 of SPEC §14.4.4 against a live stored row: the delete always wins. `updated_at` becomes the later of the +/// stored and the pushed one; audited as `delete` with the pushed `deleted_at` as `client_at`. +async fn delete_stored( + conn: &mut SqliteConnection, + ctx: &Ctx<'_>, + env: &Envelope, + stored: &K::Wire, + deleted_at: &str, + updated_at: DateTime, +) -> Result { + let stored_updated = parse_ts(K::stored_updated_at(stored)) + .ok_or_else(|| AppError::internal("stored updated_at does not parse"))?; + let new_updated_at = format_ts(stored_updated.max(updated_at)); + let version = db::next_server_version(conn).await?; + sqlx::query(K::DELETE_SQL) + .bind(deleted_at) + .bind(&new_updated_at) + .bind(ctx.caller.user_id.to_string()) + .bind(ctx.caller.device_id.to_string()) + .bind(version) + .bind(env.entity_id.to_string()) + .execute(&mut *conn) + .await?; + let after = reload::(conn, env.entity_id).await?; + audit::( + conn, + ctx, + env, + AuditAction::Delete, + deleted_at, + Some(stored), + &after, + ) + .await?; + Ok(Processed { + result: accepted(env, K::entity(after)), + push: Some(push_of(K::DELETED, env, ctx)), + }) +} + /// Steps 3 to 11 of SPEC §14.4.4 for one mutation, on the mutation's savepoint. async fn process( conn: &mut SqliteConnection, @@ -529,34 +569,7 @@ async fn process( // 7. Pushed entity is a delete: always wins. if let Some(deleted_at) = K::deleted_at(&pushed).map(str::to_owned) { if let Some(stored) = stored { - let stored_updated = parse_ts(K::stored_updated_at(&stored)) - .ok_or_else(|| AppError::internal("stored updated_at does not parse"))?; - let new_updated_at = format_ts(stored_updated.max(updated_at)); - let version = db::next_server_version(conn).await?; - sqlx::query(K::DELETE_SQL) - .bind(&deleted_at) - .bind(&new_updated_at) - .bind(ctx.caller.user_id.to_string()) - .bind(ctx.caller.device_id.to_string()) - .bind(version) - .bind(env.entity_id.to_string()) - .execute(&mut *conn) - .await?; - let after = reload::(conn, env.entity_id).await?; - audit::( - conn, - ctx, - env, - AuditAction::Delete, - &deleted_at, - Some(&stored), - &after, - ) - .await?; - return Ok(Processed { - result: accepted(env, K::entity(after)), - push: Some(push_of(K::DELETED, env, ctx)), - }); + return delete_stored::(conn, ctx, env, &stored, &deleted_at, updated_at).await; } // Created and deleted before it ever synced: store the tombstone, no audit, no push. It is validated like a // create so the row satisfies every rule of the schema (SPEC §14.4.4 step 7 asks for the references).