Skip to content

Commit 91cd893

Browse files
committed
fix(api): preserve conditional uniqueness conflicts
1 parent 849b27c commit 91cd893

6 files changed

Lines changed: 124 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/schema-forge-acton/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "schema-forge-acton"
3-
version = "0.37.1"
3+
version = "0.37.2"
44
edition = "2021"
55

66
[dependencies]

crates/schema-forge-acton/src/routes/entities.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ fn conditional_error(error: ConditionalMutationError) -> ForgeError {
135135
},
136136
ConditionalMutationError::Backend(error) => match error {
137137
schema_forge_backend::BackendError::EntityNotFound { .. } => ForgeError::from(error),
138+
schema_forge_backend::BackendError::UniqueViolation { .. } => ForgeError::Conflict {
139+
reason: "unique_violation",
140+
message: "The change conflicts with an existing record.".into(),
141+
},
138142
_ => ForgeError::BackendUnavailable {
139143
message: "The entity operation could not be completed.".into(),
140144
},
@@ -3639,6 +3643,26 @@ mod tests {
36393643
assert!(!error.to_string().contains("private database"));
36403644
}
36413645

3646+
#[test]
3647+
fn revision_unique_conflicts_preserve_status_without_constraint_details() {
3648+
let error = conditional_error(ConditionalMutationError::Backend(
3649+
schema_forge_backend::BackendError::UniqueViolation {
3650+
schema: "PrivateSchema".into(),
3651+
field: "hidden_computed_key".into(),
3652+
},
3653+
));
3654+
assert!(matches!(
3655+
error,
3656+
ForgeError::Conflict {
3657+
reason: "unique_violation",
3658+
..
3659+
}
3660+
));
3661+
assert!(!error.to_string().contains("PrivateSchema"));
3662+
assert!(!error.to_string().contains("hidden_computed_key"));
3663+
assert_eq!(error.into_response().status(), StatusCode::CONFLICT);
3664+
}
3665+
36423666
#[test]
36433667
fn revision_headers_expose_only_the_opaque_row_revision() {
36443668
assert!(revision_headers(None).unwrap().is_empty());

crates/schema-forge-acton/tests/conditional_entities.rs

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ use schema_forge_acton::{
2020
};
2121
use schema_forge_backend::{conditional::EntityRevision, entity::Entity, tenant::TenantConfig};
2222
use schema_forge_core::types::{
23-
Annotation, DynamicValue, EntityId, FieldAnnotation, FieldDefinition, FieldName, FieldType,
24-
SchemaDefinition, SchemaId, SchemaName, TenantKind, TextConstraints,
23+
Annotation, DynamicValue, EntityId, FieldAnnotation, FieldDefinition, FieldModifier, FieldName,
24+
FieldType, SchemaDefinition, SchemaId, SchemaName, TenantKind, TextConstraints,
2525
};
2626
use schema_forge_surrealdb::SurrealBackend;
2727
use std::{
@@ -51,9 +51,10 @@ async fn fixture_with_backend(
5151
SchemaId::new(),
5252
SchemaName::new("Note").unwrap(),
5353
vec![
54-
FieldDefinition::new(
54+
FieldDefinition::with_modifiers(
5555
FieldName::new("title").unwrap(),
5656
FieldType::Text(TextConstraints::unconstrained()),
57+
vec![FieldModifier::Unique],
5758
),
5859
FieldDefinition::with_annotations(
5960
FieldName::new("owner").unwrap(),
@@ -347,6 +348,39 @@ async fn postgres_http_revisions_guard_updates_noops_races_and_deletes() {
347348
assert_eq!(status, StatusCode::OK, "{body}");
348349
assert_eq!(body["fields"]["title"], "Original");
349350
let initial = response_revision(&headers);
351+
// A conditional write must preserve ordinary uniqueness semantics, and a
352+
// rejected write must roll back both fields and the revision marker.
353+
let occupied = Entity::with_id(
354+
EntityId::new("note"),
355+
SchemaName::new("Note").unwrap(),
356+
BTreeMap::from([
357+
("title".into(), DynamicValue::Text("Taken".into())),
358+
("owner".into(), DynamicValue::Text("editor".into())),
359+
]),
360+
);
361+
DynEntityStore::create(backend.as_ref(), &occupied)
362+
.await
363+
.unwrap();
364+
for method in ["PATCH", "PUT"] {
365+
let (status, headers, body) = request(
366+
&app,
367+
&path,
368+
method,
369+
Some(initial.as_str()),
370+
serde_json::json!({"title": "Taken"}),
371+
)
372+
.await;
373+
assert_eq!(status, StatusCode::CONFLICT, "{body}");
374+
assert_eq!(body["reason"], "unique_violation");
375+
assert!(body.get("field").is_none());
376+
assert!(body.get("schema").is_none());
377+
assert!(!headers.contains_key("entity-revision"));
378+
let (status, headers, body) =
379+
request(&app, &path, "GET", None, serde_json::json!({})).await;
380+
assert_eq!(status, StatusCode::OK);
381+
assert_eq!(body["fields"]["title"], "Original");
382+
assert_eq!(response_revision(&headers), initial);
383+
}
350384
let (status, headers, body) = request(
351385
&app,
352386
&path,
@@ -532,6 +566,64 @@ async fn postgres_http_revisions_guard_updates_noops_races_and_deletes() {
532566
let (status, headers, _) = request(&app, &path, "GET", None, serde_json::json!({})).await;
533567
assert_eq!(status, StatusCode::NOT_FOUND);
534568
assert!(!headers.contains_key("entity-revision"));
569+
570+
// Different records with independent valid revisions still compete for
571+
// one unique value. The loser is a business conflict, not an outage.
572+
let mut contenders = Vec::new();
573+
for title in ["Left original", "Right original"] {
574+
let entity = Entity::with_id(
575+
EntityId::new("note"),
576+
SchemaName::new("Note").unwrap(),
577+
BTreeMap::from([
578+
("title".into(), DynamicValue::Text(title.into())),
579+
("owner".into(), DynamicValue::Text("editor".into())),
580+
]),
581+
);
582+
DynEntityStore::create(backend.as_ref(), &entity)
583+
.await
584+
.unwrap();
585+
let path = format!("/schemas/Note/entities/{}", entity.id);
586+
let (status, headers, _) = request(&app, &path, "GET", None, serde_json::json!({})).await;
587+
assert_eq!(status, StatusCode::OK);
588+
contenders.push((path, response_revision(&headers), title));
589+
}
590+
let (left, right) = tokio::join!(
591+
request(
592+
&app,
593+
&contenders[0].0,
594+
"PATCH",
595+
Some(contenders[0].1.as_str()),
596+
serde_json::json!({"title":"Shared name"})
597+
),
598+
request(
599+
&app,
600+
&contenders[1].0,
601+
"PATCH",
602+
Some(contenders[1].1.as_str()),
603+
serde_json::json!({"title":"Shared name"})
604+
),
605+
);
606+
assert_eq!(
607+
[left.0, right.0]
608+
.iter()
609+
.filter(|status| **status == StatusCode::OK)
610+
.count(),
611+
1
612+
);
613+
for (result, (path, baseline, original)) in [left, right].iter().zip(&contenders) {
614+
let (status, headers, body) = request(&app, path, "GET", None, serde_json::json!({})).await;
615+
assert_eq!(status, StatusCode::OK);
616+
if result.0 == StatusCode::OK {
617+
assert_eq!(body["fields"]["title"], "Shared name");
618+
assert_ne!(&response_revision(&headers), baseline);
619+
} else {
620+
assert_eq!(result.0, StatusCode::CONFLICT, "{}", result.2);
621+
assert_eq!(result.2["reason"], "unique_violation");
622+
assert!(!result.1.contains_key("entity-revision"));
623+
assert_eq!(body["fields"]["title"], *original);
624+
assert_eq!(&response_revision(&headers), baseline);
625+
}
626+
}
535627
}
536628

537629
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

crates/schema-forge-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "schema-forge-cli"
3-
version = "0.38.1"
3+
version = "0.38.2"
44
edition = "2021"
55

66
[[bin]]

docs/conditional-entity-mutations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Requests without a condition retain ordinary CRUD behavior. Every actual backend
4545
| --- | --- |
4646
| Detail GET with `Entity-Revision` | This authorized record has a usable edit baseline. |
4747
| Detail GET without the header | Conditional mutation is unavailable for that schema/backend. Do not claim stale-edit protection. |
48+
| 409, `reason: "unique_violation"` | Another record already uses a unique value. Revise the input; the rejected write leaves the record and revision unchanged. Constraint and hidden field details are omitted. |
4849
| 409, `reason: "revision_conflict"` | The baseline changed. Keep the user's draft and reload for deliberate reconciliation. |
4950
| 409, `reason: "conditional_mutation_unsupported"` | The adapter or schema is not ready. Do not retry unconditionally. |
5051
| 400 | Malformed/duplicate revision header, or unsupported `If-Match`. Correct the request. |

0 commit comments

Comments
 (0)