Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,82 @@ pub struct ProvidedEntityEditionProvenance {
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[allow(
clippy::struct_field_names,
clippy::allow_attributes,
reason = "prefix required for flattened serde serialization into `InferredEntityProvenance`.
`#[expect]` does not work here because serde's derive macro interferes with lint \
expectation fulfillment (https://github.com/rust-lang/rust-clippy/issues/12035)"
)]
pub struct EntityDeletionProvenance {
pub deleted_by_id: ActorEntityUuid,
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
pub deleted_at_transaction_time: Timestamp<TransactionTime>,
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
pub deleted_at_decision_time: Timestamp<DecisionTime>,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
// `deny_unknown_fields` is intentionally absent: serde does not support it together with
// `#[serde(flatten)]` (https://serde.rs/container-attrs.html#deny_unknown_fields).
#[serde(rename_all = "camelCase")]
pub struct InferredEntityProvenance {
pub created_by_id: ActorEntityUuid,
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
pub created_at_transaction_time: Timestamp<TransactionTime>,
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
pub created_at_decision_time: Timestamp<DecisionTime>,
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_non_draft_created_at_transaction_time: Option<Timestamp<TransactionTime>>,
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
#[cfg_attr(target_arch = "wasm32", tsify(type = "Timestamp"))]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_non_draft_created_at_decision_time: Option<Timestamp<DecisionTime>>,
#[serde(default, flatten, skip_serializing_if = "Option::is_none")]
pub deletion: Option<EntityDeletionProvenance>,
}

/// Manual [`ToSchema`] implementation because utoipa's derive macro cannot correctly represent
/// `#[serde(flatten)]` on `Option<EntityDeletionProvenance>`: it generates an `allOf` that makes
/// the deletion fields required. The correct schema lists them as optional properties.
///
/// [`ToSchema`]: utoipa::ToSchema
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema<'static> for InferredEntityProvenance {
fn schema() -> (
&'static str,
utoipa::openapi::RefOr<utoipa::openapi::Schema>,
) {
use utoipa::openapi::{ObjectBuilder, Ref, Schema};

(
"InferredEntityProvenance",
Schema::Object(
ObjectBuilder::new()
.property("createdById", Ref::from_schema_name("ActorEntityUuid"))
.required("createdById")
.property(
"createdAtTransactionTime",
Ref::from_schema_name("Timestamp"),
)
.required("createdAtTransactionTime")
.property("createdAtDecisionTime", Ref::from_schema_name("Timestamp"))
.required("createdAtDecisionTime")
.property(
"firstNonDraftCreatedAtTransactionTime",
Ref::from_schema_name("Timestamp"),
)
.property(
"firstNonDraftCreatedAtDecisionTime",
Ref::from_schema_name("Timestamp"),
)
.property("deletedById", Ref::from_schema_name("ActorEntityUuid"))
.property(
"deletedAtTransactionTime",
Ref::from_schema_name("Timestamp"),
)
.property("deletedAtDecisionTime", Ref::from_schema_name("Timestamp"))
.build(),
)
.into(),
)
}
}

#[cfg(feature = "postgres")]
Expand Down Expand Up @@ -113,9 +175,90 @@ impl ToSql for InferredEntityProvenance {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
// `deny_unknown_fields` is intentionally absent: serde does not support it together with
// `#[serde(flatten)]` (https://serde.rs/container-attrs.html#deny_unknown_fields).
#[serde(rename_all = "camelCase")]
pub struct EntityProvenance {
#[serde(flatten)]
pub inferred: InferredEntityProvenance,
pub edition: EntityEditionProvenance,
}

/// Override tsify's generated type for [`InferredEntityProvenance`].
///
/// The main struct's `derive(tsify::Tsify)` generates
/// `type InferredEntityProvenance = { ... } & (EntityDeletionProvenance | {})` because of
/// `#[serde(flatten)]` on `Option<EntityDeletionProvenance>`. That complex type alias cannot be
/// used with `extends` in [`EntityProvenance`]'s interface declaration.
///
/// This patch generates a clean interface with the deletion fields as individually optional
/// properties, which overrides the broken declaration in the wasm output.
#[cfg(target_arch = "wasm32")]
#[expect(dead_code, reason = "Used in the generated TypeScript types")]
mod inferred_entity_provenance_patch {
use super::*;

#[derive(tsify::Tsify)]
#[serde(rename_all = "camelCase")]
pub struct InferredEntityProvenance {
pub created_by_id: ActorEntityUuid,
#[tsify(type = "Timestamp")]
pub created_at_transaction_time: Timestamp<TransactionTime>,
#[tsify(type = "Timestamp")]
pub created_at_decision_time: Timestamp<DecisionTime>,
#[tsify(type = "Timestamp")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_non_draft_created_at_transaction_time: Option<Timestamp<TransactionTime>>,
#[tsify(type = "Timestamp")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_non_draft_created_at_decision_time: Option<Timestamp<DecisionTime>>,
// Flattened from `Option<EntityDeletionProvenance>` β€” represented as individual optional
// fields instead of `& (EntityDeletionProvenance | {})`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_by_id: Option<ActorEntityUuid>,
#[tsify(type = "Timestamp")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at_transaction_time: Option<Timestamp<TransactionTime>>,
#[tsify(type = "Timestamp")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at_decision_time: Option<Timestamp<DecisionTime>>,
}
}

#[cfg(test)]
mod tests {
use uuid::Uuid;

use super::*;

#[test]
fn inferred_provenance_roundtrip_without_deletion() {
let json = serde_json::json!({
"createdById": Uuid::new_v4(),
"createdAtTransactionTime": Timestamp::<TransactionTime>::now(),
"createdAtDecisionTime": Timestamp::<DecisionTime>::now(),
});
let provenance: InferredEntityProvenance =
serde_json::from_value(json.clone()).expect("deserialization failed");
assert!(provenance.deletion.is_none());
let roundtrip = serde_json::to_value(&provenance).expect("serialization failed");
assert_eq!(roundtrip, json);
}

#[test]
fn inferred_provenance_roundtrip_with_deletion() {
let json = serde_json::json!({
"createdById": Uuid::new_v4(),
"createdAtTransactionTime": Timestamp::<TransactionTime>::now(),
"createdAtDecisionTime": Timestamp::<DecisionTime>::now(),
"deletedById": Uuid::new_v4(),
"deletedAtTransactionTime": Timestamp::<TransactionTime>::now(),
"deletedAtDecisionTime": Timestamp::<DecisionTime>::now(),
});
let provenance: InferredEntityProvenance =
serde_json::from_value(json.clone()).expect("deserialization failed");
assert!(provenance.deletion.is_some());
let roundtrip = serde_json::to_value(&provenance).expect("serialization failed");
assert_eq!(roundtrip, json);
}
}
44 changes: 32 additions & 12 deletions libs/@local/graph/api/openapi/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions libs/@local/graph/api/src/rest/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ use type_system::{
id::{EntityEditionId, EntityId, EntityRecordId, EntityUuid},
metadata::{EntityTemporalMetadata, EntityTypeIdDiff},
provenance::{
EntityEditionProvenance, EntityProvenance, InferredEntityProvenance,
ProvidedEntityEditionProvenance,
EntityDeletionProvenance, EntityEditionProvenance, EntityProvenance,
InferredEntityProvenance, ProvidedEntityEditionProvenance,
},
},
property::{
Expand Down Expand Up @@ -150,6 +150,7 @@ use crate::rest::{
EntityEditionId,
EntityMetadata,
EntityProvenance,
EntityDeletionProvenance,
EntityEditionProvenance,
InferredEntityProvenance,
ProvidedEntityEditionProvenance,
Expand Down
1 change: 1 addition & 0 deletions libs/@local/graph/postgres-store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ uuid = { workspace = true, features = ["v4", "serde"] }

[dev-dependencies]
hash-graph-migrations = { workspace = true }
hash-graph-test-data = { workspace = true }
hash-telemetry = { workspace = true }
indoc = { workspace = true }
pretty_assertions = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions libs/@local/graph/postgres-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"devDependencies": {
"@rust/hash-graph-migrations": "workspace:*",
"@rust/hash-graph-test-data": "workspace:*",
"@rust/hash-telemetry": "workspace:*"
}
}
Loading
Loading